Files
justice/www/html/Game/public/TryChangeCSS/js/play.js
T
2026-05-06 20:18:40 +00:00

2809 lines
117 KiB
JavaScript

(function () {
const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game';
const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game';
const params = new URLSearchParams(window.location.search);
const spaceId = params.get('space');
const nick = params.get('nick') || 'ผู้เล่น';
const previewMode = params.get('preview') === '1';
const forceDefaultCharacter = params.get('defaultChar') === '1';
const editorEmbedReturn = params.get('editorEmbed') === '1';
const lobbyLevelParam = params.get('lobbyLevel');
const lobbyCaseParam = params.get('case');
if (lobbyLevelParam || lobbyCaseParam) {
try {
window.__detectiveLobbyMeta = { level: lobbyLevelParam, caseId: lobbyCaseParam };
} catch (e) { /* ignore */ }
}
/** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */
const previewFillBots = previewMode && editorEmbedReturn;
const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6));
const PREVIEW_BOT_PREFIX = '__pv_bot_';
let previewBotSeq = 0;
if (!spaceId) { window.location.replace(BASE + '/lobby.html'); return; }
const socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' });
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
let mapData = null, tileSize = 32, myId = null;
let mapBackgroundImg = null;
let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, tx: null, ty: null };
const others = new Map();
const keys = {};
const characterImages = {};
function createDefaultAvatarImg() {
const c = document.createElement('canvas');
c.width = 64; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = '#7aa2f7';
ctx.beginPath();
ctx.arc(32, 22, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#9ece6a';
ctx.beginPath();
ctx.arc(32, 48, 18, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#1a1b26';
ctx.beginPath();
ctx.arc(28, 20, 3, 0, Math.PI * 2);
ctx.arc(36, 20, 3, 0, Math.PI * 2);
ctx.fill();
const img = new Image();
img.src = c.toDataURL('image/png');
return img;
}
const defaultAvatarImg = createDefaultAvatarImg();
const characterAnimations = {};
const CHARACTER_ANIM_FRAMES = 4;
const CHARACTER_ANIM_FRAME_MS = 200;
/** จังหวะเดินลูปคงที่ 4 เฟรม — อย่า modulo ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */
function walkAnimPhaseIndex(now, isWalking) {
const t = isWalking ? (typeof now === 'number' ? now : Date.now()) : 0;
return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES;
}
/** เลือกเฟรมสูงสุดที่โหลดแล้วและ <= phase (เดิมถอย) */
function pickLoadedWalkFrameIndex(anim, phase) {
if (!anim || !anim.frames || !anim.frames.length) return -1;
const maxK = Math.min(phase, anim.frames.length - 1, CHARACTER_ANIM_FRAMES - 1);
for (let k = maxK; k >= 0; k--) {
const f = anim.frames[k];
if (f && f.complete && f.naturalWidth) return k;
}
return -1;
}
/** สุ่มสีตามเลเยอร์เดียวกับ character.html: bodyColor / hairColor / headColor (ลำดับซ้อนเหมือน composeLayeredFrame) */
const PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
const PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' };
const PLAY_TINT_HEAD = ['#eaa78a', '#fbd5c4', '#fae9e1'];
const PLAY_TINT_HAIR = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#ef62b9'];
const PLAY_TINT_BODY = ['#fb4941', '#feaa11', '#fefe6d', '#adfd85', '#45fbfd', '#799afe', '#f87dff', '#fec1fe'];
const playLayerMode = {};
/** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */
const playLayerAllDirsQueued = {};
/** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก <img> อย่างเดียว) */
let playCharLayerApi = { status: 'idle', map: null };
function ensurePlayCharLayerListFetch() {
if (playCharLayerApi.status !== 'idle') return;
playCharLayerApi.status = 'loading';
fetch(SERVER + '/api/characters')
.then((r) => r.json())
.then((list) => {
const map = Object.create(null);
if (Array.isArray(list)) {
list.forEach((c) => {
if (c && c.id) map[c.id] = !!c.hasLayerFiles;
});
}
playCharLayerApi = { status: 'done', map };
})
.catch(() => {
playCharLayerApi = { status: 'done', map: Object.create(null) };
});
}
/** @returns {boolean|null} true/false จาก API, null = ยังไม่โหลดหรือไม่มีรายการ id นี้ */
function playCharLayerFromApi(characterId) {
if (playCharLayerApi.status !== 'done' || !characterId) return null;
if (Object.prototype.hasOwnProperty.call(playCharLayerApi.map, characterId)) {
return playCharLayerApi.map[characterId];
}
return null;
}
const playLayerImageCache = new Map();
const playLayerCompositeCache = new Map();
function pickRandomPlayTint() {
return {
head: PLAY_TINT_HEAD[Math.floor(Math.random() * PLAY_TINT_HEAD.length)],
hair: PLAY_TINT_HAIR[Math.floor(Math.random() * PLAY_TINT_HAIR.length)],
body: PLAY_TINT_BODY[Math.floor(Math.random() * PLAY_TINT_BODY.length)],
};
}
function playTintFromPeerId(id) {
let h = 0;
const s = id || '';
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return {
head: PLAY_TINT_HEAD[h % PLAY_TINT_HEAD.length],
hair: PLAY_TINT_HAIR[(h >>> 3) % PLAY_TINT_HAIR.length],
body: PLAY_TINT_BODY[(h >>> 7) % PLAY_TINT_BODY.length],
};
}
function hexToRgb01(hex) {
const h = (hex || '').replace('#', '').trim();
if (h.length !== 6) return [1, 1, 1];
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
}
function tintPlayLayerImageData(imageData, tintHex) {
const rgb = hexToRgb01(tintHex);
const tr = rgb[0], tg = rgb[1], tb = rgb[2];
const d = imageData.data;
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] < 12) continue;
const r = d[i], g = d[i + 1], b = d[i + 2];
const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
d[i] = Math.min(255, Math.round(tr * 255 * L));
d[i + 1] = Math.min(255, Math.round(tg * 255 * L));
d[i + 2] = Math.min(255, Math.round(tb * 255 * L));
}
}
function drawPlayTintedLayer(ctx, w, h, img, tintHex) {
if (!tintHex) {
ctx.drawImage(img, 0, 0, w, h);
return;
}
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
x.drawImage(img, 0, 0, w, h);
try {
const idata = x.getImageData(0, 0, w, h);
tintPlayLayerImageData(idata, tintHex);
x.putImageData(idata, 0, 0);
} catch (e) {
x.clearRect(0, 0, w, h);
x.drawImage(img, 0, 0, w, h);
}
ctx.drawImage(c, 0, 0, w, h);
}
function ensurePlayLayerImage(url) {
let img = playLayerImageCache.get(url);
if (!img) {
img = new Image();
img.src = url;
playLayerImageCache.set(url, img);
}
return img;
}
/** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */
function layerUrlCandidates(id, dir, layerName, frameIndex) {
const enc = encodeURIComponent(id);
const base = SERVER + '/img/characters/' + enc + '_' + dir;
const out = [];
function add(u) {
if (out.indexOf(u) === -1) out.push(u);
}
add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
add(base + '_layer_' + layerName + '.png');
return out;
}
function shadowUrlCandidates(id, dir, frameIndex) {
const c = layerUrlCandidates(id, dir, 'shadow', frameIndex);
const def = SERVER + '/img/default-shadow-' + dir + '.png';
if (c.indexOf(def) === -1) c.push(def);
return c;
}
/** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */
function resolvePlayLayerImage(urls) {
for (let i = 0; i < urls.length; i++) {
const img = ensurePlayLayerImage(urls[i]);
if (!img.complete) return { status: 'pending' };
if (img.naturalWidth > 0) return { status: 'ok', img };
}
return { status: 'missing' };
}
function ensurePlayLayerProbesAllDirections(characterId) {
if (!characterId || playLayerAllDirsQueued[characterId]) return;
playLayerAllDirsQueued[characterId] = true;
['up', 'down', 'left', 'right'].forEach((d) => ensurePlayLayerProbe(characterId, d));
}
function playCharAnyDirectionLayered(characterId) {
if (!characterId) return false;
return ['up', 'down', 'left', 'right'].some((d) => playLayerMode[characterId + '|' + d] === 'layered');
}
/** รอเฉพาะตอนยังไม่เจอ layered เลย — ถ้ามีทิศหนึ่ง layered แล้ว ไม่ต้องรอทิศอื่น */
function playCharLayerDiscoveryPending(characterId) {
if (!characterId) return true;
const api = playCharLayerFromApi(characterId);
if (api === true || api === false) return false;
if (playCharAnyDirectionLayered(characterId)) return false;
return ['up', 'down', 'left', 'right'].some((d) => {
const m = playLayerMode[characterId + '|' + d];
return m === undefined || m === 'pending';
});
}
function ensurePlayLayerProbe(characterId, dir) {
const key = characterId + '|' + dir;
if (playLayerMode[key] !== undefined && playLayerMode[key] !== 'pending') return;
if (playLayerMode[key] === 'pending') return;
playLayerMode[key] = 'pending';
const cands = layerUrlCandidates(characterId, dir, 'bodyColor', 0);
let ci = 0;
function tryNextProbe() {
if (playLayerMode[key] !== 'pending') return;
if (ci >= cands.length) {
playLayerMode[key] = 'none';
return;
}
const url = cands[ci++];
const img = ensurePlayLayerImage(url);
const fin = () => {
if (playLayerMode[key] !== 'pending') return;
if (img.naturalWidth > 0) {
playLayerMode[key] = 'layered';
return;
}
tryNextProbe();
};
img.onload = fin;
img.onerror = fin;
if (img.complete) fin();
}
tryNextProbe();
}
function getCharacterAnimFrameIndex(id, dir, now, isWalking) {
if (!id) return 0;
const key = id + '_' + dir;
const anim = characterAnimations[key];
if (!anim) return 0;
const phase = walkAnimPhaseIndex(now, isWalking);
const fi = pickLoadedWalkFrameIndex(anim, phase);
return fi >= 0 ? fi : 0;
}
function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint) {
if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null;
const w = rawImg.naturalWidth, h = rawImg.naturalHeight;
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const cctx = c.getContext('2d');
let anyTintColorLayer = false;
let skippedShadowWhilePending = false;
for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) {
const layerName = PLAY_LAYER_ORDER[li];
const urls = layerName === 'shadow'
? shadowUrlCandidates(id, dir, frameIndex)
: layerUrlCandidates(id, dir, layerName, frameIndex);
const r = resolvePlayLayerImage(urls);
/* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */
if (r.status === 'pending' && layerName === 'shadow') {
skippedShadowWhilePending = true;
continue;
}
if (r.status === 'pending') return null;
if (r.status === 'missing') continue;
const tintHex = PLAY_LAYER_TINT_KEY[layerName] ? tint[PLAY_LAYER_TINT_KEY[layerName]] : null;
if (PLAY_LAYER_TINT_KEY[layerName]) anyTintColorLayer = true;
drawPlayTintedLayer(cctx, w, h, r.img, tintHex);
}
/* ถ้าโหลดได้แต่ไม่มีเลเยอร์สีเลย จะเหลือแต่ stroke → ตัวขาว — ใช้ PNG รวมแทน */
if (!anyTintColorLayer) return null;
return { canvas: c, skipCache: skippedShadowWhilePending };
}
function getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, tint) {
if (!tint || !characterId || !rawImg) return rawImg;
ensurePlayCharLayerListFetch();
ensurePlayLayerProbesAllDirections(characterId);
const frameIndex = getCharacterAnimFrameIndex(characterId, dir, timeMs, isWalking);
if (playCharLayerDiscoveryPending(characterId)) return rawImg;
const apiFlag = playCharLayerFromApi(characterId);
const charHasLayerFiles = apiFlag === true || playCharAnyDirectionLayered(characterId);
const cacheKey = [characterId, dir, frameIndex, tint.head, tint.hair, tint.body].join('|');
const hit = playLayerCompositeCache.get(cacheKey);
if (hit) return hit;
/* มีไฟล์ *_layer_* บนเซิร์ฟเวอร์ (API) หรือ probe เจอ — ลองประกอบเลเยอร์เสมอ แม้ probe ทิศนี้ยัง none */
if (charHasLayerFiles) {
const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dir, frameIndex, tint);
if (pack && pack.canvas) {
if (!pack.skipCache) playLayerCompositeCache.set(cacheKey, pack.canvas);
return pack.canvas;
}
return rawImg;
}
/*
* ไม่มีไฟล์ *_layer_* บนเซิร์ฟเวอร์ (เช็คจาก API) → แสดง PNG รวมตามดีไซน์จริง
* ไม่ใช้แถบแนวตั้ง — มันดูเหมือนสีย้อมเลเยอร์แต่ไม่ใช่ ทำให้เข้าใจผิด
*/
return rawImg;
}
function getCharacterImg(id, direction) {
if (!id) return null;
const key = id + '_' + (direction || 'down');
if (characterImages[key]) return characterImages[key];
const img = new Image();
img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + (direction || 'down') + '.png';
characterImages[key] = img;
return img;
}
function getCharacterFrame(id, direction, now, isWalking) {
if (!id) return null;
const dir = direction || 'down';
const key = id + '_' + dir;
let anim = characterAnimations[key];
if (!anim) {
anim = { frames: [], fallback: null };
characterAnimations[key] = anim;
for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
const img = new Image();
img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png';
anim.frames.push(img);
}
anim.fallback = getCharacterImg(id, dir);
}
const phase = walkAnimPhaseIndex(now, isWalking);
const fi = pickLoadedWalkFrameIndex(anim, phase);
if (fi >= 0) return anim.frames[fi];
const fb = anim.fallback;
if (fb && fb.complete && fb.naturalWidth) return fb;
return null;
}
function getAvatarImg(characterId, direction, now, isWalking) {
const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null;
if (img) return img;
return defaultAvatarImg;
}
const DEFAULT_CHARACTER_ID = 'Chatest';
function getStoredCharacterId() {
try { return localStorage.getItem('gameCharacterId') || DEFAULT_CHARACTER_ID; } catch (e) { return DEFAULT_CHARACTER_ID; }
}
function getPlayCharacterId() {
if (forceDefaultCharacter) return DEFAULT_CHARACTER_ID;
return getStoredCharacterId();
}
const MOVE_SPEED = 0.15;
const PATH_ARRIVE_THRESH = 0.15;
function isMovementKey(code) {
return ['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].indexOf(code) !== -1;
}
function isChatFocused() {
return false;
}
let zoom = 1.4;
let froggerScore = 0;
let lastFroggerKey = 0;
let gauntletObstacles = [];
/** อินเทอร์โพเลตการวาด obstacle ระหว่างแพ็กเก็ต sync (~220ms) */
let gauntletObsRenderPrev = [];
let gauntletObsRenderNext = [];
let gauntletObsBlendT0 = 0;
let meGauntletJumpTicks = 0;
/** ค่าที่ใช้วาดการยกตัว (เลอร์ปจาก meGauntletJumpTicks ให้โค้งกระโดดไม่กระตุก) */
let meGauntletJumpVis = 0;
/** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */
let gauntletRuntimeTickMs = 220;
let gauntletRuntimeJumpTicks = 16;
/** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync */
let gauntletRuntimeTimeLimitSec = 0;
/** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */
let gauntletEndsAtMs = null;
let lastGauntletJumpKey = 0;
/** รูป lane: หลาย URL สุ่มตาม id คงที่ · laser: แยกบน/ล่าง/เส้น + สี/ความหนา */
let gauntletLaneImageUrls = [];
let gauntletLaserTopUrl = '';
let gauntletLaserBottomUrl = '';
let gauntletLaserLineUrl = '';
let gauntletLaserFillColor = 'rgba(140,230,255,0.42)';
let gauntletLaserStrokeColor = 'rgba(255,220,255,0.9)';
let gauntletLaserLineWidthPx = 2;
const gauntletAssetImageCache = new Map();
function ensureGauntletAssetImage(url) {
const u = typeof url === 'string' ? url.trim() : '';
if (!u) return null;
let rec = gauntletAssetImageCache.get(u);
if (rec) return rec;
rec = { img: new Image(), ready: false };
if (/^https?:\/\//i.test(u)) {
try { rec.img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ }
}
rec.img.onload = function () { rec.ready = true; };
rec.img.onerror = function () { rec.ready = false; };
rec.img.src = u;
gauntletAssetImageCache.set(u, rec);
return rec;
}
function pickGauntletLaneImageRec(obsId) {
if (!gauntletLaneImageUrls.length) return null;
const n = gauntletLaneImageUrls.length;
const idx = Math.abs(Number(obsId) | 0) % n;
const u = gauntletLaneImageUrls[idx];
return u ? ensureGauntletAssetImage(u) : null;
}
function clampClientLaserColor(s, def) {
const t = String(s || '').trim().slice(0, 100);
if (!t || /[<>"'`]/.test(t)) return def;
return t;
}
let playQuizPhaseLocal = null;
let playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
let playQuizText = '';
let playQuizPhaseEndsAt = 0;
let playQuizTimerInterval = null;
/** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */
let previewQuizPool = [];
let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 };
let previewQuizStep = 'read';
let previewQuizCurrent = null;
let playLiveQuizScores = {};
document.getElementById('room-id').textContent = spaceId;
function hidePlayQuizHud() {
const sb = document.getElementById('play-quiz-scoreboard');
if (sb) sb.classList.add('is-hidden');
const fb = document.getElementById('play-quiz-feedback');
if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; }
}
function renderPlayQuizScoreboard(scores) {
const wrap = document.getElementById('play-quiz-scoreboard');
const ul = document.getElementById('play-quiz-scoreboard-list');
if (!wrap || !ul || !mapData || !isQuiz()) return;
if (!scores || typeof scores !== 'object') return;
wrap.classList.remove('is-hidden');
ul.textContent = '';
const merged = { ...scores };
others.forEach((_, id) => { if (merged[id] == null) merged[id] = 0; });
if (myId != null && merged[myId] == null) merged[myId] = 0;
const rows = [];
if (myId != null) {
rows.push({ id: myId, nick: me.nickname || 'คุณ', sc: merged[myId] != null ? merged[myId] : 0 });
}
others.forEach((o, id) => {
rows.push({ id, nick: (o && o.nickname) ? String(o.nickname) : id, sc: merged[id] != null ? merged[id] : 0 });
});
rows.sort((a, b) => b.sc - a.sc || a.nick.localeCompare(b.nick, 'th'));
rows.forEach((row) => {
const li = document.createElement('li');
if (row.id === myId) li.className = 'play-quiz-scoreboard-me';
const spN = document.createElement('span');
spN.className = 'play-quiz-scoreboard-name';
spN.textContent = row.nick;
const spV = document.createElement('span');
spV.className = 'play-quiz-scoreboard-val';
spV.textContent = String(row.sc);
li.appendChild(spN);
li.appendChild(spV);
ul.appendChild(li);
});
}
function initPlayLiveQuizScoresZeros() {
if (!isQuiz() || myId == null) return;
playLiveQuizScores = {};
playLiveQuizScores[myId] = 0;
others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
renderPlayQuizScoreboard(playLiveQuizScores);
}
function showPlayQuizFeedback(r) {
const el = document.getElementById('play-quiz-feedback');
if (!el || !r || !r.results || myId == null) return;
let mine = null;
let botRight = 0, botWrong = 0;
for (let i = 0; i < r.results.length; i++) {
const row = r.results[i];
if (row.id === myId) mine = row;
else if (isPreviewBotId(row.id)) {
if (row.right) botRight++;
else botWrong++;
}
}
if (!mine) return;
el.classList.remove('is-hidden');
const botExtra = previewFillBots && (botRight + botWrong > 0)
? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)'
: '';
if (mine.right) {
el.className = 'play-quiz-feedback play-quiz-feedback-ok';
el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม' + botExtra;
} else {
el.className = 'play-quiz-feedback play-quiz-feedback-bad';
el.textContent = (mine.choice == null
? 'คุณไม่ได้ยืนในโซนตอบ — นับเป็นผิด'
: 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีก') + botExtra;
}
if (typeof window.__playQuizFeedbackT === 'number') clearTimeout(window.__playQuizFeedbackT);
window.__playQuizFeedbackT = setTimeout(() => { el.classList.add('is-hidden'); }, 4200);
}
function getQuizQuestionAreaTileBounds(md) {
if (!md || md.gameType !== 'quiz') return null;
const grid = md.quizQuestionArea;
if (!grid || !grid.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let yy = 0; yy < grid.length; yy++) {
const row = grid[yy];
if (!row) continue;
for (let xx = 0; xx < row.length; xx++) {
if (row[xx] === 1) {
if (xx < minX) minX = xx;
if (yy < minY) minY = yy;
if (xx > maxX) maxX = xx;
if (yy > maxY) maxY = yy;
}
}
}
if (minX === Infinity) return null;
return { minX, minY, maxX, maxY };
}
function syncPlayQuizMapPanel() {
const panel = document.getElementById('quiz-map-question-panel');
const textEl = document.getElementById('quiz-map-question-text');
if (!panel || !textEl || !mapData || !isQuiz()) return;
const bounds = getQuizQuestionAreaTileBounds(mapData);
const text = (playQuizText || '').trim();
if (!bounds || !text) {
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
return;
}
const camX = me.x * tileSize;
const camY = me.y * tileSize;
const left = (bounds.minX * tileSize - camX) * zoom + canvas.width / 2;
const top = (bounds.minY * tileSize - camY) * zoom + canvas.height / 2;
const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zoom;
const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zoom;
textEl.textContent = text;
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
panel.style.width = Math.round(Math.max(48, wPx)) + 'px';
panel.style.height = Math.round(Math.max(40, hPx)) + 'px';
panel.classList.remove('is-hidden');
panel.setAttribute('aria-hidden', 'false');
}
function updatePlayQuizTimerDisplay() {
const el = document.getElementById('quiz-game-timer');
if (!el) return;
if (!playQuizPhaseEndsAt) {
el.textContent = '';
return;
}
const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000));
el.textContent = s + ' วินาที';
}
function clampPreviewMs(n, def, minV, maxV) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(minV, Math.min(maxV, Math.floor(v)));
}
function buildQuizPoolFromMap(md) {
if (!md || !Array.isArray(md.quizQuestions)) return [];
return md.quizQuestions
.filter((q) => q && String(q.text || '').trim())
.map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
}
function pickRandomQuizFromPool(pool) {
if (!pool || !pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function clearPreviewBotAnswerPaths() {
if (!previewFillBots) return;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.botPath = [];
o.botAnswerWander = false;
});
}
function resetPreviewBotsQuizState() {
if (!previewFillBots) return;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.quizCannotTrue = false;
o.quizCannotFalse = false;
o.botPath = [];
o.botAnswerWander = false;
});
}
function applyPreviewReadPhase(q, poolLen) {
clearPreviewBotAnswerPaths();
previewQuizStep = 'read';
playQuizPhaseLocal = 'read';
previewQuizCurrent = q;
playQuizText = q.text;
playQuizPhaseEndsAt = Date.now() + previewQuizTiming.readMs;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · สุ่มจากชุด ' + poolLen + ' ข้อ';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
const leg = document.getElementById('quiz-play-legend');
if (leg) leg.textContent = 'โซนทองบนแผนที่ = ข้อความคำถาม · ฟ้า = จริง · ชมพู = เท็จ';
}
function applyPreviewAnswerPhase() {
previewQuizStep = 'answer';
playQuizPhaseLocal = 'answer';
playQuizPhaseEndsAt = Date.now() + previewQuizTiming.answerMs;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] เดินไปโซน ถูก / ผิด';
const leg = document.getElementById('quiz-play-legend');
if (leg && previewQuizCurrent) {
leg.textContent = previewQuizCurrent.answerTrue
? 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «จริง» — โซนสีฟ้า'
: 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «เท็จ» — โซนสีชมพู';
}
previewBotsPrepareAnswerRound();
}
function applyPreviewBetweenPhase() {
clearPreviewBotAnswerPaths();
previewQuizStep = 'between';
playQuizPhaseLocal = null;
playQuizText = 'ข้อถัดไปกำลังจะมา…';
playQuizPhaseEndsAt = Date.now() + previewQuizTiming.betweenMs;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] พักระหว่างข้อ';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
const leg = document.getElementById('quiz-play-legend');
if (leg) leg.textContent = 'รอสักครู่แล้วจะสุ่มคำถามใหม่จากชุดเดิม';
}
function quizCellOnPlay(grid, x, y) {
return !!(grid && grid[y] && grid[y][x] === 1);
}
function spawnTileWalkablePlay(md, x, y) {
const w = md.width || 20;
const h = md.height || 15;
if (x < 0 || x >= w || y < 0 || y >= h) return false;
const row = md.objects && md.objects[y];
if (row && row[x] === 1) return false;
return true;
}
/** เหมือน server pickRandomSpawnFromMap — สุ่มด้วย crypto.getRandomValues */
function pickRandomSpawnFromMapPlay(md) {
const fallback = md.spawn || { x: 1, y: 1 };
const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1;
const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1;
const grid = md.spawnArea;
if (!grid || !Array.isArray(grid)) return { x: fx, y: fy };
const w = md.width || 20;
const h = md.height || 15;
const pool = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) pool.push({ x, y });
}
}
if (!pool.length) return { x: fx, y: fy };
const u = new Uint32Array(1);
(typeof crypto !== 'undefined' && crypto.getRandomValues)
? crypto.getRandomValues(u)
: (u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0));
const pick = pool[u[0] % pool.length];
return { x: pick.x, y: pick.y };
}
const GAUNTLET_PREVIEW_MAX = 6;
function gauntletSpawnYsPlay(md, playerCount) {
const h = md.height || 15;
const lo = 1;
const hi = Math.max(lo, h - 2);
const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
if (hi <= lo) return Array.from({ length: n }, () => lo);
const ys = [];
for (let i = 0; i < n; i++) {
ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1)));
}
return ys;
}
function collectGauntletSpawnSlotsFromSpawnAreaPlay(md) {
const grid = md.spawnArea;
if (!grid || !Array.isArray(grid)) return [];
const w = md.width || 20;
const h = md.height || 15;
const slots = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) slots.push({ x, y });
}
}
slots.sort((a, b) => a.y - b.y || a.x - b.x);
return slots;
}
function collectGauntletSpawnSlotsPlay(md) {
const explicit = md.gauntletPlayerSpawns;
if (Array.isArray(explicit) && explicit.length > 0) {
const w = md.width || 20;
const h = md.height || 15;
const slots = [];
for (const raw of explicit) {
if (slots.length >= GAUNTLET_PREVIEW_MAX) break;
const x = Math.floor(Number(raw && raw.x));
const y = Math.floor(Number(raw && raw.y));
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue;
if (!spawnTileWalkablePlay(md, x, y)) continue;
slots.push({ x, y });
}
if (slots.length > 0) return slots;
}
return collectGauntletSpawnSlotsFromSpawnAreaPlay(md);
}
function gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotXFallback) {
const w = md.width || 20;
if (spawnTileWalkablePlay(md, spawnColX, y)) return spawnColX;
if (slotXFallback != null && spawnTileWalkablePlay(md, slotXFallback, y)) return slotXFallback;
for (let d = 0; d < w; d++) {
if (spawnColX + d < w && spawnTileWalkablePlay(md, spawnColX + d, y)) return spawnColX + d;
if (spawnColX - d >= 0 && spawnTileWalkablePlay(md, spawnColX - d, y)) return spawnColX - d;
}
return Math.max(0, Math.min(w - 1, spawnColX));
}
function gauntletSpawnPositionsPlay(md, playerCount) {
const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
const slots = collectGauntletSpawnSlotsPlay(md);
const fallbackYs = gauntletSpawnYsPlay(md, n);
const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1;
const out = [];
for (let i = 0; i < n; i++) {
const y = i < slots.length
? slots[i].y
: (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]);
const slotX = i < slots.length ? slots[i].x : null;
const x = gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotX);
out.push({ x, y });
}
return out;
}
/**
* โหมดทดสอบพรมแดง: จัดตำแหน่งเกิดให้ตรง server (spawnArea หรือกระจาย y)
* @param {boolean} [onlyBots=false] ถ้า true = จัดแค่บอท (หลัง game-start; ไม่ทับ me/คนจริงจาก peersSnap)
*/
function applyGauntletPreviewSpawnLayout(onlyBots) {
if (!mapData || mapData.gameType !== 'gauntlet') return;
const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
const humanCount = 1 + realIds.length;
const total = Math.min(GAUNTLET_PREVIEW_MAX, humanCount + botIds.length);
const pos = gauntletSpawnPositionsPlay(mapData, total);
if (onlyBots) {
let idx = humanCount;
botIds.forEach((bid) => {
const o = others.get(bid);
if (!o || idx >= pos.length) return;
o.x = pos[idx].x;
o.tx = pos[idx].x;
o.y = pos[idx].y;
o.ty = pos[idx].y;
idx++;
});
return;
}
let idx = 0;
if (idx < pos.length) {
me.x = pos[idx].x;
me.y = pos[idx].y;
me.tx = me.x;
me.ty = me.y;
idx++;
}
realIds.forEach((rid) => {
const o = others.get(rid);
if (!o || idx >= pos.length) return;
o.x = pos[idx].x;
o.tx = pos[idx].x;
o.y = pos[idx].y;
o.ty = pos[idx].y;
idx++;
});
botIds.forEach((bid) => {
const o = others.get(bid);
if (!o || idx >= pos.length) return;
o.x = pos[idx].x;
o.tx = pos[idx].x;
o.y = pos[idx].y;
o.ty = pos[idx].y;
idx++;
});
}
function isPreviewBotId(id) {
return typeof id === 'string' && id.indexOf(PREVIEW_BOT_PREFIX) === 0;
}
function countPlayHumans() {
let n = 1;
others.forEach((_, id) => { if (!isPreviewBotId(id)) n++; });
return n;
}
function rebalancePreviewBots() {
if (!previewFillBots || !mapData) return;
const human = countPlayHumans();
const wantBots = Math.max(0, previewTargetHeadcount - human);
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const o = others.get(bid);
if (!o) return;
if (o.botTier == null) {
const tierRoll = Math.random();
o.botTier = tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak';
}
if (o.quizCannotTrue == null) o.quizCannotTrue = false;
if (o.quizCannotFalse == null) o.quizCannotFalse = false;
if (!Array.isArray(o.botPath)) o.botPath = [];
if (o.botAnswerWander == null) o.botAnswerWander = false;
if (o.gauntletJumpTicks == null) o.gauntletJumpTicks = 0;
if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
if (o.gauntletScore == null) o.gauntletScore = 0;
if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)];
o.botWanderDx = wd[0];
o.botWanderDy = wd[1];
}
if (typeof o.botWanderNextTurn !== 'number') {
o.botWanderNextTurn = Date.now() + 400 + Math.floor(Math.random() * 900);
}
});
let botIds = [...others.keys()].filter(isPreviewBotId);
while (botIds.length > wantBots) {
const drop = botIds.pop();
if (drop) others.delete(drop);
}
botIds = [...others.keys()].filter(isPreviewBotId);
while (botIds.length < wantBots) {
const id = PREVIEW_BOT_PREFIX + (++previewBotSeq);
const sp = pickRandomSpawnFromMapPlay(mapData);
const jx = (Math.random() - 0.5) * 0.4;
const jy = (Math.random() - 0.5) * 0.4;
const x = sp.x + 0.5 + jx;
const y = sp.y + 0.5 + jy;
const tierRoll = Math.random();
const botTier = tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak';
const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)];
others.set(id, {
x, y, tx: x, ty: y,
direction: ['down', 'up', 'left', 'right'][Math.floor(Math.random() * 4)],
nickname: 'บอท',
characterId: getPlayCharacterId(),
playTint: pickRandomPlayTint(),
botTier,
gauntletJumpTicks: 0,
gauntletJumpVis: 0,
gauntletScore: 0,
quizCannotTrue: false,
quizCannotFalse: false,
botPath: [],
botAnswerWander: false,
botWanderDx: wd[0],
botWanderDy: wd[1],
botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000),
});
botIds.push(id);
}
let k = 0;
[...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => {
const o = others.get(bid);
if (!o) return;
const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)';
o.nickname = 'บอท ' + (++k) + ' ' + tag;
});
if (mapData.gameType === 'gauntlet') {
applyGauntletPreviewSpawnLayout(false);
emitGauntletPreviewRowsToServer();
}
}
function pickRandomWalkableCellInAnswerGrid(grid, o) {
if (!grid || !mapData) return null;
const w = mapData.width || 20, h = mapData.height || 15;
const pool = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] !== 1) continue;
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, NaN, NaN, o)) pool.push({ x, y });
}
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
/** ช่วงตอบ: บอทสุ่มเดินไปโซนถูก/ผิด/เดินมั่ว ตามระดับ (ฉลาด/กลาง/พลาดบ่อย) */
function previewBotsPrepareAnswerRound() {
if (!previewFillBots || !mapData || !isQuiz() || !previewQuizCurrent) return;
const correctTrue = !!previewQuizCurrent.answerTrue;
const qt = mapData.quizTrueArea;
const qf = mapData.quizFalseArea;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.botPath = [];
o.botAnswerWander = false;
const tier = o.botTier || 'avg';
let pCorrect = 0.52;
if (tier === 'sharp') pCorrect = 0.84;
else if (tier === 'weak') pCorrect = 0.22;
if (Math.random() < 0.14) {
o.botAnswerWander = true;
return;
}
const wantsCorrect = Math.random() < pCorrect;
const targetTrue = wantsCorrect ? correctTrue : !correctTrue;
const zoneGrid = targetTrue ? qt : qf;
const dest = pickRandomWalkableCellInAnswerGrid(zoneGrid, o);
if (!dest) {
o.botAnswerWander = true;
return;
}
const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
if (!path || path.length <= 1) {
o.botAnswerWander = true;
return;
}
o.botPath = path.slice(1);
});
}
/** คัดลอกตรรกะจาก server — ดีดออกนอกโซนจริง/เท็จ (โหมดทดสอบ preview บน play.html) */
function findNearestOutsideQuizAnswerZonesPlay(md, sx, sy) {
const w = md.width || 20;
const h = md.height || 15;
const tGrid = md.quizTrueArea || [];
const fGrid = md.quizFalseArea || [];
const inAnswer = (x, y) => quizCellOnPlay(tGrid, x, y) || quizCellOnPlay(fGrid, x, y);
const walkable = (x, y) => {
if (x < 0 || x >= w || y < 0 || y >= h) return false;
const row = md.objects && md.objects[y];
return row && row[x] !== 1;
};
const fx = Math.floor(Number(sx));
const fy = Math.floor(Number(sy));
if (!walkable(fx, fy)) {
const sp = md.spawn || { x: 1, y: 1 };
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
}
if (!inAnswer(fx, fy)) return { x: sx, y: sy };
const q = [[fx, fy]];
const seen = new Set([`${fx},${fy}`]);
const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
while (q.length) {
const [cx, cy] = q.shift();
for (let di = 0; di < dirs.length; di++) {
const nx = cx + dirs[di][0];
const ny = cy + dirs[di][1];
const k = `${nx},${ny}`;
if (seen.has(k)) continue;
if (!walkable(nx, ny)) continue;
seen.add(k);
if (!inAnswer(nx, ny)) {
return { x: nx + 0.5, y: ny + 0.5 };
}
q.push([nx, ny]);
}
}
const sp = md.spawn || { x: 1, y: 1 };
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
}
function quizResolveChoiceFromStanding(ox, oy, correctTrue) {
const tx = Math.floor(ox);
const ty = Math.floor(oy);
const qt = mapData.quizTrueArea;
const qf = mapData.quizFalseArea;
const inT = quizCellOnPlay(qt, tx, ty);
const inF = quizCellOnPlay(qf, tx, ty);
let choice = null;
if (inT && !inF) choice = true;
else if (inF && !inT) choice = false;
else if (inT && inF) choice = true;
const right = choice !== null && choice === correctTrue;
return { choice, right };
}
/** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง server (ผิด = ล็อกโซน + ดีดออก) */
function resolvePreviewQuizRound() {
if (!previewMode || !mapData || !isQuiz() || !previewQuizCurrent || myId == null) return;
const correctTrue = !!previewQuizCurrent.answerTrue;
const results = [];
const mine = quizResolveChoiceFromStanding(me.x, me.y, correctTrue);
let right = mine.right;
let choice = mine.choice;
if (mine.right) {
playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1;
} else {
playQuizPlayerLocal.cannotTrue = true;
playQuizPlayerLocal.cannotFalse = true;
const sp = pickRandomSpawnFromMapPlay(mapData);
const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
me.x = pos.x;
me.y = pos.y;
socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
}
results.push({
id: myId,
right,
choice,
score: playLiveQuizScores[myId] != null ? playLiveQuizScores[myId] : 0,
});
if (previewFillBots) {
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
const br = quizResolveChoiceFromStanding(o.x, o.y, correctTrue);
o.botPath = [];
o.botAnswerWander = false;
if (br.right) {
playLiveQuizScores[id] = (playLiveQuizScores[id] || 0) + 1;
} else {
o.quizCannotTrue = true;
o.quizCannotFalse = true;
const sp = pickRandomSpawnFromMapPlay(mapData);
const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
o.x = pos.x + (Math.random() - 0.5) * 0.35;
o.y = pos.y + (Math.random() - 0.5) * 0.35;
}
results.push({
id,
right: br.right,
choice: br.choice,
score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0,
});
});
}
const r = { results, scores: { ...playLiveQuizScores } };
renderPlayQuizScoreboard(playLiveQuizScores);
showPlayQuizFeedback(r);
}
function advancePreviewQuizIfDue() {
if (!previewMode || !isQuiz() || !playQuizPhaseEndsAt || Date.now() < playQuizPhaseEndsAt) return;
const pool = previewQuizPool;
if (!pool || !pool.length) return;
if (previewQuizStep === 'read') {
applyPreviewAnswerPhase();
return;
}
if (previewQuizStep === 'answer') {
resolvePreviewQuizRound();
applyPreviewBetweenPhase();
return;
}
if (previewQuizStep === 'between') {
const q = pickRandomQuizFromPool(pool);
if (q) applyPreviewReadPhase(q, pool.length);
}
}
async function loadPreviewQuizAndStart() {
resetPreviewBotsQuizState();
let settings = null;
try {
const r = await fetch(SERVER + '/api/quiz-settings');
if (r.ok) settings = await r.json();
} catch (e) { /* use map fallback */ }
let pool = [];
if (settings && Array.isArray(settings.questions) && settings.questions.length) {
pool = settings.questions
.filter((q) => q && String(q.text || '').trim())
.map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
}
if (!pool.length && mapData) pool = buildQuizPoolFromMap(mapData);
previewQuizPool = pool;
const dRead = 10000;
const dAns = 5000;
const dBet = 3500;
previewQuizTiming = {
readMs: clampPreviewMs(settings && settings.readMs, dRead, 1000, 300000),
answerMs: clampPreviewMs(settings && settings.answerMs, dAns, 1000, 300000),
betweenMs: clampPreviewMs(settings && settings.betweenMs, dBet, 0, 300000),
};
previewQuizCurrent = null;
if (!pool.length) {
playQuizPhaseLocal = 'read';
previewQuizStep = 'read';
playQuizText = 'ยังไม่มีคำถามในชุด — ตั้งคำถามใน Admin → คำถามเกม หรือในแมป (quizQuestions)';
playQuizPhaseEndsAt = 0;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] ไม่มีคำถามในระบบ';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
const leg = document.getElementById('quiz-play-legend');
if (leg) leg.textContent = 'โซนสีบนแผนที่ยังใช้ดูเลย์เอาต์ได้ตามปกติ';
initPlayLiveQuizScoresZeros();
startPlayQuizTimer();
return;
}
const q = pickRandomQuizFromPool(pool);
if (q) applyPreviewReadPhase(q, pool.length);
initPlayLiveQuizScoresZeros();
startPlayQuizTimer();
}
function startPlayQuizTimer() {
if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
playQuizTimerInterval = setInterval(() => {
updatePlayQuizTimerDisplay();
advancePreviewQuizIfDue();
}, 200);
updatePlayQuizTimerDisplay();
}
function teardownPlayQuizUi() {
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
if (ov) ov.classList.add('is-hidden');
const panel = document.getElementById('quiz-map-question-panel');
if (panel) {
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
}
playQuizPhaseLocal = null;
playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
playQuizText = '';
playQuizPhaseEndsAt = 0;
previewQuizPool = [];
previewQuizCurrent = null;
previewQuizStep = 'read';
playLiveQuizScores = {};
playPath = [];
hidePlayQuizHud();
}
function setupPlayQuizUi() {
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
if (ov) ov.classList.remove('is-hidden');
playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
const leg = document.getElementById('quiz-play-legend');
if (previewMode) {
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] กำลังโหลดคำถาม…';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = 'ดึงชุดคำถามจาก /api/quiz-settings (หรือจากแมป)…';
if (leg) leg.textContent = '';
loadPreviewQuizAndStart();
} else {
playQuizPhaseLocal = null;
playQuizText = 'รอคำถามจากโฮสต์…';
playQuizPhaseEndsAt = 0;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = 'แมพตอบคำถาม';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = 'เข้าร่วมห้องแล้ว — เมื่อโฮสต์เริ่มเกม ข้อความและเวลาจะอัปเดตที่นี่ (เล่นจริงแนะนำผ่าน room-lobby)';
if (leg) leg.textContent = 'โซนสีบนแผนที่คือจุดตอบเหมือนใน Lobby';
const tEl = document.getElementById('quiz-game-timer');
if (tEl) tEl.textContent = '';
}
}
function isFrogger() { return mapData && mapData.gameType === 'frogger'; }
function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; }
function applyGauntletTimingFromServer(payload) {
if (!payload || typeof payload !== 'object') return;
const tm = Number(payload.gauntletTickMs);
if (Number.isFinite(tm)) gauntletRuntimeTickMs = Math.max(80, Math.min(800, tm));
const jt = Number(payload.gauntletJumpTicks);
if (Number.isFinite(jt)) gauntletRuntimeJumpTicks = Math.max(4, Math.min(40, jt));
const tl = Number(payload.gauntletTimeLimitSec);
if (Number.isFinite(tl)) gauntletRuntimeTimeLimitSec = Math.max(0, Math.min(7200, tl));
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletEndsAt')) {
if (payload.gauntletEndsAt == null) gauntletEndsAtMs = null;
else {
const ge = Number(payload.gauntletEndsAt);
gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaneImageUrls') && Array.isArray(payload.gauntletLaneImageUrls)) {
gauntletLaneImageUrls = payload.gauntletLaneImageUrls.filter((x) => typeof x === 'string').slice(0, 24);
gauntletLaneImageUrls.forEach((x) => ensureGauntletAssetImage(x));
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserTopUrl')) {
gauntletLaserTopUrl = typeof payload.gauntletLaserTopUrl === 'string' ? payload.gauntletLaserTopUrl : '';
ensureGauntletAssetImage(gauntletLaserTopUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserBottomUrl')) {
gauntletLaserBottomUrl = typeof payload.gauntletLaserBottomUrl === 'string' ? payload.gauntletLaserBottomUrl : '';
ensureGauntletAssetImage(gauntletLaserBottomUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineUrl')) {
gauntletLaserLineUrl = typeof payload.gauntletLaserLineUrl === 'string' ? payload.gauntletLaserLineUrl : '';
ensureGauntletAssetImage(gauntletLaserLineUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserFillColor')) {
gauntletLaserFillColor = clampClientLaserColor(payload.gauntletLaserFillColor, gauntletLaserFillColor);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserStrokeColor')) {
gauntletLaserStrokeColor = clampClientLaserColor(payload.gauntletLaserStrokeColor, gauntletLaserStrokeColor);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineWidthPx')) {
const lw = Number(payload.gauntletLaserLineWidthPx);
if (Number.isFinite(lw)) gauntletLaserLineWidthPx = Math.max(0, Math.min(24, Math.round(lw)));
}
}
/** เลอร์ปตำแหน่งตัวละครต่อเฟรม (ยิ่งสูงยิ่งตามเป้าเร็ว) */
const GAUNTLET_VIS_LERP = 0.42;
function gauntletObsSmoothstep(t) {
t = Math.max(0, Math.min(1, t));
return t * t * (3 - 2 * t);
}
function cloneGauntletObsSnap(arr) {
if (!Array.isArray(arr)) return [];
return arr.map((o) => (o && o.id != null ? { id: o.id, kind: o.kind, x: o.x, y: o.y } : null)).filter(Boolean);
}
/** โค้งยกตัว: ขึ้นถึงจุดสูงสุดเร็ว ลงชัน (รู้สึกลงพื้นเร็วกว่าแบบสมมาตร) */
function gauntletLiftHeightNorm(air, jumpTicksMax) {
const jm = Math.max(4, jumpTicksMax || 16);
const a = Math.max(0, Math.min(jm, Number(air) || 0));
const t = a / jm;
const peakAt = 0.28;
if (t >= peakAt) {
const span = 1 - peakAt;
const u = span > 0 ? (t - peakAt) / span : 0;
return Math.max(0, 1 - u * u * 1.2);
}
return Math.min(1, t / peakAt);
}
function getGauntletObsDrawPositionsAt(nowMs) {
if (!gauntletObsRenderNext.length) return [];
const elapsed = nowMs - gauntletObsBlendT0;
const alpha = gauntletObsSmoothstep(elapsed / gauntletRuntimeTickMs);
const prevMap = new Map(gauntletObsRenderPrev.map((o) => [o.id, o]));
const out = [];
for (let i = 0; i < gauntletObsRenderNext.length; i++) {
const n = gauntletObsRenderNext[i];
if (!n) continue;
const p = prevMap.get(n.id);
let drawX = n.x;
if (p && Number.isFinite(p.x) && Number.isFinite(n.x)) drawX = p.x + (n.x - p.x) * alpha;
out.push({ id: n.id, kind: n.kind, drawX, y: n.y });
}
return out;
}
function pushGauntletObsRenderFrame(newObs) {
const now = performance.now();
const next = cloneGauntletObsSnap(newObs);
if (!gauntletObsRenderNext.length) {
gauntletObsRenderPrev = next.slice();
gauntletObsRenderNext = next.slice();
} else {
const curDraw = getGauntletObsDrawPositionsAt(now);
const curMap = new Map(curDraw.map((o) => [o.id, o.drawX]));
gauntletObsRenderPrev = next.map((n) => ({
id: n.id,
kind: n.kind,
x: curMap.has(n.id) ? curMap.get(n.id) : n.x,
y: n.y,
}));
gauntletObsRenderNext = next;
}
gauntletObsBlendT0 = now;
}
function lerpGauntletEntityPos(x, y, tx, ty) {
let nx = x;
let ny = y;
if (tx != null && Number.isFinite(tx)) {
nx += (tx - nx) * GAUNTLET_VIS_LERP;
if (Math.abs(tx - nx) < 0.02) nx = tx;
}
if (ty != null && Number.isFinite(ty)) {
ny += (ty - ny) * GAUNTLET_VIS_LERP;
if (Math.abs(ty - ny) < 0.02) ny = ty;
}
return { nx, ny };
}
/**
* บอท preview ใน Gauntlet: ตัดสินใจกระโดด (client-only; เซิร์ฟเวอร์ไม่รู้จักบอท)
* — ตอบสนองสิ่งกีดขวางที่ชนเซลล์เดียวกัน + กระโดดล่วงหน้าเมื่อ threat อยู่คอลัมน์ถัดไป
*/
function maybePreviewBotGauntletJump(o, obstacles, w, h) {
let px = Math.floor(Number(o.x)) || 0;
let py = Math.floor(Number(o.y)) || 0;
px = Math.max(0, Math.min(w - 1, px));
py = Math.max(0, Math.min(h - 1, py));
if ((o.gauntletJumpTicks || 0) > 0) return;
let sameCell = false;
let incomingCol = false;
for (let i = 0; i < obstacles.length; i++) {
const obs = obstacles[i];
if (!obs) continue;
if (obs.kind === 'lane' && typeof obs.y === 'number') {
if (obs.x === px && obs.y === py) sameCell = true;
if (obs.x === px + 1 && obs.y === py) incomingCol = true;
}
if (obs.kind === 'laser' && typeof obs.x === 'number') {
if (obs.x === px) sameCell = true;
if (obs.x === px + 1) incomingCol = true;
}
}
const tier = o.botTier || 'avg';
const roll = Math.random();
const pSame = tier === 'sharp' ? 0.96 : tier === 'weak' ? 0.62 : 0.86;
const pAhead = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.48 : 0.72;
if (sameCell && roll < pSame) {
o.gauntletJumpTicks = gauntletRuntimeJumpTicks;
return;
}
if (incomingCol && roll < pAhead) o.gauntletJumpTicks = gauntletRuntimeJumpTicks;
}
/** หนึ่ง tick เดียวกับ runGauntletTick ฝั่งเซิร์ฟเวอร์ (เฉพาะ collision + เลื่อน x) */
function applyGauntletPhysicsToPreviewBot(o, obstacles, w, h) {
let px = Math.floor(Number(o.x)) || 0;
let py = Math.floor(Number(o.y)) || 0;
px = Math.max(0, Math.min(w - 1, px));
py = Math.max(0, Math.min(h - 1, py));
const air = (o.gauntletJumpTicks || 0) > 0;
let advanceX = false;
let hitBack = false;
for (let i = 0; i < obstacles.length; i++) {
const ob = obstacles[i];
if (!ob) continue;
if (ob.kind === 'lane' && typeof ob.y === 'number' && ob.x === px && ob.y === py) {
if (air) advanceX = true;
else hitBack = true;
}
if (ob.kind === 'laser' && typeof ob.x === 'number' && ob.x === px) {
if (air) advanceX = true;
else hitBack = true;
}
}
if (advanceX) {
px = Math.min(w - 2, px + 1);
o.gauntletJumpTicks = 0;
o.gauntletScore = (o.gauntletScore || 0) + 1;
} else if (hitBack) {
px = Math.max(0, px - 1);
}
if ((o.gauntletJumpTicks || 0) > 0) o.gauntletJumpTicks--;
o.tx = px;
o.ty = py;
}
function applyGauntletPreviewBotsAfterSync() {
if (!previewFillBots || !mapData || mapData.gameType !== 'gauntlet') return;
const w = mapData.width || 20;
const h = mapData.height || 15;
others.forEach((o, id) => {
if (!isPreviewBotId(id) || !o) return;
maybePreviewBotGauntletJump(o, gauntletObstacles, w, h);
applyGauntletPhysicsToPreviewBot(o, gauntletObstacles, w, h);
});
}
/** แจ้งเซิร์ฟเวอร์แถว y ที่มีคุณ+บอททดสอบ — lane spawn ใช้เฉพาะแถวที่มี peer; บอทไม่ใช่ peer */
function emitGauntletPreviewRowsToServer() {
if (!previewFillBots || !mapData || mapData.gameType !== 'gauntlet') return;
const h = mapData.height || 15;
const ysSet = new Set();
const addY = (v) => {
const fy = Math.floor(Number(v));
if (Number.isFinite(fy) && fy >= 0 && fy < h) ysSet.add(fy);
};
addY(me.y);
others.forEach((o, id) => {
if (isPreviewBotId(id)) addY(o.y);
});
socket.emit('gauntlet-preview-rows', { ys: [...ysSet] });
}
function isLobby() { return mapData && mapData.gameType === 'lobby'; }
function isQuiz() { return mapData && mapData.gameType === 'quiz'; }
function getLane(y) {
if (!mapData || !mapData.lanes) return null;
const lane = mapData.lanes.find(l => l.y === y);
return lane || (mapData.lanes[y] != null ? mapData.lanes[y] : null);
}
function getVehiclePositions(lane, width, timeMs) {
if (!lane || (lane.type !== 'road' && lane.type !== 'water')) return [];
const speed = (lane.speed != null ? lane.speed : 1) * 0.05;
const dir = lane.dir === -1 ? -1 : 1;
const spacing = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5);
const period = width + 2;
const count = Math.max(2, Math.floor(period / spacing));
const positions = [];
for (let i = 0; i < count; i++) {
const p = i * spacing + (timeMs * speed * dir) / 60;
const pNorm = ((p % period) + period) % period;
const vx = pNorm - 1;
positions.push(Math.max(-1, Math.min(width, vx)));
}
return positions;
}
function checkFroggerCollision() {
const fy = Math.floor(me.y);
const lane = getLane(fy);
if (!lane) return false;
if (lane.type === 'road') {
const positions = getVehiclePositions(lane, mapData.width, Date.now());
for (let i = 0; i < positions.length; i++) {
if (Math.abs(positions[i] - me.x) < 1.1) return true;
}
}
if (lane.type === 'water') {
const positions = getVehiclePositions(lane, mapData.width, Date.now());
let onLog = false;
for (let i = 0; i < positions.length; i++) {
if (Math.abs(positions[i] - me.x) < 1.2) { onLog = true; break; }
}
if (!onLog) return true;
}
return false;
}
function respawnFrogger() {
const sp = mapData.spawn || { x: 1, y: mapData.height - 1 };
me.x = sp.x; me.y = sp.y;
socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
}
/** รองรับ x/y เป็น string จาก Socket — ถ้าเช็คแค่ typeof number จะร่วงไป mapData.spawn ทุกครั้ง (จุดเกิดไม่สุ่ม) */
function peerXYFromJoin(peer, spawnFb) {
const sx = Number(spawnFb && spawnFb.x);
const sy = Number(spawnFb && spawnFb.y);
const defX = Number.isFinite(sx) ? sx : 1;
const defY = Number.isFinite(sy) ? sy : 1;
if (!peer) return { x: defX, y: defY };
const x = Number(peer.x);
const y = Number(peer.y);
if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
return { x: defX, y: defY };
}
function applyMapAndStart(gameMapData, res) {
mapData = gameMapData;
if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
if (!mapData.interactive) mapData.interactive = [];
if (mapData.gameType === 'quiz') {
if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
}
tileSize = mapData.tileSize || 32;
mapBackgroundImg = null;
if (mapData.backgroundImage) {
mapBackgroundImg = new Image();
mapBackgroundImg.src = mapData.backgroundImage;
}
var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
: (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
: (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : '')));
var prevTag = previewMode ? '[ทดสอบ] ' : '';
document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
const plist = Array.isArray(res.peers) ? res.peers : [];
const myPeer = plist.find(p => p.id === myId);
const myPos = peerXYFromJoin(myPeer, mapData.spawn);
me.x = myPos.x;
me.y = myPos.y;
me.direction = myPeer?.direction ?? 'down';
me.characterId = myPeer?.characterId || null;
meGauntletJumpTicks = typeof myPeer?.gauntletJumpTicks === 'number' ? myPeer.gauntletJumpTicks : 0;
meGauntletJumpVis = meGauntletJumpTicks;
{
const sc0 = Number(myPeer && myPeer.gauntletScore);
me.gauntletScore = Number.isFinite(sc0) ? Math.max(0, sc0) : 0;
}
gauntletObstacles = [];
gauntletObsRenderPrev = [];
gauntletObsRenderNext = [];
gauntletObsBlendT0 = 0;
me.playTint = pickRandomPlayTint();
others.clear();
plist.forEach(p => {
if (p.id !== myId) {
const pos = peerXYFromJoin(p, mapData.spawn);
others.set(p.id, {
x: pos.x, y: pos.y, tx: pos.x, ty: pos.y, direction: p.direction, nickname: p.nickname, characterId: p.characterId,
playTint: playTintFromPeerId(p.id),
gauntletJumpTicks: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0,
gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0,
gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
});
}
});
if (mapData.gameType === 'gauntlet') {
me.tx = me.x;
me.ty = me.y;
if (res.gauntletEndsAt != null) {
const geJoin = Number(res.gauntletEndsAt);
gauntletEndsAtMs = Number.isFinite(geJoin) ? geJoin : null;
} else {
gauntletEndsAtMs = null;
}
fetch(SERVER + '/api/game-timing')
.then((r) => (r.ok ? r.json() : null))
.then((t) => { if (t) applyGauntletTimingFromServer(t); })
.catch(() => {});
} else {
me.tx = null;
me.ty = null;
gauntletEndsAtMs = null;
}
rebalancePreviewBots();
playPath = [];
me.isWalking = false;
if (isQuiz()) setupPlayQuizUi();
else teardownPlayQuizUi();
resizeCanvas(); draw(); tick();
}
socket.on('connect', () => {
socket.emit('join-space', { spaceId, nickname: nick, characterId: getPlayCharacterId() }, (res) => {
if (!res || !res.ok) {
const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้';
alert(errMsg);
const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg);
if (caseLocked) {
window.location.replace('room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick));
} else {
window.location.replace(BASE + '/lobby.html');
}
return;
}
myId = socket.id;
const playMapId = params.get('map');
if (playMapId) {
fetch(SERVER + '/api/maps/' + encodeURIComponent(playMapId))
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) applyMapAndStart(data, res);
else applyMapAndStart(res.mapData, res);
})
.catch(() => applyMapAndStart(res.mapData, res));
} else {
applyMapAndStart(res.mapData, res);
}
});
});
const LERP = 0.2;
socket.on('user-joined', (data) => {
if (!data || isPreviewBotId(data.id)) return;
const x = Number(data.x);
const y = Number(data.y);
const px = Number.isFinite(x) ? x : 1;
const py = Number.isFinite(y) ? y : 1;
others.set(data.id, {
x: px, y: py, tx: px, ty: py,
direction: data.direction || 'down', nickname: data.nickname,
characterId: data.characterId ?? null,
playTint: playTintFromPeerId(data.id),
gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0,
gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0,
gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
});
if (previewFillBots) rebalancePreviewBots();
});
socket.on('user-move', (data) => {
if (data && myId != null && data.id === myId) {
/* Gauntlet: ตำแหน่งอ้างอิงจาก gauntlet-sync + เลอร์ปเท่านั้น — ไม่งั้น echo จาก move จะสแนป me.x/y โดยไม่อัปเดต tx/ty ทำให้เลอร์ปผิดและชนเซิร์ฟเวอร์ไม่ตรง */
if (mapData && mapData.gameType === 'gauntlet') {
return;
}
if (data.x != null) {
const x = Number(data.x);
if (Number.isFinite(x)) me.x = x;
}
if (data.y != null) {
const y = Number(data.y);
if (Number.isFinite(y)) me.y = y;
}
if (data.direction) me.direction = data.direction;
if (data.characterId != null) me.characterId = data.characterId;
playPath = [];
return;
}
const o = others.get(data.id);
if (o) {
o.tx = data.x;
o.ty = data.y;
o.direction = data.direction || o.direction;
if (data.characterId != null) o.characterId = data.characterId;
}
});
socket.on('user-left', (data) => {
if (!data || isPreviewBotId(data.id)) return;
others.delete(data.id);
if (previewFillBots) rebalancePreviewBots();
});
socket.on('chat', (data) => {
const box = document.getElementById('chat-messages');
if (!box) return;
const div = document.createElement('div');
div.className = 'chat-msg';
div.textContent = (data.nickname || '') + ': ' + (data.text || '');
box.appendChild(div);
box.scrollTop = 1e9;
});
socket.on('quiz-phase', (p) => {
if (previewMode) return;
if (!p || !mapData || !isQuiz()) return;
if (p.text) playQuizText = p.text;
playQuizPhaseLocal = p.phase;
playQuizPhaseEndsAt = p.endsAt || 0;
const phaseEl = document.getElementById('quiz-game-phase-label');
const qEl = document.getElementById('quiz-game-question');
if (phaseEl) {
phaseEl.textContent = p.phase === 'read'
? ('อ่านคำถาม ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || ''))
: ('เดินไปโซน ถูก / ผิด — ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || ''));
}
if (qEl) qEl.textContent = playQuizText || '';
const ov = document.getElementById('quiz-game-overlay');
if (ov) ov.classList.remove('is-hidden');
if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200);
updatePlayQuizTimerDisplay();
if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros();
});
socket.on('quiz-result', (r) => {
if (previewMode || !r) return;
if (r.scores) {
playLiveQuizScores = { ...r.scores };
renderPlayQuizScoreboard(playLiveQuizScores);
}
showPlayQuizFeedback(r);
});
socket.on('quiz-player-state', (st) => {
if (previewMode) return;
if (!st) return;
playQuizPlayerLocal = {
cannotTrue: !!st.cannotTrue,
cannotFalse: !!st.cannotFalse,
eliminated: !!st.eliminated,
score: typeof st.score === 'number' ? st.score : (playQuizPlayerLocal.score || 0),
};
});
socket.on('quiz-ended', () => {
if (previewMode && isQuiz()) return;
playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
playQuizPhaseLocal = null;
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
if (ov) ov.classList.add('is-hidden');
const panel = document.getElementById('quiz-map-question-panel');
if (panel) {
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
}
playQuizText = '';
playQuizPhaseEndsAt = 0;
playLiveQuizScores = {};
hidePlayQuizHud();
});
socket.on('gauntlet-sync', (data) => {
if (!mapData || mapData.gameType !== 'gauntlet' || !data) return;
applyGauntletTimingFromServer(data);
if (Array.isArray(data.obstacles)) {
gauntletObstacles = data.obstacles;
pushGauntletObsRenderFrame(data.obstacles);
}
if (!Array.isArray(data.players)) return;
data.players.forEach((p) => {
if (!p || p.id == null || myId == null) return;
const pid = String(p.id);
const mid = String(myId);
if (pid === mid) {
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) me.tx = px;
if (Number.isFinite(py)) me.ty = py;
if (p.direction) me.direction = p.direction;
const jt = Number(p.gauntletJumpTicks);
meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
const sc = Number(p.gauntletScore);
me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
} else {
const o = others.get(p.id) ?? others.get(pid);
if (o) {
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) o.tx = px;
if (Number.isFinite(py)) o.ty = py;
if (p.direction) o.direction = p.direction;
const jt = Number(p.gauntletJumpTicks);
o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
const sc = Number(p.gauntletScore);
o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
}
}
});
if (previewFillBots) {
applyGauntletPreviewBotsAfterSync();
emitGauntletPreviewRowsToServer();
}
});
socket.on('gauntlet-ended', (data) => {
const ov = document.getElementById('gauntlet-ended-overlay');
const msgEl = document.getElementById('gauntlet-ended-message');
const titleEl = document.getElementById('gauntlet-ended-title');
const listEl = document.getElementById('gauntlet-ended-rankings');
const btn = document.getElementById('btn-gauntlet-ended-lobby');
if (!ov || !msgEl || !listEl) return;
gauntletEndsAtMs = null;
if (titleEl) {
titleEl.textContent = data && data.reason === 'time' ? 'หมดเวลา · Time up' : 'เกมจบ · Game over';
}
msgEl.textContent = (data && data.message) || 'เกมพรมแดงจบแล้ว';
listEl.innerHTML = '';
const ranks = (data && data.rankings) || [];
ranks.forEach((r, i) => {
const li = document.createElement('li');
const isMe = myId != null && r && String(r.id) === String(myId);
li.textContent = `${i + 1}. ${(r && r.nickname) || '—'}${Math.max(0, Number(r && r.score) || 0)}`;
if (isMe) li.className = 'gauntlet-ended-me';
listEl.appendChild(li);
});
ov.classList.remove('is-hidden');
function goLobby() {
window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
}
if (btn) {
btn.onclick = () => {
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
else goLobby();
};
}
});
socket.on('game-start', (ev) => {
if (!ev || !ev.mapId) return;
const applySnap = (md) => {
mapData = md;
if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
tileSize = mapData.tileSize || 32;
gauntletObstacles = [];
gauntletObsRenderPrev = [];
gauntletObsRenderNext = [];
gauntletObsBlendT0 = 0;
mapBackgroundImg = null;
if (mapData.backgroundImage) {
mapBackgroundImg = new Image();
mapBackgroundImg.src = mapData.backgroundImage;
}
if (mapData.gameType === 'gauntlet' && ev.peersSnap && Array.isArray(ev.peersSnap)) {
ev.peersSnap.forEach((p) => {
if (p.id != null && myId != null && String(p.id) === String(myId)) {
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) { me.x = px; me.tx = px; }
if (Number.isFinite(py)) { me.y = py; me.ty = py; }
me.direction = p.direction || me.direction;
const jt = Number(p.gauntletJumpTicks);
meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
meGauntletJumpVis = meGauntletJumpTicks;
const sc = Number(p.gauntletScore);
me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
} else {
const o = others.get(p.id) ?? others.get(String(p.id));
if (o) {
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) { o.x = px; o.tx = px; }
if (Number.isFinite(py)) { o.y = py; o.ty = py; }
o.direction = p.direction || o.direction;
const jt = Number(p.gauntletJumpTicks);
o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
o.gauntletJumpVis = o.gauntletJumpTicks;
const sc = Number(p.gauntletScore);
o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
}
}
});
} else {
meGauntletJumpTicks = 0;
meGauntletJumpVis = 0;
me.gauntletScore = 0;
others.forEach((o) => {
o.gauntletJumpTicks = 0;
o.gauntletJumpVis = 0;
o.gauntletScore = 0;
});
}
if (mapData.gameType === 'gauntlet') {
if (ev.gauntletEndsAt != null) {
const ge = Number(ev.gauntletEndsAt);
gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
} else {
gauntletEndsAtMs = null;
}
fetch(SERVER + '/api/game-timing')
.then((r) => (r.ok ? r.json() : null))
.then((t) => { if (t) applyGauntletTimingFromServer(t); })
.catch(() => {});
} else {
gauntletEndsAtMs = null;
}
if (mapData.gameType !== 'gauntlet') {
me.tx = null;
me.ty = null;
}
const gauntletOv = document.getElementById('gauntlet-ended-overlay');
if (gauntletOv) gauntletOv.classList.add('is-hidden');
var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
: (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
: (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : '')));
var prevTag = previewMode ? '[ทดสอบ] ' : '';
document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
if (isQuiz()) setupPlayQuizUi();
else teardownPlayQuizUi();
resizeCanvas();
if (previewFillBots && mapData.gameType === 'gauntlet') {
applyGauntletPreviewSpawnLayout(true);
emitGauntletPreviewRowsToServer();
}
};
fetch(SERVER + '/api/maps/' + encodeURIComponent(ev.mapId))
.then((r) => (r.ok ? r.json() : null))
.then((md) => { if (md) applySnap(md); });
});
function resizeCanvas() {
// ใช้ขนาดจริงของ element (viewport) เพื่อให้กล้องเลื่อนตามตัวละคร
const vw = window.innerWidth || document.documentElement.clientWidth || 800;
const vh = window.innerHeight || document.documentElement.clientHeight || 600;
const cw = canvas.clientWidth || 0;
const ch = canvas.clientHeight || 0;
canvas.width = Math.max(320, cw || vw);
canvas.height = Math.max(240, ch || vh);
}
// ให้ scroll wheel ใช้ซูมเข้า/ออกแบบง่าย ๆ
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.1 : 0.9;
zoom = Math.max(0.7, Math.min(2.5, zoom * factor));
}, { passive: false });
canvas.addEventListener('dblclick', (e) => {
if (!mapData || isFrogger() || isGauntlet() || isChatFocused()) return;
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left;
const sy = e.clientY - r.top;
const camX = me.x * tileSize;
const camY = me.y * tileSize;
const gx = (sx - canvas.width / 2) / zoom + camX;
const gy = (sy - canvas.height / 2) / zoom + camY;
const tx = Math.floor(gx / tileSize);
const ty = Math.floor(gy / tileSize);
const mw = mapData.width || 20, mh = mapData.height || 15;
if (tx < 0 || tx >= mw || ty < 0 || ty >= mh) return;
if (!canWalkLikeLobby(tx + 0.5, ty + 0.5)) return;
const path = pathfindPlay(me.x, me.y, tx + 0.5, ty + 0.5);
if (path.length <= 1) return;
playPath = path.slice(1);
});
function quizTilesFootprintPlay(px, py) {
const s = new Set();
if (!mapData) return s;
const cells = Math.max(1, Math.min(4, mapData.characterCells || 1));
const w = mapData.width || 20, h = mapData.height || 15;
const minTx = Math.floor(px);
const minTy = Math.floor(py);
const maxTx = Math.min(w - 1, minTx + cells - 1);
const maxTy = Math.min(h - 1, minTy + cells - 1);
for (let ty = minTy; ty <= maxTy; ty++) {
for (let tx = minTx; tx <= maxTx; tx++) {
if (tx >= 0 && ty >= 0) s.add(tx + ',' + ty);
}
}
return s;
}
function quizAnswerTileForbiddenForLock(lock, tx, ty) {
if (!lock || lock.eliminated) return false;
if (!mapData) return false;
const qt = mapData.quizTrueArea;
const qf = mapData.quizFalseArea;
if (lock.cannotTrue && qt && qt[ty] && qt[ty][tx] === 1) return true;
if (lock.cannotFalse && qf && qf[ty] && qf[ty][tx] === 1) return true;
return false;
}
function quizAnswerTileForbiddenPlay(tx, ty) {
if (!playQuizPlayerLocal) return false;
return quizAnswerTileForbiddenForLock({
cannotTrue: !!playQuizPlayerLocal.cannotTrue,
cannotFalse: !!playQuizPlayerLocal.cannotFalse,
eliminated: !!playQuizPlayerLocal.eliminated,
}, tx, ty);
}
/** ยืนทับโซนต้องห้ามเมื่อโดนล็อก — ใช้กับ pathfind ปลายทาง / คลิก */
function quizLockFootprintBlocksForLock(lock, px, py) {
if (!mapData || !isQuiz() || !lock || lock.eliminated) return false;
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const txi = +p[0], tyi = +p[1];
if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true;
}
return false;
}
function quizLockFootprintBlocksPlay(px, py) {
if (!playQuizPlayerLocal) return false;
return quizLockFootprintBlocksForLock({
cannotTrue: !!playQuizPlayerLocal.cannotTrue,
cannotFalse: !!playQuizPlayerLocal.cannotFalse,
eliminated: !!playQuizPlayerLocal.eliminated,
}, px, py);
}
/** บล็อกเฉพาะการ «เข้า» ช่องตอบใหม่ — ให้เดินออกจากโซนได้ถ้าเคยยืนผิดแล้ว */
function quizLockWouldEnterForbiddenForLock(lock, ox, oy, nx, ny) {
if (!mapData || !isQuiz() || !lock || lock.eliminated) return false;
const fromS = quizTilesFootprintPlay(ox, oy);
const toS = quizTilesFootprintPlay(nx, ny);
for (const k of toS) {
if (fromS.has(k)) continue;
const p = k.split(',');
const txi = +p[0], tyi = +p[1];
if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true;
}
return false;
}
function quizLockWouldEnterForbiddenPlay(ox, oy, nx, ny) {
if (!playQuizPlayerLocal) return false;
return quizLockWouldEnterForbiddenForLock({
cannotTrue: !!playQuizPlayerLocal.cannotTrue,
cannotFalse: !!playQuizPlayerLocal.cannotFalse,
eliminated: !!playQuizPlayerLocal.eliminated,
}, ox, oy, nx, ny);
}
function botQuizLock(o) {
return {
cannotTrue: !!(o && o.quizCannotTrue),
cannotFalse: !!(o && o.quizCannotFalse),
eliminated: false,
};
}
/** Same walkability as room-lobby `canWalkLobby` (LobbyA / hall). */
function canWalkLikeLobby(x, y, fromX, fromY) {
if (!mapData || !mapData.objects) return false;
const w = mapData.width || 20, h = mapData.height || 15;
const tx = Math.floor(x), ty = Math.floor(y);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const row = mapData.objects[ty];
if (!row || row[tx] === 1) return false;
const bp = mapData.blockPlayer;
if (bp && bp[ty] && bp[ty][tx] === 1) {
for (const [, o] of others) {
if (Math.floor(o.x) === tx && Math.floor(o.y) === ty) return false;
}
}
if (isQuiz() && playQuizPlayerLocal && !playQuizPlayerLocal.eliminated) {
const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
if (hasFrom) {
if (quizLockWouldEnterForbiddenPlay(fromX, fromY, x, y)) return false;
} else if (quizLockFootprintBlocksPlay(x, y)) {
return false;
}
}
return true;
}
function canWalkLikeLobbyForBot(x, y, fromX, fromY, o) {
if (!mapData || !mapData.objects) return false;
const w = mapData.width || 20, h = mapData.height || 15;
const tx = Math.floor(x), ty = Math.floor(y);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const row = mapData.objects[ty];
if (!row || row[tx] === 1) return false;
const bp = mapData.blockPlayer;
if (bp && bp[ty] && bp[ty][tx] === 1) {
for (const [, peer] of others) {
if (o && peer === o) continue;
if (Math.floor(peer.x) === tx && Math.floor(peer.y) === ty) return false;
}
}
if (isQuiz()) {
const lock = botQuizLock(o);
if (lock.cannotTrue || lock.cannotFalse) {
const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
if (hasFrom) {
if (quizLockWouldEnterForbiddenForLock(lock, fromX, fromY, x, y)) return false;
} else if (quizLockFootprintBlocksForLock(lock, x, y)) {
return false;
}
}
}
return true;
}
function stepPreviewBotAlongPath(o, w, h) {
const path = o.botPath;
if (!path || !path.length) return;
const way = path[0];
const dx = way.x - o.x, dy = way.y - o.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= PATH_ARRIVE_THRESH) {
path.shift();
while (path.length > 0) {
const w2 = path[0];
const ux = w2.x - o.x, uy = w2.y - o.y;
if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
path.shift();
}
o.x = Math.max(0, Math.min(w - 0.01, o.x));
o.y = Math.max(0, Math.min(h - 0.01, o.y));
return;
}
const len = dist || 1;
const step = Math.min(MOVE_SPEED * 1.28, len);
const nx = o.x + (dx / len) * step;
const ny = o.y + (dy / len) * step;
if (Math.abs(dy) > Math.abs(dx)) o.direction = dy > 0 ? 'down' : 'up';
else if (Math.abs(dx) > 1e-6) o.direction = dx > 0 ? 'right' : 'left';
const ox = o.x, oy = o.y;
if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
o.x = nx;
o.y = ny;
} else if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
o.x = nx;
} else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
o.y = ny;
}
o.x = Math.max(0, Math.min(w - 0.01, o.x));
o.y = Math.max(0, Math.min(h - 0.01, o.y));
if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
}
function pickRandomPreviewBotWanderDir() {
const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]];
return dirs[Math.floor(Math.random() * dirs.length)];
}
function stepPreviewBots() {
if (!previewFillBots || !mapData || isFrogger() || isGauntlet()) return;
const w = mapData.width || 20, h = mapData.height || 15;
const now = Date.now();
const inAnswerPhase = previewMode && isQuiz() && previewQuizStep === 'answer';
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.botIsWalking = false;
if (inAnswerPhase && o.botPath && o.botPath.length > 0 && !o.botAnswerWander) {
stepPreviewBotAlongPath(o, w, h);
return;
}
if (inAnswerPhase && o.botPath && o.botPath.length === 0 && !o.botAnswerWander) {
return;
}
/* ก่อนตอบ / พัก / บอทสับสน: เดินทุกเฟรมตามทิศ (เหมือนผู้เล่นกดค้าง) — เดิมรอเป็นจังหวะแล้วก้าวทีละครั้งเลยดูกระตุก */
if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
}
if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600;
if (now >= o.botWanderNextTurn) {
o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200);
if (Math.random() < 0.55) {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
}
}
const accX = o.botWanderDx;
const accY = o.botWanderDy;
if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up';
else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left';
const step = MOVE_SPEED;
const nx = o.x + accX * step;
const ny = o.y + accY * step;
const ox = o.x, oy = o.y;
if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
o.x = nx;
o.y = ny;
} else if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
o.x = nx;
} else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
o.y = ny;
} else {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
}
o.x = Math.max(0, Math.min(w - 0.01, o.x));
o.y = Math.max(0, Math.min(h - 0.01, o.y));
if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
});
}
/** A* เหมือน room-lobby — double-click ไปจุดบนแผนที่ */
function pathfindPlay(fromX, fromY, toX, toY) {
if (!mapData) return [];
const w = mapData.width || 20, h = mapData.height || 15;
const fx = Math.floor(fromX), fy = Math.floor(fromY);
const tx = Math.floor(toX), ty = Math.floor(toY);
if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobby(tx + 0.5, ty + 0.5)) return [];
if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
const key = (gx, gy) => gx + ',' + gy;
const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
const closed = new Set();
const cameFrom = {};
const gScore = { [key(fx, fy)]: 0 };
const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
while (open.length) {
open.sort((a, b) => a.f - b.f);
const cur = open.shift();
const ck = key(cur.gx, cur.gy);
if (closed.has(ck)) continue;
closed.add(ck);
if (cur.gx === tx && cur.gy === ty) {
const path = [];
let u = cur;
while (u) {
path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
u = cameFrom[key(u.gx, u.gy)];
}
return path;
}
for (const d of dirs) {
const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
if (!canWalkLikeLobby(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5)) continue;
const nk = key(nx, ny);
if (closed.has(nk)) continue;
const g = (gScore[ck] ?? Infinity) + 1;
if (g >= (gScore[nk] ?? Infinity)) continue;
gScore[nk] = g;
cameFrom[nk] = cur;
open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
}
}
return [];
}
function pathfindPlayForBot(fromX, fromY, toX, toY, o) {
if (!mapData) return [];
const w = mapData.width || 20, h = mapData.height || 15;
const fx = Math.floor(fromX), fy = Math.floor(fromY);
const tx = Math.floor(toX), ty = Math.floor(toY);
if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobbyForBot(tx + 0.5, ty + 0.5, NaN, NaN, o)) return [];
if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
const key = (gx, gy) => gx + ',' + gy;
const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
const closed = new Set();
const cameFrom = {};
const gScore = { [key(fx, fy)]: 0 };
const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
while (open.length) {
open.sort((a, b) => a.f - b.f);
const cur = open.shift();
const ck = key(cur.gx, cur.gy);
if (closed.has(ck)) continue;
closed.add(ck);
if (cur.gx === tx && cur.gy === ty) {
const path = [];
let u = cur;
while (u) {
path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
u = cameFrom[key(u.gx, u.gy)];
}
return path;
}
for (const d of dirs) {
const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
if (!canWalkLikeLobbyForBot(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5, o)) continue;
const nk = key(nx, ny);
if (closed.has(nk)) continue;
const g = (gScore[ck] ?? Infinity) + 1;
if (g >= (gScore[nk] ?? Infinity)) continue;
gScore[nk] = g;
cameFrom[nk] = cur;
open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
}
}
return [];
}
let playPath = [];
function drawGauntletLaserColumnScreen(rx, ry, rw, rh) {
ctx.save();
ctx.fillStyle = gauntletLaserFillColor;
ctx.fillRect(rx, ry, rw, rh);
const topRec = gauntletLaserTopUrl ? ensureGauntletAssetImage(gauntletLaserTopUrl) : null;
const botRec = gauntletLaserBottomUrl ? ensureGauntletAssetImage(gauntletLaserBottomUrl) : null;
const lineRec = gauntletLaserLineUrl ? ensureGauntletAssetImage(gauntletLaserLineUrl) : null;
let topH = 0;
let botH = 0;
if (topRec && topRec.img.complete && topRec.img.naturalWidth > 0) {
topH = Math.min(rh * 0.4, rw * topRec.img.naturalHeight / topRec.img.naturalWidth);
try { ctx.drawImage(topRec.img, rx, ry, rw, topH); } catch (e) { /* ignore */ }
}
if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) {
botH = Math.min(rh * 0.4, rw * botRec.img.naturalHeight / botRec.img.naturalWidth);
try { ctx.drawImage(botRec.img, rx, ry + rh - botH, rw, botH); } catch (e) { /* ignore */ }
}
const midTop = ry + topH;
const midH = Math.max(0, rh - topH - botH);
if (lineRec && lineRec.img.complete && lineRec.img.naturalWidth > 0 && midH > 1) {
const iw = lineRec.img.naturalWidth;
const ih = lineRec.img.naturalHeight;
const scale = rw / iw;
const step = Math.max(1, ih * scale);
let y = midTop;
while (y < midTop + midH) {
const piece = Math.min(step, midTop + midH - y);
const srcH = piece / scale;
try {
ctx.drawImage(lineRec.img, 0, 0, iw, srcH, rx, y, rw, piece);
} catch (e) { /* ignore */ }
y += piece;
}
}
const lw = Number(gauntletLaserLineWidthPx) || 0;
if (lw > 0) {
ctx.strokeStyle = gauntletLaserStrokeColor;
ctx.lineWidth = lw;
ctx.strokeRect(rx + lw / 2, ry + lw / 2, rw - lw, rh - lw);
}
ctx.restore();
}
function draw() {
if (!mapData) return;
const w = mapData.width, h = mapData.height;
// ศูนย์กลางกล้องคือผู้เล่น
const camX = me.x * tileSize;
const camY = me.y * tileSize;
const halfW = canvas.width / (2 * zoom);
const halfH = canvas.height / (2 * zoom);
// world bounds ที่กล้องมองเห็น (เป็นพิกัดพิกเซลของ map)
const worldMinX = camX - halfW;
const worldMaxX = camX + halfW;
const worldMinY = camY - halfH;
const worldMaxY = camY + halfH;
const mapWpx = w * tileSize, mapHpx = h * tileSize;
const visibleW = worldMaxX - worldMinX, visibleH = worldMaxY - worldMinY;
const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false';
const timeMs = Date.now();
if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) {
ctx.drawImage(mapBackgroundImg,
(worldMinX / mapWpx) * mapBackgroundImg.naturalWidth, (worldMinY / mapHpx) * mapBackgroundImg.naturalHeight,
(visibleW / mapWpx) * mapBackgroundImg.naturalWidth, (visibleH / mapHpx) * mapBackgroundImg.naturalHeight,
0, 0, canvas.width, canvas.height);
} else {
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
function worldToScreen(wx, wy) {
const sx = (wx - camX) * zoom + canvas.width / 2;
const sy = (wy - camY) * zoom + canvas.height / 2;
return [sx, sy];
}
if (showGrid) {
const startTileX = Math.max(0, Math.floor(worldMinX / tileSize));
const endTileX = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
const startTileY = Math.max(0, Math.floor(worldMinY / tileSize));
const endTileY = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
for (let y = startTileY; y <= endTileY; y++) {
const lane = isFrogger() ? getLane(y) : null;
let rowFill = null;
if (lane) {
if (lane.type === 'goal') rowFill = 'rgba(158,206,106,0.4)';
else if (lane.type === 'spawn') rowFill = 'rgba(187,154,247,0.35)';
else if (lane.type === 'road') rowFill = 'rgba(80,70,60,0.6)';
else if (lane.type === 'water') rowFill = 'rgba(125,207,255,0.5)';
} else if (isGauntlet()) {
rowFill = (y % 2 === 0) ? 'rgba(247,118,190,0.08)' : 'rgba(180,90,140,0.06)';
}
for (let x = startTileX; x <= endTileX; x++) {
const wx = x * tileSize, wy = y * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const size = tileSize * zoom;
const ob = mapData.objects?.[y]?.[x] ?? 0;
if (ob === 1) {
ctx.fillStyle = 'rgba(65,72,104,0.92)';
ctx.fillRect(sx, sy, size, size);
ctx.strokeStyle = '#565f89';
ctx.strokeRect(sx, sy, size, size);
} else {
const cellColor = showGrid && mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x];
if (cellColor) { ctx.fillStyle = cellColor; ctx.fillRect(sx, sy, size, size); }
else if (rowFill) { ctx.fillStyle = rowFill; ctx.fillRect(sx, sy, size, size); }
else if (!mapBackgroundImg || !mapBackgroundImg.complete) {
ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335';
ctx.fillRect(sx, sy, size, size);
}
}
const isInter = mapData.interactive && mapData.interactive[y] && mapData.interactive[y][x] === 1;
if (isInter) {
ctx.fillStyle = 'rgba(158,206,106,0.35)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(158,206,106,0.8)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
if (isQuiz()) {
const isQuizQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
if (isQuizQ) {
ctx.fillStyle = 'rgba(255, 214, 102, 0.32)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
const isQuizT = mapData.quizTrueArea && mapData.quizTrueArea[y] && mapData.quizTrueArea[y][x] === 1;
if (isQuizT) {
ctx.fillStyle = 'rgba(86, 202, 255, 0.38)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
const isQuizF = mapData.quizFalseArea && mapData.quizFalseArea[y] && mapData.quizFalseArea[y][x] === 1;
if (isQuizF) {
ctx.fillStyle = 'rgba(247, 118, 190, 0.38)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
}
}
}
if (isFrogger() && mapData.lanes) {
for (let y = startTileY; y <= endTileY; y++) {
const lane = getLane(y);
if (!lane || (lane.type !== 'road' && lane.type !== 'water')) continue;
const positions = getVehiclePositions(lane, mapData.width, timeMs);
const isRoad = lane.type === 'road';
for (let i = 0; i < positions.length; i++) {
const vx = positions[i];
const wx = vx * tileSize, wy = y * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const size = tileSize * zoom * (isRoad ? 1.2 : 1.5);
ctx.fillStyle = isRoad ? '#e0a060' : '#8b7355';
ctx.fillRect(sx, sy, size, size * 0.7);
ctx.strokeStyle = isRoad ? '#c0caf5' : '#9ece6a';
ctx.strokeRect(sx, sy, size, size * 0.7);
}
}
}
}
const gauntletObsDraw = isGauntlet() ? getGauntletObsDrawPositionsAt(performance.now()) : [];
if (isGauntlet() && gauntletObsDraw.length) {
const stx = Math.max(0, Math.floor(worldMinX / tileSize));
const enx = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
const sty = Math.max(0, Math.floor(worldMinY / tileSize));
const eny = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
for (let i = 0; i < gauntletObsDraw.length; i++) {
const o = gauntletObsDraw[i];
if (!o) continue;
if (o.kind === 'lane' && typeof o.y === 'number') {
if (o.drawX < stx - 2 || o.drawX > enx + 2 || o.y < sty || o.y > eny) continue;
const wx = o.drawX * tileSize, wy = o.y * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const size = tileSize * zoom;
const laneRec = pickGauntletLaneImageRec(o.id);
if (laneRec && laneRec.img.complete && laneRec.img.naturalWidth > 0) {
try {
ctx.drawImage(laneRec.img, sx + 2, sy + 2, size - 4, size - 4);
} catch (e) {
ctx.fillStyle = '#f7768e';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
}
ctx.strokeStyle = '#ff9ebc';
ctx.lineWidth = 2;
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.lineWidth = 1;
} else {
ctx.fillStyle = '#f7768e';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = '#ff9ebc';
ctx.lineWidth = 2;
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.lineWidth = 1;
}
} else if (o.kind === 'laser' && typeof o.drawX === 'number') {
if (o.drawX < stx - 2 || o.drawX > enx + 2) continue;
const wx0 = o.drawX * tileSize;
const wx1 = (o.drawX + 1) * tileSize;
const wy0 = 0;
const wy1 = h * tileSize;
const [sx0, syTop] = worldToScreen(wx0, wy0);
const [sx1, syBot] = worldToScreen(wx1, wy1);
const rx = Math.min(sx0, sx1);
const rw = Math.max(2, Math.abs(sx1 - sx0));
const ry = Math.min(syTop, syBot);
const rh = Math.abs(syBot - syTop);
drawGauntletLaserColumnScreen(rx, ry, rw, rh);
}
}
}
function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel) {
const cells = Math.max(1, Math.min(4, mapData.characterCells || 1));
const air = Number(gauntletAirTicks) || 0;
let liftWorldY = 0;
if (isGauntlet() && air > 0) {
liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
}
const cxWorld = (ax + cells * 0.5) * tileSize;
const cyBottomWorld = (ay + 1) * tileSize - liftWorldY;
const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
const r = Math.max(14, (tileSize * zoom * cells) / 2 - 2);
const size = r * 2.2;
const dir = direction || 'down';
const rawImg = getAvatarImg(characterId, dir, timeMs, isWalking);
const charImg = playTint
? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, playTint)
: rawImg;
/* รองรับทั้ง <img> และ canvas จากย้อมสี (canvas ไม่มี naturalWidth → เดิมตกวงกลม) */
let iw = 0, ih = 0;
if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
iw = charImg.width;
ih = charImg.height;
} else if (charImg && charImg.complete && charImg.naturalWidth) {
iw = charImg.naturalWidth;
ih = charImg.naturalHeight;
}
if (iw > 0 && ih > 0) {
const scale = Math.min(size / iw, size / ih, 1);
const drawW = iw * scale;
const drawH = ih * scale;
ctx.drawImage(charImg, 0, 0, iw, ih, sx - drawW / 2, sy - drawH, drawW, drawH);
} else {
const cy = sy - r;
ctx.fillStyle = isMe ? '#7aa2f7' : '#9ece6a';
ctx.beginPath();
ctx.arc(sx, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#c0caf5';
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.fillStyle = '#c0caf5';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
const nameBase = name || '';
const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0
? (nameBase + ' · ' + gauntletScoreLabel)
: nameBase;
ctx.fillText(withScore, sx, sy + 10);
}
const safeX = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
const safeY = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
function peerVisualOffset(id) {
let h = 0;
for (let i = 0; i < (id || '').length; i++) h = (h * 31 + (id || '').charCodeAt(i)) >>> 0;
return { ax: ((h % 5) - 2) * 0.1, ay: ((Math.floor(h / 5) % 5) - 2) * 0.1 };
}
const othersSorted = [...others.entries()].sort((a, b) => {
const oa = a[1], ob = b[1];
const ya = safeY(oa.y), yb = safeY(ob.y);
if (Math.abs(ya - yb) > 0.01) return ya - yb;
return safeX(oa.x) - safeX(ob.x);
});
othersSorted.forEach(([id, o]) => {
const off = isGauntlet() ? { ax: 0, ay: 0 } : peerVisualOffset(id);
const otherWalk = isGauntlet()
? ((o.gauntletJumpTicks || 0) > 0 || (o.gauntletJumpVis || 0) > 0.08)
: (isPreviewBotId(id)
? !!o.botIsWalking
: !!((o.tx != null && Math.abs((o.tx || o.x) - o.x) > 0.02) || (o.ty != null && Math.abs((o.ty || o.y) - o.y) > 0.02)));
const ot = o.playTint || playTintFromPeerId(id);
drawAvatar(safeX(o.x) + off.ax, safeY(o.y) + off.ay, false, o.nickname, o.characterId, o.direction, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0);
});
const mt = me.playTint || pickRandomPlayTint();
if (!me.playTint) me.playTint = mt;
const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : ' (คุณ)');
const meWalking = isFrogger() ? false : (isGauntlet() ? (meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08) : !!me.isWalking);
drawAvatar(safeX(me.x), safeY(me.y), true, me.nickname + meTag, me.characterId, me.direction, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0);
if (isFrogger()) {
ctx.fillStyle = '#7aa2f7';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('โหมดกบ | คะแนน: ' + froggerScore, 10, 24);
}
if (isGauntlet()) {
ctx.fillStyle = '#f7768e';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('พรมแดงสุดท้าย | คะแนน: ' + (me.gauntletScore || 0) + ' (ข้าม 1 ชิ้น +1) · สูงสุด 6 คน', 10, 24);
ctx.font = '12px sans-serif';
ctx.fillStyle = '#c0caf5';
ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · ข้ามสำเร็จ → ขวา 1 + คะแนน · ชน → ซ้าย 1', 10, 42);
ctx.fillStyle = '#e0af68';
if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
const mm = Math.floor(rem / 60);
const ss = rem % 60;
const clock = `${mm}:${String(ss).padStart(2, '0')}`;
ctx.fillText(`เหลือเวลา ${clock} · Time left ${clock}`, 10, 60);
} else if (gauntletRuntimeTimeLimitSec > 0) {
ctx.fillText('เวลา: กำลังซิงก์จากเซิร์ฟเวอร์... · Syncing timer...', 10, 60);
} else {
ctx.fillText('เวลา: ไม่จำกัด · No time limit (ตั้งได้ที่ Admin → เวลาเกม)', 10, 60);
}
}
if (isQuiz()) syncPlayQuizMapPanel();
if (previewFillBots && mapData) {
const human = countPlayHumans();
const bots = [...others.keys()].filter(isPreviewBotId).length;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = 'rgba(26,27,38,0.78)';
ctx.fillRect(6, canvas.height - 38, Math.min(canvas.width - 12, 320), 30);
ctx.fillStyle = '#a9b1d6';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + previewTargetHeadcount + ' คน)', 12, canvas.height - 18);
ctx.restore();
}
}
document.addEventListener('keydown', (e) => {
if (isMovementKey(e.code) && isChatFocused()) return;
if (isGauntlet() && mapData && !isChatFocused()) {
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
e.preventDefault();
const now = Date.now();
if (now - lastGauntletJumpKey < 200) return;
lastGauntletJumpKey = now;
socket.emit('gauntlet-jump');
return;
}
if (['ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
return;
}
}
keys[e.code] = true;
keys[e.key] = true;
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
if (isFrogger() && mapData && !isChatFocused()) {
const now = Date.now();
if (now - lastFroggerKey < 280) return;
let dx = 0, dy = 0;
if (e.code === 'ArrowUp' || e.code === 'KeyW') { dy = -1; me.direction = 'up'; }
else if (e.code === 'ArrowDown' || e.code === 'KeyS') { dy = 1; me.direction = 'down'; }
else if (e.code === 'ArrowLeft' || e.code === 'KeyA') { dx = -1; me.direction = 'left'; }
else if (e.code === 'ArrowRight' || e.code === 'KeyD') { dx = 1; me.direction = 'right'; }
if (dx !== 0 || dy !== 0) {
const nx = Math.round(me.x) + dx, ny = Math.round(me.y) + dy;
if (nx >= 0 && nx < mapData.width && ny >= 0 && ny < mapData.height) {
const row = mapData.objects && mapData.objects[ny];
if (!row || row[nx] !== 1) {
me.x = nx; me.y = ny;
lastFroggerKey = now;
socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
const lane = getLane(Math.floor(me.y));
if (lane && lane.type === 'goal') { froggerScore++; respawnFrogger(); }
else if (checkFroggerCollision()) respawnFrogger();
}
}
}
}
});
document.addEventListener('keyup', (e) => { keys[e.code] = false; keys[e.key] = false; });
let lastSend = 0;
function tick() {
if (!mapData) { requestAnimationFrame(tick); return; }
if (isFrogger()) {
if (checkFroggerCollision()) respawnFrogger();
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
if (isGauntlet()) {
me.isWalking = meGauntletJumpTicks > 0;
const stepGauntletJumpVis = (vis, tgt) => {
const t = Number(tgt) || 0;
let v = Number(vis) || 0;
const d = t - v;
const k = d < 0 ? 0.68 : 0.4;
return v + d * k;
};
meGauntletJumpVis = stepGauntletJumpVis(meGauntletJumpVis, meGauntletJumpTicks);
const mp = lerpGauntletEntityPos(me.x, me.y, me.tx, me.ty);
me.x = mp.nx;
me.y = mp.ny;
others.forEach((o) => {
const op = lerpGauntletEntityPos(o.x, o.y, o.tx, o.ty);
o.x = op.nx;
o.y = op.ny;
if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks || 0;
o.gauntletJumpVis = stepGauntletJumpVis(o.gauntletJumpVis, o.gauntletJumpTicks || 0);
});
draw();
requestAnimationFrame(tick);
return;
}
/* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */
others.forEach((o, id) => {
if (previewFillBots && isPreviewBotId(id)) return;
if (o.tx != null) o.x += (o.tx - o.x) * LERP;
if (o.ty != null) o.y += (o.ty - o.y) * LERP;
});
stepPreviewBots();
if (isChatFocused()) {
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
const w = mapData.width || 20, h = mapData.height || 15;
let accX = 0, accY = 0;
let usePath = false;
const keyPressed = keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD'];
if (playPath.length > 0 && keyPressed) playPath = [];
if (playPath.length > 0) {
const way = playPath[0];
const dx = way.x - me.x, dy = way.y - me.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= PATH_ARRIVE_THRESH) {
playPath.shift();
/* ข้าม waypoint ที่ซ้ำตำแหน่ง — กัน acc=0 ทั้งที่ยังมี path (เดินขึ้น/ลงแล้วแอนิเมชันค้าง) */
while (playPath.length > 0) {
const w2 = playPath[0];
const ux = w2.x - me.x, uy = w2.y - me.y;
if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
playPath.shift();
}
if (playPath.length === 0) {
me.isWalking = false;
me.x = Math.max(0, Math.min(w - 0.01, me.x));
me.y = Math.max(0, Math.min(h - 0.01, me.y));
const t = Date.now();
if (t - lastSend > 80) { lastSend = t; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); }
draw();
requestAnimationFrame(tick);
return;
}
usePath = true;
const next = playPath[0];
accX = next.x - me.x;
accY = next.y - me.y;
} else {
usePath = true;
accX = dx;
accY = dy;
}
if (usePath && (accX !== 0 || accY !== 0)) {
if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up';
else me.direction = accX > 0 ? 'right' : 'left';
}
}
if (!usePath) {
if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; }
if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; }
if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; }
if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; }
}
const preWalkX = me.x, preWalkY = me.y;
if (accX !== 0 || accY !== 0) {
const len = Math.sqrt(accX * accX + accY * accY) || 1;
const step = Math.min(MOVE_SPEED, len);
const nx = me.x + (accX / len) * step;
const ny = me.y + (accY / len) * step;
if (canWalkLikeLobby(nx, ny, me.x, me.y)) {
me.x = nx;
me.y = ny;
} else if (canWalkLikeLobby(nx, me.y, me.x, me.y)) {
me.x = nx;
} else if (canWalkLikeLobby(me.x, ny, me.x, me.y)) {
me.y = ny;
}
}
me.x = Math.max(0, Math.min(w - 0.01, me.x));
me.y = Math.max(0, Math.min(h - 0.01, me.y));
const movedThisTick = Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5;
me.isWalking = !!(accX !== 0 || accY !== 0) || playPath.length > 0 || movedThisTick;
const now = Date.now();
if (now - lastSend > 80) { lastSend = now; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); }
draw();
requestAnimationFrame(tick);
}
const chatForm = document.getElementById('chat-form');
if (chatForm) {
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const input = document.getElementById('chat-input');
const text = (input && input.value || '').trim();
if (text) { socket.emit('chat', text); if (input) input.value = ''; }
});
}
document.getElementById('btn-leave').addEventListener('click', () => {
socket.emit('leave-space');
const mid = params.get('map');
if (previewMode && mid) {
var edQ = '?id=' + encodeURIComponent(mid) + (editorEmbedReturn ? '&embed=1' : '');
window.location.replace(BASE + '/editor.html' + edQ);
} else {
window.location.replace(BASE + '/lobby.html');
}
});
window.addEventListener('beforeunload', () => socket.emit('leave-space'));
window.addEventListener('resize', () => {
if (!mapData || !canvas) return;
resizeCanvas();
draw();
});
})();