(function () { const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; const params = new URLSearchParams(window.location.search); const mapId = params.get('id'); const editorEmbed = params.get('embed') === '1' || params.get('from') === 'admin'; if (editorEmbed) { document.documentElement.classList.add('editor-embed-admin'); document.body.classList.add('editor-embed-admin'); } function buildEditorSearch(nextMapId) { const p = new URLSearchParams(); if (nextMapId) p.set('id', nextMapId); if (editorEmbed) p.set('embed', '1'); const s = p.toString(); return s ? ('?' + s) : ''; } const canvas = document.getElementById('editor-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const mapLoadEl = document.getElementById('map-load'); const mapName = document.getElementById('map-name'); const mapW = document.getElementById('map-w'); const mapH = document.getElementById('map-h'); const tileSizeEl = document.getElementById('tile-size'); const characterCellsEl = document.getElementById('character-cells'); const drawModeEl = document.getElementById('draw-mode'); const gameTypeEl = document.getElementById('game-type'); const statusEl = document.getElementById('editor-status'); let tileSize = 32, width = 20, height = 15, characterCells = 1; let gameType = 'zep'; let lanes = []; let ground = [], objects = [], blockPlayer = [], interactive = [], startGameArea = [], spawnArea = [], spawn = { x: 1, y: 1 }; let quizTrueArea = [], quizFalseArea = [], quizQuestionArea = []; let cellColors = []; let gauntletPlayerSpawns = []; let showMapInGame = true; let backgroundImage = null; let backgroundImageImg = null; let isSpawnMode = false; let editorFroggerAnimationId = null; function ensureInteractive() { if (!interactive.length || interactive.length !== height) { const existing = interactive.slice().map(r => r && r.slice()); interactive = []; for (let y = 0; y < height; y++) { const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0); interactive.push(row); } } else { for (let y = 0; y < height; y++) { if (!interactive[y] || interactive[y].length !== width) interactive[y] = Array(width).fill(0); } } } function ensureQuizAreas() { if (!quizTrueArea.length || quizTrueArea.length !== height) { const existingT = quizTrueArea.slice().map(r => r && r.slice()); quizTrueArea = []; for (let y = 0; y < height; y++) { const row = existingT[y] && existingT[y].length === width ? existingT[y].slice() : Array(width).fill(0); quizTrueArea.push(row); } } else { for (let y = 0; y < height; y++) { if (!quizTrueArea[y] || quizTrueArea[y].length !== width) quizTrueArea[y] = Array(width).fill(0); } } if (!quizFalseArea.length || quizFalseArea.length !== height) { const existingF = quizFalseArea.slice().map(r => r && r.slice()); quizFalseArea = []; for (let y = 0; y < height; y++) { const row = existingF[y] && existingF[y].length === width ? existingF[y].slice() : Array(width).fill(0); quizFalseArea.push(row); } } else { for (let y = 0; y < height; y++) { if (!quizFalseArea[y] || quizFalseArea[y].length !== width) quizFalseArea[y] = Array(width).fill(0); } } if (!quizQuestionArea.length || quizQuestionArea.length !== height) { const existingQ = quizQuestionArea.slice().map(r => r && r.slice()); quizQuestionArea = []; for (let y = 0; y < height; y++) { const row = existingQ[y] && existingQ[y].length === width ? existingQ[y].slice() : Array(width).fill(0); quizQuestionArea.push(row); } } else { for (let y = 0; y < height; y++) { if (!quizQuestionArea[y] || quizQuestionArea[y].length !== width) quizQuestionArea[y] = Array(width).fill(0); } } } function ensureStartGameArea() { if (!startGameArea.length || startGameArea.length !== height) { const existing = startGameArea.slice().map(r => r && r.slice()); startGameArea = []; for (let y = 0; y < height; y++) { const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0); startGameArea.push(row); } } else { for (let y = 0; y < height; y++) { if (!startGameArea[y] || startGameArea[y].length !== width) startGameArea[y] = Array(width).fill(0); } } } function sanitizeGauntletPlayerSpawns() { gauntletPlayerSpawns = gauntletPlayerSpawns .filter((s) => s && Number.isFinite(s.x) && Number.isFinite(s.y) && s.x >= 0 && s.x < width && s.y >= 0 && s.y < height && !(objects[s.y] && objects[s.y][s.x] === 1)) .map((s) => ({ x: Math.floor(s.x), y: Math.floor(s.y) })) .slice(0, 6); } function ensureSpawnArea() { if (!spawnArea.length || spawnArea.length !== height) { const existing = spawnArea.slice().map(r => r && r.slice()); spawnArea = []; for (let y = 0; y < height; y++) { const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0); spawnArea.push(row); } } else { for (let y = 0; y < height; y++) { if (!spawnArea[y] || spawnArea[y].length !== width) spawnArea[y] = Array(width).fill(0); } } } function buildDefaultLanes() { const arr = []; for (let y = 0; y < height; y++) { if (y === 0) arr.push({ y: 0, type: 'goal' }); else if (y === height - 1) arr.push({ y: height - 1, type: 'spawn' }); else arr.push({ y, type: 'safe', speed: 1, dir: 1 }); } return arr; } function ensureLanes() { if (lanes.length !== height) { const existing = lanes.slice(); lanes = []; for (let y = 0; y < height; y++) { const old = existing.find(l => l.y === y); if (old) lanes.push({ ...old, y }); else lanes.push(y === 0 ? { y: 0, type: 'goal' } : y === height - 1 ? { y: height - 1, type: 'spawn' } : { y, type: 'safe', speed: 1, dir: 1 }); } } } function getVehiclePositionsEditor(lane, 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 renderLanesTable() { const wrap = document.getElementById('frogger-lanes-table'); const froggerWrap = document.getElementById('frogger-lanes-wrap'); if (!wrap || !froggerWrap) return; ensureLanes(); wrap.innerHTML = ''; const table = document.createElement('table'); table.innerHTML = 'แถว (y)ประเภทความเร็วทิศทางระยะห่าง'; const tbody = table.querySelector('tbody'); lanes.forEach((lane) => { const tr = document.createElement('tr'); const isRoadOrWater = lane.type === 'road' || lane.type === 'water'; const spacingVal = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5); tr.innerHTML = '' + lane.y + '' + '' + '' + '' + ''; tbody.appendChild(tr); }); wrap.appendChild(table); wrap.querySelectorAll('.lane-type').forEach(sel => { sel.addEventListener('change', function () { const y = parseInt(this.dataset.y, 10); const lane = lanes.find(l => l.y === y); if (lane) { lane.type = this.value; lane.speed = lane.speed != null ? lane.speed : 1; lane.dir = lane.dir != null ? lane.dir : 1; if (lane.type === 'road' || lane.type === 'water') lane.spacing = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5); renderLanesTable(); draw(); } }); }); wrap.querySelectorAll('.lane-speed').forEach(inp => { inp.addEventListener('input', function () { const y = parseInt(this.dataset.y, 10); const lane = lanes.find(l => l.y === y); if (lane) lane.speed = parseFloat(this.value) || 1; draw(); }); }); wrap.querySelectorAll('.lane-dir').forEach(sel => { sel.addEventListener('change', function () { const y = parseInt(this.dataset.y, 10); const lane = lanes.find(l => l.y === y); if (lane) lane.dir = parseInt(this.value, 10) || 1; draw(); }); }); wrap.querySelectorAll('.lane-spacing').forEach(inp => { inp.addEventListener('input', function () { const y = parseInt(this.dataset.y, 10); const lane = lanes.find(l => l.y === y); if (lane) { lane.spacing = parseFloat(this.value) || 2; draw(); } }); }); } function toggleFroggerUI() { const froggerWrap = document.getElementById('frogger-lanes-wrap'); const hint = document.getElementById('game-type-hint'); const legEl = document.getElementById('frogger-lanes-legend'); const dqTrue = document.getElementById('draw-mode-option-quiz-true'); const dqFalse = document.getElementById('draw-mode-option-quiz-false'); const dqQ = document.getElementById('draw-mode-option-quiz-question'); if (!froggerWrap) return; const gt = gameTypeEl ? gameTypeEl.value : 'zep'; if (dqTrue) dqTrue.hidden = gt !== 'quiz'; if (dqFalse) dqFalse.hidden = gt !== 'quiz'; if (dqQ) dqQ.hidden = gt !== 'quiz'; if (gt === 'frogger') { froggerWrap.style.display = 'block'; if (legEl) legEl.innerHTML = 'แถวบน (y=0) = ปลายทาง · แถวล่าง = จุดเกิด · ถนน/น้ำ ตั้งความเร็ว ทิศทาง และ ระยะห่าง (ยิ่งมากยิ่งห่าง เกมง่ายขึ้น)'; if (hint) { hint.style.display = 'inline'; hint.innerHTML = 'แถวบน = ปลายทาง · แถวล่าง = จุดเกิด · ตั้งถนน/น้ำด้านล่าง'; } ensureLanes(); renderLanesTable(); draw(); if (editorFroggerAnimationId != null) cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = requestAnimationFrame(editorFroggerTick); } else if (gt === 'gauntlet') { froggerWrap.style.display = 'none'; if (legEl) legEl.innerHTML = 'พรมแดงสุดท้าย: ลำดับเกิดกำหนดแค่ แถว (y) — ในเกมทุกคนยืน คอลัมน์ x เดียวกัน (ซ้ายสุดจากจุดที่วาด) ไม่เฉียง'; if (hint) { hint.style.display = 'inline'; hint.innerHTML = 'โหมด พรมแดง — อุปสรรคเลนเฉพาะแถวที่มีคน + เลเซอร์ทุกแถว · ชนถอยซ้าย · ลำดับเกิด 1–6 / ช่องฟ้า · Space / W / ↑ กระโดด'; } if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; } draw(); } else if (gt === 'lobby') { froggerWrap.style.display = 'none'; if (hint) { hint.style.display = 'inline'; hint.innerHTML = 'วาดห้องโถงพื้นที่เริ่มเกม (ส้ม) = host ยืนก่อนกดเริ่ม · พื้นที่สุ่มจุดเกิด (ฟ้าอ่อน) = ผู้เล่นใหม่สุ่มเกิดในช่องนั้น'; } if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; } draw(); } else if (gt === 'quiz') { froggerWrap.style.display = 'none'; if (hint) { hint.style.display = 'inline'; hint.innerHTML = 'วาดโซนถูก (ฟ้า) / โซนผิด (ชมพู) / พื้นที่โชว์คำถาม (ทอง) · พื้นที่สุ่มจุดเกิด (ฟ้าอ่อน) = ผู้เล่นเข้าห้องสุ่มยืนในช่องนั้น · คำถามและเวลาตั้งที่ Admin → คำถามเกม'; } if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; } draw(); } else if (gt === 'stack') { froggerWrap.style.display = 'none'; if (hint) { hint.style.display = 'inline'; hint.innerHTML = 'Stack — กดสลับจังหวะให้บล็อกลงซ้อน · ไม่ใช้แถวกบ · วาดกำแพง/โซน + รูปพื้นหลัง · เล่นจริงยังทำต่อในเครื่องเล่น'; } if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; } draw(); } else { froggerWrap.style.display = 'none'; if (hint) { hint.style.display = 'inline'; hint.innerHTML = 'กบข้ามถนน = ตารางแถว · พรมแดงสุดท้าย = ไม่มีตารางแถว · สลับลงตึก = Stack · ห้องโถง = ฉากรอผู้เล่น · ตอบคำถาม = โซนถูก/ผิด + คำถาม · พื้นที่สุ่มจุดเกิด = สุ่มยืนในช่องฟ้าอ่อน'; } if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; } draw(); } const sgOpt = document.getElementById('draw-mode-option-start-game'); if (sgOpt) { const show = gt === 'lobby'; sgOpt.hidden = !show; if (!show && drawModeEl && drawModeEl.value === 'startGame') drawModeEl.value = 'wall'; } const saOpt = document.getElementById('draw-mode-option-spawn-area'); if (saOpt) { const showSa = gt !== 'frogger'; saOpt.hidden = !showSa; if (!showSa && drawModeEl && drawModeEl.value === 'spawnArea') drawModeEl.value = 'wall'; } const gSpOpt = document.getElementById('draw-mode-option-gauntlet-player-spawn'); const btnClrG = document.getElementById('btn-clear-gauntlet-spawns'); if (gSpOpt) { const showG = gt === 'gauntlet'; gSpOpt.hidden = !showG; if (!showG && drawModeEl && drawModeEl.value === 'gauntletPlayerSpawn') drawModeEl.value = 'wall'; } if (btnClrG) btnClrG.hidden = gt !== 'gauntlet'; if (drawModeEl && (drawModeEl.value === 'quizTrue' || drawModeEl.value === 'quizFalse' || drawModeEl.value === 'quizQuestion') && gt !== 'quiz') { drawModeEl.value = 'wall'; } } function initGrid() { width = Math.max(10, Math.min(50, parseInt(mapW.value, 10) || 20)); height = Math.max(10, Math.min(40, parseInt(mapH.value, 10) || 15)); tileSize = Math.max(16, Math.min(64, parseInt(tileSizeEl.value, 10) || 32)); ground = Array(height).fill(0).map(() => Array(width).fill(0)); objects = Array(height).fill(0).map(() => Array(width).fill(0)); blockPlayer = Array(height).fill(0).map(() => Array(width).fill(0)); interactive = Array(height).fill(0).map(() => Array(width).fill(0)); startGameArea = Array(height).fill(0).map(() => Array(width).fill(0)); spawnArea = Array(height).fill(0).map(() => Array(width).fill(0)); quizTrueArea = Array(height).fill(0).map(() => Array(width).fill(0)); quizFalseArea = Array(height).fill(0).map(() => Array(width).fill(0)); quizQuestionArea = Array(height).fill(0).map(() => Array(width).fill(0)); cellColors = Array(height).fill(0).map(() => Array(width).fill(null)); for (let i = 0; i < width; i++) { objects[0][i] = 1; objects[height - 1][i] = 1; } for (let i = 0; i < height; i++) { objects[i][0] = 1; objects[i][width - 1] = 1; } spawn = { x: 1, y: 1 }; if (gameType === 'frogger') { lanes = buildDefaultLanes(); ensureLanes(); } if (gameType === 'gauntlet' || gameType === 'stack') lanes = []; gauntletPlayerSpawns = []; characterCells = characterCellsEl ? Math.max(1, Math.min(4, parseInt(characterCellsEl.value, 10) || 1)) : 1; mapW.value = width; mapH.value = height; tileSizeEl.value = tileSize; if (characterCellsEl) characterCellsEl.value = characterCells; resize(); draw(); toggleFroggerUI(); } function resize() { canvas.width = width * tileSize; canvas.height = height * tileSize; sanitizeGauntletPlayerSpawns(); draw(); } function getCell(e) { const r = canvas.getBoundingClientRect(); const x = Math.floor((e.clientX - r.left) / tileSize); const y = Math.floor((e.clientY - r.top) / tileSize); return { x: Math.max(0, Math.min(width - 1, x)), y: Math.max(0, Math.min(height - 1, y)) }; } function draw() { const gtDraw = gameTypeEl ? gameTypeEl.value : gameType; const isFrogger = gtDraw === 'frogger'; const isGauntlet = gtDraw === 'gauntlet'; const isStack = gtDraw === 'stack'; const usesLanes = isFrogger; if (usesLanes) ensureLanes(); ensureInteractive(); ensureStartGameArea(); ensureSpawnArea(); if (gtDraw === 'quiz') ensureQuizAreas(); if (backgroundImageImg && backgroundImageImg.complete && backgroundImageImg.naturalWidth) { ctx.drawImage(backgroundImageImg, 0, 0, canvas.width, canvas.height); } else { ctx.fillStyle = '#1a1b26'; ctx.fillRect(0, 0, canvas.width, canvas.height); } for (let y = 0; y < height; y++) { var rowFill = null; if (usesLanes && lanes[y]) { if (lanes[y].type === 'goal') rowFill = 'rgba(158,206,106,0.35)'; else if (lanes[y].type === 'spawn') rowFill = 'rgba(187,154,247,0.35)'; else if (lanes[y].type === 'road') rowFill = 'rgba(120,100,80,0.5)'; else if (lanes[y].type === 'water') rowFill = 'rgba(125,207,255,0.4)'; } else if (isGauntlet) { rowFill = (y % 2 === 0) ? 'rgba(247,118,190,0.1)' : 'rgba(160,80,120,0.08)'; } else if (isStack) { rowFill = (y % 2 === 0) ? 'rgba(122, 162, 247, 0.09)' : 'rgba(187, 154, 247, 0.07)'; } for (let x = 0; x < width; x++) { const tx = x * tileSize, ty = y * tileSize; if (objects[y][x] === 1) { ctx.fillStyle = 'rgba(65,72,104,0.9)'; ctx.fillRect(tx, ty, tileSize, tileSize); ctx.strokeStyle = '#565f89'; ctx.strokeRect(tx, ty, tileSize, tileSize); } else { const cellColor = cellColors[y] && cellColors[y][x]; if (cellColor) { ctx.fillStyle = cellColor; ctx.fillRect(tx, ty, tileSize, tileSize); } else if (rowFill) { ctx.fillStyle = rowFill; ctx.fillRect(tx, ty, tileSize, tileSize); } else if (!backgroundImageImg || !backgroundImageImg.complete) { ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335'; ctx.fillRect(tx, ty, tileSize, tileSize); } } if (blockPlayer[y] && blockPlayer[y][x] === 1) { ctx.fillStyle = 'rgba(255,180,80,0.5)'; ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(255,180,80,0.9)'; ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); } if (interactive[y] && interactive[y][x] === 1) { ctx.fillStyle = 'rgba(158,206,106,0.45)'; ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = '#9ece6a'; ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); } if (startGameArea[y] && startGameArea[y][x] === 1) { ctx.fillStyle = 'rgba(255, 158, 100, 0.5)'; ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(255, 120, 60, 0.95)'; ctx.lineWidth = 2; ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); ctx.lineWidth = 1; } if (spawnArea[y] && spawnArea[y][x] === 1) { ctx.fillStyle = 'rgba(122, 162, 247, 0.38)'; ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(122, 162, 247, 0.92)'; ctx.lineWidth = 2; ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); ctx.lineWidth = 1; } if (gtDraw === 'quiz') { if (quizQuestionArea[y] && quizQuestionArea[y][x] === 1) { ctx.fillStyle = 'rgba(255, 214, 102, 0.42)'; ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(224, 185, 70, 0.95)'; ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); } if (quizTrueArea[y] && quizTrueArea[y][x] === 1) { ctx.fillStyle = 'rgba(86, 202, 255, 0.5)'; ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(122, 220, 255, 0.95)'; ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); } if (quizFalseArea[y] && quizFalseArea[y][x] === 1) { ctx.fillStyle = 'rgba(247, 118, 190, 0.5)'; ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); ctx.strokeStyle = 'rgba(255, 130, 200, 0.95)'; ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); } } if (spawn.x === x && spawn.y === y) { const cells = characterCellsEl ? Math.max(1, Math.min(4, parseInt(characterCellsEl.value, 10) || 1)) : characterCells; const cx = tx + tileSize / 2; const cy = ty + tileSize / 2; const size = tileSize * cells; ctx.fillStyle = 'rgba(158,206,106,0.35)'; ctx.fillRect(cx - size / 2, cy - size / 2, size, size); ctx.strokeStyle = '#9ece6a'; ctx.lineWidth = 2; ctx.strokeRect(cx - size / 2, cy - size / 2, size, size); ctx.fillStyle = 'rgba(158,206,106,0.7)'; ctx.beginPath(); ctx.arc(cx, cy, Math.min(tileSize / 3, size / 4), 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#9ece6a'; ctx.stroke(); } } } if (isFrogger && lanes.length) { const timeMs = Date.now(); for (let y = 0; y < height; y++) { const lane = lanes.find(l => l.y === y); if (!lane || (lane.type !== 'road' && lane.type !== 'water')) continue; const positions = getVehiclePositionsEditor(lane, timeMs); const isRoad = lane.type === 'road'; for (let i = 0; i < positions.length; i++) { const vx = positions[i]; const tx = vx * tileSize, ty = y * tileSize; ctx.fillStyle = isRoad ? '#e0a060' : '#8b7355'; ctx.fillRect(tx + 2, ty + 2, tileSize - 4, (tileSize - 4) * 0.7); ctx.strokeStyle = isRoad ? '#c0caf5' : '#9ece6a'; ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, (tileSize - 4) * 0.7); } } ctx.fillStyle = '#7aa2f7'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('โหมดกบข้ามถนน (จำลองรถ/ท่อนซุง)', 8, 20); } if (isGauntlet) { ctx.fillStyle = '#f7768e'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; ctx.fillText(gauntletPlayerSpawns.length ? 'พรมแดง — ลำดับ 1–6 จากโหมด «ลำดับเกิด» (คลิกซ้ายตามลำดับ)' : 'พรมแดง — ช่องฟ้า = ลำดับ 1→6 อัตโนมัติ (y บน→ล่าง) หรือใช้โหมดลำดับเกิดกำหนดเอง', 8, 20); const drawGNum = (s, num) => { const cx = s.x * tileSize + tileSize / 2; const cy = s.y * tileSize + tileSize / 2 + 4; ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.beginPath(); ctx.arc(cx, cy - 3, 12, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(String(num), cx, cy + 2); ctx.textAlign = 'left'; }; if (gauntletPlayerSpawns.length) { for (let i = 0; i < gauntletPlayerSpawns.length; i++) { const s = gauntletPlayerSpawns[i]; if (!s || s.x < 0 || s.x >= width || s.y < 0 || s.y >= height) continue; if (objects[s.y][s.x] === 1) continue; drawGNum(s, i + 1); } } else { ensureSpawnArea(); const gsSlots = []; for (let yy = 0; yy < height; yy++) { for (let xx = 0; xx < width; xx++) { if (spawnArea[yy] && spawnArea[yy][xx] === 1 && objects[yy][xx] !== 1) gsSlots.push({ x: xx, y: yy }); } } gsSlots.sort((a, b) => a.y - b.y || a.x - b.x); for (let i = 0; i < Math.min(6, gsSlots.length); i++) drawGNum(gsSlots[i], i + 1); } } if (gtDraw === 'lobby') { ctx.fillStyle = '#bb9af7'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('โหมดห้องโถง (ฉากรอผู้เล่น)', 8, 20); } if (gtDraw === 'quiz') { ctx.fillStyle = '#f7768e'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('โหมดตอบคำถาม (ถูก/ผิด)', 8, 20); } if (isStack) { ctx.fillStyle = '#7dcfff'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('โหมดสลับจังหวะลงตึก (Stack) — backdrop ฉาก · เกมซ้อนบล็อกยังไม่เปิดใน play', 8, 20); } const cells = characterCellsEl ? Math.max(1, Math.min(4, parseInt(characterCellsEl.value, 10) || 1)) : characterCells; ctx.fillStyle = '#c0caf5'; ctx.font = '12px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('ขนาดตัวละคร: ' + cells + ' ช่อง', 8, canvas.height - 8); } function editorFroggerTick() { const gt = gameTypeEl ? gameTypeEl.value : ''; if (gt !== 'frogger') return; draw(); editorFroggerAnimationId = requestAnimationFrame(editorFroggerTick); } function getCellColorWithAlpha() { const colorEl = document.getElementById('cell-color'); const alphaEl = document.getElementById('cell-alpha'); if (!colorEl) return null; const hex = colorEl.value; const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); const a = alphaEl ? parseInt(alphaEl.value, 10) / 100 : 1; return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'; } function setCell(x, y, left) { if (drawModeEl.value === 'gauntletPlayerSpawn') { const gt = gameTypeEl ? gameTypeEl.value : gameType; if (gt !== 'gauntlet') return; if (objects[y][x] === 1) { if (statusEl) statusEl.textContent = 'ช่องกำแพง — ใช้เป็นจุดเกิดไม่ได้'; return; } if (left) { if (gauntletPlayerSpawns.some((s) => s.x === x && s.y === y)) return; if (gauntletPlayerSpawns.length >= 6) { if (statusEl) statusEl.textContent = 'ครบ 6 จุดแล้ว — คลิกขวาที่ช่องเพื่อถอด หรือปุ่มล้างลำดับ'; return; } gauntletPlayerSpawns.push({ x, y }); if (statusEl) statusEl.textContent = 'พรมแดง: ผู้เล่น ' + gauntletPlayerSpawns.length + ' = ช่อง (' + x + ',' + y + ')'; } else { const before = gauntletPlayerSpawns.length; gauntletPlayerSpawns = gauntletPlayerSpawns.filter((s) => !(s.x === x && s.y === y)); if (statusEl) { statusEl.textContent = before > gauntletPlayerSpawns.length ? 'ถอดจุดเกิดที่ช่องนี้แล้ว' : 'ไม่มีจุดเกิดที่ช่องนี้'; } } return; } if (drawModeEl.value === 'wall') { objects[y][x] = left ? 1 : 0; } else if (drawModeEl.value === 'interactive') { if (!interactive[y]) interactive[y] = Array(width).fill(0); interactive[y][x] = left ? 1 : 0; } else if (drawModeEl.value === 'spawnArea') { const gt = gameTypeEl ? gameTypeEl.value : gameType; if (gt === 'frogger') return; ensureSpawnArea(); spawnArea[y][x] = left ? 1 : 0; } else if (drawModeEl.value === 'startGame') { const gt = gameTypeEl ? gameTypeEl.value : gameType; if (gt !== 'lobby') return; ensureStartGameArea(); startGameArea[y][x] = left ? 1 : 0; } else if (drawModeEl.value === 'quizTrue') { if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz') return; ensureQuizAreas(); quizTrueArea[y][x] = left ? 1 : 0; if (left) quizFalseArea[y][x] = 0; } else if (drawModeEl.value === 'quizFalse') { if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz') return; ensureQuizAreas(); quizFalseArea[y][x] = left ? 1 : 0; if (left) quizTrueArea[y][x] = 0; } else if (drawModeEl.value === 'quizQuestion') { if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz') return; ensureQuizAreas(); quizQuestionArea[y][x] = left ? 1 : 0; } else if (drawModeEl.value === 'color') { if (!cellColors[y]) cellColors[y] = Array(width).fill(null); cellColors[y][x] = left ? getCellColorWithAlpha() : null; } else { if (!blockPlayer[y]) blockPlayer[y] = Array(width).fill(0); blockPlayer[y][x] = left ? 1 : 0; } } canvas.addEventListener('mousedown', (e) => { const { x, y } = getCell(e); if (isSpawnMode) { if (objects[y][x] === 0) { spawn = { x, y }; draw(); statusEl.textContent = 'จุดเกิด: ' + x + ',' + y; } isSpawnMode = false; document.getElementById('btn-spawn').textContent = 'ตั้งจุดเกิด'; return; } setCell(x, y, e.button === 0); draw(); }); canvas.addEventListener('contextmenu', (e) => e.preventDefault()); canvas.addEventListener('mousemove', (e) => { if (e.buttons !== 1 && e.buttons !== 2) return; const { x, y } = getCell(e); setCell(x, y, e.buttons === 1); draw(); }); if (mapLoadEl) { fetch(SERVER + '/api/maps') .then(r => r.json()) .then(list => { mapLoadEl.innerHTML = ''; (list || []).forEach(m => { const opt = document.createElement('option'); opt.value = m.id; opt.textContent = (m.name || m.id) + ' (' + m.id + ')'; mapLoadEl.appendChild(opt); }); if (mapId) mapLoadEl.value = mapId; }) .catch(() => { mapLoadEl.innerHTML = ''; }); document.getElementById('btn-load').addEventListener('click', () => { const id = mapLoadEl && mapLoadEl.value; if (id) window.location.href = 'editor.html' + buildEditorSearch(id); }); } const bgInput = document.getElementById('bg-image'); if (bgInput) { bgInput.addEventListener('change', (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; const r = new FileReader(); r.onload = () => { backgroundImage = r.result; backgroundImageImg = new Image(); backgroundImageImg.onload = () => { draw(); }; backgroundImageImg.src = backgroundImage; }; r.readAsDataURL(file); e.target.value = ''; }); } document.getElementById('btn-clear-bg').addEventListener('click', () => { backgroundImage = null; backgroundImageImg = null; draw(); }); document.getElementById('btn-new').addEventListener('click', initGrid); document.getElementById('btn-spawn').addEventListener('click', () => { isSpawnMode = true; document.getElementById('btn-spawn').textContent = 'คลิกช่องที่ต้องการ'; }); if (gameTypeEl) gameTypeEl.addEventListener('change', () => { gameType = gameTypeEl.value; if (drawModeEl && drawModeEl.value === 'startGame' && gameType !== 'lobby') drawModeEl.value = 'wall'; if (drawModeEl && drawModeEl.value === 'spawnArea' && gameType === 'frogger') drawModeEl.value = 'wall'; if (drawModeEl && (drawModeEl.value === 'quizTrue' || drawModeEl.value === 'quizFalse' || drawModeEl.value === 'quizQuestion') && gameType !== 'quiz') drawModeEl.value = 'wall'; if (drawModeEl && drawModeEl.value === 'gauntletPlayerSpawn' && gameType !== 'gauntlet') drawModeEl.value = 'wall'; toggleFroggerUI(); }); const btnClearGauntletSpawns = document.getElementById('btn-clear-gauntlet-spawns'); if (btnClearGauntletSpawns) { btnClearGauntletSpawns.addEventListener('click', () => { gauntletPlayerSpawns = []; if (statusEl) statusEl.textContent = 'ล้างลำดับเกิดพรมแดง (1–6) แล้ว'; draw(); }); } function openPlayTestMap() { const id = mapId && String(mapId).trim(); if (!id) { if (statusEl) statusEl.textContent = 'บันทึกฉากก่อน จึงจะทดลองเล่นได้ (ต้องมีรหัสฉากใน URL)'; try { alert('บันทึกฉากก่อน — ปุ่มทดลองเล่นต้องใช้รหัสฉากหลังบันทึก (แก้จากฉากเดิมแล้วกดบันทึก)'); } catch (e) { /* ignore */ } return; } const nick = 'ทดสอบ' + Math.floor(Math.random() * 9000 + 1000); if (statusEl) statusEl.textContent = 'กำลังสร้างห้องทดสอบ…'; var maxPl = (gameTypeEl && gameTypeEl.value === 'gauntlet') ? 6 : 4; fetch(SERVER + '/api/spaces', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mapId: id, isPrivate: true, name: 'preview', maxPlayers: maxPl }), }) .then(function (r) { return r.json(); }) .then(function (data) { if (!data || !data.ok) { if (statusEl) statusEl.textContent = (data && data.error) || 'สร้างห้องทดสอบไม่ได้'; return; } var sid = data.spaceId; var q = 'play.html?space=' + encodeURIComponent(sid) + '&nick=' + encodeURIComponent(nick) + '&map=' + encodeURIComponent(id) + '&preview=1&defaultChar=1' + (editorEmbed ? '&editorEmbed=1' : ''); if (statusEl) statusEl.textContent = 'เปิดหน้าทดสอบเล่นในแท็บใหม่แล้ว — ปิดแท็บเมื่อเล่นเสร็จ'; window.open(BASE + '/' + q, '_blank', 'noopener,noreferrer'); }) .catch(function () { if (statusEl) statusEl.textContent = 'สร้างห้องทดสอบไม่ได้'; }); } try { window.gameEditorOpenPlayTest = openPlayTestMap; } catch (e) { /* ignore */ } var btnPlayTest = document.getElementById('btn-play-test'); if (btnPlayTest) btnPlayTest.addEventListener('click', openPlayTestMap); document.getElementById('btn-save').addEventListener('click', () => { const name = (mapName.value || '').trim() || 'ฉากใหม่'; gameType = gameTypeEl ? (gameTypeEl.value || 'zep') : 'zep'; if (gameType === 'frogger') ensureLanes(); ensureInteractive(); ensureStartGameArea(); ensureSpawnArea(); ensureQuizAreas(); characterCells = characterCellsEl ? Math.max(1, Math.min(4, parseInt(characterCellsEl.value, 10) || 1)) : 1; const showMapInGameEl = document.getElementById('show-map-in-game'); showMapInGame = showMapInGameEl ? showMapInGameEl.checked : true; const body = { name, gameType, width, height, tileSize, characterCells, ground, objects, blockPlayer, interactive, startGameArea, spawnArea, spawn, cellColors, showMapInGame, quizTrueArea, quizFalseArea, quizQuestionArea, }; if (backgroundImage) body.backgroundImage = backgroundImage; body.lanes = gameType === 'frogger' ? lanes : []; sanitizeGauntletPlayerSpawns(); body.gauntletPlayerSpawns = gameType === 'gauntlet' ? gauntletPlayerSpawns.map((s) => ({ x: s.x, y: s.y })) : []; const url = mapId ? SERVER + '/api/maps/' + mapId : SERVER + '/api/maps'; const method = mapId ? 'PUT' : 'POST'; fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(r => r.json()) .then(data => { if (data.ok) { const id = data.mapId || mapId; statusEl.textContent = 'บันทึกแล้ว รหัสฉาก: ' + id + (showMapInGame ? '' : ' (ไม่โชว์ฉาก) — ออกจากห้องแล้วเข้าใหม่ หรือกด F5 ในหน้าเล่น ถึงจะเห็นผล'); if (!mapId) window.history.replaceState({}, '', 'editor.html' + buildEditorSearch(id)); } else statusEl.textContent = data.error || 'บันทึกไม่ได้'; }) .catch(() => { statusEl.textContent = 'บันทึกไม่ได้'; }); }); const cellColorWrap = document.getElementById('cell-color-wrap'); const cellAlphaEl = document.getElementById('cell-alpha'); const cellAlphaValue = document.getElementById('cell-alpha-value'); if (drawModeEl) drawModeEl.addEventListener('change', () => { if (cellColorWrap) cellColorWrap.style.display = drawModeEl.value === 'color' ? 'inline' : 'none'; }); if (cellColorWrap && drawModeEl) cellColorWrap.style.display = drawModeEl.value === 'color' ? 'inline' : 'none'; if (cellAlphaEl && cellAlphaValue) cellAlphaEl.addEventListener('input', () => { cellAlphaValue.textContent = cellAlphaEl.value; draw(); }); mapW.addEventListener('change', resize); mapH.addEventListener('change', resize); tileSizeEl.addEventListener('change', resize); if (characterCellsEl) characterCellsEl.addEventListener('input', draw); if (mapId) { fetch(SERVER + '/api/maps/' + mapId) .then(r => r.json()) .then(m => { width = m.width; height = m.height; tileSize = m.tileSize || 32; characterCells = Math.max(1, Math.min(4, m.characterCells || 1)); ground = m.ground || []; objects = m.objects || []; blockPlayer = m.blockPlayer ? m.blockPlayer.map(r => r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); interactive = m.interactive && m.interactive.length ? m.interactive.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); startGameArea = m.startGameArea && m.startGameArea.length ? m.startGameArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); spawnArea = m.spawnArea && m.spawnArea.length ? m.spawnArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); quizTrueArea = m.quizTrueArea && m.quizTrueArea.length ? m.quizTrueArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); quizFalseArea = m.quizFalseArea && m.quizFalseArea.length ? m.quizFalseArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); quizQuestionArea = m.quizQuestionArea && m.quizQuestionArea.length ? m.quizQuestionArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); cellColors = (m.cellColors && m.cellColors.length === height) ? m.cellColors.map(r => r && r.slice ? r.slice() : Array(width).fill(null)) : Array(height).fill(0).map(() => Array(width).fill(null)); showMapInGame = m.showMapInGame !== false; const showMapEl = document.getElementById('show-map-in-game'); if (showMapEl) showMapEl.checked = showMapInGame; ensureInteractive(); ensureStartGameArea(); ensureSpawnArea(); backgroundImage = m.backgroundImage || null; if (backgroundImage) { backgroundImageImg = new Image(); backgroundImageImg.onload = () => { draw(); }; backgroundImageImg.src = backgroundImage; } else backgroundImageImg = null; spawn = m.spawn || { x: 1, y: 1 }; gauntletPlayerSpawns = Array.isArray(m.gauntletPlayerSpawns) ? m.gauntletPlayerSpawns.map((s) => ({ x: Math.floor(Number(s.x)), y: Math.floor(Number(s.y)) })) .filter((s) => Number.isFinite(s.x) && Number.isFinite(s.y)) : []; gameType = m.gameType || 'zep'; if (gameTypeEl) gameTypeEl.value = gameType; lanes = (m.lanes && m.lanes.length) ? m.lanes.map(l => ({ ...l })) : []; if (gameType === 'gauntlet' || gameType === 'stack') lanes = []; if (gameType === 'frogger') { ensureLanes(); if (lanes.length !== height) ensureLanes(); } mapName.value = m.name || ''; mapW.value = width; mapH.value = height; tileSizeEl.value = tileSize; if (characterCellsEl) characterCellsEl.value = characterCells; sanitizeGauntletPlayerSpawns(); resize(); draw(); toggleFroggerUI(); }) .catch(() => initGrid()); } else { initGrid(); toggleFroggerUI(); } })();