2809 lines
117 KiB
JavaScript
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();
|
|
});
|
|
})();
|