7768 lines
318 KiB
JavaScript
7768 lines
318 KiB
JavaScript
(function () {
|
||
const BASE = '/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';
|
||
/** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */
|
||
const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest';
|
||
let firstCharacterDefaultResolved = null;
|
||
const firstCharacterDefaultPromise = fetch(BASE + '/api/characters')
|
||
.then((r) => r.json())
|
||
.then((list) => {
|
||
if (Array.isArray(list) && list.length > 0 && list[0] && list[0].id) {
|
||
firstCharacterDefaultResolved = String(list[0].id);
|
||
} else {
|
||
firstCharacterDefaultResolved = '';
|
||
}
|
||
})
|
||
.catch(() => {
|
||
firstCharacterDefaultResolved = '';
|
||
});
|
||
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;
|
||
/** Stack preview: บอทไม่วาบบนแผนที่ — ใช้ไฮไลต์แถบแข่งแทน */
|
||
let lastStackPreviewActorId = null;
|
||
let lastStackPreviewActorUntil = 0;
|
||
/** Stack preview: สลับตา Player 1 = มนุษย์, 2–6 = บอท (เฉพาะตาปัจจุบันเล่นได้) */
|
||
const STACK_PREVIEW_TURN_COUNT = 6;
|
||
let stackPreviewTurnOrder = null;
|
||
let stackPreviewTurnIndex = 0;
|
||
let stackPreviewBotThinkUntil = 0;
|
||
|
||
function rebuildStackPreviewTurnOrder() {
|
||
if (!previewFillBots || !mapData || mapData.gameType !== 'stack') {
|
||
stackPreviewTurnOrder = null;
|
||
return;
|
||
}
|
||
const bots = [...others.keys()].filter(isPreviewBotId).sort();
|
||
stackPreviewTurnOrder = [{ kind: 'human', seat: 1 }];
|
||
for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
|
||
stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
|
||
}
|
||
stackPreviewTurnIndex = 0;
|
||
stackPreviewBotThinkUntil = 0;
|
||
}
|
||
|
||
function advanceStackPreviewTurn() {
|
||
if (!previewFillBots || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return;
|
||
stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % STACK_PREVIEW_TURN_COUNT;
|
||
stackPreviewBotThinkUntil = 0;
|
||
}
|
||
|
||
function isStackPreviewHumanTurn() {
|
||
if (!previewFillBots || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return true;
|
||
const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
|
||
return !!(cur && cur.kind === 'human');
|
||
}
|
||
|
||
function getStackPreviewCurrentSeat() {
|
||
if (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return 1;
|
||
const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
|
||
return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1;
|
||
}
|
||
if (!spaceId) { window.location.replace(BASE + '/lobby.html'); return; }
|
||
|
||
const socket = io({ path: BASE + '/socket.io' });
|
||
const canvas = document.getElementById('game-canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
let mapData = null, tileSize = 32, myId = null;
|
||
let stackMini = null;
|
||
let stackFall = null;
|
||
let lastStackTickMs = performance.now();
|
||
let mapBackgroundImg = null;
|
||
let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, tx: null, ty: null, quizCarryHeld: null };
|
||
const others = new Map();
|
||
/** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */
|
||
const STACK_PREVIEW_HUD_LOG_MAX = 8;
|
||
let stackPreviewHudLog = [];
|
||
function stackPreviewPushHudLog(line) {
|
||
if (!previewFillBots) return;
|
||
stackPreviewHudLog.push(String(line || '').slice(0, 96));
|
||
while (stackPreviewHudLog.length > STACK_PREVIEW_HUD_LOG_MAX) stackPreviewHudLog.shift();
|
||
}
|
||
function stackPreviewLogStackDrop(hit, actor) {
|
||
if (!previewFillBots || !hit) return;
|
||
const bid = actor && actor.botId;
|
||
let nm = 'NODE';
|
||
if (actor && actor.human) nm = (me.nickname || 'YOU').toUpperCase().replace(/\s+/g, '_').slice(0, 12);
|
||
else if (bid && others.get(bid)) nm = String(others.get(bid).nickname || 'BOT').toUpperCase().replace(/\s+/g, '_').slice(0, 12);
|
||
if (hit.miss) stackPreviewPushHudLog(`>>> ${nm}: SECTOR_MISS`);
|
||
else if (hit.perfect) stackPreviewPushHudLog(`>>> ${nm}: PERFECT +${hit.pts} SYN`);
|
||
else stackPreviewPushHudLog(`>>> ${nm}: LOCK +${hit.pts}`);
|
||
}
|
||
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(BASE + '/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 = BASE + '/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 = BASE + '/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 = BASE + '/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 = BASE + '/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;
|
||
}
|
||
function getStoredCharacterId() {
|
||
try {
|
||
const v = localStorage.getItem('gameCharacterId');
|
||
if (v) return v;
|
||
} catch (e) { /* ignore */ }
|
||
if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID;
|
||
return firstCharacterDefaultResolved || '';
|
||
}
|
||
function getPlayCharacterId() {
|
||
if (forceDefaultCharacter) {
|
||
if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID;
|
||
return firstCharacterDefaultResolved || '';
|
||
}
|
||
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;
|
||
/** Stack: รอบสวิงต่อวินาที — จาก GET /api/game-timing · ค่าน้อย = สวิงช้า */
|
||
let playStackSwingHz = 0.55;
|
||
/** Stack: ความกว้างบล็อก (tile) — null = คำนวณจากโซนลงบนแผนที่ */
|
||
let playStackBlockWidthTiles = null;
|
||
/** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */
|
||
let playJumpSurviveJumpHeightMult = 1.5;
|
||
/** 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 = {};
|
||
/** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */
|
||
let quizCarryPool = [];
|
||
let quizCarryCurrent = null;
|
||
/** editor embed + preview: นับ 3–2–1 ก่อนโชว์คำถามและให้เดิน/หยิบได้ */
|
||
let quizCarryEmbedPendingQuestion = null;
|
||
let quizCarryEmbedCountdownStartAt = 0;
|
||
let quizCarryEmbedCountdownEndAt = 0;
|
||
/** quiz_carry: epoch ให้หยิบตัวเลือกได้ · epoch ปิดรอบตอบ (ตั้งที่ Admin แท็บหยิบมาวาง) */
|
||
let quizCarryOptionRevealAt = 0;
|
||
let quizCarryAnswerCloseAt = 0;
|
||
let quizCarryCarryTimingMs = { carryReadMs: 3000, carryAnswerMs: 5000 };
|
||
|
||
/** jump_survive — กล้องตามตัวในกรอบ + แพลตฟอร์มเลื่อนขึ้น (scroll) ไม่ลากทั้งฉาก */
|
||
let jumpSurviveCamCenterX = 0;
|
||
let jumpSurviveCamCenterY = 0;
|
||
let jumpSurviveEliminated = false;
|
||
/** จับเวลาโหมดกระโดดให้รอด (วินาทีบน HUD) */
|
||
let jumpSurviveSessionStartMs = 0;
|
||
let jumpSurvivePlatformScrollPx = 0;
|
||
let jumpSurviveVy = 0;
|
||
let jumpSurviveLastTickMs = 0;
|
||
let jumpSurviveJumpQueued = false;
|
||
let jumpSurviveOnGround = false;
|
||
let jumpSurviveLastEmitT = 0;
|
||
|
||
/** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */
|
||
let spaceShooterBullets = [];
|
||
let spaceShooterAsteroids = [];
|
||
let spaceShooterPopups = [];
|
||
let spaceShooterLastTickMs = 0;
|
||
let spaceShooterSpawnAccMs = 0;
|
||
let spaceShooterFireCd = 0;
|
||
let spaceShooterSessionStartMs = 0;
|
||
let spaceShooterLastMoveEmit = 0;
|
||
let spaceShooterGameEnded = false;
|
||
|
||
/** balloon_boss — ลูกโป้งยิงบอส (Mega Virus) */
|
||
let balloonBossPendingShots = [];
|
||
let balloonBossPlayerBullets = [];
|
||
let balloonBossBossBullets = [];
|
||
let balloonBossSessionStartMs = 0;
|
||
let balloonBossLastTickMs = 0;
|
||
let balloonBossLastMoveEmit = 0;
|
||
let balloonBossPlayerFireCd = 0;
|
||
let balloonBossGameEnded = false;
|
||
let balloonBossHitFx = [];
|
||
let balloonBossBossFireAcc = 0;
|
||
/** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */
|
||
let quizBattleMcqPool = [];
|
||
let quizBattleAnsweredComps = new Set();
|
||
let quizBattleModalCompId = null;
|
||
const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd'];
|
||
|
||
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() && !isQuizCarry() && !isQuizBattle())) 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() && !isQuizCarry() && !isQuizBattle()) || 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 getTileBoundsForOnesGrid(grid) {
|
||
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 getQuizQuestionAreaTileBounds(md) {
|
||
if (!md || md.gameType !== 'quiz') return null;
|
||
return getTileBoundsForOnesGrid(md.quizQuestionArea);
|
||
}
|
||
|
||
/** quiz_carry: โซนทองพิเศษสำหรับข้อความคำถาม (ถ้าไม่วาด ใช้โซนกลางม่วงแทนใน syncPlayQuizMapPanel) */
|
||
function getQuizCarryQuestionAreaTileBounds(md) {
|
||
if (!md || md.gameType !== 'quiz_carry') return null;
|
||
return getTileBoundsForOnesGrid(md.quizQuestionArea);
|
||
}
|
||
|
||
function syncPlayQuizMapPanel() {
|
||
const panel = document.getElementById('quiz-map-question-panel');
|
||
const textEl = document.getElementById('quiz-map-question-text');
|
||
if (!panel || !textEl || !mapData) return;
|
||
/* เอดิเตอร์ embed: ซ่อนแผงทองเฉพาะโหมด quiz (ทับกลาง) — quiz_carry ต้องโชว์ตามโซนที่วาดบนแผนที่ */
|
||
if (previewMode && editorEmbedReturn && !isQuizCarry()) {
|
||
panel.classList.add('is-hidden');
|
||
panel.setAttribute('aria-hidden', 'true');
|
||
return;
|
||
}
|
||
if (isQuizBattle()) {
|
||
panel.classList.add('is-hidden');
|
||
panel.setAttribute('aria-hidden', 'true');
|
||
return;
|
||
}
|
||
if (!isQuiz() && !isQuizCarry()) return;
|
||
const bounds = isQuiz()
|
||
? getQuizQuestionAreaTileBounds(mapData)
|
||
: (getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(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');
|
||
}
|
||
|
||
/** quiz_carry + embed: คำถามไปที่แผงทองบนแผนที่ / ใน overlay นับถอยหลัง — ไม่ใช้แถบบน */
|
||
function syncQuizCarryEmbedQuestionStrip() {
|
||
const wrap = document.getElementById('quiz-carry-embed-q-strip');
|
||
const p = document.getElementById('quiz-carry-embed-q-strip-text');
|
||
if (!wrap || !p) return;
|
||
wrap.classList.remove('quiz-carry-embed-q-strip--countdown');
|
||
wrap.classList.add('is-hidden');
|
||
wrap.setAttribute('aria-hidden', 'true');
|
||
}
|
||
|
||
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;
|
||
let x = sp.x + 0.5 + jx;
|
||
let y = sp.y + 0.5 + jy;
|
||
if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
|
||
const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(x, y);
|
||
x = pathSnap.x;
|
||
y = pathSnap.y;
|
||
}
|
||
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,
|
||
quizCarryHeld: null,
|
||
botPath: [],
|
||
botAnswerWander: false,
|
||
botWanderDx: wd[0],
|
||
botWanderDy: wd[1],
|
||
botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000),
|
||
jumpSurviveEliminated: false,
|
||
spaceShooterScore: 0,
|
||
balloonBossScore: 0,
|
||
balloonBossBalloons: 5,
|
||
balloonBossEliminated: false,
|
||
botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160),
|
||
});
|
||
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 === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
|
||
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
|
||
const o = others.get(bid);
|
||
if (!o) return;
|
||
const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
|
||
o.x = s.x;
|
||
o.y = s.y;
|
||
o.tx = s.x;
|
||
o.ty = s.y;
|
||
});
|
||
}
|
||
if (mapData.gameType === 'quiz_carry') {
|
||
const t0 = performance.now();
|
||
[...others.keys()].filter(isPreviewBotId).forEach((bid, idx) => {
|
||
const o = others.get(bid);
|
||
if (!o) return;
|
||
o.botQuizCarryPathfindAfter = t0 + idx * 90 + Math.floor(Math.random() * 140);
|
||
});
|
||
}
|
||
if (mapData.gameType === 'gauntlet') {
|
||
applyGauntletPreviewSpawnLayout(false);
|
||
emitGauntletPreviewRowsToServer();
|
||
}
|
||
if (mapData.gameType === 'stack') {
|
||
applyStackPreviewSpawnLayout();
|
||
rebuildStackPreviewTurnOrder();
|
||
}
|
||
if (mapData.gameType === 'jump_survive') {
|
||
applyJumpSurvivePreviewSpawnLayout(false);
|
||
}
|
||
if (mapData.gameType === 'space_shooter') {
|
||
applySpaceShooterSpawnLayoutPlay();
|
||
}
|
||
if (mapData.gameType === 'balloon_boss') {
|
||
applyBalloonBossSpawnLayoutPlay();
|
||
}
|
||
}
|
||
|
||
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(BASE + '/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();
|
||
resetQuizCarryPlayState();
|
||
resetQuizBattlePlayState();
|
||
}
|
||
|
||
function setupPlayQuizUi() {
|
||
if (playQuizTimerInterval) {
|
||
clearInterval(playQuizTimerInterval);
|
||
playQuizTimerInterval = null;
|
||
}
|
||
const ov = document.getElementById('quiz-game-overlay');
|
||
if (ov) {
|
||
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
|
||
else 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 isStack() { return mapData && mapData.gameType === 'stack'; }
|
||
function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; }
|
||
function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; }
|
||
function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; }
|
||
function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; }
|
||
function isQuizBattle() { return mapData && mapData.gameType === 'quiz_battle'; }
|
||
|
||
const QUIZ_CARRY_MAX_OPTION_SLOTS = 16;
|
||
|
||
function quizCarryNeonColorForChoice(choiceIndex) {
|
||
const i = Math.max(0, choiceIndex | 0);
|
||
const h = (i * 47 + 100) % 360;
|
||
return 'hsl(' + h + ', 72%, 62%)';
|
||
}
|
||
|
||
function quizCarryMinimapOptionFillCss(ov) {
|
||
const i = Math.max(0, ov - 1);
|
||
const h = (i * 47 + 100) % 360;
|
||
return 'hsla(' + h + ', 58%, 52%, 0.32)';
|
||
}
|
||
|
||
function normalizeQuizCarryQuestionFromAny(q) {
|
||
if (!q) return null;
|
||
let text = String(q.text || q.question || q.prompt || q.title || '').trim();
|
||
if (Array.isArray(q.choices) && q.choices.length >= 2) {
|
||
const choices = q.choices.map((c) => String(c));
|
||
let ci = Number(q.correctIndex);
|
||
if (!Number.isFinite(ci) || ci < 0 || ci >= choices.length) ci = 0;
|
||
if (!text) text = 'เลือกคำตอบที่ถูกต้อง — หยิบตัวเลือกไปวางโซนส่งคำตอบ';
|
||
return { text, choices, correctIndex: ci };
|
||
}
|
||
if (!text) return null;
|
||
const t = !!q.answerTrue;
|
||
return { text, choices: ['จริง', 'เท็จ'], correctIndex: t ? 0 : 1 };
|
||
}
|
||
|
||
function buildQuizCarryPoolFromMap(md) {
|
||
if (!md || !Array.isArray(md.quizQuestions)) return [];
|
||
const out = [];
|
||
for (let i = 0; i < md.quizQuestions.length; i++) {
|
||
const n = normalizeQuizCarryQuestionFromAny(md.quizQuestions[i]);
|
||
if (n) out.push(n);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function getQuizCarryHubTileBounds(md) {
|
||
if (!md || md.gameType !== 'quiz_carry') return null;
|
||
const grid = md.quizCarryHubArea;
|
||
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 };
|
||
}
|
||
|
||
/** จุดกลางโซนตัวเลือกหยิบมาวาง (ค่า grid 1..N = ลำดับ choices) เพื่อวาดข้อความบนแผนที่ */
|
||
function getQuizCarryOptionClusterBounds(md, slot1toN) {
|
||
const g = md && md.quizCarryOptionArea;
|
||
if (!g || !g.length) return null;
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
let n = 0, sumX = 0, sumY = 0;
|
||
for (let yy = 0; yy < g.length; yy++) {
|
||
const row = g[yy];
|
||
if (!row) continue;
|
||
for (let xx = 0; xx < row.length; xx++) {
|
||
if (row[xx] === slot1toN) {
|
||
if (xx < minX) minX = xx;
|
||
if (yy < minY) minY = yy;
|
||
if (xx > maxX) maxX = xx;
|
||
if (yy > maxY) maxY = yy;
|
||
sumX += xx + 0.5;
|
||
sumY += yy + 0.5;
|
||
n++;
|
||
}
|
||
}
|
||
}
|
||
if (!n || minX === Infinity) return null;
|
||
return {
|
||
minX, minY, maxX, maxY,
|
||
cx: sumX / n,
|
||
cy: sumY / n,
|
||
};
|
||
}
|
||
|
||
function canvasWordWrapLines(ctx, text, maxWidth) {
|
||
const raw = String(text || '').trim();
|
||
if (!raw) return [];
|
||
const words = raw.split(/\s+/);
|
||
const lines = [];
|
||
let cur = words[0];
|
||
for (let i = 1; i < words.length; i++) {
|
||
const w = words[i];
|
||
const test = cur + ' ' + w;
|
||
if (ctx.measureText(test).width <= maxWidth) cur = test;
|
||
else {
|
||
lines.push(cur);
|
||
cur = w;
|
||
}
|
||
}
|
||
lines.push(cur);
|
||
return lines;
|
||
}
|
||
|
||
function quizCarryOptionHeldByAnyone(optionIdx) {
|
||
if (optionIdx == null || optionIdx < 0) return false;
|
||
if (me.quizCarryHeld === optionIdx) return true;
|
||
for (const o of others.values()) {
|
||
if (o && o.quizCarryHeld === optionIdx) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** ตัวเลือกหนึ่งข้อ — มีคนถือป้ายอยู่ได้แค่คนเดียว (หยิบซ้ำไม่ได้) */
|
||
function pickQuizCarryTargetOptionIndexAvoidingTaken(preferredIdx) {
|
||
const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
|
||
if (n < 1) return null;
|
||
let p = preferredIdx | 0;
|
||
if (p < 0 || p >= n) p = 0;
|
||
if (!quizCarryOptionHeldByAnyone(p)) return p;
|
||
const avail = [];
|
||
for (let i = 0; i < n; i++) {
|
||
if (!quizCarryOptionHeldByAnyone(i)) avail.push(i);
|
||
}
|
||
if (!avail.length) return null;
|
||
return avail[Math.floor(Math.random() * avail.length)];
|
||
}
|
||
|
||
/**
|
||
* วาดป้ายมุมมน + ขอบเรือง + ข้อความขาว (รูปแบบเดียวกันทั้งแมปและติดตัว)
|
||
* @param tether ถ้ามี วาดเส้นขาวจาก (x0,y0) ถึง (x1,y1) ก่อนป้าย
|
||
*/
|
||
function drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, tether) {
|
||
const neon = quizCarryNeonColorForChoice(choiceIndex);
|
||
const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2));
|
||
const simplePreviewPlaque = previewMode && editorEmbedReturn;
|
||
function pathBoard() {
|
||
ctx.beginPath();
|
||
if (typeof ctx.roundRect === 'function') ctx.roundRect(signX, signY, w, h, rr);
|
||
else ctx.rect(signX, signY, w, h);
|
||
}
|
||
if (!simplePreviewPlaque) {
|
||
for (let g = 4; g >= 1; g--) {
|
||
ctx.strokeStyle = neon;
|
||
ctx.globalAlpha = 0.07 + g * 0.06;
|
||
ctx.lineWidth = 3 + g * 4;
|
||
pathBoard();
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
ctx.fillStyle = 'rgba(12, 10, 20, 0.88)';
|
||
pathBoard();
|
||
ctx.fill();
|
||
ctx.strokeStyle = neon;
|
||
if (simplePreviewPlaque) {
|
||
ctx.lineWidth = 2;
|
||
ctx.shadowBlur = 0;
|
||
pathBoard();
|
||
ctx.stroke();
|
||
} else {
|
||
ctx.lineWidth = 2.5;
|
||
ctx.shadowColor = neon;
|
||
ctx.shadowBlur = 14;
|
||
pathBoard();
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 8;
|
||
pathBoard();
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
const attachX = signX + w / 2;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = '#f8f9ff';
|
||
if (!simplePreviewPlaque) {
|
||
ctx.shadowColor = 'rgba(0,0,0,0.55)';
|
||
ctx.shadowBlur = 3;
|
||
}
|
||
const textMidY = signY + padY + lineH / 2;
|
||
for (let li = 0; li < lines.length; li++) {
|
||
ctx.fillText(lines[li], attachX, textMidY + li * lineH);
|
||
}
|
||
ctx.shadowBlur = 0;
|
||
if (tether && Number.isFinite(tether.x0) && Number.isFinite(tether.y0)) {
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
ctx.shadowBlur = 0;
|
||
ctx.beginPath();
|
||
ctx.moveTo(tether.x0, tether.y0);
|
||
ctx.lineTo(tether.x1, tether.y1);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
function drawQuizCarryChoiceLabels(ctx, worldToScreen, zoom) {
|
||
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
|
||
if (!quizCarryOptionsPickableNow()) return;
|
||
const choices = quizCarryCurrent.choices;
|
||
if (!choices || !choices.length) return;
|
||
const maxSlots = choices.length;
|
||
const ts = tileSize * zoom;
|
||
ctx.save();
|
||
for (let i = 0; i < Math.min(choices.length, maxSlots); i++) {
|
||
if (quizCarryOptionHeldByAnyone(i)) continue;
|
||
const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
|
||
if (!b) continue;
|
||
const choiceText = String(choices[i] || '').trim();
|
||
if (!choiceText) continue;
|
||
const line = choiceText;
|
||
const fontPx = Math.max(10, Math.min(18, ts * 0.24));
|
||
ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
|
||
const tileSpanX = (b.maxX - b.minX + 1);
|
||
const tileSpanY = (b.maxY - b.minY + 1);
|
||
let maxW = Math.max(56, tileSpanX * ts - 14);
|
||
const lineH = fontPx * 1.2;
|
||
let textLines = canvasWordWrapLines(ctx, line, maxW);
|
||
if (!textLines.length) textLines = [line];
|
||
if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
|
||
const fp2 = Math.max(9, fontPx * 0.85);
|
||
ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
|
||
const lh2 = fp2 * 1.2;
|
||
maxW = Math.max(48, maxW - 4);
|
||
textLines = canvasWordWrapLines(ctx, line, maxW);
|
||
if (textLines.length * lh2 > tileSpanY * ts - 8) {
|
||
textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
|
||
}
|
||
for (let ti = 0; ti < textLines.length; ti++) {
|
||
let s = textLines[ti];
|
||
if (ctx.measureText(s).width <= maxW) continue;
|
||
while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
|
||
textLines[ti] = s + '…';
|
||
}
|
||
const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
|
||
const padX = 10;
|
||
const padY = 8;
|
||
const w = Math.ceil(maxLineW + padX * 2);
|
||
const h = Math.ceil(textLines.length * lh2 + padY * 2);
|
||
const wx = b.cx * tileSize;
|
||
const wy = b.cy * tileSize;
|
||
const [sx, sy] = worldToScreen(wx, wy);
|
||
const signX = sx - w / 2;
|
||
const signY = sy - h / 2;
|
||
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null);
|
||
continue;
|
||
}
|
||
for (let ti = 0; ti < textLines.length; ti++) {
|
||
let s = textLines[ti];
|
||
if (ctx.measureText(s).width <= maxW) continue;
|
||
while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
|
||
textLines[ti] = s + '…';
|
||
}
|
||
let maxLineW = 0;
|
||
for (let ti = 0; ti < textLines.length; ti++) {
|
||
const lw = ctx.measureText(textLines[ti]).width;
|
||
if (lw > maxLineW) maxLineW = lw;
|
||
}
|
||
const padX = 10;
|
||
const padY = 8;
|
||
const w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
|
||
const h = Math.ceil(textLines.length * lineH + padY * 2);
|
||
const wx = b.cx * tileSize;
|
||
const wy = b.cy * tileSize;
|
||
const [sx, sy] = worldToScreen(wx, wy);
|
||
const signX = sx - w / 2;
|
||
const signY = sy - h / 2;
|
||
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
function quizCarryHubCellPlay(md, tx, ty) {
|
||
const g = md && md.quizCarryHubArea;
|
||
return !!(g && g[ty] && g[ty][tx] === 1);
|
||
}
|
||
|
||
function quizCarryInteractiveCellPlay(md, tx, ty) {
|
||
const g = md && md.interactive;
|
||
return !!(g && g[ty] && g[ty][tx] === 1);
|
||
}
|
||
|
||
function quizCarryMapHasAnswerInteractive(md) {
|
||
if (!md || !md.interactive || !md.interactive.length) return false;
|
||
const h = md.height || 15, w = md.width || 20;
|
||
for (let y = 0; y < h; y++) {
|
||
const row = md.interactive[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < w; x++) {
|
||
if (row[x] === 1) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** @returns {number|null} index ใน choices จากช่องหมายเลข 1..N บนแมป */
|
||
function quizCarryOptionIndexAtPlay(md, tx, ty) {
|
||
const g = md && md.quizCarryOptionArea;
|
||
if (!g || !g[ty]) return null;
|
||
const v = g[ty][tx];
|
||
if (v < 1 || v > QUIZ_CARRY_MAX_OPTION_SLOTS) return null;
|
||
const idx = v - 1;
|
||
const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
|
||
if (n < 1 || idx >= n) return null;
|
||
return idx;
|
||
}
|
||
|
||
/** ร่างตัวละครทับช่องโซนกลาง (ม่วง) — ใช้บล็อกการเดิน */
|
||
function quizCarryFootprintOverlapsHub(px, py) {
|
||
if (!mapData || !isQuizCarry()) return false;
|
||
const md = mapData;
|
||
for (const k of quizTilesFootprintPlay(px, py)) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
if (quizCarryHubCellPlay(md, tx, ty)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** ยืนข้างนอกแต่ชิดโซนกลาง — ใช้ให้กด F ส่งคำตอบได้โดยไม่ต้องย่ำบนม่วง */
|
||
function quizCarryFootprintAdjacentToHub(md, px, py) {
|
||
if (!md) return false;
|
||
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
||
for (const k of quizTilesFootprintPlay(px, py)) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
if (quizCarryHubCellPlay(md, tx, ty)) continue;
|
||
for (let di = 0; di < dirs.length; di++) {
|
||
const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
|
||
if (quizCarryHubCellPlay(md, nx, ny)) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function quizCarryCanSubmitAtHub(md, px, py) {
|
||
if (!md) return false;
|
||
return quizCarryFootprintOverlapsHub(px, py) || quizCarryFootprintAdjacentToHub(md, px, py);
|
||
}
|
||
|
||
function quizCarryFootprintOverlapsInteractive(md, px, py) {
|
||
if (!md) return false;
|
||
for (const k of quizTilesFootprintPlay(px, py)) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
if (quizCarryInteractiveCellPlay(md, tx, ty)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function quizCarryFootprintAdjacentToInteractive(md, px, py) {
|
||
if (!md) return false;
|
||
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
||
for (const k of quizTilesFootprintPlay(px, py)) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
if (quizCarryInteractiveCellPlay(md, tx, ty)) continue;
|
||
for (let di = 0; di < dirs.length; di++) {
|
||
const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
|
||
if (quizCarryInteractiveCellPlay(md, nx, ny)) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function quizCarryCanSubmitAtInteractiveZone(md, px, py) {
|
||
if (!md) return false;
|
||
return quizCarryFootprintOverlapsInteractive(md, px, py) || quizCarryFootprintAdjacentToInteractive(md, px, py);
|
||
}
|
||
|
||
/** ส่งคำตอบ: ถ้ามีโซน interactive (เขียว) ในแมป ใช้โซนนั้น — ไม่มี fallback ชิดโซนกลาง */
|
||
function quizCarryCanSubmitAnswerAt(md, px, py) {
|
||
if (!md) return false;
|
||
if (quizCarryMapHasAnswerInteractive(md)) {
|
||
return quizCarryCanSubmitAtInteractiveZone(md, px, py);
|
||
}
|
||
return quizCarryCanSubmitAtHub(md, px, py);
|
||
}
|
||
|
||
/** ข้อความใน HUD / โซนคำถาม — ไม่รวมตัวเลือก (แสดงบนแผนที่ตามจุดสี) */
|
||
function formatQuizCarryQuestionHud(q) {
|
||
if (!q) return '';
|
||
return String(q.text || q.question || q.prompt || q.title || '').trim();
|
||
}
|
||
|
||
function quizCarryRestoreEmbedPhaseLabel() {
|
||
const phaseEl = document.getElementById('quiz-game-phase-label');
|
||
if (!phaseEl || !mapData) return;
|
||
phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData)
|
||
? 'กด F หยิบบนโซนตัวเลือก · กด F ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้'
|
||
: 'กด F หยิบบนโซนตัวเลือก · กด F ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้';
|
||
}
|
||
|
||
function isQuizCarryEmbedCountdownBlockingMovement() {
|
||
return !!(previewMode && editorEmbedReturn && isQuizCarry() && quizCarryEmbedCountdownEndAt > Date.now());
|
||
}
|
||
|
||
function quizCarryOptionsPickableNow() {
|
||
if (!isQuizCarry() || !quizCarryCurrent) return true;
|
||
if (!quizCarryOptionRevealAt || quizCarryOptionRevealAt <= 0) return true;
|
||
return Date.now() >= quizCarryOptionRevealAt;
|
||
}
|
||
|
||
function quizCarryApplyPhaseTimersForCurrentQuestion() {
|
||
if (!quizCarryCurrent) {
|
||
quizCarryOptionRevealAt = 0;
|
||
quizCarryAnswerCloseAt = 0;
|
||
return;
|
||
}
|
||
const readMs = Math.max(0, Math.floor(Number(quizCarryCarryTimingMs.carryReadMs)) || 0);
|
||
const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000);
|
||
const t0 = Date.now();
|
||
if (readMs <= 0) {
|
||
quizCarryOptionRevealAt = 0;
|
||
quizCarryAnswerCloseAt = t0 + ansMs;
|
||
} else {
|
||
quizCarryOptionRevealAt = t0 + readMs;
|
||
quizCarryAnswerCloseAt = quizCarryOptionRevealAt + ansMs;
|
||
}
|
||
}
|
||
|
||
function updateQuizCarryCarryPhaseHud() {
|
||
if (!isQuizCarry() || !quizCarryCurrent) return;
|
||
const phaseEl = document.getElementById('quiz-game-phase-label');
|
||
if (!phaseEl) return;
|
||
const now = Date.now();
|
||
if (quizCarryOptionRevealAt > 0 && now < quizCarryOptionRevealAt) {
|
||
const remain = Math.max(0, Math.ceil((quizCarryOptionRevealAt - now) / 1000));
|
||
phaseEl.textContent = 'อ่านคำถาม — ตัวเลือกขึ้นใน ' + remain + ' วิ';
|
||
return;
|
||
}
|
||
if (quizCarryAnswerCloseAt > now) {
|
||
const remain = Math.max(0, Math.ceil((quizCarryAnswerCloseAt - now) / 1000));
|
||
phaseEl.textContent = 'หยิบ/ส่งคำตอบ — เหลือ ' + remain + ' วิ';
|
||
return;
|
||
}
|
||
quizCarryRestoreEmbedPhaseLabel();
|
||
}
|
||
|
||
function tickQuizCarryRoundTimers() {
|
||
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
|
||
if (quizCarryEmbedCountdownEndAt > Date.now()) return;
|
||
if (quizCarryEmbedPendingQuestion) return;
|
||
if (!quizCarryAnswerCloseAt || quizCarryAnswerCloseAt <= 0) return;
|
||
if (Date.now() < quizCarryAnswerCloseAt) return;
|
||
quizCarryAnswerCloseAt = 0;
|
||
quizCarryOptionRevealAt = 0;
|
||
showQuizCarryToast('หมดเวลา — ข้อถัดไป', false);
|
||
me.quizCarryHeld = null;
|
||
others.forEach((o) => {
|
||
if (!o) return;
|
||
o.quizCarryHeld = null;
|
||
o.botPath = [];
|
||
});
|
||
renderPlayQuizScoreboard(playLiveQuizScores);
|
||
quizCarryPickNextQuestion();
|
||
}
|
||
|
||
/** นับ 3–2–1: ไม่โชว์ข้อความคำถามใน overlay — มีแค่เลข */
|
||
function hideQuizCarryEmbedCountdownQuestionChrome() {
|
||
const cq = document.getElementById('quiz-carry-embed-countdown-q');
|
||
const ck = document.getElementById('quiz-carry-embed-countdown-kicker');
|
||
if (cq) {
|
||
cq.textContent = '';
|
||
cq.style.display = 'none';
|
||
}
|
||
if (ck) ck.style.display = 'none';
|
||
}
|
||
|
||
function hideQuizCarryEmbedCountdownOverlay() {
|
||
const ov = document.getElementById('quiz-carry-embed-countdown');
|
||
if (ov) {
|
||
ov.classList.add('is-hidden');
|
||
ov.setAttribute('aria-hidden', 'true');
|
||
}
|
||
hideQuizCarryEmbedCountdownQuestionChrome();
|
||
}
|
||
|
||
function tickQuizCarryEmbedCountdown() {
|
||
if (!previewMode || !editorEmbedReturn || !isQuizCarry()) return;
|
||
if (!quizCarryEmbedPendingQuestion || quizCarryEmbedCountdownEndAt <= 0) return;
|
||
const now = Date.now();
|
||
const ov = document.getElementById('quiz-carry-embed-countdown');
|
||
const numEl = document.getElementById('quiz-carry-embed-countdown-num');
|
||
if (now >= quizCarryEmbedCountdownEndAt) {
|
||
const pq = quizCarryEmbedPendingQuestion;
|
||
quizCarryEmbedPendingQuestion = null;
|
||
quizCarryEmbedCountdownEndAt = 0;
|
||
quizCarryEmbedCountdownStartAt = 0;
|
||
hideQuizCarryEmbedCountdownOverlay();
|
||
if (!pq) return;
|
||
quizCarryCurrent = pq;
|
||
playQuizText = formatQuizCarryQuestionHud(pq);
|
||
const qEl = document.getElementById('quiz-game-question');
|
||
if (qEl) qEl.textContent = playQuizText;
|
||
quizCarryRestoreEmbedPhaseLabel();
|
||
others.forEach((o) => {
|
||
if (!o) return;
|
||
o.botQuizCarryPathfindAfter = 0;
|
||
});
|
||
syncQuizCarryEmbedQuestionStrip();
|
||
quizCarryApplyPhaseTimersForCurrentQuestion();
|
||
return;
|
||
}
|
||
const elapsed = now - quizCarryEmbedCountdownStartAt;
|
||
const n = 3 - Math.min(2, Math.floor(elapsed / 1000));
|
||
if (numEl) numEl.textContent = String(Math.max(1, Math.min(3, n)));
|
||
if (ov) {
|
||
ov.classList.remove('is-hidden');
|
||
ov.setAttribute('aria-hidden', 'false');
|
||
}
|
||
hideQuizCarryEmbedCountdownQuestionChrome();
|
||
syncQuizCarryEmbedQuestionStrip();
|
||
}
|
||
|
||
function quizCarryPickNextQuestion() {
|
||
if (!quizCarryPool.length) {
|
||
quizCarryCurrent = null;
|
||
quizCarryEmbedPendingQuestion = null;
|
||
quizCarryEmbedCountdownEndAt = 0;
|
||
quizCarryEmbedCountdownStartAt = 0;
|
||
quizCarryOptionRevealAt = 0;
|
||
quizCarryAnswerCloseAt = 0;
|
||
hideQuizCarryEmbedCountdownOverlay();
|
||
playQuizText = 'ไม่มีคำถาม — ใส่ quizQuestions ในแมป (choices + correctIndex) หรือตั้งใน Admin';
|
||
return;
|
||
}
|
||
let q;
|
||
let guard = 0;
|
||
do {
|
||
q = quizCarryPool[Math.floor(Math.random() * quizCarryPool.length)];
|
||
guard++;
|
||
} while (guard < 12 && quizCarryPool.length > 1 && quizCarryCurrent && q && q.text === quizCarryCurrent.text);
|
||
if (previewMode && editorEmbedReturn) {
|
||
quizCarryEmbedPendingQuestion = q;
|
||
quizCarryEmbedCountdownStartAt = Date.now();
|
||
quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000;
|
||
quizCarryCurrent = null;
|
||
playQuizText = '';
|
||
quizCarryOptionRevealAt = 0;
|
||
quizCarryAnswerCloseAt = 0;
|
||
me.quizCarryHeld = null;
|
||
others.forEach((o) => {
|
||
if (!o) return;
|
||
o.quizCarryHeld = null;
|
||
o.botPath = [];
|
||
o.botPathStuckTicks = 0;
|
||
/* ต้องใช้ performance.now() ช่วงกับ stepQuizCarryPreviewBots — ห้ามใช้ Date.now() epoch ไม่งั้นบอทค้าง pathfind ตลอด */
|
||
o.botQuizCarryPathfindAfter = 0;
|
||
});
|
||
const qEl = document.getElementById('quiz-game-question');
|
||
if (qEl) qEl.textContent = '…';
|
||
const phaseEl = document.getElementById('quiz-game-phase-label');
|
||
if (phaseEl) phaseEl.textContent = 'รอเริ่มคำถาม — นับถอยหลัง 3 · 2 · 1';
|
||
tickQuizCarryEmbedCountdown();
|
||
syncQuizCarryEmbedQuestionStrip();
|
||
return;
|
||
}
|
||
quizCarryCurrent = q;
|
||
playQuizText = formatQuizCarryQuestionHud(q);
|
||
const qEl = document.getElementById('quiz-game-question');
|
||
if (qEl) qEl.textContent = playQuizText;
|
||
me.quizCarryHeld = null;
|
||
others.forEach((o) => {
|
||
if (!o) return;
|
||
o.quizCarryHeld = null;
|
||
});
|
||
quizCarryApplyPhaseTimersForCurrentQuestion();
|
||
}
|
||
|
||
function showQuizCarryToast(msg, ok) {
|
||
const el = document.getElementById('play-quiz-feedback');
|
||
if (!el) return;
|
||
el.classList.remove('is-hidden');
|
||
el.className = ok ? 'play-quiz-feedback play-quiz-feedback-ok' : 'play-quiz-feedback play-quiz-feedback-bad';
|
||
el.textContent = msg || '';
|
||
if (typeof window.__quizCarryToastT === 'number') clearTimeout(window.__quizCarryToastT);
|
||
window.__quizCarryToastT = setTimeout(() => { el.classList.add('is-hidden'); }, 2800);
|
||
}
|
||
|
||
/**
|
||
* หยิบ/ส่งคำตอบ — ต้องกด F (fromKey) สำหรับผู้เล่น · บอท preview เรียกแบบ silent เมื่อหยุดเดินแล้ว
|
||
* @returns {boolean} ทำ action สำเร็จหรือไม่
|
||
*/
|
||
function tryQuizCarryInteractionForPlayer(actorId, ox, oy, opts) {
|
||
opts = opts || {};
|
||
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
|
||
const md = mapData;
|
||
const footprint = quizTilesFootprintPlay(ox, oy);
|
||
const ent = actorId === myId ? me : others.get(actorId);
|
||
if (!ent) return false;
|
||
let pickupIdx = null;
|
||
for (const k of footprint) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
|
||
if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
|
||
}
|
||
const canSubmitAnswer = quizCarryCanSubmitAnswerAt(md, ox, oy);
|
||
const correct = quizCarryCurrent.correctIndex;
|
||
let held = ent.quizCarryHeld;
|
||
if (held != null && canSubmitAnswer) {
|
||
if (!quizCarryOptionsPickableNow()) return false;
|
||
ent.quizCarryHeld = null;
|
||
const ok = held === correct;
|
||
if (ok) {
|
||
playLiveQuizScores[actorId] = (playLiveQuizScores[actorId] || 0) + 1;
|
||
showQuizCarryToast('ถูกต้อง +1 แต้ม — สุ่มคำถามใหม่', true);
|
||
me.quizCarryHeld = null;
|
||
others.forEach((o) => { if (o) o.quizCarryHeld = null; });
|
||
quizCarryPickNextQuestion();
|
||
} else {
|
||
showQuizCarryToast('ผิด — คืนป้ายที่โซนตัวเลือกแล้ว กด F หยิบใหม่ได้', false);
|
||
}
|
||
renderPlayQuizScoreboard(playLiveQuizScores);
|
||
return true;
|
||
}
|
||
if (held == null && pickupIdx != null) {
|
||
if (!quizCarryOptionsPickableNow()) return false;
|
||
if (quizCarryOptionHeldByAnyone(pickupIdx)) {
|
||
if (opts.fromKey && actorId === myId && !opts.silent) {
|
||
showQuizCarryToast('ตัวเลือกนี้มีคนถืออยู่แล้ว — เลือกข้ออื่น', false);
|
||
}
|
||
return false;
|
||
}
|
||
ent.quizCarryHeld = pickupIdx;
|
||
const label = (quizCarryCurrent.choices && quizCarryCurrent.choices[pickupIdx]) || String(pickupIdx);
|
||
showQuizCarryToast('หยิบ: ' + label, true);
|
||
return true;
|
||
}
|
||
if (opts.fromKey && actorId === myId && !opts.silent) {
|
||
const t = Date.now();
|
||
if (t - (me.quizCarryHintT || 0) > 2200) {
|
||
me.quizCarryHintT = t;
|
||
if (held != null && !canSubmitAnswer) {
|
||
showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
|
||
? 'ถือป้ายอยู่ — ยืนที่โซน interactive (เขียว) หรือชิดขอบแล้วกด F เพื่อส่ง'
|
||
: 'ถือป้ายอยู่ — ยืนชิดโซนกลาง (ม่วง) แล้วกด F เพื่อส่ง', false);
|
||
} else {
|
||
showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
|
||
? 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนโซนเขียว (interactive) แล้วกด F ส่งเมื่อถือป้าย'
|
||
: 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนชิดโซนกลางแล้วกด F ส่งเมื่อถือป้าย', false);
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function resetQuizCarryPlayState() {
|
||
quizCarryPool = [];
|
||
quizCarryCurrent = null;
|
||
quizCarryEmbedPendingQuestion = null;
|
||
quizCarryEmbedCountdownStartAt = 0;
|
||
quizCarryEmbedCountdownEndAt = 0;
|
||
quizCarryOptionRevealAt = 0;
|
||
quizCarryAnswerCloseAt = 0;
|
||
hideQuizCarryEmbedCountdownOverlay();
|
||
me.quizCarryHeld = null;
|
||
others.forEach((o) => {
|
||
if (!o) return;
|
||
o.quizCarryHeld = null;
|
||
o.botPath = [];
|
||
});
|
||
}
|
||
|
||
async function loadQuizCarryPoolAndStart() {
|
||
let pool = [];
|
||
try {
|
||
const r = await fetch(BASE + '/api/quiz-settings');
|
||
if (r.ok) {
|
||
const s = await r.json();
|
||
quizCarryCarryTimingMs = {
|
||
carryReadMs: clampPreviewMs(s.carryReadMs, 3000, 0, 120000),
|
||
carryAnswerMs: clampPreviewMs(s.carryAnswerMs, 5000, 1000, 300000),
|
||
};
|
||
const carryOnly = s && Array.isArray(s.carryQuestions) && s.carryQuestions.length > 0;
|
||
const source = carryOnly ? s.carryQuestions : (s && Array.isArray(s.questions) ? s.questions : []);
|
||
for (let i = 0; i < source.length; i++) {
|
||
const n = normalizeQuizCarryQuestionFromAny(source[i]);
|
||
if (n) pool.push(n);
|
||
}
|
||
}
|
||
} catch (e) { /* map only */ }
|
||
if (!pool.length && mapData) pool = buildQuizCarryPoolFromMap(mapData);
|
||
quizCarryPool = pool;
|
||
playLiveQuizScores = {};
|
||
if (myId != null) playLiveQuizScores[myId] = 0;
|
||
others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
|
||
if (!pool.length) {
|
||
playQuizText = 'ไม่มีคำถาม — ตั้งใน Admin → คำถามหลายตัวเลือก หรือใส่ในแมป (quizQuestions)';
|
||
const qEl = document.getElementById('quiz-game-question');
|
||
if (qEl) qEl.textContent = playQuizText;
|
||
return;
|
||
}
|
||
quizCarryPickNextQuestion();
|
||
if (!(previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > Date.now())) {
|
||
quizCarryRestoreEmbedPhaseLabel();
|
||
}
|
||
}
|
||
|
||
function setupPlayQuizCarryUi() {
|
||
if (playQuizTimerInterval) {
|
||
clearInterval(playQuizTimerInterval);
|
||
playQuizTimerInterval = null;
|
||
}
|
||
const ov = document.getElementById('quiz-game-overlay');
|
||
if (ov) {
|
||
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
|
||
else ov.classList.remove('is-hidden');
|
||
}
|
||
const tEl = document.getElementById('quiz-game-timer');
|
||
if (tEl) tEl.textContent = '';
|
||
const leg = document.getElementById('quiz-play-legend');
|
||
if (leg) {
|
||
leg.textContent = quizCarryMapHasAnswerInteractive(mapData)
|
||
? 'แต่ละตัวเลือกมีคนถือได้คนเดียว — ส่งคำตอบที่โซน interactive (เขียว) · โซนกลางม่วงเป็นกำแพง'
|
||
: 'แต่ละตัวเลือกมีคนถือได้คนเดียว — โซนกลางเป็นกำแพง · ยืนชิดขอบแล้วกด F ส่งเมื่อถือป้าย';
|
||
}
|
||
resetQuizCarryPlayState();
|
||
if (previewMode) {
|
||
loadQuizCarryPoolAndStart();
|
||
} else {
|
||
playQuizText = 'เข้าห้องแล้ว — คำถามจากแมป (เล่นจริงแนะนำซิงก์ผ่านโฮสต์ในอนาคต)';
|
||
loadQuizCarryPoolAndStart();
|
||
}
|
||
renderPlayQuizScoreboard(playLiveQuizScores);
|
||
}
|
||
|
||
function pickRandomTileForQuizCarryOption(md, optionIndex, o) {
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const want = optionIndex + 1;
|
||
const pool = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const row = md.quizCarryOptionArea && md.quizCarryOptionArea[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < w; x++) {
|
||
if (row[x] === want && spawnTileWalkablePlay(md, x, y)) {
|
||
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
|
||
}
|
||
}
|
||
}
|
||
if (!pool.length) return null;
|
||
return pool[Math.floor(Math.random() * pool.length)];
|
||
}
|
||
|
||
function pickRandomTileInQuizCarryHub(md, o) {
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const pool = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const row = md.quizCarryHubArea && md.quizCarryHubArea[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < w; x++) {
|
||
if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
|
||
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
|
||
}
|
||
}
|
||
}
|
||
if (!pool.length) return null;
|
||
return pool[Math.floor(Math.random() * pool.length)];
|
||
}
|
||
|
||
function pickRandomTileInQuizCarryInteractive(md, o) {
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const pool = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const row = md.interactive && md.interactive[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < w; x++) {
|
||
if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
|
||
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
|
||
}
|
||
}
|
||
}
|
||
if (!pool.length) return null;
|
||
return pool[Math.floor(Math.random() * pool.length)];
|
||
}
|
||
|
||
function pickRandomTileForQuizCarrySubmit(md, o) {
|
||
if (quizCarryMapHasAnswerInteractive(md)) {
|
||
const p = pickRandomTileInQuizCarryInteractive(md, o);
|
||
if (p) return p;
|
||
}
|
||
return pickRandomTileInQuizCarryHub(md, o);
|
||
}
|
||
|
||
function stepQuizCarryPreviewBots() {
|
||
if (!previewFillBots || !isQuizCarry() || !mapData || !quizCarryCurrent) return;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const nowPf = performance.now();
|
||
const pathfindMinGap = editorEmbedReturn ? 280 : 140;
|
||
others.forEach((o, id) => {
|
||
if (!isPreviewBotId(id)) return;
|
||
if (quizCarryFootprintOverlapsHub(o.x, o.y)) {
|
||
const sn = snapPositionOutOfQuizCarryHubIfNeeded(o.x, o.y);
|
||
if (Math.abs(sn.x - o.x) > 0.02 || Math.abs(sn.y - o.y) > 0.02) {
|
||
o.x = sn.x;
|
||
o.y = sn.y;
|
||
o.botPath = [];
|
||
o.botPathStuckTicks = 0;
|
||
o.botQuizCarryPathfindAfter = 0;
|
||
}
|
||
}
|
||
if (!o.botPath || o.botPath.length === 0) {
|
||
tryQuizCarryInteractionForPlayer(id, o.x, o.y, { silent: true });
|
||
}
|
||
/* ตอบถูกแล้ว pickNext เริ่มนับถอยหลัง → quizCarryCurrent เป็น null ในรอบ forEach เดียวกัน */
|
||
if (!quizCarryCurrent) return;
|
||
const tier = o.botTier || 'avg';
|
||
const pCorrect = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.35 : 0.65;
|
||
const rawWant = Math.random() < pCorrect ? quizCarryCurrent.correctIndex
|
||
: Math.floor(Math.random() * Math.max(2, (quizCarryCurrent.choices || []).length));
|
||
const wantIdx = pickQuizCarryTargetOptionIndexAvoidingTaken(rawWant);
|
||
if (!o.botPath || !o.botPath.length) {
|
||
if (typeof o.botQuizCarryPathfindAfter !== 'number') o.botQuizCarryPathfindAfter = 0;
|
||
if (nowPf < o.botQuizCarryPathfindAfter) return;
|
||
const jitter = (String(id).length * 31 + ((o.x | 0) ^ (o.y | 0)) * 7) % 160;
|
||
if (quizCarryOptionsPickableNow()) {
|
||
if (o.quizCarryHeld == null && wantIdx != null) {
|
||
const dest = pickRandomTileForQuizCarryOption(mapData, wantIdx, o);
|
||
if (dest) {
|
||
const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
|
||
if (path && path.length > 1) o.botPath = path.slice(1);
|
||
}
|
||
} else {
|
||
const sub = pickRandomTileForQuizCarrySubmit(mapData, o);
|
||
if (sub) {
|
||
const path = pathfindPlayForBot(o.x, o.y, sub.x + 0.5, sub.y + 0.5, o);
|
||
if (path && path.length > 1) o.botPath = path.slice(1);
|
||
}
|
||
}
|
||
o.botQuizCarryPathfindAfter = nowPf + pathfindMinGap + jitter;
|
||
} else {
|
||
o.botQuizCarryPathfindAfter = nowPf + 120;
|
||
}
|
||
} else {
|
||
stepPreviewBotAlongPath(o, w, h);
|
||
}
|
||
clampPlayEntityFootprintToMap(o, mapData);
|
||
});
|
||
separateClumpedPreviewBots();
|
||
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
|
||
const ob = others.get(bid);
|
||
if (ob) clampPlayEntityFootprintToMap(ob, mapData);
|
||
});
|
||
}
|
||
|
||
function getStackAreaBoundsPlay(area, w, h) {
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
let any = false;
|
||
for (let y = 0; y < h; y++) {
|
||
const row = area && area[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < w; x++) {
|
||
if (row[x] === 1) {
|
||
any = true;
|
||
minX = Math.min(minX, x);
|
||
maxX = Math.max(maxX, x);
|
||
minY = Math.min(minY, y);
|
||
maxY = Math.max(maxY, y);
|
||
}
|
||
}
|
||
}
|
||
if (!any) return null;
|
||
return {
|
||
minX, maxX, minY, maxY,
|
||
cx: (minX + maxX + 1) / 2,
|
||
cy: (minY + maxY + 1) / 2,
|
||
};
|
||
}
|
||
|
||
function getStackCameraCentersPx() {
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const ts = tileSize;
|
||
const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
|
||
const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
|
||
let cx = (w / 2) * ts;
|
||
let cy = (h / 2) * ts;
|
||
if (land && rel) {
|
||
cx = land.cx * ts;
|
||
cy = ((land.cy + rel.cy) / 2) * ts;
|
||
} else if (land) {
|
||
cx = land.cx * ts;
|
||
cy = land.cy * ts;
|
||
} else if (rel) {
|
||
cx = rel.cx * ts;
|
||
cy = rel.cy * ts;
|
||
}
|
||
return { px: cx, py: cy };
|
||
}
|
||
|
||
/** ใช้ playStackBlockWidthTiles + แผนที่ — เรียกหลังโหลด /api/game-timing เมื่อหอว่าง */
|
||
function reapplyStackMiniSizingFromGlobals() {
|
||
if (!mapData || !isStack() || !stackMini) return;
|
||
if (stackMini.layers && stackMini.layers.length > 0) return;
|
||
if (stackFall || stackMini.settling) return;
|
||
const wMap = mapData.width || 20;
|
||
const hMap = mapData.height || 15;
|
||
const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
|
||
const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
|
||
const iw = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
|
||
? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
|
||
: autoW;
|
||
stackMini.initialWidthTiles = iw;
|
||
stackMini.widthTiles = iw;
|
||
}
|
||
|
||
function resetStackMinigameState() {
|
||
stackMini = null;
|
||
if (!mapData || !isStack()) return;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
|
||
const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
|
||
let towerCX = w * 0.5;
|
||
let swingAmp = 1.85;
|
||
let floorWorldY = (h - 1) * tileSize;
|
||
let swingWorldY = floorWorldY - tileSize * 1.35;
|
||
if (land) {
|
||
towerCX = land.cx;
|
||
floorWorldY = (land.maxY + 1) * tileSize;
|
||
const span = Math.max(1, land.maxX - land.minX + 1);
|
||
swingAmp = Math.min(span * 0.44, Math.max(0.6, span * 0.36));
|
||
}
|
||
if (rel) {
|
||
swingWorldY = (rel.cy + 0.5) * tileSize;
|
||
const spanR = Math.max(1, rel.maxX - rel.minX + 1);
|
||
swingAmp = Math.max(swingAmp, Math.min(spanR * 0.42, 4.2));
|
||
}
|
||
const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
|
||
const initW = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
|
||
? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
|
||
: autoW;
|
||
stackFall = null;
|
||
stackMini = {
|
||
topCenterX: towerCX,
|
||
widthTiles: initW,
|
||
initialWidthTiles: initW,
|
||
craneWorldX: towerCX * tileSize,
|
||
swingAmp,
|
||
phase: Math.random() * Math.PI * 2,
|
||
phaseSpeed: playStackSwingHz,
|
||
floorWorldY,
|
||
swingWorldY,
|
||
layerWorldH: Math.max(14, tileSize * 0.3),
|
||
blockStripH: Math.max(16, tileSize * 0.32),
|
||
layers: [],
|
||
lives: 3,
|
||
score: 0,
|
||
combo: 0,
|
||
lastDropAt: 0,
|
||
stackTiltRad: 0,
|
||
stackTiltVel: 0,
|
||
};
|
||
if (previewFillBots) {
|
||
me.stackPreviewHumanPts = 0;
|
||
stackPreviewHudLog = [];
|
||
}
|
||
}
|
||
|
||
function stackEaseDrop(t) {
|
||
t = Math.max(0, Math.min(1, t));
|
||
return t * t;
|
||
}
|
||
|
||
/** ตกช้า/นุ่มขึ้นเมื่อปล่อยเพี้ยนกลาง (คล้าย Tower of Babel — มีน้ำหนักก่อนนิ่ง) */
|
||
function stackEaseDropPhys(t, sloppyN) {
|
||
t = Math.max(0, Math.min(1, t));
|
||
if (sloppyN < 0.14) return t * t;
|
||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||
}
|
||
|
||
/**
|
||
* @param {object} hit
|
||
* @param {{ human?: boolean, botId?: string } | undefined} previewAttribution — โหมด preview: แยกคะแนนแสดงในแถบ (ตึกยังเป็นของร่วม)
|
||
*/
|
||
function applyStackHitFromDrop(hit, previewAttribution) {
|
||
if (!stackMini || !hit) return;
|
||
if (hit.miss) {
|
||
stackMini.lives = Math.max(0, stackMini.lives - 1);
|
||
stackMini.combo = 0;
|
||
if (stackMini.lives <= 0) {
|
||
stackMini.score = Math.max(0, stackMini.score - 2);
|
||
stackMini.lives = 3;
|
||
stackMini.layers = [];
|
||
stackMini.widthTiles = stackMini.initialWidthTiles;
|
||
const lb = getStackAreaBoundsPlay(mapData.stackLandArea, mapData.width || 20, mapData.height || 15);
|
||
stackMini.topCenterX = lb ? lb.cx : (mapData.width || 20) * 0.5;
|
||
stackMini.combo = 0;
|
||
stackMini.stackTiltRad = 0;
|
||
stackMini.stackTiltVel = 0;
|
||
}
|
||
} else {
|
||
const W = stackMini.initialWidthTiles;
|
||
stackMini.layers.push({ w: W, cx: hit.placedCx });
|
||
stackMini.score += hit.pts;
|
||
if (previewFillBots && previewAttribution) {
|
||
if (previewAttribution.human) {
|
||
me.stackPreviewHumanPts = (me.stackPreviewHumanPts || 0) + hit.pts;
|
||
} else if (previewAttribution.botId) {
|
||
const ob = others.get(previewAttribution.botId);
|
||
if (ob) ob.stackBotScore = (ob.stackBotScore || 0) + hit.pts;
|
||
}
|
||
}
|
||
stackMini.combo = hit.perfect ? stackMini.combo + 1 : 0;
|
||
stackMini.widthTiles = W;
|
||
stackMini.topCenterX = hit.placedCx;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* บล็อกทุกชั้นหน้ากว้างเท่ากัน (initialWidthTiles) · พลาดเมื่อทับแท่นไม่พอ
|
||
* วาดชั้นเป็นสี่เหลี่ยมแนวนอน (ไม่หมุนต่อชั้น) เพื่อกองตรง ไม่บิดเป็นกองไม้
|
||
*/
|
||
function computeStackDropResult(m) {
|
||
const raw = m.swingAmp * Math.sin(m.phase);
|
||
const wMap = mapData.width || 20;
|
||
const hMap = mapData.height || 15;
|
||
const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
|
||
const fallW = m.initialWidthTiles;
|
||
let supportCx;
|
||
let supportW;
|
||
if (m.layers.length === 0) {
|
||
supportCx = land ? land.cx : wMap * 0.5;
|
||
supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
|
||
} else {
|
||
const sup = m.layers[m.layers.length - 1];
|
||
supportCx = sup.cx;
|
||
supportW = fallW;
|
||
}
|
||
const c1 = m.topCenterX + raw;
|
||
const half1 = fallW * 0.5;
|
||
const half0 = supportW * 0.5;
|
||
const left = Math.max(supportCx - half0, c1 - half1);
|
||
const right = Math.min(supportCx + half0, c1 + half1);
|
||
const overlap = right - left;
|
||
const missThreshold =
|
||
m.layers.length === 0
|
||
? Math.max(0.32, fallW * 0.11)
|
||
: Math.max(0.26, fallW * 0.17);
|
||
const miss = overlap < missThreshold;
|
||
const placedCx = c1;
|
||
const delta = c1 - supportCx;
|
||
const span = supportW * 0.5 + fallW * 0.5;
|
||
const offN = Math.abs(delta) / Math.max(0.01, span);
|
||
const perfect = !miss && offN < 0.075;
|
||
const bonus = perfect ? Math.min(4, m.combo || 0) : 0;
|
||
const pts = perfect ? (2 + bonus) : (miss ? 0 : 1);
|
||
const supportRatio = fallW > 0 ? overlap / fallW : 0;
|
||
return {
|
||
miss,
|
||
perfect,
|
||
pts,
|
||
landOffsetTiles: raw,
|
||
placedW: fallW,
|
||
placedCx,
|
||
overlap,
|
||
supportRatio,
|
||
delta,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* เริ่มแอนิเมชันบล็อกร่วง (ผู้เล่น/บอทใช้ path เดียวกัน)
|
||
* @param {object} hit จาก computeStackDropResult
|
||
* @param {{ human?: boolean, botId?: string } | null} previewActor โหมด preview: ใครเป็นคนปล่อย (สำหรับคะแนน + เลื่อนตา)
|
||
* @returns {boolean}
|
||
*/
|
||
function startStackFallAnimation(hit, previewActor) {
|
||
if (!stackMini || !hit || stackFall || stackMini.settling) return false;
|
||
const m = stackMini;
|
||
const tw = m.widthTiles * tileSize;
|
||
const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
|
||
const lh = m.layerWorldH || Math.max(14, tileSize * 0.3);
|
||
let y1 = m.floorWorldY - (m.layers.length + 1) * lh;
|
||
if (y1 <= m.swingWorldY) y1 = m.swingWorldY + lh * 0.55;
|
||
const landOff = hit.landOffsetTiles || 0;
|
||
const landCxWorld = (m.topCenterX + landOff) * tileSize;
|
||
const xLeft0 = swingXW - tw / 2;
|
||
const xLeft1 = landCxWorld - tw / 2;
|
||
const offN = Math.min(1, Math.abs(landOff) / Math.max(0.01, m.swingAmp));
|
||
const dur = Math.max(400, Math.min(1280, 240 + (y1 - m.swingWorldY) * 0.92 + offN * 560));
|
||
const tiltMax = hit.miss
|
||
? 0.28 * (0.25 + offN)
|
||
: 0.16 * offN * (hit.perfect ? 0.1 : 1);
|
||
const isHumanPreview = !!(previewActor && previewActor.human);
|
||
const botIdPreview = previewActor && previewActor.botId ? previewActor.botId : null;
|
||
stackFall = {
|
||
t0: performance.now(),
|
||
dur,
|
||
xLeft0,
|
||
xLeft1,
|
||
tw,
|
||
y0: m.swingWorldY,
|
||
y1,
|
||
hit,
|
||
tiltMax,
|
||
sloppyN: offN,
|
||
previewHumanDrop: isHumanPreview,
|
||
previewBotId: botIdPreview,
|
||
};
|
||
return true;
|
||
}
|
||
|
||
/** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */
|
||
function tryHumanStackDrop() {
|
||
if (!stackMini || !isStack() || stackFall || stackMini.settling) return false;
|
||
if (previewFillBots && mapData && mapData.gameType === 'stack' &&
|
||
(!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
|
||
rebuildStackPreviewTurnOrder();
|
||
}
|
||
if (previewFillBots && !isStackPreviewHumanTurn()) return false;
|
||
const now = Date.now();
|
||
if (now - stackMini.lastDropAt < 480) return false;
|
||
const hit = computeStackDropResult(stackMini);
|
||
if (!startStackFallAnimation(hit, previewFillBots ? { human: true } : null)) return false;
|
||
if (previewFillBots) {
|
||
lastStackPreviewActorId = '__human__';
|
||
lastStackPreviewActorUntil = Date.now() + 900;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** บอท preview: ปล่อยแบบมีแอนิเมชันร่วงเหมือนผู้เล่น — apply หลังแอนิเมชันจบ */
|
||
function simulatePreviewBotStackDrop(botId, o) {
|
||
if (!stackMini || stackFall || stackMini.settling) return;
|
||
const hit = computeStackDropResult(stackMini);
|
||
if (!startStackFallAnimation(hit, previewFillBots ? { botId } : null)) return;
|
||
o.stackBotGlowUntil = Date.now() + Math.max(900, 400 + (stackFall && stackFall.dur ? stackFall.dur : 800));
|
||
lastStackPreviewActorId = botId;
|
||
lastStackPreviewActorUntil = Date.now() + Math.max(1000, 500 + (stackFall && stackFall.dur ? stackFall.dur : 800));
|
||
}
|
||
|
||
/** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */
|
||
function stepPreviewStackTurnBased(nowMs) {
|
||
if (!previewFillBots || !isStack() || !stackMini || stackFall || stackMini.settling) return;
|
||
if (mapData && mapData.gameType === 'stack' &&
|
||
(!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
|
||
rebuildStackPreviewTurnOrder();
|
||
}
|
||
const order = stackPreviewTurnOrder;
|
||
if (!order || order.length !== STACK_PREVIEW_TURN_COUNT) return;
|
||
const cur = order[stackPreviewTurnIndex];
|
||
if (!cur || cur.kind !== 'bot') return;
|
||
if (!cur.botId) {
|
||
advanceStackPreviewTurn();
|
||
return;
|
||
}
|
||
const o = others.get(cur.botId);
|
||
if (!o) {
|
||
advanceStackPreviewTurn();
|
||
return;
|
||
}
|
||
if (stackPreviewBotThinkUntil === 0) {
|
||
stackPreviewBotThinkUntil = nowMs + 320 + Math.random() * 480;
|
||
return;
|
||
}
|
||
if (nowMs < stackPreviewBotThinkUntil) return;
|
||
stackPreviewBotThinkUntil = 0;
|
||
simulatePreviewBotStackDrop(cur.botId, o);
|
||
}
|
||
|
||
function stackTickFrame() {
|
||
const now = performance.now();
|
||
const dt = Math.min(0.06, (now - lastStackTickMs) / 1000);
|
||
lastStackTickMs = now;
|
||
if (stackMini && stackMini.settling) {
|
||
const s = stackMini.settling;
|
||
const u = (now - s.t0) / s.dur;
|
||
if (u >= 1) {
|
||
const wasHuman = !!s.previewHumanDrop;
|
||
const settleBotId = s.previewBotId || null;
|
||
applyStackHitFromDrop({ miss: true, perfect: false, pts: 0, landOffsetTiles: 0 }, undefined);
|
||
if (previewFillBots && (wasHuman || settleBotId)) {
|
||
stackPreviewLogStackDrop({ miss: true, perfect: false, pts: 0 }, { human: wasHuman, botId: settleBotId });
|
||
}
|
||
stackMini.settling = null;
|
||
stackMini.lastDropAt = Date.now();
|
||
if (previewFillBots && (wasHuman || settleBotId)) advanceStackPreviewTurn();
|
||
}
|
||
} else if (stackFall) {
|
||
const t = (now - stackFall.t0) / stackFall.dur;
|
||
if (t >= 1) {
|
||
const wasHuman = !!stackFall.previewHumanDrop;
|
||
const fallBotId = stackFall.previewBotId || null;
|
||
const hit = stackFall.hit;
|
||
const stableMin = 0.52;
|
||
if (hit.miss) {
|
||
applyStackHitFromDrop(hit);
|
||
if (previewFillBots && (wasHuman || fallBotId)) {
|
||
stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
|
||
}
|
||
stackMini.lastDropAt = Date.now();
|
||
stackFall = null;
|
||
if (previewFillBots && (wasHuman || fallBotId)) advanceStackPreviewTurn();
|
||
} else if (hit.supportRatio != null && hit.supportRatio < stableMin) {
|
||
const stripH = stackMini.blockStripH || Math.max(16, tileSize * 0.32);
|
||
stackMini.settling = {
|
||
t0: performance.now(),
|
||
dur: Math.max(820, Math.min(2600, 720 + (1 - hit.supportRatio) * 3400)),
|
||
hit,
|
||
xLeft0: stackFall.xLeft1,
|
||
yTop0: stackFall.y1,
|
||
twWorld: stackFall.tw,
|
||
stripH,
|
||
delta: hit.delta || 0,
|
||
previewHumanDrop: wasHuman,
|
||
previewBotId: fallBotId,
|
||
};
|
||
stackFall = null;
|
||
} else {
|
||
let previewAttr;
|
||
if (previewFillBots) {
|
||
if (wasHuman) previewAttr = { human: true };
|
||
else if (fallBotId) previewAttr = { botId: fallBotId };
|
||
}
|
||
applyStackHitFromDrop(hit, previewAttr);
|
||
if (previewFillBots && (wasHuman || fallBotId)) {
|
||
stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
|
||
}
|
||
stackMini.lastDropAt = Date.now();
|
||
stackFall = null;
|
||
if (previewFillBots && (wasHuman || fallBotId)) advanceStackPreviewTurn();
|
||
}
|
||
}
|
||
} else if (stackMini) {
|
||
stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2;
|
||
}
|
||
stepPreviewStackTurnBased(Date.now());
|
||
}
|
||
|
||
function applyStackPreviewSpawnLayout() {
|
||
if (!mapData || mapData.gameType !== 'stack') return;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
|
||
const cx = land ? land.cx : w / 2 - 0.5;
|
||
const cy = land ? land.cy : h / 2 - 0.5;
|
||
me.x = cx;
|
||
me.y = cy;
|
||
me.tx = cx;
|
||
me.ty = cy;
|
||
others.forEach((o, id) => {
|
||
if (!isPreviewBotId(id)) return;
|
||
o.x = cx + (Math.random() - 0.5) * 0.15;
|
||
o.y = cy + (Math.random() - 0.5) * 0.15;
|
||
o.tx = o.x;
|
||
o.ty = o.y;
|
||
o.stackBotScore = 0;
|
||
o.stackBotNextTry = Date.now() + 300 + Math.random() * 700;
|
||
});
|
||
}
|
||
|
||
function drawStackMinigame(ctx, worldToScreen, zoom) {
|
||
if (!stackMini) return;
|
||
const m = stackMini;
|
||
const layerWorldH = m.layerWorldH || Math.max(14, tileSize * 0.3);
|
||
const stripH = m.blockStripH || Math.max(16, tileSize * 0.32);
|
||
const floorY = m.floorWorldY;
|
||
const guideTopY = Math.max(0, m.swingWorldY - tileSize * 6.5);
|
||
const [gtx, gty] = worldToScreen(m.topCenterX * tileSize, guideTopY);
|
||
const [gbx, gby] = worldToScreen(m.topCenterX * tileSize, floorY);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
|
||
ctx.lineWidth = Math.max(1, zoom * 0.85);
|
||
ctx.setLineDash([5, 7]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(gtx, gty);
|
||
ctx.lineTo(gbx, gby);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.lineWidth = 1;
|
||
|
||
const nLay = m.layers.length;
|
||
for (let i = 0; i < nLay; i++) {
|
||
const L = m.layers[i];
|
||
const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize;
|
||
const cxW = L.cx * tileSize;
|
||
const yb = floorY - (i + 1) * layerWorldH;
|
||
const xl = cxW - lw / 2;
|
||
const [sx, sy] = worldToScreen(xl, yb);
|
||
const drawW = lw * zoom;
|
||
const drawH = layerWorldH * zoom;
|
||
const hue = (i * 47) % 360;
|
||
ctx.fillStyle = 'hsla(' + hue + ', 68%, 56%, 0.9)';
|
||
ctx.fillRect(sx, sy, drawW, drawH);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.38)';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(sx, sy, drawW, drawH);
|
||
}
|
||
if (m.settling) {
|
||
const s = m.settling;
|
||
const u = Math.min(1, (performance.now() - s.t0) / s.dur);
|
||
const ue = u * u;
|
||
const rotSign = s.delta >= 0 ? 1 : -1;
|
||
const rotRad = ue * 1.22 * rotSign;
|
||
const yFall = ue * ue * tileSize * 6.5;
|
||
const xSlide = rotSign * ue * tileSize * 0.62;
|
||
const cxW = s.xLeft0 + s.twWorld / 2 + xSlide;
|
||
const cyW = s.yTop0 + s.stripH * 0.5 + yFall;
|
||
const [cxs, cys] = worldToScreen(cxW, cyW);
|
||
const drawW = s.twWorld * zoom;
|
||
const drawH = s.stripH * zoom;
|
||
const hue = (m.layers.length * 47) % 360;
|
||
ctx.save();
|
||
ctx.translate(cxs, cys);
|
||
ctx.rotate(rotRad);
|
||
ctx.fillStyle = 'hsla(' + hue + ', 70%, 52%, 0.93)';
|
||
ctx.fillRect(-drawW / 2, -drawH / 2, drawW, drawH);
|
||
ctx.strokeStyle = 'rgba(255, 200, 140, 0.9)';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(-drawW / 2, -drawH / 2, drawW, drawH);
|
||
ctx.restore();
|
||
} else {
|
||
const twWorld = m.widthTiles * tileSize;
|
||
const drawStripPx = stripH * zoom;
|
||
let blockLeftWorld;
|
||
let blockTopWorld;
|
||
let fallTiltRad = 0;
|
||
if (stackFall) {
|
||
const tRaw = (performance.now() - stackFall.t0) / stackFall.dur;
|
||
const t = Math.min(1, tRaw);
|
||
const u = stackEaseDropPhys(t, stackFall.sloppyN);
|
||
blockLeftWorld = stackFall.xLeft0 + (stackFall.xLeft1 - stackFall.xLeft0) * u;
|
||
blockTopWorld = stackFall.y0 + (stackFall.y1 - stackFall.y0) * u;
|
||
fallTiltRad = stackFall.tiltMax * Math.sin(Math.PI * u);
|
||
} else {
|
||
const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
|
||
blockLeftWorld = swingXW - twWorld / 2;
|
||
blockTopWorld = m.swingWorldY;
|
||
const cableTopY = Math.max(0, m.swingWorldY - tileSize * 6);
|
||
const craneX = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
|
||
const cxMid = blockLeftWorld + twWorld / 2;
|
||
const [tcx, tcy] = worldToScreen(craneX, cableTopY);
|
||
const [bcx, bcy] = worldToScreen(cxMid, blockTopWorld);
|
||
ctx.strokeStyle = 'rgba(90, 88, 86, 0.75)';
|
||
ctx.lineWidth = Math.max(2.2, zoom * 1.4);
|
||
ctx.setLineDash([]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(tcx, tcy);
|
||
ctx.lineTo(bcx, bcy);
|
||
ctx.stroke();
|
||
ctx.strokeStyle = 'rgba(125, 207, 255, 0.35)';
|
||
ctx.lineWidth = Math.max(1, zoom * 0.65);
|
||
ctx.setLineDash([3, 5]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(tcx, tcy);
|
||
ctx.lineTo(bcx, bcy);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.lineWidth = 1;
|
||
}
|
||
const hitMissTint = stackFall && stackFall.hit && stackFall.hit.miss;
|
||
ctx.fillStyle = hitMissTint ? 'rgba(247, 118, 190, 0.88)' : 'rgba(125, 207, 255, 0.95)';
|
||
ctx.strokeStyle = hitMissTint ? 'rgba(255, 180, 210, 0.98)' : 'rgba(192, 202, 245, 0.98)';
|
||
const cxW = blockLeftWorld + twWorld / 2;
|
||
const cyW = blockTopWorld + stripH * 0.5;
|
||
const [cxs, cys] = worldToScreen(cxW, cyW);
|
||
ctx.save();
|
||
ctx.translate(cxs, cys);
|
||
ctx.rotate(fallTiltRad);
|
||
ctx.fillRect(-twWorld * zoom / 2, -drawStripPx / 2, twWorld * zoom, drawStripPx);
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(-twWorld * zoom / 2, -drawStripPx / 2, twWorld * zoom, drawStripPx);
|
||
ctx.lineWidth = 1;
|
||
ctx.restore();
|
||
}
|
||
const [fsx, fsy] = worldToScreen(0, floorY);
|
||
const [fex, fey] = worldToScreen(mapData.width * tileSize, floorY);
|
||
ctx.strokeStyle = 'rgba(247, 118, 190, 0.65)';
|
||
ctx.lineWidth = 3;
|
||
ctx.beginPath();
|
||
ctx.moveTo(fsx, fsy);
|
||
ctx.lineTo(fex, fey);
|
||
ctx.stroke();
|
||
ctx.lineWidth = 1;
|
||
}
|
||
|
||
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)));
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(payload, 'stackSwingHz')) {
|
||
const sh = Number(payload.stackSwingHz);
|
||
if (Number.isFinite(sh)) playStackSwingHz = Math.max(0.08, Math.min(2.8, sh));
|
||
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockWidthTiles')) {
|
||
const raw = payload.stackBlockWidthTiles;
|
||
if (raw == null || raw === '') playStackBlockWidthTiles = null;
|
||
else {
|
||
const nw = Number(raw);
|
||
playStackBlockWidthTiles = Number.isFinite(nw) && nw >= 0.85
|
||
? Math.max(0.85, Math.min(3.2, Math.round(nw * 100) / 100))
|
||
: null;
|
||
}
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveJumpHeightMult')) {
|
||
const jm = Number(payload.jumpSurviveJumpHeightMult);
|
||
if (Number.isFinite(jm)) {
|
||
playJumpSurviveJumpHeightMult = Math.round(Math.max(0.5, Math.min(4, jm)) * 100) / 100;
|
||
}
|
||
}
|
||
if (isStack() && stackMini) reapplyStackMiniSizingFromGlobals();
|
||
}
|
||
|
||
/** เลอร์ปตำแหน่งตัวละครต่อเฟรม (ยิ่งสูงยิ่งตามเป้าเร็ว) */
|
||
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 };
|
||
}
|
||
|
||
/** Hub = 0/1 · กริดตัวเลือก = 0 หรือเลข 1..16 (ห้ามบังคับ === 1 เหมือน hub — เลข 2–16จะหาย) */
|
||
function normalizeQuizCarryLayersInPlay(md) {
|
||
if (!md || md.gameType !== 'quiz_carry') return;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const maxOpt = QUIZ_CARRY_MAX_OPTION_SLOTS;
|
||
const srcHub = md.quizCarryHubArea || [];
|
||
const hubRows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = srcHub[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
hubRows.push(row);
|
||
}
|
||
md.quizCarryHubArea = hubRows;
|
||
const srcOpt = md.quizCarryOptionArea || [];
|
||
const optRows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = srcOpt[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) {
|
||
const v = r && r[x];
|
||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||
row.push(Number.isFinite(n) && n >= 1 && n <= maxOpt ? Math.floor(n) : 0);
|
||
}
|
||
optRows.push(row);
|
||
}
|
||
md.quizCarryOptionArea = optRows;
|
||
}
|
||
|
||
/** โดม = ช่องติดกัน (4 ทิศ) ต่อ 1 ข้อ · comp id เรียงตามการค้นพบบนแมป */
|
||
function normalizeQuizBattleDomeInPlay(md) {
|
||
if (!md || md.gameType !== 'quiz_battle') return;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const src = md.quizBattleDomeArea || [];
|
||
const grid = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
grid.push(row);
|
||
}
|
||
md.quizBattleDomeArea = grid;
|
||
const comp = Array(h).fill(0).map(() => Array(w).fill(0));
|
||
let nextId = 0;
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
if (grid[y][x] !== 1 || comp[y][x]) continue;
|
||
nextId++;
|
||
const stack = [[x, y]];
|
||
comp[y][x] = nextId;
|
||
while (stack.length) {
|
||
const c = stack.pop();
|
||
const cx = c[0], cy = c[1];
|
||
for (let i = 0; i < 4; i++) {
|
||
const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
|
||
const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
|
||
const nx = cx + dx, ny = cy + dy;
|
||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||
if (grid[ny][nx] !== 1 || comp[ny][nx]) continue;
|
||
comp[ny][nx] = nextId;
|
||
stack.push([nx, ny]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
md.quizBattleDomeComp = comp;
|
||
}
|
||
|
||
/** เส้นทาง Quiz Battle — วาดอย่างน้อย 1 ช่องแล้วจะเดินได้เฉพาะบนเส้นทาง (เปลี่ยนแค่รูปพื้นหลังได้) */
|
||
function normalizeQuizBattlePathInPlay(md) {
|
||
if (!md || md.gameType !== 'quiz_battle') return;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const src = md.quizBattlePathArea || [];
|
||
const grid = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
grid.push(row);
|
||
}
|
||
md.quizBattlePathArea = grid;
|
||
}
|
||
|
||
function quizBattlePathModeActive(md) {
|
||
if (!md || md.gameType !== 'quiz_battle' || !md.quizBattlePathArea) return false;
|
||
const g = md.quizBattlePathArea;
|
||
for (let y = 0; y < g.length; y++) {
|
||
const row = g[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function quizBattleFootprintFullyOnPath(md, px, py) {
|
||
if (!md || !quizBattlePathModeActive(md)) return true;
|
||
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
|
||
const tiles = quizTilesFootprintPlay(px, py);
|
||
if (tiles.size === 0) return false;
|
||
const g = md.quizBattlePathArea;
|
||
for (const k of tiles) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
if (!g[ty] || g[ty][tx] !== 1) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** สแน็ปบนเลน — เช็คแค่กำแพง + อยู่บน path (ให้ตรงกับ server) ไม่ใช้ blockPlayer/ผู้เล่นคนอื่น เพราะจะทำให้หาจุดสแน็ปไม่ได้แล้วค้างนอกเลน */
|
||
function quizBattleSnapTargetValidPlay(x, y) {
|
||
if (!mapData || !mapData.objects) return false;
|
||
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
for (const k of quizTilesFootprintPlay(x, y)) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
|
||
const row = mapData.objects[ty];
|
||
if (!row || row[tx] === 1) return false;
|
||
}
|
||
if (quizBattlePathModeActive(mapData) && !quizBattleFootprintFullyOnPath(mapData, x, y)) return false;
|
||
return true;
|
||
}
|
||
|
||
function snapPositionOntoQuizBattlePathIfNeeded(px, py) {
|
||
if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return { x: px, y: py };
|
||
if (quizBattleSnapTargetValidPlay(px, py)) return { x: px, y: py };
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const g = mapData.quizBattlePathArea;
|
||
let bestX = null, bestY = null, bestD = Infinity;
|
||
for (let ty = 0; ty < h; ty++) {
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (!g[ty] || g[ty][tx] !== 1) continue;
|
||
const nx = tx + 0.01;
|
||
const ny = ty + 0.01;
|
||
if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
|
||
const d = Math.abs(nx - px) + Math.abs(ny - py);
|
||
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
|
||
}
|
||
}
|
||
if (bestX != null) return { x: bestX, y: bestY };
|
||
/* footprint ใหญ่กว่าเลน (เช่น 2×2 บนทางแคบ 1 ช่อง): หาช่อง path ใกล้สุดที่เดินได้อย่างน้อยที่เซ็นเตอร์ — ดีกว่าปล่อยค้างนอกเลน */
|
||
for (let ty = 0; ty < h; ty++) {
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (!g[ty] || g[ty][tx] !== 1) continue;
|
||
if (!spawnTileWalkablePlay(mapData, tx, ty)) continue;
|
||
const nx = tx + 0.5;
|
||
const ny = ty + 0.5;
|
||
const d = Math.abs(nx - px) + Math.abs(ny - py);
|
||
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
|
||
}
|
||
}
|
||
if (bestX != null) return { x: bestX, y: bestY };
|
||
return { x: px, y: py };
|
||
}
|
||
|
||
/** บังคับให้ผู้เล่นอยู่บนเลน — เรียกก่อน return กลาง tick (แชท / path หมด) เพราะเดิมข้ามบล็อกสแน็ปท้าย tick */
|
||
function enforceQuizBattleLaneOnMePlay() {
|
||
if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return;
|
||
if (quizBattleSnapTargetValidPlay(me.x, me.y)) return;
|
||
const snapped = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
|
||
me.x = snapped.x;
|
||
me.y = snapped.y;
|
||
}
|
||
|
||
function hideQuizBattleMcqModal() {
|
||
const ov = document.getElementById('quiz-battle-mcq-overlay');
|
||
if (ov) {
|
||
ov.classList.add('is-hidden');
|
||
ov.setAttribute('aria-hidden', 'true');
|
||
}
|
||
}
|
||
|
||
function flashQuizBattleFeedback(text, ok) {
|
||
const el = document.getElementById('play-quiz-feedback');
|
||
if (!el) return;
|
||
el.textContent = text || '';
|
||
el.classList.remove('is-hidden', 'play-quiz-feedback-ok', 'play-quiz-feedback-bad');
|
||
el.classList.add(ok ? 'play-quiz-feedback-ok' : 'play-quiz-feedback-bad');
|
||
if (typeof window.__quizBattleFbT === 'number') clearTimeout(window.__quizBattleFbT);
|
||
window.__quizBattleFbT = setTimeout(() => { el.classList.add('is-hidden'); }, 1800);
|
||
}
|
||
|
||
function getQuizBattleDomeCompIdsUnderFootprint(px, py) {
|
||
if (!mapData || !isQuizBattle()) return [];
|
||
const comp = mapData.quizBattleDomeComp;
|
||
const grid = mapData.quizBattleDomeArea;
|
||
if (!comp || !grid) return [];
|
||
const ids = [];
|
||
const seen = Object.create(null);
|
||
for (const k of quizTilesFootprintPlay(px, py)) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
const cid = comp[ty] && comp[ty][tx];
|
||
if (grid[ty] && grid[ty][tx] === 1 && cid > 0 && !seen[cid]) {
|
||
seen[cid] = 1;
|
||
ids.push(cid);
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
function getPrimaryQuizBattleDomeCompAt(px, py) {
|
||
const arr = getQuizBattleDomeCompIdsUnderFootprint(px, py);
|
||
if (!arr.length) return null;
|
||
return Math.min.apply(null, arr);
|
||
}
|
||
|
||
function showQuizBattleMcqModal(q) {
|
||
const ov = document.getElementById('quiz-battle-mcq-overlay');
|
||
const textEl = document.getElementById('quiz-battle-mcq-text');
|
||
if (!ov || !textEl || !q) return;
|
||
textEl.textContent = q.text || '';
|
||
const labels = ['A', 'B', 'C'];
|
||
for (let i = 0; i < 3; i++) {
|
||
const btn = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]');
|
||
if (btn) {
|
||
const ch = (q.choices && q.choices[i]) ? String(q.choices[i]) : '';
|
||
btn.textContent = labels[i] + (ch ? (': ' + ch.slice(0, 96)) : '');
|
||
}
|
||
}
|
||
ov.classList.remove('is-hidden');
|
||
ov.setAttribute('aria-hidden', 'false');
|
||
}
|
||
|
||
function trySubmitQuizBattleChoice(idx) {
|
||
if (quizBattleModalCompId == null || !mapData || !isQuizBattle()) return;
|
||
const compId = quizBattleModalCompId;
|
||
const pool = quizBattleMcqPool;
|
||
if (!pool.length) {
|
||
flashQuizBattleFeedback('ยังไม่มีคำถาม — ตั้ง battleQuizMcq ใน Admin → Quiz Battle', false);
|
||
quizBattleModalCompId = null;
|
||
hideQuizBattleMcqModal();
|
||
return;
|
||
}
|
||
const q = pool[(compId - 1) % pool.length];
|
||
if (!q) return;
|
||
const ok = Number(q.correctIndex) === Number(idx);
|
||
if (ok) {
|
||
quizBattleAnsweredComps.add(compId);
|
||
if (myId != null) {
|
||
if (!playLiveQuizScores) playLiveQuizScores = {};
|
||
playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1;
|
||
renderPlayQuizScoreboard(playLiveQuizScores);
|
||
}
|
||
flashQuizBattleFeedback('ถูกต้อง · +1', true);
|
||
} else {
|
||
flashQuizBattleFeedback('ยังไม่ถูก — ลองใหม่ (กด E อีกครั้ง)', false);
|
||
}
|
||
quizBattleModalCompId = null;
|
||
hideQuizBattleMcqModal();
|
||
}
|
||
|
||
function tryOpenQuizBattleFromKey() {
|
||
if (!isQuizBattle() || !mapData || myId == null) return;
|
||
const compId = getPrimaryQuizBattleDomeCompAt(me.x, me.y);
|
||
if (compId == null) {
|
||
flashQuizBattleFeedback('ยังไม่ยืนบนโดมคำถาม', false);
|
||
return;
|
||
}
|
||
if (quizBattleAnsweredComps.has(compId)) {
|
||
flashQuizBattleFeedback('ตอบโดมนี้ถูกแล้ว', false);
|
||
return;
|
||
}
|
||
const pool = quizBattleMcqPool;
|
||
if (!pool.length) {
|
||
flashQuizBattleFeedback('ยังไม่มีคำถามในระบบ (Admin → Quiz Battle)', false);
|
||
return;
|
||
}
|
||
const q = pool[(compId - 1) % pool.length];
|
||
if (!q) return;
|
||
quizBattleModalCompId = compId;
|
||
showQuizBattleMcqModal(q);
|
||
}
|
||
|
||
function pushSanitizedBattleMcqFromPayload(s) {
|
||
if (!s || !Array.isArray(s.battleQuizMcq)) return 0;
|
||
let n = 0;
|
||
for (let i = 0; i < s.battleQuizMcq.length; i++) {
|
||
const raw = s.battleQuizMcq[i];
|
||
if (!raw || !String(raw.text || '').trim()) continue;
|
||
const ch = Array.isArray(raw.choices) ? raw.choices : [];
|
||
if (ch.length !== 3) continue;
|
||
const a = String(ch[0] || '').trim();
|
||
const b = String(ch[1] || '').trim();
|
||
const c = String(ch[2] || '').trim();
|
||
if (!a || !b || !c) continue;
|
||
let ci = Number(raw.correctIndex);
|
||
if (!Number.isFinite(ci)) ci = 0;
|
||
ci = Math.max(0, Math.min(2, Math.floor(ci)));
|
||
quizBattleMcqPool.push({
|
||
text: String(raw.text).trim(),
|
||
choices: [a, b, c],
|
||
correctIndex: ci,
|
||
});
|
||
n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/**
|
||
* โหลดชุดข้อ A/B/C — ลอง Node ก่อน แล้ว fallback PHP อ่านจากดิสก์ (กรณี nginx ไม่ส่ง /api/quiz-settings ถึง Node)
|
||
*/
|
||
async function loadQuizBattleMcqPool() {
|
||
quizBattleMcqPool = [];
|
||
const bust = '_=' + Date.now();
|
||
const tryUrls = [
|
||
BASE + '/api/quiz-settings?' + bust,
|
||
BASE + '/api-quiz-battle-mcq.php?' + bust,
|
||
];
|
||
for (let u = 0; u < tryUrls.length; u++) {
|
||
try {
|
||
const r = await fetch(tryUrls[u], { cache: 'no-store' });
|
||
if (!r.ok) continue;
|
||
const s = await r.json();
|
||
if (pushSanitizedBattleMcqFromPayload(s) > 0) break;
|
||
} catch (e) { /* next url */ }
|
||
}
|
||
}
|
||
|
||
function resetQuizBattlePlayState() {
|
||
quizBattleMcqPool = [];
|
||
quizBattleAnsweredComps = new Set();
|
||
quizBattleModalCompId = null;
|
||
hideQuizBattleMcqModal();
|
||
}
|
||
|
||
function setupPlayQuizBattleUi() {
|
||
if (playQuizTimerInterval) {
|
||
clearInterval(playQuizTimerInterval);
|
||
playQuizTimerInterval = null;
|
||
}
|
||
const ov = document.getElementById('quiz-game-overlay');
|
||
/* ทดสอบจากเอดิเตอร์: อย่าเปิดแผงคำถามเต็มจอล่าง — บังมองเห็นบอท/แผนที่ (overlay ยังอัปเดตข้อความไว้ถ้าเปิด preview แบบไม่ embed) */
|
||
if (ov) {
|
||
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
|
||
else ov.classList.remove('is-hidden');
|
||
}
|
||
const phaseEl = document.getElementById('quiz-game-phase-label');
|
||
if (phaseEl) phaseEl.textContent = 'Quiz Battle';
|
||
const qEl = document.getElementById('quiz-game-question');
|
||
if (qEl) qEl.textContent = 'เดินทับโดม (สีฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C';
|
||
const tEl = document.getElementById('quiz-game-timer');
|
||
if (tEl) tEl.textContent = '';
|
||
const leg = document.getElementById('quiz-play-legend');
|
||
if (leg) {
|
||
leg.textContent = quizBattlePathModeActive(mapData)
|
||
? 'โหมดเส้นทาง: เดินได้เฉพาะช่องเส้นทาง (ม่วงบนแผนที่) — วางโดมบนเส้นทาง · ไม่วาดเส้นทางใน Editor = เดินอิสระ · เปลี่ยนรูปพื้นหลังได้ทีหลัง'
|
||
: 'คำถามจาก Admin → Quiz Battle (battleQuizMcq) · ช่องโดมติดกัน = 1 ข้อ · ถูกแล้วได้ +1 ต่อโดม';
|
||
}
|
||
quizBattleAnsweredComps = new Set();
|
||
quizBattleModalCompId = null;
|
||
quizBattleMcqPool = [];
|
||
hideQuizBattleMcqModal();
|
||
playLiveQuizScores = {};
|
||
if (myId != null) playLiveQuizScores[myId] = 0;
|
||
others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
|
||
renderPlayQuizScoreboard(playLiveQuizScores);
|
||
loadQuizBattleMcqPool().then(() => {
|
||
const q2 = document.getElementById('quiz-game-question');
|
||
if (!q2) return;
|
||
if (!quizBattleMcqPool.length) {
|
||
q2.textContent = 'ยังไม่มีคำถาม — ไปที่ Admin แท็บ Quiz Battle แล้วบันทึกข้อ A/B/C (ครบคำถาม + A + B + C) · หรือเช็คว่าเซิร์ฟเวอร์เกม (Node) รันอยู่ · ลองรีเฟรชแบบ hard refresh (Ctrl+F5)';
|
||
} else {
|
||
const q0 = quizBattleMcqPool[0];
|
||
const snippet = q0 && q0.text ? String(q0.text).trim().slice(0, 100) : '';
|
||
const more = q0 && q0.text && String(q0.text).trim().length > 100 ? '…' : '';
|
||
q2.textContent = snippet
|
||
? ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ · ตัวอย่าง: 「' + snippet + more + '」\nเข้าโดมสีฟ้า (ขอบแดง) แล้วกด E เพื่อเลือก A / B / C')
|
||
: ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ — เดินเข้าโดม (ฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C');
|
||
}
|
||
const ov2 = document.getElementById('quiz-game-overlay');
|
||
if (ov2 && previewMode && editorEmbedReturn) ov2.classList.add('is-hidden');
|
||
});
|
||
}
|
||
|
||
function normalizeJumpSurvivePlatformAreaInPlay(md) {
|
||
if (!md || md.gameType !== 'jump_survive') return;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const src = md.jumpSurvivePlatformArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
rows.push(row);
|
||
}
|
||
md.jumpSurvivePlatformArea = rows;
|
||
}
|
||
|
||
function normalizeShooterSpawnSlotsInPlay(md) {
|
||
if (!md || md.gameType !== 'space_shooter') return;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const src = md.shooterSpawnSlots || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) {
|
||
const v = r && r[x];
|
||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||
row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
|
||
}
|
||
rows.push(row);
|
||
}
|
||
md.shooterSpawnSlots = rows;
|
||
}
|
||
|
||
function getShooterSpawnWorldCenterFromMap(md, slot1to6, ts) {
|
||
if (!md || !md.shooterSpawnSlots) return null;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const g = md.shooterSpawnSlots;
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildSpaceShooterParticipantRefsPlay() {
|
||
const refs = [];
|
||
if (me) refs.push({ id: myId, ref: me });
|
||
const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
|
||
humanIds.forEach((id) => {
|
||
const o = others.get(id);
|
||
if (o) refs.push({ id, ref: o });
|
||
});
|
||
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
|
||
botIds.forEach((id) => {
|
||
const o = others.get(id);
|
||
if (o) refs.push({ id, ref: o });
|
||
});
|
||
return refs.filter((r) => r.ref);
|
||
}
|
||
|
||
function applySpaceShooterSpawnLayoutPlay() {
|
||
if (!mapData || mapData.gameType !== 'space_shooter') return;
|
||
normalizeShooterSpawnSlotsInPlay(mapData);
|
||
const ts = tileSize;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const { cw, ch } = getCharacterFootprintWH(mapData);
|
||
const mw = w * ts, mh = h * ts;
|
||
const refs = buildSpaceShooterParticipantRefsPlay();
|
||
const n = refs.length || 1;
|
||
refs.forEach((entry, idx) => {
|
||
const ent = entry.ref;
|
||
const slot = (idx % 6) + 1;
|
||
const cen = getShooterSpawnWorldCenterFromMap(mapData, slot, ts);
|
||
let cx, cy;
|
||
if (cen) {
|
||
cx = cen.cx;
|
||
cy = cen.cy;
|
||
} else {
|
||
cx = ((idx + 1) / (n + 1)) * mw;
|
||
cy = Math.min(mh - ts * 1.2, (h - 1.5) * ts);
|
||
}
|
||
cx = Math.max(ts * 0.5, Math.min(mw - ts * 0.5, cx));
|
||
cy = clampSpaceShooterWorldCy(cy, mh);
|
||
ent.spaceShooterCx = cx;
|
||
ent.spaceShooterCy = cy;
|
||
if (typeof ent.spaceShooterScore !== 'number' || !Number.isFinite(ent.spaceShooterScore)) ent.spaceShooterScore = 0;
|
||
ent.x = cx / ts - cw * 0.5;
|
||
ent.y = cy / ts - ch * 0.92;
|
||
ent.tx = ent.x;
|
||
ent.ty = ent.y;
|
||
ent.direction = 'up';
|
||
});
|
||
}
|
||
|
||
function spaceShooterTimeLimitSecPlay() {
|
||
const t = Number(mapData && mapData.spaceShooterTimeSec);
|
||
if (Number.isFinite(t) && t === 0) return 0;
|
||
if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
|
||
return 90;
|
||
}
|
||
|
||
function spaceShooterRemainingSecPlay() {
|
||
const lim = spaceShooterTimeLimitSecPlay();
|
||
if (lim <= 0) return null;
|
||
const elapsed = (performance.now() - spaceShooterSessionStartMs) / 1000;
|
||
return Math.max(0, Math.ceil(lim - elapsed));
|
||
}
|
||
|
||
function endSpaceShooterTimeUp() {
|
||
if (spaceShooterGameEnded || !mapData || mapData.gameType !== 'space_shooter') return;
|
||
spaceShooterGameEnded = true;
|
||
spaceShooterBullets = [];
|
||
spaceShooterAsteroids = [];
|
||
spaceShooterSpawnAccMs = 0;
|
||
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;
|
||
if (titleEl) titleEl.textContent = 'หมดเวลา · Time up';
|
||
msgEl.textContent = 'ยิงยานอวกาศจบแล้ว · SPACE SHOOTER finished — อันดับจากคะแนนยิงหิน';
|
||
listEl.innerHTML = '';
|
||
const ranks = [];
|
||
if (myId != null) {
|
||
ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.spaceShooterScore | 0) });
|
||
}
|
||
others.forEach((o, id) => {
|
||
ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.spaceShooterScore | 0) });
|
||
});
|
||
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
|
||
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();
|
||
};
|
||
}
|
||
}
|
||
|
||
function syncSpaceShooterFootFromShipCenter(ent) {
|
||
if (!ent || !mapData || ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
|
||
const { cw, ch } = getCharacterFootprintWH(mapData);
|
||
const ts = tileSize;
|
||
ent.x = ent.spaceShooterCx / ts - cw * 0.5;
|
||
ent.y = ent.spaceShooterCy / ts - ch * 0.92;
|
||
}
|
||
|
||
/** จำกัดกลางยานแนวตั้งให้อยู่แค่ครึ่งล่างของแมพ (ไม่ขึ้นเกินกึ่งกลางความสูง) */
|
||
function clampSpaceShooterWorldCy(cy, mhPx) {
|
||
if (!Number.isFinite(cy) || !Number.isFinite(mhPx) || mhPx <= 0) return cy;
|
||
const edge = 20;
|
||
const lo = mhPx * 0.5;
|
||
const hi = Math.max(lo + 4, mhPx - edge);
|
||
return Math.max(lo, Math.min(hi, cy));
|
||
}
|
||
|
||
function stepSpaceShooterPreviewBots(dt) {
|
||
if (spaceShooterGameEnded || !previewFillBots || !mapData || mapData.gameType !== 'space_shooter') return;
|
||
const ts = tileSize;
|
||
const mw = (mapData.width || 20) * ts;
|
||
const mh = (mapData.height || 15) * ts;
|
||
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
|
||
const o = others.get(bid);
|
||
if (!o || o.spaceShooterCx == null) return;
|
||
let nearest = null, nd = 1e9;
|
||
for (let i = 0; i < spaceShooterAsteroids.length; i++) {
|
||
const a = spaceShooterAsteroids[i];
|
||
if (!a) continue;
|
||
const d = Math.abs(a.x - o.spaceShooterCx) + Math.abs(a.y - o.spaceShooterCy) * 0.15;
|
||
if (d < nd) { nd = d; nearest = a; }
|
||
}
|
||
let vx = 0;
|
||
if (nearest) vx = nearest.x > o.spaceShooterCx ? 1 : nearest.x < o.spaceShooterCx ? -1 : 0;
|
||
else vx = Math.sin(performance.now() / 900 + bid.length) > 0 ? 1 : -1;
|
||
let vy = 0;
|
||
if (nearest && Math.abs(nearest.y - o.spaceShooterCy) > ts * 0.25) {
|
||
vy = nearest.y > o.spaceShooterCy ? 1 : -1;
|
||
} else if (!nearest) {
|
||
vy = Math.sin(performance.now() / 700 + bid.charCodeAt(0)) > 0 ? 1 : -1;
|
||
}
|
||
const spd = (o.botTier === 'sharp' ? 195 : o.botTier === 'weak' ? 115 : 155);
|
||
o.spaceShooterCx = Math.max(18, Math.min(mw - 18, o.spaceShooterCx + vx * spd * dt));
|
||
o.spaceShooterCy = clampSpaceShooterWorldCy(o.spaceShooterCy + vy * spd * 0.7 * dt, mh);
|
||
syncSpaceShooterFootFromShipCenter(o);
|
||
o.tx = o.x;
|
||
o.ty = o.y;
|
||
if (typeof o.spaceShooterBotFireCd !== 'number') o.spaceShooterBotFireCd = 0;
|
||
o.spaceShooterBotFireCd -= dt;
|
||
if (o.spaceShooterBotFireCd <= 0 && nearest && nd < ts * 5) {
|
||
o.spaceShooterBotFireCd = o.botTier === 'sharp' ? 0.22 : o.botTier === 'weak' ? 0.48 : 0.34;
|
||
spaceShooterBullets.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 16, vy: -500, ownerId: bid });
|
||
}
|
||
});
|
||
}
|
||
|
||
function spaceShooterTickFrame() {
|
||
if (!mapData || mapData.gameType !== 'space_shooter') return;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const ts = tileSize;
|
||
const mw = w * ts, mh = h * ts;
|
||
const { cw, ch } = getCharacterFootprintWH(mapData);
|
||
const now = performance.now();
|
||
const dt = Math.min(0.055, spaceShooterLastTickMs ? (now - spaceShooterLastTickMs) / 1000 : 0.016);
|
||
spaceShooterLastTickMs = now;
|
||
|
||
if (spaceShooterGameEnded) {
|
||
const wallClock = Date.now();
|
||
for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
|
||
if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
|
||
}
|
||
return;
|
||
}
|
||
const remSec = spaceShooterRemainingSecPlay();
|
||
if (remSec != null && remSec <= 0) {
|
||
endSpaceShooterTimeUp();
|
||
return;
|
||
}
|
||
|
||
const interval = Math.max(320, Number(mapData.spaceShooterAsteroidIntervalMs) || 1040);
|
||
spaceShooterSpawnAccMs += dt * 1000;
|
||
while (spaceShooterSpawnAccMs >= interval) {
|
||
spaceShooterSpawnAccMs -= interval;
|
||
spaceShooterAsteroids.push({
|
||
x: 36 + Math.random() * (mw - 72),
|
||
y: -45 - Math.random() * 90,
|
||
r: 13 + Math.random() * 17,
|
||
vy: 58 + Math.random() * 62,
|
||
hp: 1,
|
||
});
|
||
}
|
||
|
||
others.forEach((o, id) => {
|
||
if (isPreviewBotId(id)) return;
|
||
if (o.tx != null) o.x += (o.tx - o.x) * 0.28;
|
||
if (o.ty != null) o.y += (o.ty - o.y) * 0.28;
|
||
o.spaceShooterCx = (o.x + cw * 0.5) * ts;
|
||
o.spaceShooterCy = clampSpaceShooterWorldCy((o.y + ch * 0.92) * ts, mh);
|
||
});
|
||
|
||
let vx = 0;
|
||
let vy = 0;
|
||
if (!isChatFocused()) {
|
||
if (keys['ArrowLeft'] || keys['KeyA']) vx -= 1;
|
||
if (keys['ArrowRight'] || keys['KeyD']) vx += 1;
|
||
if (keys['ArrowUp'] || keys['KeyW']) vy -= 1;
|
||
if (keys['ArrowDown'] || keys['KeyS']) vy += 1;
|
||
}
|
||
const moveSpd = 280;
|
||
if (me.spaceShooterCx != null) {
|
||
me.spaceShooterCx = Math.max(18, Math.min(mw - 18, me.spaceShooterCx + vx * moveSpd * dt));
|
||
}
|
||
if (me.spaceShooterCy != null) {
|
||
me.spaceShooterCy = clampSpaceShooterWorldCy(me.spaceShooterCy + vy * moveSpd * dt, mh);
|
||
}
|
||
syncSpaceShooterFootFromShipCenter(me);
|
||
|
||
spaceShooterFireCd -= dt;
|
||
const fireHeld = !isChatFocused() && !!keys['Space'];
|
||
if (fireHeld && spaceShooterFireCd <= 0 && me.spaceShooterCy != null) {
|
||
spaceShooterFireCd = 0.21;
|
||
spaceShooterBullets.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 20, vy: -580, ownerId: myId });
|
||
}
|
||
|
||
stepSpaceShooterPreviewBots(dt);
|
||
|
||
for (let i = spaceShooterBullets.length - 1; i >= 0; i--) {
|
||
const b = spaceShooterBullets[i];
|
||
b.y += b.vy * dt;
|
||
if (b.y < -50 || b.x < -30 || b.x > mw + 30) spaceShooterBullets.splice(i, 1);
|
||
}
|
||
for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) {
|
||
const a = spaceShooterAsteroids[ai];
|
||
a.y += a.vy * dt;
|
||
if (a.y > mh + 100) spaceShooterAsteroids.splice(ai, 1);
|
||
}
|
||
|
||
for (let bi = spaceShooterBullets.length - 1; bi >= 0; bi--) {
|
||
const b = spaceShooterBullets[bi];
|
||
let hit = -1;
|
||
for (let ai = 0; ai < spaceShooterAsteroids.length; ai++) {
|
||
const a = spaceShooterAsteroids[ai];
|
||
const dx = b.x - a.x, dy = b.y - a.y;
|
||
const rr = (a.r + 7) * (a.r + 7);
|
||
if (dx * dx + dy * dy <= rr) { hit = ai; break; }
|
||
}
|
||
if (hit >= 0) {
|
||
const a = spaceShooterAsteroids[hit];
|
||
a.hp = (a.hp || 1) - 1;
|
||
spaceShooterBullets.splice(bi, 1);
|
||
if (a.hp <= 0) {
|
||
spaceShooterAsteroids.splice(hit, 1);
|
||
const add = 5;
|
||
if (b.ownerId === myId) {
|
||
me.spaceShooterScore = Math.max(0, (me.spaceShooterScore || 0) + add);
|
||
spaceShooterPopups.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
|
||
} else {
|
||
const o = others.get(b.ownerId);
|
||
if (o) {
|
||
o.spaceShooterScore = Math.max(0, (o.spaceShooterScore || 0) + add);
|
||
spaceShooterPopups.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const wallClock = Date.now();
|
||
for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
|
||
/* until ใช้ Date.now() — ห้ามเทียบกับ performance.now() (สเกลคนละระบบ → ลบไม่ออก) */
|
||
if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
|
||
}
|
||
|
||
me.direction = 'up';
|
||
me.isWalking = vx !== 0 || vy !== 0;
|
||
const tEmit = Date.now();
|
||
if (tEmit - spaceShooterLastMoveEmit > 90 && socket && myId != null) {
|
||
spaceShooterLastMoveEmit = tEmit;
|
||
socket.emit('move', {
|
||
x: me.x, y: me.y, direction: me.direction,
|
||
spaceShooterScore: Math.max(0, me.spaceShooterScore | 0),
|
||
});
|
||
}
|
||
}
|
||
|
||
function drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
|
||
if (!mapData || !isSpaceShooter()) return;
|
||
const refs = buildSpaceShooterParticipantRefsPlay();
|
||
const wallNow = typeof timeMs === 'number' ? timeMs : Date.now();
|
||
|
||
for (let i = 0; i < spaceShooterAsteroids.length; i++) {
|
||
const a = spaceShooterAsteroids[i];
|
||
const [sx, sy] = worldToScreen(a.x, a.y);
|
||
const sr = Math.max(6, a.r * zDraw);
|
||
const grd = ctx.createRadialGradient(sx - sr * 0.25, sy - sr * 0.25, sr * 0.1, sx, sy, sr);
|
||
grd.addColorStop(0, 'rgba(90, 85, 95, 0.95)');
|
||
grd.addColorStop(0.55, 'rgba(35, 32, 42, 0.98)');
|
||
grd.addColorStop(1, 'rgba(18, 16, 24, 1)');
|
||
ctx.fillStyle = grd;
|
||
ctx.beginPath();
|
||
ctx.arc(sx, sy, sr, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255, 90, 60, 0.75)';
|
||
ctx.lineWidth = Math.max(1.5, zDraw * 1.2);
|
||
ctx.beginPath();
|
||
ctx.arc(sx, sy, sr * 0.88, 0.6, 1.9);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.arc(sx + sr * 0.15, sy + sr * 0.1, sr * 0.35, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.shadowColor = 'rgba(255, 60, 40, 0.55)';
|
||
ctx.shadowBlur = sr * 0.45;
|
||
ctx.strokeStyle = 'rgba(255, 120, 80, 0.35)';
|
||
ctx.beginPath();
|
||
ctx.arc(sx, sy, sr + 3 * zDraw, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
|
||
for (let i = 0; i < spaceShooterBullets.length; i++) {
|
||
const b = spaceShooterBullets[i];
|
||
for (let k = 0; k < 4; k++) {
|
||
const t = k / 4;
|
||
const yy = b.y + t * 22;
|
||
const [bx, by] = worldToScreen(b.x, yy);
|
||
ctx.fillStyle = `rgba(255, ${180 - k * 35}, ${80 - k * 15}, ${0.95 - k * 0.18})`;
|
||
ctx.beginPath();
|
||
ctx.arc(bx, by, Math.max(2, 3.2 * zDraw - k * 0.4), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
refs.forEach((entry, idx) => {
|
||
const ent = entry.ref;
|
||
if (ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
|
||
const [sx, sy] = worldToScreen(ent.spaceShooterCx, ent.spaceShooterCy);
|
||
const col = SPACE_SHOOTER_SHIP_COLORS[idx % SPACE_SHOOTER_SHIP_COLORS.length];
|
||
const bodyW = 14 * zDraw;
|
||
const bodyH = 22 * zDraw;
|
||
ctx.save();
|
||
ctx.translate(sx, sy);
|
||
ctx.fillStyle = col;
|
||
ctx.strokeStyle = 'rgba(180, 255, 255, 0.85)';
|
||
ctx.lineWidth = Math.max(1.2, zDraw);
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, -bodyH * 0.55);
|
||
ctx.lineTo(-bodyW * 0.55, bodyH * 0.42);
|
||
ctx.lineTo(0, bodyH * 0.22);
|
||
ctx.lineTo(bodyW * 0.55, bodyH * 0.42);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
ctx.fillStyle = 'rgba(255, 220, 120, 0.95)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(-bodyW * 0.22, bodyH * 0.38);
|
||
ctx.lineTo(0, bodyH * 0.72);
|
||
ctx.lineTo(bodyW * 0.22, bodyH * 0.38);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.restore();
|
||
const name = (ent.nickname || '').slice(0, 10);
|
||
if (name) {
|
||
ctx.font = `${Math.max(9, 10 * zDraw)}px system-ui, "Kanit", sans-serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = 'rgba(224, 242, 255, 0.92)';
|
||
ctx.strokeStyle = 'rgba(0, 20, 40, 0.85)';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeText(name, sx, sy - bodyH * 0.62);
|
||
ctx.fillText(name, sx, sy - bodyH * 0.62);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
});
|
||
|
||
for (let i = 0; i < spaceShooterPopups.length; i++) {
|
||
const p = spaceShooterPopups[i];
|
||
if (wallNow >= p.until) continue;
|
||
const [px, py] = worldToScreen(p.x, p.y);
|
||
const dur = 700;
|
||
const age = 1 - Math.max(0, Math.min(1, (p.until - wallNow) / dur));
|
||
ctx.save();
|
||
ctx.globalAlpha = Math.max(0, 1 - age * 0.92);
|
||
ctx.font = `bold ${Math.max(12, 16 * zDraw)}px Orbitron, ui-sans-serif, sans-serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = '#ffe066';
|
||
ctx.strokeStyle = 'rgba(120, 60, 0, 0.6)';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeText(p.text || '+5', px, py - age * 18);
|
||
ctx.fillText(p.text || '+5', px, py - age * 18);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
const BALLOON_BOSS_PLAYER_COLORS = ['#ff79c6', '#7aa2f7', '#f9e2af', '#f7768e', '#9ece6a', '#bb9af7'];
|
||
|
||
function normalizeBalloonBossPlayerSlotsInPlay(md) {
|
||
if (!md || md.gameType !== 'balloon_boss') return;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const src = md.balloonBossPlayerSlots || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) {
|
||
const v = r && r[x];
|
||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||
row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
|
||
}
|
||
rows.push(row);
|
||
}
|
||
md.balloonBossPlayerSlots = rows;
|
||
}
|
||
|
||
function getBalloonBossPlayerSpawnWorldCenterFromMap(md, slot1to6, ts) {
|
||
if (!md || !md.balloonBossPlayerSlots) return null;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const g = md.balloonBossPlayerSlots;
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getBalloonBossBossWorldCenterPlay(md, ts) {
|
||
const bx = md && md.balloonBossBossSpawn;
|
||
if (bx && Number.isFinite(Number(bx.x)) && Number.isFinite(Number(bx.y))) {
|
||
return { cx: (Number(bx.x) + 0.5) * ts, cy: (Number(bx.y) + 0.5) * ts };
|
||
}
|
||
const w = md.width || 20, h = md.height || 15;
|
||
return { cx: (w * 0.5) * ts, cy: (h * 0.38) * ts };
|
||
}
|
||
|
||
function buildBalloonBossParticipantRefsPlay() {
|
||
const refs = [];
|
||
if (me) refs.push({ id: myId, ref: me });
|
||
const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
|
||
humanIds.forEach((id) => {
|
||
const o = others.get(id);
|
||
if (o) refs.push({ id, ref: o });
|
||
});
|
||
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
|
||
botIds.forEach((id) => {
|
||
const o = others.get(id);
|
||
if (o) refs.push({ id, ref: o });
|
||
});
|
||
return refs.filter((r) => r.ref);
|
||
}
|
||
|
||
function balloonBossBalloonsStartPlay() {
|
||
const n = Number(mapData && mapData.balloonBossBalloonsPerPlayer);
|
||
return Math.max(1, Math.min(12, Number.isFinite(n) ? Math.floor(n) : 5));
|
||
}
|
||
|
||
function balloonBossMaxHpPlay() {
|
||
const n = Number(mapData && mapData.balloonBossMaxHp);
|
||
return Math.max(20, Math.min(9999, Number.isFinite(n) ? Math.floor(n) : 100));
|
||
}
|
||
|
||
function balloonBossFireDelayMsPlay() {
|
||
const n = Number(mapData && mapData.balloonBossFireDelayMs);
|
||
return Math.max(120, Math.min(2000, Number.isFinite(n) ? Math.floor(n) : 380));
|
||
}
|
||
|
||
/** ความเร่งยาน (px/s²) — ต่ำ = คุมยาก ลื่น */
|
||
function balloonBossShipAccelPlay() {
|
||
const n = Number(mapData && mapData.balloonBossShipAccel);
|
||
return Math.max(80, Math.min(520, Number.isFinite(n) ? n : 210));
|
||
}
|
||
|
||
/** ความเร็วสูงสุดยาน (px/s) */
|
||
function balloonBossShipMaxSpeedPlay() {
|
||
const n = Number(mapData && mapData.balloonBossShipMaxSpeed);
|
||
return Math.max(60, Math.min(320, Number.isFinite(n) ? n : 158));
|
||
}
|
||
|
||
/** แรงหน่วงต่อวินาที (velocity *= 1 - min(1, drag*dt)) — สูง = หยุดเร็วขึ้น */
|
||
function balloonBossShipDragPlay() {
|
||
const n = Number(mapData && mapData.balloonBossShipDrag);
|
||
return Math.max(0.4, Math.min(6, Number.isFinite(n) ? n : 1.65));
|
||
}
|
||
|
||
function balloonBossTimeLimitSecPlay() {
|
||
const t = Number(mapData && mapData.balloonBossTimeSec);
|
||
if (Number.isFinite(t) && t === 0) return 0;
|
||
if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
|
||
return 120;
|
||
}
|
||
|
||
function balloonBossRemainingSecPlay() {
|
||
const lim = balloonBossTimeLimitSecPlay();
|
||
if (lim <= 0) return null;
|
||
const elapsed = (performance.now() - balloonBossSessionStartMs) / 1000;
|
||
return Math.max(0, Math.ceil(lim - elapsed));
|
||
}
|
||
|
||
function balloonBossTeamDamagePlay() {
|
||
let s = Math.max(0, me.balloonBossScore | 0);
|
||
others.forEach((o) => { s += Math.max(0, o.balloonBossScore | 0); });
|
||
return s;
|
||
}
|
||
|
||
function syncBalloonBossFootFromCenter(ent) {
|
||
if (!ent || !mapData || ent.balloonBossCx == null || ent.balloonBossCy == null) return;
|
||
const { cw, ch } = getCharacterFootprintWH(mapData);
|
||
const ts = tileSize;
|
||
ent.x = ent.balloonBossCx / ts - cw * 0.5;
|
||
ent.y = ent.balloonBossCy / ts - ch * 0.92;
|
||
}
|
||
|
||
function clampBalloonBossWorld(cx, cy, mw, mh) {
|
||
const e = 22;
|
||
return {
|
||
cx: Math.max(e, Math.min(mw - e, cx)),
|
||
cy: Math.max(e, Math.min(mh - e, cy)),
|
||
};
|
||
}
|
||
|
||
function applyBalloonBossSpawnLayoutPlay() {
|
||
if (!mapData || mapData.gameType !== 'balloon_boss') return;
|
||
normalizeBalloonBossPlayerSlotsInPlay(mapData);
|
||
const ts = tileSize;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const { cw, ch } = getCharacterFootprintWH(mapData);
|
||
const mw = w * ts, mh = h * ts;
|
||
const refs = buildBalloonBossParticipantRefsPlay();
|
||
const n = refs.length || 1;
|
||
const bStart = balloonBossBalloonsStartPlay();
|
||
refs.forEach((entry, idx) => {
|
||
const ent = entry.ref;
|
||
const slot = (idx % 6) + 1;
|
||
const cen = getBalloonBossPlayerSpawnWorldCenterFromMap(mapData, slot, ts);
|
||
let cx, cy;
|
||
if (cen) {
|
||
cx = cen.cx;
|
||
cy = cen.cy;
|
||
} else {
|
||
const t = (idx + 1) / (n + 1);
|
||
cx = t * mw;
|
||
cy = Math.min(mh - ts * 1.2, (h - 1.2) * ts);
|
||
}
|
||
const cl = clampBalloonBossWorld(cx, cy, mw, mh);
|
||
ent.balloonBossCx = cl.cx;
|
||
ent.balloonBossCy = cl.cy;
|
||
if (typeof ent.balloonBossScore !== 'number' || !Number.isFinite(ent.balloonBossScore)) ent.balloonBossScore = 0;
|
||
if (typeof ent.balloonBossBalloons !== 'number' || !Number.isFinite(ent.balloonBossBalloons)) ent.balloonBossBalloons = bStart;
|
||
if (typeof ent.balloonBossHitIframe !== 'number') ent.balloonBossHitIframe = 0;
|
||
ent.balloonBossVelX = 0;
|
||
ent.balloonBossVelY = 0;
|
||
ent.x = ent.balloonBossCx / ts - cw * 0.5;
|
||
ent.y = ent.balloonBossCy / ts - ch * 0.92;
|
||
ent.tx = ent.x;
|
||
ent.ty = ent.y;
|
||
ent.direction = 'up';
|
||
});
|
||
}
|
||
|
||
function endBalloonBossGame(reason) {
|
||
if (balloonBossGameEnded || !mapData || mapData.gameType !== 'balloon_boss') return;
|
||
balloonBossGameEnded = true;
|
||
balloonBossPendingShots = [];
|
||
balloonBossPlayerBullets = [];
|
||
balloonBossBossBullets = [];
|
||
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;
|
||
if (titleEl) {
|
||
titleEl.textContent = reason === 'victory' ? 'ชนะบอส! · Boss defeated' : 'หมดเวลา · Time up';
|
||
}
|
||
msgEl.textContent = reason === 'victory'
|
||
? 'MEGA VIRUS ถูกกำจัดแล้ว — อันดับจากความเสียหายที่ทำกับบอส'
|
||
: 'หมดเวลา — อันดับจากความเสียหายที่ทำกับบอส';
|
||
listEl.innerHTML = '';
|
||
const ranks = [];
|
||
if (myId != null) {
|
||
ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.balloonBossScore | 0) });
|
||
}
|
||
others.forEach((o, id) => {
|
||
ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.balloonBossScore | 0) });
|
||
});
|
||
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
|
||
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) || '—'} — DMG ${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();
|
||
};
|
||
}
|
||
}
|
||
|
||
function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) {
|
||
if (!previewFillBots || !mapData || mapData.gameType !== 'balloon_boss') return;
|
||
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
|
||
const o = others.get(bid);
|
||
if (!o || o.balloonBossEliminated || o.balloonBossCx == null) return;
|
||
const wob = Math.sin(performance.now() / 800 + bid.length) * 0.55;
|
||
const wob2 = Math.cos(performance.now() / 1100 + bid.charCodeAt(0)) * 0.45;
|
||
const spd = 48;
|
||
let nx = o.balloonBossCx + wob * spd * dt;
|
||
let ny = o.balloonBossCy + wob2 * spd * 0.72 * dt;
|
||
const cl = clampBalloonBossWorld(nx, ny, mw, mh);
|
||
o.balloonBossCx = cl.cx;
|
||
o.balloonBossCy = cl.cy;
|
||
syncBalloonBossFootFromCenter(o);
|
||
o.tx = o.x;
|
||
o.ty = o.y;
|
||
if (typeof o.balloonBossBotNextFire === 'number') o.balloonBossBotNextFire -= dt;
|
||
else o.balloonBossBotNextFire = 0.8 + Math.random() * 0.9;
|
||
if (o.balloonBossBotNextFire <= 0) {
|
||
o.balloonBossBotNextFire = 0.9 + Math.random() * 1.1;
|
||
const delayMs = balloonBossFireDelayMsPlay();
|
||
const dx = bossCx - o.balloonBossCx, dy = bossCy - o.balloonBossCy;
|
||
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
balloonBossPendingShots.push({
|
||
releaseAt: performance.now() + delayMs,
|
||
sx: o.balloonBossCx, sy: o.balloonBossCy,
|
||
vx: (dx / d) * 520, vy: (dy / d) * 520,
|
||
ownerId: bid,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function balloonBossTickFrame() {
|
||
if (!mapData || mapData.gameType !== 'balloon_boss') return;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const ts = tileSize;
|
||
const mw = w * ts, mh = h * ts;
|
||
const { cw, ch } = getCharacterFootprintWH(mapData);
|
||
others.forEach((o, id) => {
|
||
if (isPreviewBotId(id)) return;
|
||
if (!o || o.balloonBossEliminated) return;
|
||
if (o.tx != null) o.x += (o.tx - o.x) * 0.24;
|
||
if (o.ty != null) o.y += (o.ty - o.y) * 0.24;
|
||
const cx = (o.x + cw * 0.5) * ts;
|
||
const cy = (o.y + ch * 0.92) * ts;
|
||
const cl = clampBalloonBossWorld(cx, cy, mw, mh);
|
||
o.balloonBossCx = cl.cx;
|
||
o.balloonBossCy = cl.cy;
|
||
});
|
||
const now = performance.now();
|
||
const dt = Math.min(0.055, balloonBossLastTickMs ? (now - balloonBossLastTickMs) / 1000 : 0.016);
|
||
balloonBossLastTickMs = now;
|
||
const bossC = getBalloonBossBossWorldCenterPlay(mapData, ts);
|
||
const bossR = Math.max(36, ts * 1.15);
|
||
const maxHp = balloonBossMaxHpPlay();
|
||
const teamDmg = balloonBossTeamDamagePlay();
|
||
|
||
if (me.balloonBossEliminated) {
|
||
me.balloonBossVelX = 0;
|
||
me.balloonBossVelY = 0;
|
||
}
|
||
if (balloonBossGameEnded) {
|
||
for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
|
||
balloonBossHitFx[hi].t -= dt;
|
||
if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
|
||
}
|
||
return;
|
||
}
|
||
const remSec = balloonBossRemainingSecPlay();
|
||
if (remSec != null && remSec <= 0) {
|
||
endBalloonBossGame('time');
|
||
return;
|
||
}
|
||
if (teamDmg >= maxHp) {
|
||
endBalloonBossGame('victory');
|
||
return;
|
||
}
|
||
|
||
for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
|
||
balloonBossHitFx[hi].t -= dt;
|
||
if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
|
||
}
|
||
|
||
const aliveRefs = buildBalloonBossParticipantRefsPlay().filter((e) => e.ref && !e.ref.balloonBossEliminated);
|
||
|
||
for (let pi = balloonBossPendingShots.length - 1; pi >= 0; pi--) {
|
||
const pnd = balloonBossPendingShots[pi];
|
||
if (now >= pnd.releaseAt) {
|
||
balloonBossPlayerBullets.push({ x: pnd.sx, y: pnd.sy, vx: pnd.vx, vy: pnd.vy, ownerId: pnd.ownerId });
|
||
balloonBossPendingShots.splice(pi, 1);
|
||
}
|
||
}
|
||
|
||
const bossFireEvery = 0.92;
|
||
balloonBossBossFireAcc += dt;
|
||
while (balloonBossBossFireAcc >= bossFireEvery) {
|
||
balloonBossBossFireAcc -= bossFireEvery;
|
||
if (aliveRefs.length) {
|
||
const wave = Math.floor(now / 1000);
|
||
const tgt = aliveRefs[wave % aliveRefs.length].ref;
|
||
if (tgt && tgt.balloonBossCx != null) {
|
||
const dx = tgt.balloonBossCx - bossC.cx, dy = tgt.balloonBossCy - bossC.cy;
|
||
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const spd = 290;
|
||
balloonBossBossBullets.push({ x: bossC.cx, y: bossC.cy, vx: (dx / d) * spd, vy: (dy / d) * spd });
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyBalloonBossHitToEntity(ent) {
|
||
if (!ent || ent.balloonBossEliminated) return;
|
||
if (ent.balloonBossHitIframe > now) return;
|
||
ent.balloonBossHitIframe = now + 520;
|
||
ent.balloonBossBalloons = Math.max(0, (ent.balloonBossBalloons | 0) - 1);
|
||
if (ent.balloonBossBalloons <= 0) {
|
||
ent.balloonBossEliminated = true;
|
||
ent.balloonBossBalloons = 0;
|
||
}
|
||
if (ent === me && socket) {
|
||
socket.emit('move', {
|
||
x: me.x, y: me.y, direction: me.direction,
|
||
balloonBossBalloons: me.balloonBossBalloons,
|
||
balloonBossEliminated: !!me.balloonBossEliminated,
|
||
balloonBossScore: Math.max(0, me.balloonBossScore | 0),
|
||
});
|
||
}
|
||
}
|
||
|
||
for (let bi = balloonBossBossBullets.length - 1; bi >= 0; bi--) {
|
||
const b = balloonBossBossBullets[bi];
|
||
b.x += b.vx * dt;
|
||
b.y += b.vy * dt;
|
||
if (b.x < -80 || b.x > mw + 80 || b.y < -80 || b.y > mh + 80) {
|
||
balloonBossBossBullets.splice(bi, 1);
|
||
continue;
|
||
}
|
||
let hit = false;
|
||
aliveRefs.forEach((entry) => {
|
||
if (hit) return;
|
||
const ent = entry.ref;
|
||
if (!ent || ent.balloonBossCx == null) return;
|
||
const dx = b.x - ent.balloonBossCx, dy = b.y - ent.balloonBossCy;
|
||
if (dx * dx + dy * dy <= (26 * 26)) {
|
||
hit = true;
|
||
applyBalloonBossHitToEntity(ent);
|
||
}
|
||
});
|
||
if (hit) balloonBossBossBullets.splice(bi, 1);
|
||
}
|
||
|
||
for (let i = balloonBossPlayerBullets.length - 1; i >= 0; i--) {
|
||
const b = balloonBossPlayerBullets[i];
|
||
b.x += b.vx * dt;
|
||
b.y += b.vy * dt;
|
||
if (b.x < -60 || b.x > mw + 60 || b.y < -60 || b.y > mh + 60) {
|
||
balloonBossPlayerBullets.splice(i, 1);
|
||
continue;
|
||
}
|
||
const dx = b.x - bossC.cx, dy = b.y - bossC.cy;
|
||
if (dx * dx + dy * dy <= bossR * bossR) {
|
||
balloonBossHitFx.push({ x: bossC.cx, y: bossC.cy, t: 0.22 });
|
||
const add = 1;
|
||
if (b.ownerId === myId) {
|
||
me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) + add);
|
||
} else {
|
||
const o = others.get(b.ownerId);
|
||
if (o) o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) + add);
|
||
}
|
||
balloonBossPlayerBullets.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
let inX = 0, inY = 0;
|
||
if (!me.balloonBossEliminated && !isChatFocused()) {
|
||
if (keys['ArrowLeft'] || keys['KeyA']) inX -= 1;
|
||
if (keys['ArrowRight'] || keys['KeyD']) inX += 1;
|
||
if (keys['ArrowUp'] || keys['KeyW']) inY -= 1;
|
||
if (keys['ArrowDown'] || keys['KeyS']) inY += 1;
|
||
}
|
||
const accel = balloonBossShipAccelPlay();
|
||
const maxSpd = balloonBossShipMaxSpeedPlay();
|
||
const dragPerSec = balloonBossShipDragPlay();
|
||
if (typeof me.balloonBossVelX !== 'number' || !Number.isFinite(me.balloonBossVelX)) me.balloonBossVelX = 0;
|
||
if (typeof me.balloonBossVelY !== 'number' || !Number.isFinite(me.balloonBossVelY)) me.balloonBossVelY = 0;
|
||
let nvx = me.balloonBossVelX;
|
||
let nvy = me.balloonBossVelY;
|
||
if (inX !== 0 || inY !== 0) {
|
||
const il = Math.sqrt(inX * inX + inY * inY) || 1;
|
||
nvx += (inX / il) * accel * dt;
|
||
nvy += (inY / il) * accel * dt;
|
||
}
|
||
const drag = Math.min(1, dragPerSec * dt);
|
||
nvx *= (1 - drag);
|
||
nvy *= (1 - drag);
|
||
const curSpd = Math.sqrt(nvx * nvx + nvy * nvy);
|
||
if (curSpd > maxSpd) {
|
||
const s = maxSpd / curSpd;
|
||
nvx *= s;
|
||
nvy *= s;
|
||
}
|
||
me.balloonBossVelX = nvx;
|
||
me.balloonBossVelY = nvy;
|
||
if (!me.balloonBossEliminated && me.balloonBossCx != null) {
|
||
const ncx = me.balloonBossCx + nvx * dt;
|
||
const ncy = me.balloonBossCy + nvy * dt;
|
||
const cl = clampBalloonBossWorld(ncx, ncy, mw, mh);
|
||
if (Math.abs(cl.cx - ncx) > 0.5) me.balloonBossVelX = 0;
|
||
if (Math.abs(cl.cy - ncy) > 0.5) me.balloonBossVelY = 0;
|
||
me.balloonBossCx = cl.cx;
|
||
me.balloonBossCy = cl.cy;
|
||
}
|
||
syncBalloonBossFootFromCenter(me);
|
||
me.direction = 'up';
|
||
me.isWalking = Math.abs(me.balloonBossVelX) > 8 || Math.abs(me.balloonBossVelY) > 8 || inX !== 0 || inY !== 0;
|
||
|
||
balloonBossPlayerFireCd -= dt;
|
||
const wantFire = !isChatFocused() && !!keys['Space'] && !me.balloonBossEliminated;
|
||
if (wantFire && balloonBossPlayerFireCd <= 0 && me.balloonBossCy != null) {
|
||
balloonBossPlayerFireCd = 0.35;
|
||
const delayMs = balloonBossFireDelayMsPlay();
|
||
const dx = bossC.cx - me.balloonBossCx, dy = bossC.cy - me.balloonBossCy;
|
||
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
balloonBossPendingShots.push({
|
||
releaseAt: now + delayMs,
|
||
sx: me.balloonBossCx, sy: me.balloonBossCy,
|
||
vx: (dx / d) * 480, vy: (dy / d) * 480,
|
||
ownerId: myId,
|
||
});
|
||
}
|
||
|
||
stepBalloonBossPreviewBots(dt, mw, mh, bossC.cx, bossC.cy, ts);
|
||
|
||
const tEmit = Date.now();
|
||
if (tEmit - balloonBossLastMoveEmit > 95 && socket && myId != null) {
|
||
balloonBossLastMoveEmit = tEmit;
|
||
socket.emit('move', {
|
||
x: me.x, y: me.y, direction: me.direction,
|
||
balloonBossScore: Math.max(0, me.balloonBossScore | 0),
|
||
balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0),
|
||
balloonBossEliminated: !!me.balloonBossEliminated,
|
||
});
|
||
}
|
||
}
|
||
|
||
function drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
|
||
if (!mapData || !isBalloonBoss()) return;
|
||
const ts = tileSize;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const mw = w * ts, mh = h * ts;
|
||
const bossC = getBalloonBossBossWorldCenterPlay(mapData, ts);
|
||
const maxHp = balloonBossMaxHpPlay();
|
||
const dmg = balloonBossTeamDamagePlay();
|
||
const hpLeft = Math.max(0, maxHp - dmg);
|
||
const bossR = Math.max(36, ts * 1.15);
|
||
const refs = buildBalloonBossParticipantRefsPlay();
|
||
const leaderId = (() => {
|
||
let best = null, bs = -1;
|
||
refs.forEach((e) => {
|
||
const sc = Math.max(0, e.ref.balloonBossScore | 0);
|
||
if (sc > bs) { bs = sc; best = e.id; }
|
||
});
|
||
return best;
|
||
})();
|
||
|
||
for (let hi = 0; hi < balloonBossHitFx.length; hi++) {
|
||
const fx = balloonBossHitFx[hi];
|
||
const [hx, hy] = worldToScreen(fx.x, fx.y);
|
||
const pulse = 1 + (fx.t || 0) * 3;
|
||
ctx.save();
|
||
ctx.globalAlpha = Math.min(1, (fx.t || 0) * 4);
|
||
ctx.strokeStyle = 'rgba(255, 220, 120, 0.9)';
|
||
ctx.lineWidth = 3 * zDraw;
|
||
ctx.beginPath();
|
||
ctx.arc(hx, hy, bossR * zDraw * 0.5 * pulse, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
const [bsx, bsy] = worldToScreen(bossC.cx, bossC.cy);
|
||
ctx.save();
|
||
ctx.translate(bsx, bsy);
|
||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.75)';
|
||
ctx.lineWidth = Math.max(2, 2.5 * zDraw);
|
||
ctx.beginPath();
|
||
for (let k = 0; k < 6; k++) {
|
||
const ang = (k / 6) * Math.PI * 2 - Math.PI / 2;
|
||
const px = Math.cos(ang) * bossR * zDraw * 1.05;
|
||
const py = Math.sin(ang) * bossR * zDraw * 1.05;
|
||
if (k === 0) ctx.moveTo(px, py);
|
||
else ctx.lineTo(px, py);
|
||
}
|
||
ctx.closePath();
|
||
ctx.stroke();
|
||
ctx.fillStyle = 'rgba(0, 40, 60, 0.35)';
|
||
ctx.fill();
|
||
|
||
const skullR = bossR * zDraw * 0.62;
|
||
const grd = ctx.createRadialGradient(-skullR * 0.2, -skullR * 0.25, skullR * 0.1, 0, 0, skullR);
|
||
grd.addColorStop(0, 'rgba(55, 50, 62, 0.98)');
|
||
grd.addColorStop(1, 'rgba(22, 18, 30, 1)');
|
||
ctx.fillStyle = grd;
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, skullR, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.fillStyle = 'rgba(255, 40, 60, 0.95)';
|
||
ctx.shadowColor = 'rgba(255, 0, 0, 0.8)';
|
||
ctx.shadowBlur = 12 * zDraw;
|
||
ctx.beginPath();
|
||
ctx.arc(-skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
|
||
ctx.arc(skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.shadowBlur = 0;
|
||
|
||
ctx.fillStyle = 'rgba(255, 100, 200, 0.9)';
|
||
ctx.font = `bold ${Math.max(10, 11 * zDraw)}px ui-monospace, monospace`;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('MEGA VIRUS : [ ' + hpLeft + ' / ' + maxHp + ' ]', 0, -bossR * zDraw - 14);
|
||
const barW = bossR * zDraw * 2.2;
|
||
const barH = Math.max(5, 6 * zDraw);
|
||
ctx.fillStyle = 'rgba(0,0,0,0.55)';
|
||
ctx.fillRect(-barW / 2, -bossR * zDraw - 8, barW, barH);
|
||
ctx.fillStyle = 'rgba(255, 80, 200, 0.85)';
|
||
ctx.fillRect(-barW / 2, -bossR * zDraw - 8, barW * (hpLeft / maxHp), barH);
|
||
ctx.strokeStyle = 'rgba(180, 255, 255, 0.5)';
|
||
ctx.strokeRect(-barW / 2, -bossR * zDraw - 8, barW, barH);
|
||
ctx.restore();
|
||
|
||
function drawBalloonsCluster(sx, sy, count, col, z) {
|
||
const n = Math.max(0, Math.min(8, count | 0));
|
||
for (let b = 0; b < n; b++) {
|
||
const ox = (b - (n - 1) / 2) * 7 * z;
|
||
const oy = -18 * z - Math.abs(b - (n - 1) / 2) * 2 * z;
|
||
ctx.fillStyle = col;
|
||
ctx.beginPath();
|
||
ctx.ellipse(sx + ox, sy + oy, 6.5 * z, 8 * z, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
refs.forEach((entry, idx) => {
|
||
const ent = entry.ref;
|
||
if (ent.balloonBossCx == null || ent.balloonBossCy == null) return;
|
||
const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy);
|
||
const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
|
||
const z = zDraw;
|
||
ctx.save();
|
||
if (ent.balloonBossEliminated) ctx.globalAlpha = 0.38;
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.65)';
|
||
ctx.lineWidth = Math.max(1.2, 1.5 * z);
|
||
ctx.beginPath();
|
||
ctx.arc(sx, sy, 22 * z, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
drawBalloonsCluster(sx, sy, ent.balloonBossBalloons | 0, col, z);
|
||
const nm = String(ent.nickname || '').slice(0, 12);
|
||
ctx.font = `${Math.max(9, 10 * z)}px system-ui, "Kanit", sans-serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = 'rgba(248, 250, 252, 0.95)';
|
||
ctx.strokeStyle = 'rgba(0, 10, 30, 0.85)';
|
||
ctx.lineWidth = 3;
|
||
let label = nm;
|
||
if (entry.id === leaderId && leaderId != null) label = '♔ ' + nm;
|
||
ctx.strokeText(label, sx, sy + 28 * z);
|
||
ctx.fillText(label, sx, sy + 28 * z);
|
||
const charImg = getPlayTintedAvatarSource(
|
||
getAvatarImg(ent.characterId, 'up', timeMs, !!ent.isWalking),
|
||
ent.characterId, 'up', timeMs, !!ent.isWalking,
|
||
ent.playTint || playTintFromPeerId(entry.id)
|
||
);
|
||
const size = 26 * z;
|
||
if (charImg && ((charImg.tagName === 'CANVAS' && charImg.width > 0) || (charImg.complete && charImg.naturalWidth))) {
|
||
const iw = charImg.tagName === 'CANVAS' ? charImg.width : charImg.naturalWidth;
|
||
const ih = charImg.tagName === 'CANVAS' ? charImg.height : charImg.naturalHeight;
|
||
const sc = Math.min(size / iw, size / ih, 1);
|
||
const dw = iw * sc, dh = ih * sc;
|
||
ctx.drawImage(charImg, 0, 0, iw, ih, sx - dw / 2, sy - dh + 4 * z, dw, dh);
|
||
} else {
|
||
ctx.fillStyle = col;
|
||
ctx.fillRect(sx - 10 * z, sy - 18 * z, 20 * z, 22 * z);
|
||
}
|
||
ctx.restore();
|
||
});
|
||
|
||
for (let i = 0; i < balloonBossPlayerBullets.length; i++) {
|
||
const b = balloonBossPlayerBullets[i];
|
||
const [bx, by] = worldToScreen(b.x, b.y);
|
||
ctx.save();
|
||
ctx.translate(bx, by);
|
||
ctx.rotate(Math.atan2(b.vy, b.vx) + Math.PI / 2);
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, -8 * zDraw);
|
||
ctx.lineTo(-5 * zDraw, 6 * zDraw);
|
||
ctx.lineTo(5 * zDraw, 6 * zDraw);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.fillStyle = 'rgba(0, 255, 200, 0.5)';
|
||
ctx.font = `${Math.max(7, 8 * zDraw)}px monospace`;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('01001', 0, 12 * zDraw);
|
||
ctx.restore();
|
||
}
|
||
for (let i = 0; i < balloonBossBossBullets.length; i++) {
|
||
const b = balloonBossBossBullets[i];
|
||
const [bx, by] = worldToScreen(b.x, b.y);
|
||
ctx.fillStyle = 'rgba(255, 60, 120, 0.92)';
|
||
ctx.beginPath();
|
||
ctx.arc(bx, by, Math.max(3, 4.5 * zDraw), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255, 200, 255, 0.6)';
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
function jumpSurviveCollectPlatformCells(md) {
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const pa = md.jumpSurvivePlatformArea;
|
||
const out = [];
|
||
if (!pa) return out;
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
if (pa[y] && pa[y][x] === 1) out.push({ x, y });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** ความสูงแมปเป็นคาบวนของแพลตฟอร์ม — แถวที่เลื่อนออกบนจะวนมาโผล่ล่าง */
|
||
function jumpSurvivePlatformPeriodPx(md) {
|
||
return (md.height || 15) * tileSize;
|
||
}
|
||
|
||
/**
|
||
* ยืนบนหน้าแพลตฟอร์มช่อง (tx,ty) — เลือกชั้น k ที่ใกล้ hintY (กล้อง / กลางแมป)
|
||
*/
|
||
function jumpSurviveGridPosStandingOnPlatform(md, tx, ty, scrollPx) {
|
||
const ts = tileSize;
|
||
const h = md.height || 15;
|
||
const period = jumpSurvivePlatformPeriodPx(md);
|
||
const { cw, ch } = getCharacterFootprintWH(md);
|
||
const rawTop = ty * ts - scrollPx;
|
||
const hintY = (jumpSurviveCamCenterY > 1)
|
||
? jumpSurviveCamCenterY
|
||
: h * ts * 0.72;
|
||
const k = Math.round((hintY - rawTop - ts * 0.35) / period);
|
||
const platTop = rawTop + k * period;
|
||
const feetY = platTop;
|
||
const pxc = (tx + 0.5) * ts;
|
||
return {
|
||
x: pxc / ts - cw * 0.5,
|
||
y: feetY / ts - ch,
|
||
};
|
||
}
|
||
|
||
function jumpSurviveGridPosFromTileCenter(md, tx, ty) {
|
||
return jumpSurviveGridPosStandingOnPlatform(md, tx, ty, 0);
|
||
}
|
||
|
||
function jumpSurviveRandomStandGridPos(md) {
|
||
const cells = jumpSurviveCollectPlatformCells(md);
|
||
const sc = jumpSurvivePlatformScrollPx;
|
||
if (cells.length) {
|
||
const c = cells[Math.floor(Math.random() * cells.length)];
|
||
return jumpSurviveGridPosStandingOnPlatform(md, c.x, c.y, sc);
|
||
}
|
||
const sp = md.spawn || { x: 1, y: 1 };
|
||
return jumpSurviveGridPosStandingOnPlatform(md, Number(sp.x) || 1, Number(sp.y) || 1, sc);
|
||
}
|
||
|
||
/** ทดสอบจากเอดิเตอร์: วางคน+บอทบนแพลตฟอร์ม (ไม่ให้เกิดบน spawnArea บนฟ้าแล้วลอย) */
|
||
function applyJumpSurvivePreviewSpawnLayout(onlyBots) {
|
||
if (!mapData || mapData.gameType !== 'jump_survive') return;
|
||
const cells = jumpSurviveCollectPlatformCells(mapData);
|
||
cells.sort((a, b) => b.y - a.y || a.x - b.x);
|
||
const sc = jumpSurvivePlatformScrollPx;
|
||
const pool = cells.length
|
||
? cells.map((c) => jumpSurviveGridPosStandingOnPlatform(mapData, c.x, c.y, sc))
|
||
: [jumpSurviveGridPosStandingOnPlatform(mapData, Number(mapData.spawn && mapData.spawn.x) || 1, Number(mapData.spawn && mapData.spawn.y) || 1, sc)];
|
||
const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
|
||
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
|
||
|
||
function stampJumpBotState(o) {
|
||
if (!o) return;
|
||
o.jumpSurviveVy = 0;
|
||
o.jumpSurviveOnGround = true;
|
||
o.jumpSurviveEliminated = false;
|
||
o.tx = o.x;
|
||
o.ty = o.y;
|
||
}
|
||
|
||
if (onlyBots) {
|
||
let idx = 1 + realIds.length;
|
||
botIds.forEach((bid) => {
|
||
const o = others.get(bid);
|
||
if (!o || !pool.length) return;
|
||
const p = pool[idx % pool.length];
|
||
idx++;
|
||
o.x = p.x + (Math.random() - 0.5) * 0.08;
|
||
o.y = p.y + (Math.random() - 0.5) * 0.08;
|
||
stampJumpBotState(o);
|
||
});
|
||
return;
|
||
}
|
||
|
||
let idx = 0;
|
||
function assignEnt(ent) {
|
||
if (!ent || !pool.length) return;
|
||
const p = pool[idx % pool.length];
|
||
idx++;
|
||
ent.x = p.x + (Math.random() - 0.5) * 0.08;
|
||
ent.y = p.y + (Math.random() - 0.5) * 0.08;
|
||
stampJumpBotState(ent);
|
||
}
|
||
assignEnt(me);
|
||
realIds.forEach((rid) => assignEnt(others.get(rid)));
|
||
botIds.forEach((bid) => assignEnt(others.get(bid)));
|
||
jumpSurviveInitRuntime();
|
||
}
|
||
|
||
/**
|
||
* ฟิสิกส์กระโดดร่วม (ผู้เล่น + บอท preview)
|
||
* @returns {{ pxc: number, feetY: number, vy: number, onGround: boolean, died: boolean }}
|
||
*/
|
||
function jumpSurvivePhysicsIntegrate(md, opts) {
|
||
const {
|
||
dt,
|
||
cw, ch,
|
||
pxc0, feetY0, vy0, wasOnGround,
|
||
jumpQueued,
|
||
moveLeft, moveRight,
|
||
camCenterY,
|
||
halfViewH,
|
||
} = opts;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const ts = tileSize;
|
||
const bw = ts * 0.34;
|
||
const bh = ts * 0.9;
|
||
let pxc = pxc0;
|
||
let feetY = feetY0;
|
||
let vy = vy0;
|
||
let onGround = wasOnGround;
|
||
|
||
const gFull = Number(md.jumpSurviveGravity) > 0 ? Number(md.jumpSurviveGravity) : 3200;
|
||
const g = gFull * dt;
|
||
let jumpV0;
|
||
if (Number(md.jumpSurviveJumpImpulse) > 0) {
|
||
jumpV0 = -Number(md.jumpSurviveJumpImpulse);
|
||
} else {
|
||
const mult = (typeof playJumpSurviveJumpHeightMult === 'number' && playJumpSurviveJumpHeightMult > 0)
|
||
? playJumpSurviveJumpHeightMult
|
||
: 1.5;
|
||
const charHpixels = ch * ts;
|
||
const impulse = Math.sqrt(Math.max(1e-6, 2 * gFull * mult * charHpixels));
|
||
jumpV0 = -Math.min(impulse, ts * 36);
|
||
}
|
||
const moveX = (Number(md.jumpSurviveMovePxPerSec) > 0 ? Number(md.jumpSurviveMovePxPerSec) : 200) * dt;
|
||
|
||
if (jumpQueued && onGround) {
|
||
vy = jumpV0;
|
||
onGround = false;
|
||
}
|
||
vy += g;
|
||
|
||
let nx = pxc;
|
||
if (moveLeft) nx -= moveX;
|
||
if (moveRight) nx += moveX;
|
||
nx = Math.max(bw + 1, Math.min(w * ts - bw - 1, nx));
|
||
|
||
const headY0 = feetY - bh;
|
||
const tryL = nx - bw;
|
||
const tryR = nx + bw;
|
||
if (!jumpSurviveOverlapsSolid(md, tryL, headY0, tryR, feetY)) {
|
||
pxc = nx;
|
||
}
|
||
|
||
feetY += vy * dt;
|
||
let headTop = feetY - bh;
|
||
if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
|
||
if (vy > 0) {
|
||
for (let s = 0; s < 22; s++) {
|
||
feetY -= ts * 0.035;
|
||
headTop = feetY - bh;
|
||
if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
|
||
vy = 0;
|
||
onGround = true;
|
||
break;
|
||
}
|
||
}
|
||
} else if (vy < 0) {
|
||
for (let s = 0; s < 22; s++) {
|
||
feetY += ts * 0.035;
|
||
headTop = feetY - bh;
|
||
if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
|
||
vy = 0;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
onGround = false;
|
||
}
|
||
|
||
headTop = feetY - bh;
|
||
const headBandBottom = headTop + bh * 0.42;
|
||
if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY) &&
|
||
jumpSurviveOverlapsObjectWall(md, pxc - bw, headTop, pxc + bw, headBandBottom)) {
|
||
return { pxc, feetY, vy, onGround, died: true };
|
||
}
|
||
|
||
headTop = feetY - bh;
|
||
const topKill = camCenterY - halfViewH - ts * 0.35;
|
||
const botKill = camCenterY + halfViewH + ts * 0.85;
|
||
if (feetY < topKill || feetY > botKill || headTop < -ts * 2) {
|
||
return { pxc, feetY, vy, onGround, died: true };
|
||
}
|
||
|
||
return { pxc, feetY, vy, onGround, died: false };
|
||
}
|
||
|
||
function jumpSurviveEntityFromHitbox(o, md, pxc, feetYpx) {
|
||
const { cw, ch } = getCharacterFootprintWH(md);
|
||
o.x = pxc / tileSize - cw * 0.5;
|
||
o.y = feetYpx / tileSize - ch;
|
||
}
|
||
|
||
function jumpSurviveInitRuntime() {
|
||
jumpSurviveLastTickMs = performance.now();
|
||
jumpSurviveSessionStartMs = performance.now();
|
||
jumpSurviveVy = 0;
|
||
jumpSurviveJumpQueued = false;
|
||
jumpSurviveOnGround = false;
|
||
jumpSurvivePlatformScrollPx = 0;
|
||
const { cw, ch } = getCharacterFootprintWH(mapData);
|
||
const ts = tileSize;
|
||
jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts;
|
||
jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts;
|
||
}
|
||
|
||
function jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom) {
|
||
const ts = tileSize;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const x0 = Math.floor(left / ts);
|
||
const x1 = Math.floor((right - 1e-6) / ts);
|
||
const y0 = Math.floor(top / ts);
|
||
const y1 = Math.floor((bottom - 1e-6) / ts);
|
||
for (let ty = y0; ty <= y1; ty++) {
|
||
for (let tx = x0; tx <= x1; tx++) {
|
||
if (tx < 0 || tx >= w || ty >= h) return true;
|
||
if (ty < 0) continue;
|
||
const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
|
||
if (ob === 1) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx) {
|
||
const ts = tileSize;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const pa = md.jumpSurvivePlatformArea;
|
||
if (!pa) return false;
|
||
const period = jumpSurvivePlatformPeriodPx(md);
|
||
if (period < ts) return false;
|
||
const x0 = Math.max(0, Math.floor(left / ts));
|
||
const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
|
||
const vm = ts * 2;
|
||
for (let ty = 0; ty < h; ty++) {
|
||
for (let tx = x0; tx <= x1; tx++) {
|
||
if (!pa[ty] || pa[ty][tx] !== 1) continue;
|
||
const rawTop = ty * ts - scrollPx;
|
||
const kMin = Math.ceil((top - rawTop - ts - vm) / period);
|
||
const kMax = Math.floor((bottom - rawTop + vm) / period);
|
||
for (let k = kMin; k <= kMax; k++) {
|
||
const platTop = rawTop + k * period;
|
||
const platBot = platTop + ts;
|
||
const tileLeft = tx * ts;
|
||
const tileRight = (tx + 1) * ts;
|
||
if (platBot > top && platTop < bottom && right > tileLeft && left < tileRight) return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function jumpSurviveOverlapsSolid(md, left, top, right, bottom) {
|
||
if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
|
||
return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, jumpSurvivePlatformScrollPx);
|
||
}
|
||
|
||
/** Head/body overlaps กำแพง (objects=1) เท่านั้น — ใช้ตรวจชนฝาบนแล้วตาย */
|
||
function jumpSurviveOverlapsObjectWall(md, left, top, right, bottom) {
|
||
const ts = tileSize;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const x0 = Math.floor(left / ts);
|
||
const x1 = Math.floor((right - 1e-6) / ts);
|
||
const y0 = Math.floor(top / ts);
|
||
const y1 = Math.floor((bottom - 1e-6) / ts);
|
||
for (let ty = y0; ty <= y1; ty++) {
|
||
for (let tx = x0; tx <= x1; tx++) {
|
||
if (tx < 0 || tx >= w || ty < 0 || ty >= h) continue;
|
||
const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
|
||
if (ob === 1) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function jumpSurviveSyncMeFromHitbox(pxc, feetYpx) {
|
||
jumpSurviveEntityFromHitbox(me, mapData, pxc, feetYpx);
|
||
}
|
||
|
||
function jumpSurviveCameraTargetPx(md) {
|
||
const { cw, ch } = getCharacterFootprintWH(md);
|
||
const ts = tileSize;
|
||
const cx = (e) => (e.x + cw * 0.5) * ts;
|
||
const cy = (e) => (e.y + ch * 0.5) * ts;
|
||
if (!jumpSurviveEliminated) return { px: cx(me), py: cy(me) };
|
||
const aliveHumanIds = [...others.keys()].filter((id) => {
|
||
if (isPreviewBotId(id)) return false;
|
||
const o = others.get(id);
|
||
return o && !o.jumpSurviveEliminated;
|
||
});
|
||
const aliveBotIds = [...others.keys()].filter((id) => {
|
||
if (!isPreviewBotId(id)) return false;
|
||
const o = others.get(id);
|
||
return o && !o.jumpSurviveEliminated;
|
||
});
|
||
const useIds = aliveHumanIds.length ? aliveHumanIds : aliveBotIds;
|
||
if (!useIds.length) return { px: cx(me), py: cy(me) };
|
||
let sx = 0, sy = 0;
|
||
useIds.forEach((id) => {
|
||
const o = others.get(id);
|
||
if (!o) return;
|
||
sx += cx(o);
|
||
sy += cy(o);
|
||
});
|
||
const n = useIds.length;
|
||
return { px: sx / n, py: sy / n };
|
||
}
|
||
|
||
function stepJumpSurvivePreviewBots(dt, halfViewH) {
|
||
if (!previewFillBots || !mapData || !isJumpSurvive()) return;
|
||
const md = mapData;
|
||
const { cw, ch } = getCharacterFootprintWH(md);
|
||
const ts = tileSize;
|
||
const wpx = (md.width || 20) * ts;
|
||
const centerPx = wpx * 0.5;
|
||
others.forEach((o, id) => {
|
||
if (!isPreviewBotId(id)) return;
|
||
if (o.jumpSurviveEliminated) return;
|
||
if (typeof o.jumpSurviveVy !== 'number') o.jumpSurviveVy = 0;
|
||
if (o.jumpSurviveOnGround == null) o.jumpSurviveOnGround = false;
|
||
if (typeof o.jumpSurviveNextJumpAi !== 'number') o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * 500);
|
||
if (typeof o.jumpSurviveWishNext !== 'number') o.jumpSurviveWishNext = Date.now() + 200;
|
||
if (o.jumpSurviveWishX == null) o.jumpSurviveWishX = 0;
|
||
|
||
let pxc = (o.x + cw * 0.5) * ts;
|
||
let feetY = (o.y + ch) * ts;
|
||
|
||
let jumpQ = false;
|
||
if (o.jumpSurviveOnGround && Date.now() >= o.jumpSurviveNextJumpAi) {
|
||
o.jumpSurviveNextJumpAi = Date.now() + 320 + Math.floor(Math.random() * 1100);
|
||
const tier = o.botTier || 'avg';
|
||
const jp = tier === 'sharp' ? 0.5 : tier === 'weak' ? 0.25 : 0.36;
|
||
if (Math.random() < jp) jumpQ = true;
|
||
}
|
||
|
||
if (Date.now() >= o.jumpSurviveWishNext) {
|
||
o.jumpSurviveWishNext = Date.now() + 380 + Math.floor(Math.random() * 700);
|
||
const tier = o.botTier || 'avg';
|
||
if (pxc < centerPx - ts * 1.8) o.jumpSurviveWishX = 1;
|
||
else if (pxc > centerPx + ts * 1.8) o.jumpSurviveWishX = -1;
|
||
else if (tier === 'sharp') {
|
||
o.jumpSurviveWishX = Math.random() < 0.55 ? (Math.random() < 0.5 ? -1 : 1) : 0;
|
||
} else {
|
||
o.jumpSurviveWishX = Math.random() < 0.4 ? (Math.random() < 0.5 ? -1 : 1) : 0;
|
||
}
|
||
}
|
||
|
||
const moveLeft = o.jumpSurviveWishX < 0;
|
||
const moveRight = o.jumpSurviveWishX > 0;
|
||
|
||
const r = jumpSurvivePhysicsIntegrate(md, {
|
||
dt,
|
||
cw,
|
||
ch,
|
||
pxc0: pxc,
|
||
feetY0: feetY,
|
||
vy0: o.jumpSurviveVy,
|
||
wasOnGround: o.jumpSurviveOnGround,
|
||
jumpQueued: jumpQ,
|
||
moveLeft,
|
||
moveRight,
|
||
camCenterY: jumpSurviveCamCenterY,
|
||
halfViewH,
|
||
});
|
||
|
||
if (r.died) {
|
||
o.jumpSurviveEliminated = true;
|
||
o.jumpSurviveVy = 0;
|
||
jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
|
||
o.tx = o.x;
|
||
o.ty = o.y;
|
||
o.botIsWalking = false;
|
||
return;
|
||
}
|
||
o.jumpSurviveVy = r.vy;
|
||
o.jumpSurviveOnGround = r.onGround;
|
||
jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
|
||
o.tx = o.x;
|
||
o.ty = o.y;
|
||
if (moveRight) o.direction = 'right';
|
||
else if (moveLeft) o.direction = 'left';
|
||
o.botIsWalking = Math.abs(r.vy) > 40 || moveLeft || moveRight;
|
||
});
|
||
}
|
||
|
||
function jumpSurviveTickFrame() {
|
||
if (!mapData || !isJumpSurvive()) return;
|
||
const md = mapData;
|
||
const ts = tileSize;
|
||
const now = performance.now();
|
||
const dt = Math.min(0.05, Math.max(0, (now - jumpSurviveLastTickMs) / 1000));
|
||
jumpSurviveLastTickMs = now;
|
||
|
||
const halfViewH = canvas.height / (2 * zoom);
|
||
const rise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt;
|
||
jumpSurvivePlatformScrollPx += rise;
|
||
|
||
const camTgt = jumpSurviveCameraTargetPx(md);
|
||
const ck = Math.min(1, dt * 7);
|
||
jumpSurviveCamCenterX += (camTgt.px - jumpSurviveCamCenterX) * ck;
|
||
jumpSurviveCamCenterY += (camTgt.py - jumpSurviveCamCenterY) * ck;
|
||
|
||
if (jumpSurviveEliminated) {
|
||
stepJumpSurvivePreviewBots(dt, halfViewH);
|
||
return;
|
||
}
|
||
|
||
const { cw, ch } = getCharacterFootprintWH(md);
|
||
let pxc = (me.x + cw * 0.5) * ts;
|
||
let feetY = (me.y + ch) * ts;
|
||
|
||
const jq = jumpSurviveJumpQueued;
|
||
jumpSurviveJumpQueued = false;
|
||
|
||
const r = jumpSurvivePhysicsIntegrate(md, {
|
||
dt,
|
||
cw,
|
||
ch,
|
||
pxc0: pxc,
|
||
feetY0: feetY,
|
||
vy0: jumpSurviveVy,
|
||
wasOnGround: jumpSurviveOnGround,
|
||
jumpQueued: jq,
|
||
moveLeft: !!(keys['ArrowLeft'] || keys['KeyA']),
|
||
moveRight: !!(keys['ArrowRight'] || keys['KeyD']),
|
||
camCenterY: jumpSurviveCamCenterY,
|
||
halfViewH,
|
||
});
|
||
|
||
if (r.died) {
|
||
jumpSurviveEliminated = true;
|
||
jumpSurviveVy = 0;
|
||
jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
|
||
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyW', 'KeyS', 'KeyD'].forEach((c) => { keys[c] = false; });
|
||
} else {
|
||
jumpSurviveVy = r.vy;
|
||
jumpSurviveOnGround = r.onGround;
|
||
jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
|
||
}
|
||
|
||
stepJumpSurvivePreviewBots(dt, halfViewH);
|
||
|
||
if (!jumpSurviveEliminated) {
|
||
me.isWalking = Math.abs(jumpSurviveVy) > 40 || !!(keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']);
|
||
if (keys['ArrowRight'] || keys['KeyD']) me.direction = 'right';
|
||
else if (keys['ArrowLeft'] || keys['KeyA']) me.direction = 'left';
|
||
|
||
if (Date.now() - jumpSurviveLastEmitT > 80) {
|
||
jumpSurviveLastEmitT = Date.now();
|
||
socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyMapAndStart(gameMapData, res) {
|
||
const prevWasSpaceShooter = mapData && mapData.gameType === 'space_shooter';
|
||
const prevWasBalloonBoss = mapData && mapData.gameType === 'balloon_boss';
|
||
mapData = gameMapData;
|
||
if (mapData.gameType !== 'space_shooter') {
|
||
spaceShooterGameEnded = false;
|
||
if (prevWasSpaceShooter) {
|
||
const gend = document.getElementById('gauntlet-ended-overlay');
|
||
if (gend) gend.classList.add('is-hidden');
|
||
}
|
||
}
|
||
if (mapData.gameType !== 'balloon_boss') {
|
||
balloonBossGameEnded = false;
|
||
if (prevWasBalloonBoss) {
|
||
const gend = document.getElementById('gauntlet-ended-overlay');
|
||
if (gend) gend.classList.add('is-hidden');
|
||
}
|
||
}
|
||
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 = [];
|
||
}
|
||
if (mapData.gameType === 'stack') {
|
||
if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
|
||
if (!mapData.stackLandArea) mapData.stackLandArea = [];
|
||
}
|
||
if (mapData.gameType === 'quiz_carry') {
|
||
if (!mapData.quizCarryHubArea) mapData.quizCarryHubArea = [];
|
||
if (!mapData.quizCarryOptionArea) mapData.quizCarryOptionArea = [];
|
||
if (!mapData.quizQuestions) mapData.quizQuestions = [];
|
||
if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
|
||
normalizeQuizCarryLayersInPlay(mapData);
|
||
}
|
||
if (mapData.gameType === 'quiz_battle') {
|
||
if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
|
||
if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
|
||
normalizeQuizBattlePathInPlay(mapData);
|
||
normalizeQuizBattleDomeInPlay(mapData);
|
||
}
|
||
tileSize = mapData.tileSize || 32;
|
||
mapBackgroundImg = null;
|
||
if (mapData.backgroundImage) {
|
||
mapBackgroundImg = new Image();
|
||
mapBackgroundImg.src = mapData.backgroundImage;
|
||
}
|
||
var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
|
||
: (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
|
||
: (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
|
||
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;
|
||
if (isQuizCarry()) {
|
||
const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(me.x, me.y);
|
||
me.x = hubSnap.x;
|
||
me.y = hubSnap.y;
|
||
}
|
||
if (isQuizBattle()) {
|
||
const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
|
||
me.x = pathSnap.x;
|
||
me.y = pathSnap.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;
|
||
}
|
||
{
|
||
const sh0 = Number(myPeer && myPeer.spaceShooterScore);
|
||
me.spaceShooterScore = Number.isFinite(sh0) ? Math.max(0, sh0) : 0;
|
||
}
|
||
{
|
||
const bSt = balloonBossBalloonsStartPlay();
|
||
const bs0 = Number(myPeer && myPeer.balloonBossScore);
|
||
me.balloonBossScore = Number.isFinite(bs0) ? Math.max(0, bs0) : 0;
|
||
const bb0 = Number(myPeer && myPeer.balloonBossBalloons);
|
||
me.balloonBossBalloons = Number.isFinite(bb0) ? Math.max(0, Math.floor(bb0)) : bSt;
|
||
me.balloonBossEliminated = !!(myPeer && myPeer.balloonBossEliminated);
|
||
}
|
||
gauntletObstacles = [];
|
||
gauntletObsRenderPrev = [];
|
||
gauntletObsRenderNext = [];
|
||
gauntletObsBlendT0 = 0;
|
||
me.playTint = pickRandomPlayTint();
|
||
others.clear();
|
||
plist.forEach(p => {
|
||
if (p.id !== myId) {
|
||
const pos = peerXYFromJoin(p, mapData.spawn);
|
||
let px = pos.x, py = pos.y;
|
||
if (isQuizCarry()) {
|
||
const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(px, py);
|
||
px = hubSnap.x;
|
||
py = hubSnap.y;
|
||
}
|
||
if (isQuizBattle()) {
|
||
const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(px, py);
|
||
px = pathSnap.x;
|
||
py = pathSnap.y;
|
||
}
|
||
others.set(p.id, {
|
||
x: px, y: py, tx: px, ty: py, 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; })(),
|
||
spaceShooterScore: (() => { const s = Number(p.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
|
||
balloonBossScore: (() => { const s = Number(p.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
|
||
balloonBossBalloons: (() => {
|
||
const s = Number(p.balloonBossBalloons);
|
||
return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
|
||
})(),
|
||
balloonBossEliminated: !!p.balloonBossEliminated,
|
||
quizCarryHeld: null,
|
||
});
|
||
}
|
||
});
|
||
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(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((t) => {
|
||
if (t) applyGauntletTimingFromServer(t);
|
||
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
|
||
})
|
||
.catch(() => {});
|
||
} else {
|
||
me.tx = null;
|
||
me.ty = null;
|
||
gauntletEndsAtMs = null;
|
||
}
|
||
rebalancePreviewBots();
|
||
if (mapData.gameType === 'space_shooter') {
|
||
applySpaceShooterSpawnLayoutPlay();
|
||
}
|
||
if (mapData.gameType === 'balloon_boss') {
|
||
applyBalloonBossSpawnLayoutPlay();
|
||
}
|
||
if (isStack()) {
|
||
lastStackTickMs = performance.now();
|
||
resetStackMinigameState();
|
||
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((t) => {
|
||
if (t) applyGauntletTimingFromServer(t);
|
||
else reapplyStackMiniSizingFromGlobals();
|
||
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
|
||
})
|
||
.catch(() => {
|
||
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
|
||
});
|
||
} else {
|
||
stackMini = null;
|
||
}
|
||
playPath = [];
|
||
me.isWalking = false;
|
||
if (mapData.gameType === 'jump_survive') {
|
||
if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
|
||
if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
|
||
normalizeJumpSurvivePlatformAreaInPlay(mapData);
|
||
jumpSurviveEliminated = false;
|
||
jumpSurviveInitRuntime();
|
||
}
|
||
if (mapData.gameType === 'space_shooter') {
|
||
spaceShooterGameEnded = false;
|
||
normalizeShooterSpawnSlotsInPlay(mapData);
|
||
spaceShooterBullets = [];
|
||
spaceShooterAsteroids = [];
|
||
spaceShooterPopups = [];
|
||
spaceShooterLastTickMs = performance.now();
|
||
spaceShooterSpawnAccMs = 0;
|
||
spaceShooterFireCd = 0;
|
||
spaceShooterSessionStartMs = performance.now();
|
||
spaceShooterLastMoveEmit = 0;
|
||
}
|
||
if (mapData.gameType === 'balloon_boss') {
|
||
balloonBossGameEnded = false;
|
||
normalizeBalloonBossPlayerSlotsInPlay(mapData);
|
||
balloonBossPendingShots = [];
|
||
balloonBossPlayerBullets = [];
|
||
balloonBossBossBullets = [];
|
||
balloonBossHitFx = [];
|
||
balloonBossLastTickMs = performance.now();
|
||
balloonBossPlayerFireCd = 0;
|
||
balloonBossSessionStartMs = performance.now();
|
||
balloonBossLastMoveEmit = 0;
|
||
balloonBossBossFireAcc = 0;
|
||
}
|
||
if (isQuiz()) setupPlayQuizUi();
|
||
else if (isQuizCarry()) setupPlayQuizCarryUi();
|
||
else if (isQuizBattle()) setupPlayQuizBattleUi();
|
||
else teardownPlayQuizUi();
|
||
resizeCanvas(); draw(); tick();
|
||
}
|
||
|
||
socket.on('connect', () => {
|
||
firstCharacterDefaultPromise.then(() => {
|
||
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;
|
||
if (previewMode && editorEmbedReturn && res.mapData && res.mapData.gameType === 'lobby') {
|
||
const q = [];
|
||
q.push('space=' + encodeURIComponent(spaceId));
|
||
q.push('nick=' + encodeURIComponent(nick));
|
||
const mid = params.get('map');
|
||
if (mid) q.push('map=' + encodeURIComponent(mid));
|
||
q.push('preview=1');
|
||
q.push('editorEmbed=1');
|
||
if (params.get('defaultChar') != null) q.push('defaultChar=' + encodeURIComponent(params.get('defaultChar')));
|
||
window.location.replace(BASE + '/room-lobby.html?' + q.join('&'));
|
||
return;
|
||
}
|
||
const playMapId = params.get('map');
|
||
if (playMapId) {
|
||
fetch(BASE + '/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; })(),
|
||
spaceShooterScore: (() => { const s = Number(data.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
|
||
balloonBossScore: (() => { const s = Number(data.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
|
||
balloonBossBalloons: (() => {
|
||
const s = Number(data.balloonBossBalloons);
|
||
return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
|
||
})(),
|
||
balloonBossEliminated: !!data.balloonBossEliminated,
|
||
quizCarryHeld: null,
|
||
});
|
||
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 (mapData && mapData.gameType === 'stack') {
|
||
return;
|
||
}
|
||
if (mapData && mapData.gameType === 'jump_survive') {
|
||
return;
|
||
}
|
||
if (mapData && mapData.gameType === 'space_shooter') {
|
||
if (data.spaceShooterScore != null) {
|
||
const s = Number(data.spaceShooterScore);
|
||
if (Number.isFinite(s)) me.spaceShooterScore = Math.max(0, s);
|
||
}
|
||
return;
|
||
}
|
||
if (mapData && mapData.gameType === 'balloon_boss') {
|
||
if (data.balloonBossScore != null) {
|
||
const s = Number(data.balloonBossScore);
|
||
if (Number.isFinite(s)) me.balloonBossScore = Math.max(0, s);
|
||
}
|
||
if (data.balloonBossBalloons != null) {
|
||
const b = Number(data.balloonBossBalloons);
|
||
if (Number.isFinite(b)) me.balloonBossBalloons = Math.max(0, Math.floor(b));
|
||
}
|
||
if (data.balloonBossEliminated != null) me.balloonBossEliminated = !!data.balloonBossEliminated;
|
||
return;
|
||
}
|
||
if (mapData && mapData.gameType === 'quiz_battle') {
|
||
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;
|
||
}
|
||
const s = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
|
||
me.x = s.x;
|
||
me.y = s.y;
|
||
if (data.direction) me.direction = data.direction;
|
||
if (data.characterId != null) me.characterId = data.characterId;
|
||
playPath = [];
|
||
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) {
|
||
if (mapData && mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData) && !isPreviewBotId(data.id)) {
|
||
const px = Number(data.x);
|
||
const py = Number(data.y);
|
||
if (Number.isFinite(px) && Number.isFinite(py)) {
|
||
const sn = snapPositionOntoQuizBattlePathIfNeeded(px, py);
|
||
o.tx = sn.x;
|
||
o.ty = sn.y;
|
||
o.x = sn.x;
|
||
o.y = sn.y;
|
||
} else {
|
||
o.tx = data.x;
|
||
o.ty = data.y;
|
||
}
|
||
} else {
|
||
o.tx = data.x;
|
||
o.ty = data.y;
|
||
}
|
||
o.direction = data.direction || o.direction;
|
||
if (data.characterId != null) o.characterId = data.characterId;
|
||
if (mapData && mapData.gameType === 'space_shooter' && data.spaceShooterScore != null) {
|
||
const s = Number(data.spaceShooterScore);
|
||
if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s);
|
||
}
|
||
if (mapData && mapData.gameType === 'balloon_boss') {
|
||
if (data.balloonBossScore != null) {
|
||
const s = Number(data.balloonBossScore);
|
||
if (Number.isFinite(s)) o.balloonBossScore = Math.max(0, s);
|
||
}
|
||
if (data.balloonBossBalloons != null) {
|
||
const b = Number(data.balloonBossBalloons);
|
||
if (Number.isFinite(b)) o.balloonBossBalloons = Math.max(0, Math.floor(b));
|
||
}
|
||
if (data.balloonBossEliminated != null) o.balloonBossEliminated = !!data.balloonBossEliminated;
|
||
}
|
||
}
|
||
});
|
||
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.gameType !== 'space_shooter') {
|
||
spaceShooterGameEnded = false;
|
||
}
|
||
if (mapData.gameType !== 'balloon_boss') {
|
||
balloonBossGameEnded = false;
|
||
}
|
||
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(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((t) => {
|
||
if (t) applyGauntletTimingFromServer(t);
|
||
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
|
||
})
|
||
.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');
|
||
if (mapData.gameType === 'stack') {
|
||
if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
|
||
if (!mapData.stackLandArea) mapData.stackLandArea = [];
|
||
rebalancePreviewBots();
|
||
lastStackTickMs = performance.now();
|
||
resetStackMinigameState();
|
||
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((t) => {
|
||
if (t) applyGauntletTimingFromServer(t);
|
||
else reapplyStackMiniSizingFromGlobals();
|
||
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
|
||
})
|
||
.catch(() => {
|
||
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
|
||
});
|
||
} else {
|
||
stackMini = null;
|
||
}
|
||
if (mapData.gameType === 'jump_survive') {
|
||
if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
|
||
if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
|
||
normalizeJumpSurvivePlatformAreaInPlay(mapData);
|
||
jumpSurviveEliminated = false;
|
||
jumpSurviveInitRuntime();
|
||
}
|
||
if (mapData.gameType === 'space_shooter') {
|
||
spaceShooterGameEnded = false;
|
||
normalizeShooterSpawnSlotsInPlay(mapData);
|
||
spaceShooterBullets = [];
|
||
spaceShooterAsteroids = [];
|
||
spaceShooterPopups = [];
|
||
spaceShooterLastTickMs = performance.now();
|
||
spaceShooterSpawnAccMs = 0;
|
||
spaceShooterFireCd = 0;
|
||
spaceShooterSessionStartMs = performance.now();
|
||
spaceShooterLastMoveEmit = 0;
|
||
}
|
||
if (mapData.gameType === 'balloon_boss') {
|
||
balloonBossGameEnded = false;
|
||
normalizeBalloonBossPlayerSlotsInPlay(mapData);
|
||
balloonBossPendingShots = [];
|
||
balloonBossPlayerBullets = [];
|
||
balloonBossBossBullets = [];
|
||
balloonBossHitFx = [];
|
||
balloonBossLastTickMs = performance.now();
|
||
balloonBossPlayerFireCd = 0;
|
||
balloonBossSessionStartMs = performance.now();
|
||
balloonBossLastMoveEmit = 0;
|
||
balloonBossBossFireAcc = 0;
|
||
me.balloonBossScore = 0;
|
||
me.balloonBossBalloons = balloonBossBalloonsStartPlay();
|
||
me.balloonBossEliminated = false;
|
||
others.forEach((o) => {
|
||
o.balloonBossScore = 0;
|
||
o.balloonBossBalloons = balloonBossBalloonsStartPlay();
|
||
o.balloonBossEliminated = false;
|
||
});
|
||
}
|
||
if (mapData.gameType === 'quiz_battle') {
|
||
if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
|
||
if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
|
||
normalizeQuizBattlePathInPlay(mapData);
|
||
normalizeQuizBattleDomeInPlay(mapData);
|
||
if (quizBattlePathModeActive(mapData)) {
|
||
const ms = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
|
||
me.x = ms.x;
|
||
me.y = ms.y;
|
||
others.forEach((o) => {
|
||
const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
|
||
o.x = s.x;
|
||
o.y = s.y;
|
||
});
|
||
}
|
||
}
|
||
var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
|
||
: (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
|
||
: (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
|
||
var prevTag = previewMode ? '[ทดสอบ] ' : '';
|
||
document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
|
||
if (isQuiz()) setupPlayQuizUi();
|
||
else if (isQuizCarry()) setupPlayQuizCarryUi();
|
||
else if (isQuizBattle()) setupPlayQuizBattleUi();
|
||
else teardownPlayQuizUi();
|
||
resizeCanvas();
|
||
if (mapData.gameType === 'balloon_boss') {
|
||
applyBalloonBossSpawnLayoutPlay();
|
||
}
|
||
if (previewFillBots && mapData.gameType === 'gauntlet') {
|
||
applyGauntletPreviewSpawnLayout(true);
|
||
emitGauntletPreviewRowsToServer();
|
||
}
|
||
if (previewFillBots && mapData.gameType === 'jump_survive') {
|
||
applyJumpSurvivePreviewSpawnLayout(true);
|
||
}
|
||
if (previewFillBots && mapData.gameType === 'space_shooter') {
|
||
applySpaceShooterSpawnLayoutPlay();
|
||
}
|
||
if (previewFillBots && mapData.gameType === 'balloon_boss') {
|
||
applyBalloonBossSpawnLayoutPlay();
|
||
}
|
||
};
|
||
fetch(BASE + '/api/maps/' + encodeURIComponent(ev.mapId))
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((md) => { if (md) applySnap(md); });
|
||
});
|
||
|
||
function resizeCanvas() {
|
||
const vw = window.innerWidth || document.documentElement.clientWidth || 800;
|
||
const vh = window.innerHeight || document.documentElement.clientHeight || 600;
|
||
const header = document.querySelector('.game-header');
|
||
const headerH = header && header.offsetHeight ? header.offsetHeight : 48;
|
||
const parent = canvas && canvas.parentElement;
|
||
let cw = canvas.clientWidth || 0;
|
||
let ch = canvas.clientHeight || 0;
|
||
if (parent && parent.clientWidth > 80) cw = Math.max(cw, parent.clientWidth);
|
||
if (parent && parent.clientHeight > 80) ch = Math.max(ch, parent.clientHeight);
|
||
if (ch < 120) ch = Math.max(240, vh - headerH - 6);
|
||
if (cw < 120) cw = Math.max(320, vw);
|
||
canvas.width = Math.max(320, cw);
|
||
canvas.height = Math.max(240, ch);
|
||
}
|
||
|
||
// ให้ 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('click', (e) => {
|
||
if (!mapData || !isStack() || isChatFocused()) return;
|
||
tryHumanStackDrop();
|
||
});
|
||
canvas.addEventListener('dblclick', (e) => {
|
||
if (!mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss() || isChatFocused() || isQuizCarryEmbedCountdownBlockingMovement()) 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 getCharacterFootprintWH(md) {
|
||
if (!md) return { cw: 1, ch: 1 };
|
||
let cw = Math.floor(Number(md.characterCellsW));
|
||
let ch = Math.floor(Number(md.characterCellsH));
|
||
const hasW = Number.isFinite(cw) && cw >= 1;
|
||
const hasH = Number.isFinite(ch) && ch >= 1;
|
||
if (hasW && hasH) {
|
||
return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) };
|
||
}
|
||
const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1));
|
||
return { cw: leg, ch: leg };
|
||
}
|
||
|
||
/** กันตัวละคร footprint ล้นออกนอกแมป — เดิมใช้ w-0.01 ทำให้ cw/ch>1 ชนขอบแล้วตรรกะเดินค้าง */
|
||
function clampPlayEntityFootprintToMap(oEnt, md) {
|
||
if (!oEnt || !md) return;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const { cw, ch } = getCharacterFootprintWH(md);
|
||
const maxX = Math.max(0, w - cw + 0.999);
|
||
const maxY = Math.max(0, h - ch + 0.999);
|
||
if (!Number.isFinite(oEnt.x)) oEnt.x = 0.5;
|
||
if (!Number.isFinite(oEnt.y)) oEnt.y = 0.5;
|
||
oEnt.x = Math.max(0, Math.min(maxX, oEnt.x));
|
||
oEnt.y = Math.max(0, Math.min(maxY, oEnt.y));
|
||
}
|
||
|
||
/** แยกบอท preview ที่ทับกันบนจุดเดียว (มักชนกันที่ขอบแมป) */
|
||
function separateClumpedPreviewBots() {
|
||
if (!previewFillBots || !mapData) return;
|
||
const ids = [...others.keys()].filter(isPreviewBotId);
|
||
const minD = 0.52;
|
||
const pushAmt = MOVE_SPEED * 0.5;
|
||
for (let i = 0; i < ids.length; i++) {
|
||
for (let j = i + 1; j < ids.length; j++) {
|
||
const a = others.get(ids[i]);
|
||
const b = others.get(ids[j]);
|
||
if (!a || !b) continue;
|
||
let ddx = b.x - a.x, ddy = b.y - a.y;
|
||
let d = Math.sqrt(ddx * ddx + ddy * ddy);
|
||
if (d >= minD) continue;
|
||
if (d < 1e-5) {
|
||
ddx = (i + j * 2) % 2 === 0 ? 1 : -1;
|
||
ddy = ((i + j) % 3) - 1;
|
||
d = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
|
||
}
|
||
const ux = -ddx / d, uy = -ddy / d;
|
||
const vx = ddx / d, vy = ddy / d;
|
||
const ax2 = a.x + ux * pushAmt, ay2 = a.y + uy * pushAmt;
|
||
const bx2 = b.x + vx * pushAmt, by2 = b.y + vy * pushAmt;
|
||
if (canWalkLikeLobbyForBot(ax2, ay2, a.x, a.y, a)) { a.x = ax2; a.y = ay2; }
|
||
if (canWalkLikeLobbyForBot(bx2, by2, b.x, b.y, b)) { b.x = bx2; b.y = by2; }
|
||
clampPlayEntityFootprintToMap(a, mapData);
|
||
clampPlayEntityFootprintToMap(b, mapData);
|
||
}
|
||
}
|
||
}
|
||
|
||
function quizTilesFootprintPlay(px, py) {
|
||
const s = new Set();
|
||
if (!mapData) return s;
|
||
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
|
||
const { cw, ch } = getCharacterFootprintWH(mapData);
|
||
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 + cw - 1);
|
||
const maxTy = Math.min(h - 1, minTy + ch - 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;
|
||
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
for (const k of quizTilesFootprintPlay(x, y)) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
|
||
const row = mapData.objects[ty];
|
||
if (!row || row[tx] === 1) return false;
|
||
}
|
||
if (quizTilesFootprintPlay(x, y).size === 0) return false;
|
||
const bp = mapData.blockPlayer;
|
||
if (bp) {
|
||
for (const k of quizTilesFootprintPlay(x, y)) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
if (!bp[ty] || bp[ty][tx] !== 1) continue;
|
||
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;
|
||
}
|
||
}
|
||
if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false;
|
||
if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
|
||
if (!quizBattleFootprintFullyOnPath(mapData, x, y)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function canWalkLikeLobbyForBot(x, y, fromX, fromY, o) {
|
||
if (!mapData || !mapData.objects) return false;
|
||
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
for (const k of quizTilesFootprintPlay(x, y)) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
|
||
const row = mapData.objects[ty];
|
||
if (!row || row[tx] === 1) return false;
|
||
}
|
||
if (quizTilesFootprintPlay(x, y).size === 0) return false;
|
||
const bp = mapData.blockPlayer;
|
||
if (bp) {
|
||
for (const k of quizTilesFootprintPlay(x, y)) {
|
||
const p = k.split(',');
|
||
const tx = +p[0], ty = +p[1];
|
||
if (!bp[ty] || bp[ty][tx] !== 1) continue;
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false;
|
||
if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
|
||
if (!quizBattleFootprintFullyOnPath(mapData, x, y)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** ถ้าสปอว์น/โหลดมาทับโซน hub ให้หาจุดใกล้ ๆ ที่ footprint ไม่ทับ hub */
|
||
function snapPositionOutOfQuizCarryHubIfNeeded(x, y) {
|
||
if (!mapData || !isQuizCarry()) return { x, y };
|
||
if (!quizCarryFootprintOverlapsHub(x, y)) return { x, y };
|
||
const w = mapData.width || 20, h = mapData.height || 15;
|
||
const maxR = Math.max(w, h) + 6;
|
||
for (let r = 1; r <= maxR; r++) {
|
||
for (let dy = -r; dy <= r; dy++) {
|
||
for (let dx = -r; dx <= r; dx++) {
|
||
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
|
||
const nx = x + dx * 0.45;
|
||
const ny = y + dy * 0.45;
|
||
if (nx < 0 || ny < 0 || nx > w - 0.02 || ny > h - 0.02) continue;
|
||
if (quizCarryFootprintOverlapsHub(nx, ny)) continue;
|
||
if (!canWalkLikeLobby(nx, ny, NaN, NaN)) continue;
|
||
return { x: nx, y: ny };
|
||
}
|
||
}
|
||
}
|
||
return { x, y };
|
||
}
|
||
|
||
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();
|
||
}
|
||
clampPlayEntityFootprintToMap(o, mapData);
|
||
return;
|
||
}
|
||
const len = dist || 1;
|
||
const pathStepMul = (previewFillBots && editorEmbedReturn) ? 0.56 : 1;
|
||
const step = Math.min(MOVE_SPEED * 1.28 * pathStepMul, 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;
|
||
const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
|
||
if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
|
||
o.x = nx;
|
||
o.y = ny;
|
||
} else if (!pathStrict) {
|
||
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 {
|
||
/* เส้นตรงไป waypoint อาจตัดมุม hub / block — ลองก้าวแนวแกนตามทิศหลักอย่างใดอย่างหนึ่ง */
|
||
const cstep = Math.min(MOVE_SPEED * 1.2 * pathStepMul, Math.max(Math.abs(dx), Math.abs(dy), 1e-4));
|
||
const sx = dx > 1e-4 ? 1 : dx < -1e-4 ? -1 : 0;
|
||
const sy = dy > 1e-4 ? 1 : dy < -1e-4 ? -1 : 0;
|
||
const tryAxisX = () => {
|
||
if (sx === 0) return false;
|
||
if (!canWalkLikeLobbyForBot(o.x + sx * cstep, o.y, o.x, o.y, o)) return false;
|
||
o.x += sx * cstep;
|
||
return true;
|
||
};
|
||
const tryAxisY = () => {
|
||
if (sy === 0) return false;
|
||
if (!canWalkLikeLobbyForBot(o.x, o.y + sy * cstep, o.x, o.y, o)) return false;
|
||
o.y += sy * cstep;
|
||
return true;
|
||
};
|
||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||
if (!tryAxisX()) tryAxisY();
|
||
} else {
|
||
if (!tryAxisY()) tryAxisX();
|
||
}
|
||
}
|
||
}
|
||
clampPlayEntityFootprintToMap(o, mapData);
|
||
if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) {
|
||
o.botIsWalking = true;
|
||
o.botPathStuckTicks = 0;
|
||
} else {
|
||
o.botPathStuckTicks = (o.botPathStuckTicks | 0) + 1;
|
||
if ((o.botPathStuckTicks | 0) >= 20) {
|
||
o.botPathStuckTicks = 0;
|
||
if (path.length) path.shift();
|
||
if (isQuizCarry() && previewFillBots) o.botQuizCarryPathfindAfter = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
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() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss()) return;
|
||
if (isQuizCarry()) {
|
||
stepQuizCarryPreviewBots();
|
||
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;
|
||
const pathStrictW = isQuizBattle() && quizBattlePathModeActive(mapData);
|
||
if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
|
||
o.x = nx;
|
||
o.y = ny;
|
||
} else if (!pathStrictW) {
|
||
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);
|
||
}
|
||
} else {
|
||
const d = pickRandomPreviewBotWanderDir();
|
||
o.botWanderDx = d[0];
|
||
o.botWanderDy = d[1];
|
||
o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
|
||
}
|
||
if (!Number.isFinite(o.x)) o.x = 0.5;
|
||
if (!Number.isFinite(o.y)) o.y = 0.5;
|
||
clampPlayEntityFootprintToMap(o, mapData);
|
||
if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
|
||
});
|
||
separateClumpedPreviewBots();
|
||
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
|
||
const ob = others.get(bid);
|
||
if (ob) clampPlayEntityFootprintToMap(ob, mapData);
|
||
});
|
||
if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
|
||
others.forEach((o, id) => {
|
||
if (!isPreviewBotId(id)) return;
|
||
const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
|
||
o.x = s.x;
|
||
o.y = s.y;
|
||
o.tx = s.x;
|
||
o.ty = s.y;
|
||
});
|
||
}
|
||
}
|
||
|
||
/** 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();
|
||
}
|
||
|
||
/** Stack preview: HUD แนว cyberdeck — SCORE ซ้าย, ตาปัจจุบันขวา, เทอร์มินัลล่างซ้าย */
|
||
function drawStackPreviewCyberHud(ctx, cw, ch) {
|
||
if (!previewFillBots || !stackMini || !mapData || !isStack()) return;
|
||
const now = Date.now();
|
||
const pad = 12;
|
||
const currentSeat = getStackPreviewCurrentSeat();
|
||
const order = stackPreviewTurnOrder;
|
||
const mono = '11px ui-monospace, "Cascadia Mono", Consolas, monospace';
|
||
const monoSm = '9px ui-monospace, Consolas, monospace';
|
||
const lives = stackMini.lives != null ? stackMini.lives : 3;
|
||
const lifeSlots = 3;
|
||
const teamScore = stackMini.score || 0;
|
||
const combo = stackMini.combo || 0;
|
||
|
||
function roundRectPath(x, y, w, h, r) {
|
||
const rr = Math.min(r, w / 2, h / 2);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + rr, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, rr);
|
||
ctx.arcTo(x + w, y + h, x, y + h, rr);
|
||
ctx.arcTo(x, y + h, x, y, rr);
|
||
ctx.arcTo(x, y, x + w, y, rr);
|
||
ctx.closePath();
|
||
}
|
||
|
||
function drawHudAvatar(cx, baselineY, maxSize, characterId, playTint) {
|
||
const timeMs = now;
|
||
const dir = 'down';
|
||
const rawImg = getAvatarImg(characterId, dir, timeMs, false);
|
||
const charImg = playTint ? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, false, playTint) : rawImg;
|
||
let iw = 0; let 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;
|
||
}
|
||
const ax = cx - maxSize / 2;
|
||
const ay = baselineY - maxSize;
|
||
ctx.save();
|
||
roundRectPath(ax, ay, maxSize, maxSize, 5);
|
||
ctx.clip();
|
||
if (iw > 0 && ih > 0) {
|
||
const scale = Math.min(maxSize / iw, maxSize / ih, 1);
|
||
const dw = iw * scale;
|
||
const dh = ih * scale;
|
||
ctx.drawImage(charImg, 0, 0, iw, ih, cx - dw / 2, baselineY - dh, dw, dh);
|
||
} else {
|
||
ctx.fillStyle = 'rgba(80, 200, 255, 0.35)';
|
||
ctx.fillRect(ax, ay, maxSize, maxSize);
|
||
}
|
||
ctx.restore();
|
||
ctx.strokeStyle = 'rgba(0, 234, 255, 0.5)';
|
||
ctx.lineWidth = 1.5;
|
||
roundRectPath(ax, ay, maxSize, maxSize, 5);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function resolveEntryVisual(entry) {
|
||
if (entry.kind === 'human') {
|
||
return {
|
||
name: String(me.nickname || 'YOU').toUpperCase(),
|
||
score: me.stackPreviewHumanPts || 0,
|
||
characterId: me.characterId,
|
||
playTint: me.playTint || playTintFromPeerId(String(myId || 'me')),
|
||
};
|
||
}
|
||
const o = entry.botId ? others.get(entry.botId) : null;
|
||
return {
|
||
name: o ? String(o.nickname || 'BOT').replace(/\s+/g, ' ').toUpperCase().slice(0, 12) : '—',
|
||
score: o ? (o.stackBotScore || 0) : 0,
|
||
characterId: o ? o.characterId : null,
|
||
playTint: o ? (o.playTint || playTintFromPeerId(entry.botId)) : null,
|
||
};
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
|
||
const boardW = Math.min(248, Math.max(200, cw * 0.36));
|
||
const rowH = 42;
|
||
const headH = 38;
|
||
const boardH = headH + STACK_PREVIEW_TURN_COUNT * rowH + 10;
|
||
|
||
ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
|
||
roundRectPath(pad, 8, boardW, boardH, 8);
|
||
ctx.fill();
|
||
ctx.strokeStyle = 'rgba(0, 230, 255, 0.5)';
|
||
ctx.lineWidth = 1.5;
|
||
roundRectPath(pad, 8, boardW, boardH, 8);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = '#5cefff';
|
||
ctx.font = 'bold 12px ui-sans-serif, system-ui, sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText('SCORE', pad + 14, 16);
|
||
ctx.fillStyle = 'rgba(94, 239, 255, 0.55)';
|
||
ctx.font = monoSm;
|
||
ctx.fillText('shared stack · P1–P6', pad + 62, 18);
|
||
|
||
let rowY = 8 + headH;
|
||
const drawRows = (entries) => {
|
||
entries.forEach((entry) => {
|
||
const isCurrent = entry.seat === currentSeat;
|
||
const v = resolveEntryVisual(entry);
|
||
let actionFlash = false;
|
||
if (entry.kind === 'human') {
|
||
actionFlash = lastStackPreviewActorId === '__human__' && now < lastStackPreviewActorUntil;
|
||
} else if (entry.botId) {
|
||
const ob = others.get(entry.botId);
|
||
actionFlash = !!(ob && ((now < (ob.stackBotGlowUntil || 0)) || (entry.botId === lastStackPreviewActorId && now < lastStackPreviewActorUntil)));
|
||
}
|
||
if (isCurrent) {
|
||
ctx.fillStyle = 'rgba(255, 0, 180, 0.1)';
|
||
roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
|
||
ctx.fill();
|
||
ctx.strokeStyle = 'rgba(0, 255, 220, 0.9)';
|
||
ctx.lineWidth = 2;
|
||
roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
|
||
ctx.stroke();
|
||
} else if (actionFlash) {
|
||
ctx.strokeStyle = 'rgba(255, 214, 102, 0.65)';
|
||
ctx.lineWidth = 1.5;
|
||
roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
|
||
ctx.stroke();
|
||
}
|
||
const avS = 34;
|
||
drawHudAvatar(pad + 10 + avS / 2, rowY + avS - 3, avS, v.characterId, v.playTint);
|
||
ctx.fillStyle = isCurrent ? '#e0f7ff' : '#89b4fa';
|
||
ctx.font = '600 10px ui-sans-serif, system-ui, sans-serif';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText('P' + entry.seat, pad + 14 + avS + 6, rowY + 12);
|
||
ctx.fillStyle = '#c0caf5';
|
||
ctx.font = 'bold 10px ' + mono;
|
||
ctx.fillText(v.name.slice(0, 11), pad + 14 + avS + 6, rowY + 28);
|
||
ctx.fillStyle = actionFlash ? '#ffe066' : '#7dfcff';
|
||
ctx.font = 'bold 13px ' + mono;
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(String(v.score), pad + boardW - 12, rowY + rowH / 2);
|
||
ctx.textAlign = 'left';
|
||
rowY += rowH;
|
||
});
|
||
};
|
||
|
||
if (order && order.length === STACK_PREVIEW_TURN_COUNT) {
|
||
drawRows(order);
|
||
} else {
|
||
const bots = [...others.keys()].filter(isPreviewBotId).sort();
|
||
const rows = [{ kind: 'human', seat: 1 }];
|
||
for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
|
||
rows.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
|
||
}
|
||
drawRows(rows);
|
||
}
|
||
|
||
const rightW = 136;
|
||
let rightX = cw - pad - rightW;
|
||
const topY = 8;
|
||
if (rightX < pad + boardW + 14) {
|
||
rightX = Math.max(pad, cw - pad - rightW);
|
||
}
|
||
const bigAv = Math.min(78, Math.max(56, ch * 0.11));
|
||
const turnPanelH = bigAv + 118;
|
||
ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
|
||
roundRectPath(rightX, topY, rightW, turnPanelH, 8);
|
||
ctx.fill();
|
||
ctx.strokeStyle = 'rgba(187, 154, 247, 0.55)';
|
||
ctx.lineWidth = 1.5;
|
||
roundRectPath(rightX, topY, rightW, turnPanelH, 8);
|
||
ctx.stroke();
|
||
|
||
let curEntry = null;
|
||
if (order && order.length === STACK_PREVIEW_TURN_COUNT) {
|
||
curEntry = order.find((e) => e.seat === currentSeat) || null;
|
||
}
|
||
const curV = curEntry ? resolveEntryVisual(curEntry) : resolveEntryVisual({ kind: 'human', seat: 1 });
|
||
const framePad = 8;
|
||
const avCx = rightX + rightW / 2;
|
||
const bigFrameTop = topY + framePad - 2;
|
||
const bigFrameH = bigAv + 8;
|
||
ctx.strokeStyle = 'rgba(255, 120, 200, 0.75)';
|
||
ctx.lineWidth = 2;
|
||
roundRectPath(rightX + framePad - 2, topY + framePad - 2, rightW - (framePad - 2) * 2, bigAv + 8, 6);
|
||
ctx.stroke();
|
||
drawHudAvatar(avCx, bigFrameTop + bigFrameH - 4, bigAv, curV.characterId, curV.playTint);
|
||
|
||
ctx.fillStyle = '#f5c2e7';
|
||
ctx.font = 'bold 10px ui-sans-serif, system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText('ACTIVE NODE', rightX + rightW / 2, topY + bigAv + 22);
|
||
ctx.fillStyle = '#7dfcff';
|
||
ctx.font = 'bold 11px ' + mono;
|
||
ctx.fillText('P' + currentSeat + ' · ' + curV.name.slice(0, 10), rightX + rightW / 2, topY + bigAv + 38);
|
||
|
||
ctx.fillStyle = '#bb9af7';
|
||
ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif';
|
||
ctx.fillText('SYSTEM INTEGRITY', rightX + rightW / 2, topY + bigAv + 58);
|
||
ctx.font = '14px sans-serif';
|
||
let heartStr = '';
|
||
for (let i = 0; i < lifeSlots; i++) {
|
||
heartStr += i < lives ? '♥ ' : '♡ ';
|
||
}
|
||
ctx.fillStyle = lives <= 1 ? '#f7768e' : '#9ece6a';
|
||
ctx.fillText(heartStr.trim(), rightX + rightW / 2, topY + bigAv + 74);
|
||
|
||
ctx.fillStyle = 'rgba(125, 252, 255, 0.85)';
|
||
ctx.font = monoSm;
|
||
ctx.fillText('TEAM ' + teamScore + ' · COMBO x' + combo, rightX + rightW / 2, topY + bigAv + 96);
|
||
|
||
const termW = Math.min(380, Math.max(220, cw * 0.42));
|
||
const termH = 86;
|
||
const termY = Math.max(boardH + 24, ch - termH - 52);
|
||
ctx.fillStyle = 'rgba(18, 8, 32, 0.88)';
|
||
roundRectPath(pad, termY, termW, termH, 6);
|
||
ctx.fill();
|
||
ctx.strokeStyle = 'rgba(187, 100, 255, 0.45)';
|
||
ctx.lineWidth = 1.5;
|
||
roundRectPath(pad, termY, termW, termH, 6);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = '#c099ff';
|
||
ctx.font = '600 10px ' + mono;
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText('// NODE_LOG', pad + 10, termY + 8);
|
||
ctx.fillStyle = '#7dcfff';
|
||
ctx.font = monoSm;
|
||
let ly = termY + 26;
|
||
const logLines = stackPreviewHudLog.length
|
||
? stackPreviewHudLog.slice(-5)
|
||
: ['>>> Awaiting stack sync…', '>>> Space / click = release (your turn only)'];
|
||
logLines.forEach((ln) => {
|
||
ctx.fillText(ln, pad + 10, ly);
|
||
ly += 12;
|
||
});
|
||
|
||
const decryptPct = Math.min(100, Math.floor((stackMini.layers.length || 0) * 11 + Math.min(40, teamScore * 3)));
|
||
const barY = termY + termH - 18;
|
||
const barW = termW - 20;
|
||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||
ctx.fillRect(pad + 10, barY, barW, 8);
|
||
ctx.fillStyle = 'rgba(0, 255, 200, 0.65)';
|
||
ctx.fillRect(pad + 10, barY, Math.max(4, barW * decryptPct / 100), 8);
|
||
ctx.strokeStyle = 'rgba(0, 234, 255, 0.4)';
|
||
ctx.strokeRect(pad + 10, barY, barW, 8);
|
||
ctx.fillStyle = '#94e2d5';
|
||
ctx.font = monoSm;
|
||
ctx.fillText('TOWER_LINK ' + decryptPct + '%', pad + 10, barY - 14);
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
function useCyberPlayHud() {
|
||
return !!(mapData && (isJumpSurvive() || isGauntlet() || isSpaceShooter() || isBalloonBoss()));
|
||
}
|
||
|
||
function syncPlayCyberHud() {
|
||
const root = document.getElementById('play-cyber-hud');
|
||
const stackEl = document.getElementById('play-canvas-stack');
|
||
const on = useCyberPlayHud();
|
||
if (root) {
|
||
root.classList.toggle('is-hidden', !on);
|
||
root.setAttribute('aria-hidden', on ? 'false' : 'true');
|
||
}
|
||
if (stackEl) stackEl.classList.toggle('play-cyber-vignette', !!on);
|
||
const gw = document.querySelector('.game-wrap');
|
||
if (gw) gw.classList.toggle('play-cyber-active', !!on);
|
||
if (!on || !root) return;
|
||
const timeVal = document.getElementById('play-cyber-time-val');
|
||
const timeSub = document.getElementById('play-cyber-time-sub');
|
||
const portrait = document.getElementById('play-cyber-portrait-img');
|
||
const statusEl = document.getElementById('play-cyber-self-status');
|
||
const hintEl = document.getElementById('play-cyber-hint');
|
||
const previewEl = document.getElementById('play-cyber-preview-line');
|
||
|
||
if (timeSub) {
|
||
timeSub.textContent = isGauntlet() ? 'GAUNTLET · SURVIVAL RUN'
|
||
: (isSpaceShooter() ? 'SPACE SHOOTER · ARCADE'
|
||
: (isBalloonBoss() ? 'BALLOON BOSS · MEGA VIRUS' : 'JUMP SURVIVE · NODE UPLINK'));
|
||
}
|
||
if (timeVal) {
|
||
if (isGauntlet()) {
|
||
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;
|
||
timeVal.textContent = `${mm}:${String(ss).padStart(2, '0')}`;
|
||
} else if (gauntletRuntimeTimeLimitSec > 0) {
|
||
timeVal.textContent = '···';
|
||
} else {
|
||
timeVal.textContent = '∞';
|
||
}
|
||
} else if (isSpaceShooter()) {
|
||
if (spaceShooterGameEnded) {
|
||
timeVal.textContent = '0';
|
||
} else {
|
||
const rem = spaceShooterRemainingSecPlay();
|
||
timeVal.textContent = rem != null ? String(rem) : '∞';
|
||
}
|
||
} else if (isBalloonBoss()) {
|
||
if (balloonBossGameEnded) {
|
||
timeVal.textContent = '0';
|
||
} else {
|
||
const rem = balloonBossRemainingSecPlay();
|
||
timeVal.textContent = rem != null ? String(rem) : '∞';
|
||
}
|
||
} else {
|
||
const t = jumpSurviveSessionStartMs > 0
|
||
? Math.max(0, Math.floor((performance.now() - jumpSurviveSessionStartMs) / 1000))
|
||
: 0;
|
||
timeVal.textContent = String(t);
|
||
}
|
||
}
|
||
|
||
if (portrait) {
|
||
const cid = me.characterId || getPlayCharacterId();
|
||
const fr = getCharacterFrame(cid, me.direction || 'down', Date.now(), !!me.isWalking);
|
||
const fb = fr && fr.src ? fr : getCharacterImg(cid, 'down');
|
||
if (fb && fb.src) portrait.src = fb.src;
|
||
}
|
||
if (statusEl) {
|
||
if (isJumpSurvive() && jumpSurviveEliminated) {
|
||
statusEl.textContent = 'SPECTATOR · LINK SEVERED';
|
||
} else if (isBalloonBoss() && me.balloonBossEliminated) {
|
||
statusEl.textContent = 'SPECTATOR · BALLOONS GONE';
|
||
} else {
|
||
statusEl.textContent = 'OPERATOR ONLINE';
|
||
}
|
||
}
|
||
if (hintEl) {
|
||
if (isJumpSurvive()) {
|
||
hintEl.textContent = jumpSurviveEliminated
|
||
? 'Spectate remaining nodes · Exit room to reset session'
|
||
: 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)';
|
||
} else if (isSpaceShooter()) {
|
||
hintEl.textContent = spaceShooterGameEnded
|
||
? 'จบแล้ว · Session ended — ดูอันดับบน overlay / see rankings on overlay'
|
||
: 'A D / ← → = move · W S / ↑ ↓ = up/down (lower half of map only) · Space = fire · +5 per hit';
|
||
} else if (isBalloonBoss()) {
|
||
hintEl.textContent = balloonBossGameEnded
|
||
? 'จบแล้ว · Session ended — see overlay'
|
||
: (me.balloonBossEliminated
|
||
? 'คุณตกรอบแล้ว — ดูเพื่อนเล่น · You are out — spectate'
|
||
: 'ยานมีแรงเฉื่อย — A D / arrows / W S เร่งทิศทาง · ปล่อยปุ่มแล้วยังไหล (แรงหน่วง) · Space = ยิง (ดีเลย์) · ลูกโป้งหมด = ตกรอบ');
|
||
} else {
|
||
hintEl.textContent = 'Space / W / ↑ jump · Lanes + lasers · Clear = right + score';
|
||
}
|
||
}
|
||
if (previewEl) {
|
||
if (previewFillBots && mapData) {
|
||
const human = countPlayHumans();
|
||
const bots = [...others.keys()].filter(isPreviewBotId).length;
|
||
previewEl.textContent = `SIM · humans ${human} + bots ${bots} · target ${previewTargetHeadcount}`;
|
||
previewEl.classList.remove('is-hidden');
|
||
} else {
|
||
previewEl.textContent = '';
|
||
previewEl.classList.add('is-hidden');
|
||
}
|
||
}
|
||
|
||
const ul = document.getElementById('play-cyber-score-list');
|
||
if (!ul) return;
|
||
const safeYv = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
|
||
const rows = [];
|
||
rows.push({
|
||
id: myId,
|
||
nickname: me.nickname || nick,
|
||
characterId: me.characterId || getPlayCharacterId(),
|
||
score: isGauntlet() ? (me.gauntletScore || 0)
|
||
: (isSpaceShooter() ? (me.spaceShooterScore || 0)
|
||
: (isBalloonBoss() ? (me.balloonBossScore || 0) : 0)),
|
||
y: safeYv(me.y),
|
||
eliminated: !!(isJumpSurvive() && jumpSurviveEliminated) || !!(isBalloonBoss() && me.balloonBossEliminated),
|
||
isMe: true,
|
||
});
|
||
others.forEach((o, id) => {
|
||
rows.push({
|
||
id,
|
||
nickname: o.nickname || id.slice(0, 8),
|
||
characterId: o.characterId,
|
||
score: isGauntlet() ? (o.gauntletScore || 0)
|
||
: (isSpaceShooter() ? (o.spaceShooterScore || 0)
|
||
: (isBalloonBoss() ? (o.balloonBossScore || 0) : 0)),
|
||
y: safeYv(o.y),
|
||
eliminated: !!(isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated)
|
||
|| !!(isBalloonBoss() && o.balloonBossEliminated),
|
||
isMe: false,
|
||
});
|
||
});
|
||
let leaderId = null;
|
||
if (isGauntlet() || isSpaceShooter() || isBalloonBoss()) {
|
||
let mx = -1;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (rows[i].score > mx) {
|
||
mx = rows[i].score;
|
||
leaderId = rows[i].id;
|
||
}
|
||
}
|
||
} else {
|
||
let best = Infinity;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (!rows[i].eliminated && rows[i].y < best) {
|
||
best = rows[i].y;
|
||
leaderId = rows[i].id;
|
||
}
|
||
}
|
||
}
|
||
rows.sort((a, b) => {
|
||
if (isGauntlet() || isSpaceShooter() || isBalloonBoss()) return b.score - a.score;
|
||
if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
|
||
return a.y - b.y;
|
||
});
|
||
|
||
ul.innerHTML = '';
|
||
const cap = 8;
|
||
for (let r = 0; r < Math.min(cap, rows.length); r++) {
|
||
const row = rows[r];
|
||
const li = document.createElement('li');
|
||
li.className = 'play-cyber-score-row' + (row.isMe ? ' is-me' : '') + (row.eliminated ? ' is-out' : '');
|
||
const av = document.createElement('img');
|
||
av.className = 'play-cyber-score-av';
|
||
av.alt = '';
|
||
const cid = row.characterId ? String(row.characterId) : '';
|
||
if (!cid) {
|
||
av.src = defaultAvatarImg.src;
|
||
} else {
|
||
const im = getCharacterImg(cid, 'down');
|
||
if (im && im.src) av.src = im.src;
|
||
}
|
||
const mid = document.createElement('div');
|
||
mid.className = 'play-cyber-score-mid';
|
||
const name = document.createElement('span');
|
||
name.className = 'play-cyber-score-name';
|
||
if (row.id === leaderId && leaderId != null) {
|
||
const crown = document.createElement('span');
|
||
crown.className = 'play-cyber-lead-crown';
|
||
crown.textContent = '♔';
|
||
crown.title = 'Leader';
|
||
name.appendChild(crown);
|
||
}
|
||
name.appendChild(document.createTextNode(String(row.nickname || '').slice(0, 14)));
|
||
const st = document.createElement('span');
|
||
st.className = 'play-cyber-score-state';
|
||
st.textContent = row.eliminated
|
||
? (isBalloonBoss() ? 'OUT' : 'OFFLINE')
|
||
: (isBalloonBoss()
|
||
? ('♥' + Math.max(0, row.isMe ? (me.balloonBossBalloons | 0) : ((() => {
|
||
const ox = others.get(row.id);
|
||
return ox ? (ox.balloonBossBalloons | 0) : 0;
|
||
})())))
|
||
: 'LINK_OK');
|
||
mid.appendChild(name);
|
||
mid.appendChild(st);
|
||
const sc = document.createElement('span');
|
||
sc.className = 'play-cyber-score-val';
|
||
if (isGauntlet() || isSpaceShooter() || isBalloonBoss()) {
|
||
sc.textContent = String(row.score);
|
||
} else {
|
||
sc.textContent = row.eliminated ? '—' : String(r + 1);
|
||
}
|
||
li.appendChild(av);
|
||
li.appendChild(mid);
|
||
li.appendChild(sc);
|
||
ul.appendChild(li);
|
||
}
|
||
}
|
||
|
||
function draw() {
|
||
if (!mapData) return;
|
||
const w = mapData.width, h = mapData.height;
|
||
|
||
const stackCam = isStack() ? getStackCameraCentersPx() : null;
|
||
let camX = stackCam ? stackCam.px : me.x * tileSize;
|
||
let camY = stackCam ? stackCam.py : me.y * tileSize;
|
||
if (isJumpSurvive()) {
|
||
camX = jumpSurviveCamCenterX;
|
||
camY = jumpSurviveCamCenterY;
|
||
}
|
||
let zDraw = zoom;
|
||
if (isSpaceShooter() || isBalloonBoss()) {
|
||
const mwPx = w * tileSize, mhPx = h * tileSize;
|
||
camX = mwPx * 0.5;
|
||
camY = mhPx * 0.5;
|
||
zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
|
||
}
|
||
const halfW = canvas.width / (2 * zDraw);
|
||
const halfH = canvas.height / (2 * zDraw);
|
||
|
||
// 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 = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
if (isSpaceShooter()) {
|
||
ctx.save();
|
||
for (let s = 0; s < 120; s++) {
|
||
const sx = (Math.sin(s * 12.9898 + w) * 0.5 + 0.5) * canvas.width;
|
||
const sy = (Math.cos(s * 4.1415 + h) * 0.5 + 0.5) * canvas.height;
|
||
const br = (s % 4 === 0) ? 1.6 : 0.9;
|
||
ctx.fillStyle = s % 7 === 0 ? 'rgba(200, 240, 255, 0.35)' : 'rgba(255, 255, 255, 0.12)';
|
||
ctx.beginPath();
|
||
ctx.arc(sx, sy, br, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
} else if (isBalloonBoss()) {
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(30, 45, 120, 0.25)';
|
||
for (let col = 0; col < 24; col++) {
|
||
const x = (col + 0.5) * (canvas.width / 24);
|
||
for (let r = 0; r < 18; r++) {
|
||
const bit = (col * 17 + r * 31 + Math.floor(timeMs / 80)) % 2;
|
||
ctx.fillStyle = bit ? 'rgba(0, 255, 200, 0.14)' : 'rgba(180, 200, 255, 0.08)';
|
||
ctx.fillRect(x - 1, (r / 18) * canvas.height + ((timeMs / 40 + col * 3) % 40), 2.2, 10);
|
||
}
|
||
}
|
||
ctx.fillStyle = 'rgba(240, 248, 255, 0.55)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, canvas.height);
|
||
const baseY = canvas.height * 0.88;
|
||
for (let k = 0; k <= 40; k++) {
|
||
const bx = (k / 40) * canvas.width;
|
||
const by = baseY - Math.abs(Math.sin(k * 1.7 + w)) * canvas.height * 0.08 - (k % 3) * 4;
|
||
ctx.lineTo(bx, by);
|
||
}
|
||
ctx.lineTo(canvas.width, canvas.height);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
function worldToScreen(wx, wy) {
|
||
const sx = (wx - camX) * zDraw + canvas.width / 2;
|
||
const sy = (wy - camY) * zDraw + 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)';
|
||
} else if (isStack()) {
|
||
rowFill = (y % 2 === 0) ? 'rgba(122, 162, 247, 0.06)' : 'rgba(187, 154, 247, 0.05)';
|
||
} else if (isJumpSurvive()) {
|
||
rowFill = (y % 2 === 0) ? 'rgba(90, 200, 255, 0.07)' : 'rgba(60, 140, 200, 0.06)';
|
||
} else if (isSpaceShooter()) {
|
||
rowFill = (y % 2 === 0) ? 'rgba(30, 45, 110, 0.22)' : 'rgba(20, 32, 78, 0.28)';
|
||
} else if (isBalloonBoss()) {
|
||
rowFill = (y % 2 === 0) ? 'rgba(40, 25, 80, 0.2)' : 'rgba(25, 18, 60, 0.24)';
|
||
} else if (isQuizBattle()) {
|
||
rowFill = (y % 2 === 0) ? 'rgba(110, 175, 255, 0.06)' : 'rgba(230, 110, 140, 0.055)';
|
||
}
|
||
for (let x = startTileX; x <= endTileX; x++) {
|
||
const wx = x * tileSize, wy = y * tileSize;
|
||
const [sx, sy] = worldToScreen(wx, wy);
|
||
const size = tileSize * zDraw;
|
||
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 (isQuizCarry()) {
|
||
const hub = mapData.quizCarryHubArea && mapData.quizCarryHubArea[y] && mapData.quizCarryHubArea[y][x] === 1;
|
||
if (hub) {
|
||
ctx.fillStyle = 'rgba(187, 154, 247, 0.35)';
|
||
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
ctx.strokeStyle = 'rgba(200, 170, 255, 0.75)';
|
||
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
}
|
||
const isCarryQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
|
||
if (isCarryQ) {
|
||
ctx.fillStyle = 'rgba(255, 214, 102, 0.3)';
|
||
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
ctx.strokeStyle = 'rgba(224, 185, 70, 0.82)';
|
||
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
}
|
||
const ov = mapData.quizCarryOptionArea && mapData.quizCarryOptionArea[y] && mapData.quizCarryOptionArea[y][x];
|
||
if (ov >= 1 && ov <= QUIZ_CARRY_MAX_OPTION_SLOTS) {
|
||
ctx.fillStyle = quizCarryMinimapOptionFillCss(ov);
|
||
ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
|
||
}
|
||
}
|
||
if (isQuizBattle() && showGrid && mapData.quizBattlePathArea && mapData.quizBattlePathArea[y] && mapData.quizBattlePathArea[y][x] === 1) {
|
||
ctx.fillStyle = 'rgba(186, 130, 255, 0.4)';
|
||
ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2);
|
||
ctx.strokeStyle = 'rgba(220, 170, 255, 0.55)';
|
||
ctx.strokeRect(sx + 1, sy + 1, size - 2, size - 2);
|
||
}
|
||
if (isQuizBattle() && showGrid && mapData.quizBattleDomeArea && mapData.quizBattleDomeArea[y] && mapData.quizBattleDomeArea[y][x] === 1) {
|
||
const cid = mapData.quizBattleDomeComp && mapData.quizBattleDomeComp[y] && mapData.quizBattleDomeComp[y][x];
|
||
const done = cid > 0 && quizBattleAnsweredComps.has(cid);
|
||
ctx.fillStyle = done ? 'rgba(90, 90, 110, 0.4)' : 'rgba(100, 200, 255, 0.36)';
|
||
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
ctx.strokeStyle = done ? 'rgba(140, 140, 160, 0.65)' : 'rgba(255, 100, 130, 0.82)';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
ctx.lineWidth = 1;
|
||
}
|
||
if (isStack()) {
|
||
const sr = mapData.stackReleaseArea, sl = mapData.stackLandArea;
|
||
if (sr && sr[y] && sr[y][x] === 1) {
|
||
ctx.fillStyle = 'rgba(125, 207, 255, 0.28)';
|
||
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
ctx.strokeStyle = 'rgba(122, 220, 255, 0.55)';
|
||
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
}
|
||
if (sl && sl[y] && sl[y][x] === 1) {
|
||
ctx.fillStyle = 'rgba(247, 118, 190, 0.26)';
|
||
ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
|
||
ctx.strokeStyle = 'rgba(255, 130, 200, 0.5)';
|
||
ctx.strokeRect(sx + 3, sy + 3, size - 6, size - 6);
|
||
}
|
||
}
|
||
if (isBalloonBoss() && showGrid && mapData.balloonBossPlayerSlots) {
|
||
const sv = mapData.balloonBossPlayerSlots[y] && mapData.balloonBossPlayerSlots[y][x];
|
||
if (sv >= 1 && sv <= 6) {
|
||
ctx.fillStyle = 'rgba(255, 120, 200, 0.12)';
|
||
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
ctx.strokeStyle = 'rgba(255, 160, 220, 0.45)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
ctx.lineWidth = 1;
|
||
ctx.fillStyle = 'rgba(255, 228, 250, 0.92)';
|
||
ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
}
|
||
if (isSpaceShooter() && showGrid && mapData.shooterSpawnSlots) {
|
||
const sv = mapData.shooterSpawnSlots[y] && mapData.shooterSpawnSlots[y][x];
|
||
if (sv >= 1 && sv <= 6) {
|
||
ctx.fillStyle = 'rgba(0, 255, 240, 0.14)';
|
||
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
ctx.strokeStyle = 'rgba(120, 255, 255, 0.45)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
|
||
ctx.lineWidth = 1;
|
||
ctx.fillStyle = 'rgba(220, 250, 255, 0.9)';
|
||
ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`;
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (isJumpSurvive() && showGrid && mapData.jumpSurvivePlatformArea) {
|
||
const pa = mapData.jumpSurvivePlatformArea;
|
||
const sc = jumpSurvivePlatformScrollPx;
|
||
const tsz = tileSize;
|
||
const period = h * tsz;
|
||
const visTop = worldMinY;
|
||
const visBot = worldMaxY;
|
||
const eps = 1e-6;
|
||
for (let py = 0; py < h; py++) {
|
||
for (let px = 0; px < w; px++) {
|
||
if (!pa[py] || pa[py][px] !== 1) continue;
|
||
const tileWorldLeft = px * tsz;
|
||
const tileWorldRight = tileWorldLeft + tsz;
|
||
if (tileWorldRight <= worldMinX || tileWorldLeft >= worldMaxX) continue;
|
||
const rawTop = py * tsz - sc;
|
||
const kMin = Math.ceil((visTop - rawTop - tsz + eps) / period);
|
||
const kMax = Math.floor((visBot - rawTop - eps) / period);
|
||
if (kMax < kMin) continue;
|
||
for (let kk = kMin; kk <= kMax; kk++) {
|
||
const platTop = rawTop + kk * period;
|
||
if (platTop + tsz <= visTop || platTop >= visBot) continue;
|
||
const [psx, psy] = worldToScreen(tileWorldLeft, platTop);
|
||
const psz = tsz * zDraw;
|
||
ctx.fillStyle = 'rgba(0, 40, 52, 0.72)';
|
||
ctx.fillRect(psx + 3, psy + 3, psz - 6, psz - 6);
|
||
ctx.strokeStyle = 'rgba(0, 255, 240, 0.55)';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(psx + 3, psy + 3, psz - 6, psz - 6);
|
||
ctx.strokeStyle = 'rgba(120, 255, 255, 0.35)';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(psx + 5, psy + 5, psz - 10, psz - 10);
|
||
const grad = ctx.createLinearGradient(psx, psy, psx, psy + psz);
|
||
grad.addColorStop(0, 'rgba(0, 255, 240, 0.12)');
|
||
grad.addColorStop(0.5, 'rgba(0, 0, 0, 0)');
|
||
grad.addColorStop(1, 'rgba(0, 180, 200, 0.08)');
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(psx + 4, psy + 4, psz - 8, psz - 8);
|
||
ctx.lineWidth = 1;
|
||
if (zDraw >= 1.15 && psz > 22) {
|
||
ctx.save();
|
||
ctx.font = `600 ${Math.max(7, Math.min(11, psz * 0.14))}px "Share Tech Mono", ui-monospace, monospace`;
|
||
ctx.fillStyle = 'rgba(180, 255, 255, 0.5)';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText('DATA', psx + psz * 0.5, psy + psz * 0.38);
|
||
ctx.font = `500 ${Math.max(5, Math.min(8, psz * 0.1))}px "Share Tech Mono", ui-monospace, monospace`;
|
||
ctx.fillStyle = 'rgba(120, 230, 255, 0.38)';
|
||
ctx.fillText('/SEC/BLOCK', psx + psz * 0.5, psy + psz * 0.62);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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 * zDraw * (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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (isQuizCarry()) {
|
||
drawQuizCarryChoiceLabels(ctx, worldToScreen, zDraw);
|
||
}
|
||
|
||
if (isStack() && stackMini) {
|
||
drawStackMinigame(ctx, worldToScreen, zDraw);
|
||
}
|
||
|
||
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 * zDraw;
|
||
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 drawQuizCarryHeldSignBoard(sxFeet, feetSy, halfSpriteW, spriteTopY, signText, _faceDir, choiceIndex) {
|
||
if (!signText) return;
|
||
ctx.save();
|
||
const bodyH = Math.max(18, feetSy - spriteTopY);
|
||
const midBodyY = spriteTopY + bodyH * 0.48;
|
||
const bodyCx = sxFeet;
|
||
const halfW = Math.max(10, halfSpriteW || 0);
|
||
const maxPlaqueW = Math.min(280, Math.max(100, halfW * 2.4 + tileSize * zDraw * 0.5));
|
||
|
||
const fontPx = Math.max(12, Math.min(17, tileSize * zDraw * 0.22));
|
||
ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", "Kanit", sans-serif`;
|
||
const padX = 12;
|
||
const padY = 9;
|
||
const maxInner = Math.min(maxPlaqueW - padX * 2, Math.max(72, tileSize * zDraw * 4.2));
|
||
const raw = String(signText).trim();
|
||
let lines = canvasWordWrapLines(ctx, raw, maxInner);
|
||
if (!lines.length) lines = [raw];
|
||
if (lines.length > 3) lines = lines.slice(0, 3);
|
||
for (let i = 0; i < lines.length; i++) {
|
||
let s = lines[i];
|
||
if (ctx.measureText(s).width <= maxInner) continue;
|
||
while (s.length > 2 && ctx.measureText(s + '…').width > maxInner) s = s.slice(0, -1);
|
||
lines[i] = s + '…';
|
||
}
|
||
let maxLineW = 0;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const lw = ctx.measureText(lines[i]).width;
|
||
if (lw > maxLineW) maxLineW = lw;
|
||
}
|
||
const lineH = fontPx * 1.22;
|
||
let w = Math.ceil(Math.max(maxLineW, 56) + padX * 2);
|
||
let h = Math.ceil(lines.length * lineH + padY * 2);
|
||
w = Math.min(maxPlaqueW, Math.max(96, w));
|
||
h = Math.max(40, h);
|
||
|
||
let signX = bodyCx - w / 2;
|
||
let signY = midBodyY - h / 2;
|
||
|
||
if (signX + w > canvas.width - 6) signX = canvas.width - w - 6;
|
||
if (signX < 6) signX = 6;
|
||
if (signY < 6) signY = 6;
|
||
if (signY + h > feetSy + 8) signY = feetSy + 8 - h;
|
||
|
||
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, null);
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel, quizCarrySignPayload) {
|
||
const { cw, ch } = getCharacterFootprintWH(mapData);
|
||
const air = Number(gauntletAirTicks) || 0;
|
||
let liftWorldY = 0;
|
||
if (isGauntlet() && air > 0) {
|
||
liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
|
||
}
|
||
const cxWorld = (ax + cw * 0.5) * tileSize;
|
||
/* ใช้ ay แบบต่อเนื่อง — ห้าม floor(ay) เดิมทำให้เท้ากระโดดทีละช่องขณะเดิน (กระตุกแนวตั้ง) */
|
||
const cyBottomWorld = (ay + ch) * tileSize - liftWorldY;
|
||
const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
|
||
const cellSpan = Math.max(cw, ch);
|
||
const r = Math.max(14, (tileSize * zDraw * cellSpan) / 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;
|
||
}
|
||
let halfSpriteW = r;
|
||
let spriteTopY = sy - 2 * r;
|
||
if (iw > 0 && ih > 0) {
|
||
const scale = Math.min(size / iw, size / ih, 1);
|
||
const drawW = iw * scale;
|
||
const drawH = ih * scale;
|
||
halfSpriteW = drawW / 2;
|
||
spriteTopY = sy - drawH;
|
||
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();
|
||
spriteTopY = cy - r;
|
||
}
|
||
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);
|
||
if (isQuizCarry() && quizCarrySignPayload) {
|
||
drawQuizCarryHeldSignBoard(
|
||
sx, sy, halfSpriteW, spriteTopY,
|
||
quizCarrySignPayload.text, dir, quizCarrySignPayload.choiceIndex
|
||
);
|
||
}
|
||
}
|
||
function quizCarrySignForEntity(ent) {
|
||
if (!isQuizCarry() || !quizCarryCurrent || !ent || ent.quizCarryHeld == null) return null;
|
||
const idx = ent.quizCarryHeld;
|
||
const ch = quizCarryCurrent.choices;
|
||
if (!Array.isArray(ch) || idx < 0 || idx >= ch.length) return null;
|
||
const t = String(ch[idx] != null ? ch[idx] : '').trim();
|
||
if (!t) return null;
|
||
return { text: t, choiceIndex: idx };
|
||
}
|
||
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);
|
||
});
|
||
if (!isStack() && !isSpaceShooter() && !isBalloonBoss()) {
|
||
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);
|
||
const botOut = isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated;
|
||
const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname;
|
||
if (botOut) ctx.save();
|
||
if (botOut) ctx.globalAlpha = 0.4;
|
||
drawAvatar(safeX(o.x) + off.ax, safeY(o.y) + off.ay, false, peerName, o.characterId, o.direction, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0, quizCarrySignForEntity(o));
|
||
if (botOut) ctx.restore();
|
||
});
|
||
const mt = me.playTint || pickRandomPlayTint();
|
||
if (!me.playTint) me.playTint = mt;
|
||
const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : (isJumpSurvive() && jumpSurviveEliminated ? ' (ตกรอบ)' : ' (คุณ)'));
|
||
const meWalking = isFrogger() ? false : (isGauntlet() ? (meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08) : !!me.isWalking);
|
||
if (isJumpSurvive() && jumpSurviveEliminated) ctx.save();
|
||
if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4;
|
||
drawAvatar(safeX(me.x), safeY(me.y), true, me.nickname + meTag, me.characterId, me.direction, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0, quizCarrySignForEntity(me));
|
||
if (isJumpSurvive() && jumpSurviveEliminated) ctx.restore();
|
||
}
|
||
if (isFrogger()) {
|
||
ctx.fillStyle = '#7aa2f7';
|
||
ctx.font = 'bold 14px sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('โหมดกบ | คะแนน: ' + froggerScore, 10, 24);
|
||
}
|
||
if (isJumpSurvive() && !useCyberPlayHud()) {
|
||
ctx.fillStyle = 'rgba(148, 226, 213, 0.95)';
|
||
ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif';
|
||
ctx.textAlign = 'left';
|
||
if (jumpSurviveEliminated) {
|
||
ctx.fillStyle = 'rgba(247, 118, 190, 0.95)';
|
||
ctx.fillText('คุณตกรอบแล้ว (ชนหรือหลุดเพดาน/พื้นจอ) · เล่นต่อไม่ได้ — ดูเพื่อนได้ · รอรอบใหม่หรือออกจากห้อง', 10, 22);
|
||
} else {
|
||
ctx.fillText('กระโดดให้รอด · Space / W / ↑ · A D / ← → · แพลตฟอร์มวน · ชนหรือหลุดบน/ล่างจอ = ตกรอบ (ดูเพื่อนต่อ)', 10, 22);
|
||
}
|
||
}
|
||
if (isGauntlet() && !useCyberPlayHud()) {
|
||
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 (isStack() && stackMini) {
|
||
if (previewFillBots) {
|
||
ctx.fillStyle = 'rgba(125, 220, 255, 0.9)';
|
||
ctx.font = '600 12px ui-sans-serif, system-ui, sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('Stack · shared tower · Space / click = release on your turn', 10, 22);
|
||
drawStackPreviewCyberHud(ctx, canvas.width, canvas.height);
|
||
} else {
|
||
ctx.fillStyle = '#7dcfff';
|
||
ctx.font = 'bold 15px sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('Stack | คะแนน ' + stackMini.score + ' · ชีวิต ' + stackMini.lives + ' · combo x' + (stackMini.combo || 0), 10, 24);
|
||
ctx.font = '12px sans-serif';
|
||
ctx.fillStyle = '#c0caf5';
|
||
ctx.fillText('Space / คลิก = ปล่อย', 10, 44);
|
||
}
|
||
}
|
||
if (isQuiz() || isQuizCarry() || isQuizBattle()) {
|
||
syncPlayQuizMapPanel();
|
||
syncQuizCarryEmbedQuestionStrip();
|
||
}
|
||
if (previewFillBots && mapData && !useCyberPlayHud()) {
|
||
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)';
|
||
const hudW = isStack() ? Math.min(canvas.width - 12, 520) : Math.min(canvas.width - 12, 320);
|
||
ctx.fillRect(6, canvas.height - 38, hudW, 30);
|
||
ctx.fillStyle = '#a9b1d6';
|
||
ctx.font = '12px sans-serif';
|
||
ctx.textAlign = 'left';
|
||
let line = 'ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + previewTargetHeadcount + ' คน)';
|
||
if (isStack()) {
|
||
line += ' · ตึกร่วม + HUD ซ้าย/ขวา';
|
||
}
|
||
ctx.fillText(line, 12, canvas.height - 18);
|
||
ctx.restore();
|
||
}
|
||
if (isSpaceShooter()) {
|
||
drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs);
|
||
}
|
||
if (isBalloonBoss()) {
|
||
drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs);
|
||
}
|
||
syncPlayCyberHud();
|
||
}
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (isMovementKey(e.code) && isChatFocused()) return;
|
||
if ((isSpaceShooter() || isBalloonBoss()) && mapData && !isChatFocused()) {
|
||
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
if (isStack() && mapData && !isChatFocused()) {
|
||
if (e.code === 'Space') {
|
||
e.preventDefault();
|
||
tryHumanStackDrop();
|
||
return;
|
||
}
|
||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
|
||
e.preventDefault();
|
||
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;
|
||
}
|
||
}
|
||
if (isJumpSurvive() && mapData && !isChatFocused()) {
|
||
if (jumpSurviveEliminated) {
|
||
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
|
||
return;
|
||
}
|
||
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
|
||
e.preventDefault();
|
||
if (!e.repeat) jumpSurviveJumpQueued = true;
|
||
return;
|
||
}
|
||
if (e.code === 'ArrowDown' || e.code === 'KeyS') {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
}
|
||
if (isQuizBattle() && mapData && !isChatFocused()) {
|
||
if (e.code === 'KeyE') {
|
||
e.preventDefault();
|
||
if (!e.repeat && myId != null) tryOpenQuizBattleFromKey();
|
||
return;
|
||
}
|
||
}
|
||
if (isQuizCarry() && mapData && !isChatFocused()) {
|
||
if (e.code === 'KeyF') {
|
||
e.preventDefault();
|
||
if (!e.repeat && myId != null) tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
|
||
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 (isStack()) {
|
||
stackTickFrame();
|
||
me.isWalking = false;
|
||
draw();
|
||
requestAnimationFrame(tick);
|
||
return;
|
||
}
|
||
if (isJumpSurvive()) {
|
||
jumpSurviveTickFrame();
|
||
draw();
|
||
requestAnimationFrame(tick);
|
||
return;
|
||
}
|
||
if (isSpaceShooter()) {
|
||
spaceShooterTickFrame();
|
||
draw();
|
||
requestAnimationFrame(tick);
|
||
return;
|
||
}
|
||
if (isBalloonBoss()) {
|
||
balloonBossTickFrame();
|
||
draw();
|
||
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;
|
||
});
|
||
tickQuizCarryEmbedCountdown();
|
||
tickQuizCarryRoundTimers();
|
||
updateQuizCarryCarryPhaseHud();
|
||
stepPreviewBots();
|
||
if (isChatFocused()) {
|
||
me.isWalking = false;
|
||
enforceQuizBattleLaneOnMePlay();
|
||
draw();
|
||
requestAnimationFrame(tick);
|
||
return;
|
||
}
|
||
if (isQuizCarryEmbedCountdownBlockingMovement()) {
|
||
me.isWalking = false;
|
||
playPath = [];
|
||
enforceQuizBattleLaneOnMePlay();
|
||
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));
|
||
enforceQuizBattleLaneOnMePlay();
|
||
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;
|
||
/** Quiz Battle + เส้นทาง: ห้ามเลื่อนแกนเดียว (slide) — ไม่งั้นเดินทแยงหลุดออกนอกเลนได้ */
|
||
const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
|
||
if (canWalkLikeLobby(nx, ny, me.x, me.y)) {
|
||
me.x = nx;
|
||
me.y = ny;
|
||
} else if (!pathStrict) {
|
||
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));
|
||
enforceQuizBattleLaneOnMePlay();
|
||
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');
|
||
}
|
||
});
|
||
(function wireQuizBattleMcqModal() {
|
||
const ov = document.getElementById('quiz-battle-mcq-overlay');
|
||
if (!ov) return;
|
||
ov.addEventListener('click', (e) => {
|
||
const t = e.target;
|
||
if (!t) return;
|
||
if (t.classList && t.classList.contains('quiz-battle-choice')) {
|
||
const idx = parseInt(t.getAttribute('data-idx'), 10);
|
||
if (Number.isFinite(idx) && idx >= 0 && idx <= 2) trySubmitQuizBattleChoice(idx);
|
||
return;
|
||
}
|
||
if (t.id === 'quiz-battle-mcq-close') {
|
||
quizBattleModalCompId = null;
|
||
hideQuizBattleMcqModal();
|
||
return;
|
||
}
|
||
if (t.classList && t.classList.contains('quiz-battle-mcq-backdrop')) {
|
||
quizBattleModalCompId = null;
|
||
hideQuizBattleMcqModal();
|
||
}
|
||
});
|
||
})();
|
||
window.addEventListener('beforeunload', () => socket.emit('leave-space'));
|
||
window.addEventListener('resize', () => {
|
||
if (!mapData || !canvas) return;
|
||
resizeCanvas();
|
||
draw();
|
||
});
|
||
})();
|