Files

4126 lines
199 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const BASE = '/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 characterCellsWEl = document.getElementById('character-cells-w');
const characterCellsHEl = document.getElementById('character-cells-h');
const characterCollisionWEl = document.getElementById('character-collision-w');
const characterCollisionHEl = document.getElementById('character-collision-h');
const blockPlayerSeparationWEl = document.getElementById('block-player-separation-w');
const blockPlayerSeparationHEl = document.getElementById('block-player-separation-h');
const drawModeEl = document.getElementById('draw-mode');
const gameTypeEl = document.getElementById('game-type');
const statusEl = document.getElementById('editor-status');
const QUIZ_CARRY_EDITOR_OPT_COUNT = 16;
function quizCarryEditorDrawPalette(slot1based) {
const i = Math.max(0, Math.min(QUIZ_CARRY_EDITOR_OPT_COUNT - 1, slot1based - 1));
const h = (i * 47 + 100) % 360;
return {
fill: 'hsla(' + h + ',58%,48%,0.52)',
stroke: 'hsl(' + h + ',65%,42%)',
};
}
function quizCarryEditorCarryModes() {
const m = ['quizCarryHub', 'carryEmbedCountdown'];
for (let n = 1; n <= QUIZ_CARRY_EDITOR_OPT_COUNT; n++) m.push('quizCarryOpt' + n);
return m;
}
let tileSize = 32, width = 20, height = 15, characterCellsW = 1, characterCellsH = 1;
function clampCharacterFootprint(n) {
const v = parseInt(n, 10);
if (!Number.isFinite(v)) return 1;
return Math.max(1, Math.min(4, v));
}
function readBlockPlayerSeparationWHForSave() {
let w = blockPlayerSeparationWEl ? Math.floor(Number(blockPlayerSeparationWEl.value)) : 0;
let h = blockPlayerSeparationHEl ? Math.floor(Number(blockPlayerSeparationHEl.value)) : 0;
if (!Number.isFinite(w) || w < 0) w = 0;
if (!Number.isFinite(h) || h < 0) h = 0;
return { w: Math.min(4, w), h: Math.min(4, h) };
}
function readCharacterFootprintInputs() {
const cw = characterCellsWEl ? clampCharacterFootprint(characterCellsWEl.value) : characterCellsW;
const ch = characterCellsHEl ? clampCharacterFootprint(characterCellsHEl.value) : characterCellsH;
let colW = characterCollisionWEl ? parseInt(String(characterCollisionWEl.value), 10) : cw;
let colH = characterCollisionHEl ? parseInt(String(characterCollisionHEl.value), 10) : ch;
if (!Number.isFinite(colW) || colW < 1) colW = cw;
if (!Number.isFinite(colH) || colH < 1) colH = ch;
colW = Math.max(1, Math.min(cw, Math.floor(colW)));
colH = Math.max(1, Math.min(ch, Math.floor(colH)));
return { cw, ch, colW, colH };
}
function applyCharacterFootprintInputs(cw, ch) {
characterCellsW = clampCharacterFootprint(cw);
characterCellsH = clampCharacterFootprint(ch);
if (characterCellsWEl) characterCellsWEl.value = String(characterCellsW);
if (characterCellsHEl) characterCellsHEl.value = String(characterCellsH);
if (characterCollisionWEl) {
let v = parseInt(String(characterCollisionWEl.value), 10);
if (!Number.isFinite(v) || v < 1) v = characterCellsW;
characterCollisionWEl.value = String(Math.max(1, Math.min(characterCellsW, v)));
}
if (characterCollisionHEl) {
let v = parseInt(String(characterCollisionHEl.value), 10);
if (!Number.isFinite(v) || v < 1) v = characterCellsH;
characterCollisionHEl.value = String(Math.max(1, Math.min(characterCellsH, v)));
}
}
let gameType = 'zep';
let lanes = [];
let ground = [], objects = [], blockPlayer = [], interactive = [], startGameArea = [], spawnArea = [], spawn = { x: 1, y: 1 };
let quizTrueArea = [], quizFalseArea = [], quizQuestionArea = [];
let quizCarryHubArea = [], quizCarryOptionArea = [];
/** quiz_carry embed: ช่อง = 1 วางกล่องนับ 3-2-1 บนแมปเมื่อเลือก anchor = grid */
let carryEmbedCountdownArea = [];
let quizBattleDomeArea = [];
/** quiz_battle: กริด 0/1 — ถ้ามีอย่างน้อย 1 ช่อง = ในเกมเดินได้เฉพาะบนเส้นทาง */
let quizBattlePathArea = [];
let stackReleaseArea = [], stackLandArea = [];
/** jump_survive: แพลตฟอร์ม { x, y, w, h } tile — ใช้เต็มที่เมื่อมีมุมข้าง */
let jumpSurvivePlatforms = [];
let jumpSurvivePlatformArea = [];
/** jump_survive: กริด 0/1 — ช่องที่ 1 = โซนตาย (ชน hitbox แล้วตายทันทีในเกม) */
let jumpSurviveHazardArea = [];
/** กริด 0 / 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles ใน game-timing */
let jumpSurvivePlatformVariantArea = [];
/** จาก GET /api/game-timing — แสดงในแคนวาสเอดิเตอร์ตามช่อง variant */
let editorJumpSurvivePlatformTilesCfg = [
{ url: '', w: 0, h: 0 },
{ url: '', w: 0, h: 0 },
{ url: '', w: 0, h: 0 },
];
let editorJumpSurvivePlatformTileImgs = [null, null, null];
let editorJumpSurvivePlatformTimingGen = 0;
let cellColors = [];
let gauntletPlayerSpawns = [];
/** gauntlet: แถว y บน–ล่างของแถบเลเซอร์ (null = เต็มความสูงแมป) */
let gauntletLaserRowStart = null;
let gauntletLaserRowEnd = null;
/** ล็อบบี้ / ZEP / quiz_carry / ฯลฯ — P1..P6 ตามลำดับ join เมื่อเลือกโหมด slots6 */
let lobbyPlayerSpawns = [null, null, null, null, null, null];
/** space_shooter: กริด 0 หรือ 1–6 = ลำดับผู้เล่น P1–P6 */
let shooterSpawnSlots = [];
/** balloon_boss: กริด 0 หรือ 1–6 = จุดเกิดผู้เล่น · บอส = tile เดียว */
let balloonBossPlayerSlots = [];
let balloonBossBossSpawn = null;
let customizeSpot = null; // จุด "ห้องแต่งตัว" ในฉาก (เดินไปกด F) — วางเองในeditor
let showMapInGame = true;
let backgroundImage = null;
let backgroundImageImg = null;
/** พื้นหลังเลื่อนแนวตั้ง — เฉพาะแมป mnpz6rkp (Editor) */
const EDITOR_SCROLL_BG_MAP_ID = 'mnpz6rkp';
const EDITOR_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnpz6rkp/intro.png';
const EDITOR_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnpz6rkp/loop.png';
let editorBgScrollConfig = {
enabled: false,
speedPxPerSec: 56,
/** 'up' = ล่าง→บน · 'down' = บน→ล่าง */
scrollDirection: 'up',
introImage: null,
loopImage: null,
};
let editorBgScrollIntroImg = null;
let editorBgScrollLoopImg = null;
/** viewport top ใน strip (>=0) — เพิ่ม = เลื่อนขึ้น (ล่าง→บน) */
let editorBgScrollPx = 0;
let editorBgScrollRaf = null;
let editorBgScrollLastTs = 0;
function stopEditorBgScrollRaf() {
if (editorBgScrollRaf != null) {
cancelAnimationFrame(editorBgScrollRaf);
editorBgScrollRaf = null;
}
editorBgScrollLastTs = 0;
}
function isEditorScrollBgMap() {
return mapId === EDITOR_SCROLL_BG_MAP_ID;
}
function isEditorScrollBgDrawing() {
if (!isEditorScrollBgMap() || !editorBgScrollConfig.enabled) return false;
const a = editorBgScrollIntroImg;
const b = editorBgScrollLoopImg;
return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
}
function loadEditorBgScrollImagePair(introSrc, loopSrc, onDone) {
stopEditorBgScrollRaf();
editorBgScrollIntroImg = null;
editorBgScrollLoopImg = null;
let pending = 2;
const doneOne = () => {
pending--;
if (pending <= 0 && typeof onDone === 'function') onDone();
};
const im1 = new Image();
im1.onload = () => {
editorBgScrollIntroImg = im1;
doneOne();
};
im1.onerror = () => { doneOne(); };
im1.src = introSrc;
const im2 = new Image();
im2.onload = () => {
editorBgScrollLoopImg = im2;
doneOne();
};
im2.onerror = () => { doneOne(); };
im2.src = loopSrc;
}
function applyEditorBgScrollFromMap(m) {
if (!isEditorScrollBgMap()) {
editorBgScrollConfig = { enabled: false, speedPxPerSec: 56, scrollDirection: 'up', introImage: null, loopImage: null };
stopEditorBgScrollRaf();
editorBgScrollIntroImg = null;
editorBgScrollLoopImg = null;
return;
}
const raw = m && m.editorBgScroll && typeof m.editorBgScroll === 'object' ? m.editorBgScroll : {};
const sp = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 56));
const sd = String(raw.scrollDirection || raw.direction || 'up').toLowerCase();
editorBgScrollConfig = {
enabled: !!raw.enabled,
speedPxPerSec: sp,
scrollDirection: (sd === 'down' || sd === 'top' || sd === 'toptobottom') ? 'down' : 'up',
introImage: typeof raw.introImage === 'string' && raw.introImage.length ? raw.introImage : null,
loopImage: typeof raw.loopImage === 'string' && raw.loopImage.length ? raw.loopImage : null,
};
const introSrc = editorBgScrollConfig.introImage || EDITOR_SCROLL_BG_DEFAULT_INTRO;
const loopSrc = editorBgScrollConfig.loopImage || EDITOR_SCROLL_BG_DEFAULT_LOOP;
loadEditorBgScrollImagePair(introSrc, loopSrc, () => {
syncEditorBgScrollUiFromConfig();
syncEditorBgScrollInitialToBottom();
draw();
if (editorBgScrollConfig.enabled) startEditorBgScrollRaf();
});
}
function syncEditorBgScrollInitialToBottom() {
const intro = editorBgScrollIntroImg;
if (!intro || !intro.complete || !intro.naturalWidth) return;
const cwR = Math.max(1, Math.round(canvas.width));
const chR = Math.max(1, Math.round(canvas.height));
const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
if (editorBgScrollConfig.scrollDirection === 'down') {
editorBgScrollPx = 0;
} else {
editorBgScrollPx = Math.max(0, drawHIntro - chR);
}
}
function syncEditorBgScrollUiFromConfig() {
const block = document.getElementById('editor-bg-scroll-block');
if (block) block.hidden = !isEditorScrollBgMap();
const en = document.getElementById('editor-bg-scroll-enabled');
if (en) en.checked = !!editorBgScrollConfig.enabled;
const sp = document.getElementById('editor-bg-scroll-speed');
if (sp) sp.value = String(editorBgScrollConfig.speedPxPerSec || 56);
const dir = document.getElementById('editor-bg-scroll-direction');
if (dir) dir.value = editorBgScrollConfig.scrollDirection === 'down' ? 'down' : 'up';
}
/** Stack Tower ภารกิจ (mnn93hpi) — HUD embed + พื้นหลังแนวตั้ง intro/loop */
const EDITOR_STACK_TOWER_CAM_MAP_ID = 'mnn93hpi';
const EDITOR_STACK_TOWER_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnn93hpi/intro.png';
const EDITOR_STACK_TOWER_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnn93hpi/loop.png';
let stackTowerBgScrollConfig = {
enabled: false,
speedPxPerSec: 0,
scrollDirection: 'down',
introImage: null,
loopImage: null,
stepEveryLayers: 0,
stepScrollPx: 0,
stepAnimMs: 520,
releaseGapWorldPx: null,
};
let stackTowerBgScrollIntroImg = null;
let stackTowerBgScrollLoopImg = null;
let stackTowerBgScrollPx = 0;
function isEditorStackTowerMissionMap() {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
return mapId === EDITOR_STACK_TOWER_CAM_MAP_ID && gt === 'stack';
}
function syncEditorStackTowerVerticalBgBlockVisibility() {
const block = document.getElementById('editor-stack-tower-vertical-bg-block');
if (!block) return;
block.hidden = !isEditorStackTowerMissionMap();
}
function syncStackTowerCamBlockVisibilityOnly() {
syncEditorStackTowerVerticalBgBlockVisibility();
if (!isEditorStackTowerMissionMap()) stopStackTowerBgScrollRaf();
}
function stopStackTowerBgScrollRaf() {
/* เคยใช้ RAF — เก็บชื่อให้จุดเรียกเดิมไม่พัง */
}
function isEditorStackTowerBgDrawing() {
if (!isEditorStackTowerMissionMap() || !stackTowerBgScrollConfig.enabled) return false;
const a = stackTowerBgScrollIntroImg;
const b = stackTowerBgScrollLoopImg;
return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
}
function loadStackTowerBgScrollImagePair(introSrc, loopSrc, onDone) {
stopStackTowerBgScrollRaf();
stackTowerBgScrollIntroImg = null;
stackTowerBgScrollLoopImg = null;
let pending = 2;
const doneOne = () => {
pending--;
if (pending <= 0 && typeof onDone === 'function') onDone();
};
const im1 = new Image();
im1.onload = () => {
stackTowerBgScrollIntroImg = im1;
doneOne();
};
im1.onerror = () => { doneOne(); };
im1.src = introSrc;
const im2 = new Image();
im2.onload = () => {
stackTowerBgScrollLoopImg = im2;
doneOne();
};
im2.onerror = () => { doneOne(); };
im2.src = loopSrc;
}
function syncStackTowerBgScrollUiFromConfig() {
const en = document.getElementById('editor-stack-tower-vbg-enabled');
const sp = document.getElementById('editor-stack-tower-vbg-speed');
const dir = document.getElementById('editor-stack-tower-vbg-direction');
const stL = document.getElementById('editor-stack-tower-vbg-step-layers');
const stPx = document.getElementById('editor-stack-tower-vbg-step-px');
const stMs = document.getElementById('editor-stack-tower-vbg-step-anim-ms');
const relG = document.getElementById('editor-stack-tower-vbg-release-gap');
if (en) en.checked = !!stackTowerBgScrollConfig.enabled;
if (sp) sp.value = String(stackTowerBgScrollConfig.speedPxPerSec != null ? stackTowerBgScrollConfig.speedPxPerSec : 0);
if (dir) dir.value = stackTowerBgScrollConfig.scrollDirection === 'down' ? 'down' : 'up';
if (stL) stL.value = String(stackTowerBgScrollConfig.stepEveryLayers != null ? stackTowerBgScrollConfig.stepEveryLayers : 0);
if (stPx) stPx.value = String(stackTowerBgScrollConfig.stepScrollPx != null ? stackTowerBgScrollConfig.stepScrollPx : 0);
if (stMs) stMs.value = String(stackTowerBgScrollConfig.stepAnimMs != null ? stackTowerBgScrollConfig.stepAnimMs : 520);
if (relG) {
const g = stackTowerBgScrollConfig.releaseGapWorldPx;
relG.value = g != null && Number.isFinite(Number(g)) ? String(Math.floor(Number(g))) : '';
}
}
function syncStackTowerBgScrollInitialToBottom() {
const intro = stackTowerBgScrollIntroImg;
if (!intro || !intro.complete || !intro.naturalWidth) return;
const cwR = Math.max(1, Math.round(canvas.width));
const chR = Math.max(1, Math.round(canvas.height));
const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
const rawDownLike = stackTowerBgScrollConfig.scrollDirection === 'down';
const flowDown = !rawDownLike;
if (flowDown) {
stackTowerBgScrollPx = 0;
} else {
stackTowerBgScrollPx = Math.max(0, drawHIntro - chR);
}
}
function foldStackTowerBgScrollSViewEditor(S, drawHIntro, drawHLoop) {
const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
const s = Number(S) || 0;
if (s < dI) return s;
return s - Math.floor((s - dI) / dH) * dH;
}
function drawStackTowerScrollingBackground(cw, ch) {
const intro = stackTowerBgScrollIntroImg;
const loop = stackTowerBgScrollLoopImg;
if (!intro || !intro.complete || !loop || !loop.complete) return;
const cwR = Math.max(1, Math.round(cw));
const chR = Math.max(1, Math.round(ch));
const scaleI = cwR / intro.naturalWidth;
const drawHIntro = Math.round(intro.naturalHeight * scaleI);
const scaleL = cwR / loop.naturalWidth;
const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
const S = foldStackTowerBgScrollSViewEditor(stackTowerBgScrollPx, drawHIntro, drawHLoop);
const vp0 = S;
const vp1 = S + chR;
const yLimit = vp1 + drawHLoop * 96;
const rawDownLike = stackTowerBgScrollConfig.scrollDirection === 'down';
const flowDown = !rawDownLike;
function stripTopToDestY(stripTop, drawH) {
if (!flowDown) return stripTop - S;
return S + chR - (stripTop + drawH);
}
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.beginPath();
ctx.rect(0, 0, cwR, chR);
ctx.clip();
if (vp0 < 0) {
let k = 1;
if (vp0 < -drawHLoop * 4) {
k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
}
for (; k < 50000; k++) {
const y0 = -k * drawHLoop;
if (y0 >= vp1 + drawHLoop) break;
if (y0 + drawHLoop <= vp0) continue;
const dy = Math.round(stripTopToDestY(y0, drawHLoop));
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
}
}
if (drawHIntro > vp0 && vp1 > 0) {
const dy = Math.round(stripTopToDestY(0, drawHIntro));
ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
}
let y = drawHIntro;
if (y < yLimit && vp1 > drawHIntro) {
if (vp0 > drawHIntro) {
const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
y = drawHIntro + n * drawHLoop;
}
while (y < yLimit) {
const dy = Math.round(stripTopToDestY(y, drawHLoop));
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
y += drawHLoop;
}
}
ctx.restore();
}
function applyStackTowerBgScrollFromMap(m) {
stopStackTowerBgScrollRaf();
stackTowerBgScrollIntroImg = null;
stackTowerBgScrollLoopImg = null;
stackTowerBgScrollPx = 0;
if (!isEditorStackTowerMissionMap()) {
stackTowerBgScrollConfig = {
enabled: false,
speedPxPerSec: 0,
scrollDirection: 'down',
introImage: null,
loopImage: null,
stepEveryLayers: 0,
stepScrollPx: 0,
stepAnimMs: 520,
releaseGapWorldPx: null,
};
return;
}
const raw = m && m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {};
const spNum = Number(raw.speedPxPerSec);
const sp = Number.isFinite(spNum) ? Math.max(0, Math.min(400, Math.floor(spNum))) : 0;
const sd = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
const sel = Math.floor(Number(raw.stepEveryLayers));
const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0;
const ssp = Math.floor(Number(raw.stepScrollPx));
const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0;
const sam = Math.floor(Number(raw.stepAnimMs));
const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520;
const rg = Number(raw.releaseGapWorldPx);
const releaseGapWorldPx = Number.isFinite(rg) && rg >= 0 ? Math.min(800, Math.floor(rg)) : null;
stackTowerBgScrollConfig = {
enabled: raw.enabled === true,
speedPxPerSec: sp,
scrollDirection: (sd === 'down' || sd === 'top' || sd === 'toptobottom') ? 'down' : 'up',
introImage: typeof raw.introImage === 'string' && raw.introImage.length ? raw.introImage : null,
loopImage: typeof raw.loopImage === 'string' && raw.loopImage.length ? raw.loopImage : null,
stepEveryLayers,
stepScrollPx,
stepAnimMs,
releaseGapWorldPx,
};
const introSrc = stackTowerBgScrollConfig.introImage || EDITOR_STACK_TOWER_BG_DEFAULT_INTRO;
const loopSrc = stackTowerBgScrollConfig.loopImage || EDITOR_STACK_TOWER_BG_DEFAULT_LOOP;
loadStackTowerBgScrollImagePair(introSrc, loopSrc, () => {
syncStackTowerBgScrollUiFromConfig();
syncStackTowerBgScrollInitialToBottom();
draw();
});
}
function readStackTowerBgScrollForSave() {
if (!isEditorStackTowerMissionMap()) return undefined;
const en = document.getElementById('editor-stack-tower-vbg-enabled');
const spEl = document.getElementById('editor-stack-tower-vbg-speed');
const spNum = Number(spEl && spEl.value);
const sp = Number.isFinite(spNum) ? Math.max(0, Math.min(400, Math.floor(spNum))) : 0;
const dirEl = document.getElementById('editor-stack-tower-vbg-direction');
const stLEl = document.getElementById('editor-stack-tower-vbg-step-layers');
const stPxEl = document.getElementById('editor-stack-tower-vbg-step-px');
const stMsEl = document.getElementById('editor-stack-tower-vbg-step-anim-ms');
const relEl = document.getElementById('editor-stack-tower-vbg-release-gap');
const selRaw = Number(stLEl && stLEl.value);
const stepEveryLayers = Number.isFinite(selRaw) ? Math.max(0, Math.min(200, Math.floor(selRaw))) : 0;
const sspRaw = Number(stPxEl && stPxEl.value);
const stepScrollPx = Number.isFinite(sspRaw) ? Math.max(0, Math.min(8000, Math.floor(sspRaw))) : 0;
const samRaw = Number(stMsEl && stMsEl.value);
const stepAnimMs = Number.isFinite(samRaw) ? Math.max(120, Math.min(4000, Math.floor(samRaw))) : 520;
const relStr = relEl && String(relEl.value).trim();
const relNum = relStr === '' ? null : Number(relStr);
const releaseGapWorldPx = relNum != null && Number.isFinite(relNum) && relNum >= 0
? Math.min(800, Math.floor(relNum))
: null;
const out = {
enabled: !!(en && en.checked),
speedPxPerSec: sp,
scrollDirection: dirEl && dirEl.value === 'down' ? 'down' : 'up',
stepEveryLayers,
stepScrollPx,
stepAnimMs,
};
if (releaseGapWorldPx != null) out.releaseGapWorldPx = releaseGapWorldPx;
if (stackTowerBgScrollConfig.introImage) out.introImage = stackTowerBgScrollConfig.introImage;
if (stackTowerBgScrollConfig.loopImage) out.loopImage = stackTowerBgScrollConfig.loopImage;
return out;
}
function applyStackTowerCameraFollowFromMap(m) {
stopStackTowerBgScrollRaf();
syncEditorStackTowerVerticalBgBlockVisibility();
if (!isEditorStackTowerMissionMap()) {
stackTowerBgScrollConfig = {
enabled: false,
speedPxPerSec: 0,
scrollDirection: 'down',
introImage: null,
loopImage: null,
stepEveryLayers: 0,
stepScrollPx: 0,
stepAnimMs: 520,
releaseGapWorldPx: null,
};
stackTowerBgScrollIntroImg = null;
stackTowerBgScrollLoopImg = null;
stackTowerBgScrollPx = 0;
return;
}
applyStackTowerBgScrollFromMap(m || {});
}
function readStackTowerCameraFollowForSave() {
if (!isEditorStackTowerMissionMap()) return undefined;
return { enabled: false, pxPerLayer: 12, maxPx: 260 };
}
/** พื้นหลังแนวนอน start + วน 2–4 + finish — เฉพาะแมป Last Light mno9kb07 (Editor + map JSON: gauntletCrownRunwayBg) */
const EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID = 'mno9kb07';
const EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_START = BASE + '/img/editor-bg-mno9kb07/start.png';
const EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP2 = BASE + '/img/editor-bg-mno9kb07/loop2.png';
const EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP3 = BASE + '/img/editor-bg-mno9kb07/loop3.png';
const EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP4 = BASE + '/img/editor-bg-mno9kb07/loop4.png';
const EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_FINISH = BASE + '/img/editor-bg-mno9kb07/finish.png';
let editorGauntletRunwayBgConfig = {
enabled: true,
speedPxPerSec: 48,
runwaySpawnAlignFrac: 0.32,
startImage: null,
loopImage2: null,
loopImage3: null,
loopImage4: null,
finishImage: null,
};
let editorGauntletRunwayBgImgs = [null, null, null, null, null];
let editorGauntletRunwayBgScrollPx = 0;
let editorGauntletRunwayBgRaf = null;
let editorGauntletRunwayBgLastTs = 0;
/** ชื่อไฟล์ล่าสุดที่ผู้ใช้เลือก (ช่อง input จะว่างหลังอ่านไฟล์ — ใช้โชว์สถานะแทน) */
let editorGauntletRunwayBgFileNames = {
startImage: '',
loopImage2: '',
loopImage3: '',
loopImage4: '',
finishImage: '',
};
const RUNWAY_PICK_MAX_W = 2048;
const RUNWAY_PICK_MAX_H = 1152;
const RUNWAY_PICK_COMPRESS_MIN_LEN = 650000;
/** ลดขนาด data URL ก่อนเก็บใน JSON — กันบันทึกไม่ขึ้นเมื่อรวมแมปใหญ่เกิน client_max_body_size */
function compressRunwayDataUrlIfLarge(dataUrl, done) {
if (typeof dataUrl !== 'string' || dataUrl.indexOf('data:image') !== 0) {
done(dataUrl);
return;
}
const im = new Image();
im.onload = () => {
try {
const nw = im.naturalWidth;
const nh = im.naturalHeight;
const needShrink = dataUrl.length > RUNWAY_PICK_COMPRESS_MIN_LEN
|| nw > RUNWAY_PICK_MAX_W
|| nh > RUNWAY_PICK_MAX_H;
if (!needShrink || nw < 1 || nh < 1) {
done(dataUrl);
return;
}
let w = nw;
let h = nh;
const sc = Math.min(1, RUNWAY_PICK_MAX_W / w, RUNWAY_PICK_MAX_H / h);
w = Math.max(1, Math.round(w * sc));
h = Math.max(1, Math.round(h * sc));
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const g = c.getContext('2d');
if (!g) {
done(dataUrl);
return;
}
g.imageSmoothingEnabled = true;
g.drawImage(im, 0, 0, w, h);
let best = c.toDataURL('image/jpeg', 0.84);
try {
const wu = c.toDataURL('image/webp', 0.84);
if (wu.length < best.length) best = wu;
} catch (e1) { /* webp ไม่รองรับ */ }
done(best.length < dataUrl.length ? best : dataUrl);
} catch (e) {
done(dataUrl);
}
};
im.onerror = () => { done(dataUrl); };
im.src = dataUrl;
}
function isEditorGauntletRunwayBgMap() {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
return mapId === EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID && gt === 'gauntlet';
}
function stopEditorGauntletRunwayBgRaf() {
if (editorGauntletRunwayBgRaf != null) {
cancelAnimationFrame(editorGauntletRunwayBgRaf);
editorGauntletRunwayBgRaf = null;
}
editorGauntletRunwayBgLastTs = 0;
}
function syncEditorGauntletRunwayBgBlockVisibility() {
const block = document.getElementById('editor-gauntlet-runway-bg-block');
if (!block) return;
/** โชว์ทันทีจากรหัสใน URL — ไม่ผูกกับค่า gameType ตอนก่อน fetch (เดิมเป็น zep ทำให้บล็อกถูกซ่อนจนเหมือนไม่มีฟีเจอร์) */
const show = mapId === EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID;
if (!show) stopEditorGauntletRunwayBgRaf();
block.hidden = !show;
}
/** Admin embed: แสดง HUD จำลองแบบ play (mnn93hpi / cyber SCORE + TIME + SYSTEM INTEGRITY) เมื่อโหมด Stack */
function syncEditorStackHudMock() {
const el = document.getElementById('editor-stack-hud-mock');
if (!el || !editorEmbed) return;
const gt = gameTypeEl ? gameTypeEl.value : gameType;
const show = gt === 'stack';
el.classList.toggle('is-hidden', !show);
el.setAttribute('aria-hidden', show ? 'false' : 'true');
}
function editorGauntletRunwayBgEligibleFromMap(m) {
const gt = (m && m.gameType != null && m.gameType !== '') ? m.gameType : (gameTypeEl ? gameTypeEl.value : gameType);
return mapId === EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID && gt === 'gauntlet';
}
function editorGauntletRunwayBgStripWidthsAtCh(chR) {
const ch0 = Math.max(1, Math.round(chR));
const wOf = (im) => {
if (!im || !im.complete || !im.naturalWidth || !im.naturalHeight) return 0;
return Math.max(1, Math.round(im.naturalWidth * (ch0 / im.naturalHeight)));
};
return {
w0: wOf(editorGauntletRunwayBgImgs[0]),
w1: wOf(editorGauntletRunwayBgImgs[1]),
w2: wOf(editorGauntletRunwayBgImgs[2]),
w3: wOf(editorGauntletRunwayBgImgs[3]),
};
}
function isEditorGauntletRunwayBgDrawing() {
if (!isEditorGauntletRunwayBgMap() || !editorGauntletRunwayBgConfig.enabled) return false;
for (let i = 0; i < 5; i++) {
const im = editorGauntletRunwayBgImgs[i];
if (!im || !im.complete || !im.naturalWidth) return false;
}
return true;
}
function drawRunwayHSliceEditor(ctx, img, worldX0, segW, scrollLeft, cwR, chR) {
if (!img || !img.complete || !img.naturalWidth || !img.naturalHeight || segW <= 0) return;
const natW = img.naturalWidth;
const natH = img.naturalHeight;
const v0 = scrollLeft;
const v1 = scrollLeft + cwR;
const o0 = Math.max(worldX0, v0);
const o1 = Math.min(worldX0 + segW, v1);
if (o1 <= o0) return;
const u0 = ((o0 - worldX0) / segW) * natW;
const u1 = ((o1 - worldX0) / segW) * natW;
const dx = o0 - scrollLeft;
const dw = o1 - o0;
ctx.drawImage(img, u0, 0, u1 - u0, natH, dx, 0, dw, chR);
}
function drawEditorGauntletRunwayScrollingBackground(cw, ch) {
const start = editorGauntletRunwayBgImgs[0];
const L2 = editorGauntletRunwayBgImgs[1];
const L3 = editorGauntletRunwayBgImgs[2];
const L4 = editorGauntletRunwayBgImgs[3];
const cwR = Math.max(1, Math.round(cw));
const chR = Math.max(1, Math.round(ch));
const { w0, w1, w2, w3 } = editorGauntletRunwayBgStripWidthsAtCh(chR);
const cycle = w1 + w2 + w3;
const S = editorGauntletRunwayBgScrollPx;
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, cwR, chR);
if (w0 > 0) drawRunwayHSliceEditor(ctx, start, 0, w0, S, cwR, chR);
if (cycle <= 0) {
ctx.restore();
return;
}
const ws = [w1, w2, w3];
const ims = [L2, L3, L4];
const uTotal = Math.max(0, S - w0);
let cycN = Math.floor(uTotal / cycle);
let uRem = uTotal - cycN * cycle;
let si = 0;
while (si < 3 && uRem >= ws[si]) {
uRem -= ws[si];
si++;
}
if (si >= 3) {
cycN++;
si = 0;
uRem = 0;
}
let acc = 0;
for (let k = 0; k < si; k++) acc += ws[k];
let curLeft = w0 + cycN * cycle + acc;
let guard = 0;
let idx = si;
while (curLeft < S + cwR && guard++ < 500) {
drawRunwayHSliceEditor(ctx, ims[idx], curLeft, ws[idx], S, cwR, chR);
curLeft += ws[idx];
idx = (idx + 1) % 3;
}
ctx.restore();
}
function editorGauntletRunwayBgTick(ts) {
editorGauntletRunwayBgRaf = null;
if (!isEditorGauntletRunwayBgDrawing()) return;
if (!editorGauntletRunwayBgLastTs) editorGauntletRunwayBgLastTs = ts;
const dt = Math.min(64, Math.max(0, ts - editorGauntletRunwayBgLastTs));
editorGauntletRunwayBgLastTs = ts;
editorGauntletRunwayBgScrollPx += (editorGauntletRunwayBgConfig.speedPxPerSec || 48) * (dt / 1000);
draw();
editorGauntletRunwayBgRaf = requestAnimationFrame(editorGauntletRunwayBgTick);
}
function startEditorGauntletRunwayBgRaf() {
if (!isEditorGauntletRunwayBgDrawing()) return;
stopEditorGauntletRunwayBgRaf();
editorGauntletRunwayBgLastTs = 0;
editorGauntletRunwayBgRaf = requestAnimationFrame(editorGauntletRunwayBgTick);
}
function loadEditorGauntletRunwayBgImages(s0, s1, s2, s3, s4, onDone) {
stopEditorGauntletRunwayBgRaf();
editorGauntletRunwayBgImgs = [null, null, null, null, null];
let pending = 5;
const doneOne = () => {
pending--;
if (pending <= 0 && typeof onDone === 'function') onDone();
};
const srcs = [s0, s1, s2, s3, s4];
for (let i = 0; i < 5; i++) {
const im = new Image();
const idx = i;
im.onload = () => {
editorGauntletRunwayBgImgs[idx] = im;
doneOne();
};
im.onerror = () => { doneOne(); };
im.src = srcs[i];
}
}
function applyGauntletCrownRunwayBgFromMap(m) {
syncEditorGauntletRunwayBgBlockVisibility();
if (mapId !== EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID) {
editorGauntletRunwayBgConfig = {
enabled: true,
speedPxPerSec: 48,
runwaySpawnAlignFrac: 0.32,
startImage: null,
loopImage2: null,
loopImage3: null,
loopImage4: null,
finishImage: null,
};
editorGauntletRunwayBgFileNames = { startImage: '', loopImage2: '', loopImage3: '', loopImage4: '', finishImage: '' };
stopEditorGauntletRunwayBgRaf();
editorGauntletRunwayBgImgs = [null, null, null, null, null];
return;
}
editorGauntletRunwayBgFileNames = { startImage: '', loopImage2: '', loopImage3: '', loopImage4: '', finishImage: '' };
const raw = m && m.gauntletCrownRunwayBg && typeof m.gauntletCrownRunwayBg === 'object' ? m.gauntletCrownRunwayBg : {};
const sp = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 48));
const afRaw = Number(raw.runwaySpawnAlignFrac);
const af = Number.isFinite(afRaw) ? Math.max(0.02, Math.min(0.95, afRaw)) : 0.32;
editorGauntletRunwayBgConfig = {
enabled: raw.enabled !== false,
speedPxPerSec: sp,
runwaySpawnAlignFrac: af,
startImage: typeof raw.startImage === 'string' && raw.startImage.length ? raw.startImage : null,
loopImage2: typeof raw.loopImage2 === 'string' && raw.loopImage2.length ? raw.loopImage2 : null,
loopImage3: typeof raw.loopImage3 === 'string' && raw.loopImage3.length ? raw.loopImage3 : null,
loopImage4: typeof raw.loopImage4 === 'string' && raw.loopImage4.length ? raw.loopImage4 : null,
finishImage: typeof raw.finishImage === 'string' && raw.finishImage.length ? raw.finishImage : null,
};
const eligible = editorGauntletRunwayBgEligibleFromMap(m || {});
if (!editorGauntletRunwayBgConfig.enabled) {
stopEditorGauntletRunwayBgRaf();
editorGauntletRunwayBgImgs = [null, null, null, null, null];
syncEditorGauntletRunwayBgUiFromConfig();
draw();
return;
}
const s0 = editorGauntletRunwayBgConfig.startImage || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_START;
const s1 = editorGauntletRunwayBgConfig.loopImage2 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP2;
const s2 = editorGauntletRunwayBgConfig.loopImage3 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP3;
const s3 = editorGauntletRunwayBgConfig.loopImage4 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP4;
const s4 = editorGauntletRunwayBgConfig.finishImage || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_FINISH;
editorGauntletRunwayBgScrollPx = 0;
loadEditorGauntletRunwayBgImages(s0, s1, s2, s3, s4, () => {
syncEditorGauntletRunwayBgUiFromConfig();
draw();
stopEditorGauntletRunwayBgRaf();
if (eligible && editorGauntletRunwayBgConfig.enabled) startEditorGauntletRunwayBgRaf();
});
}
function syncEditorGauntletRunwayBgUiFromConfig() {
const en = document.getElementById('editor-gauntlet-runway-bg-enabled');
const sp = document.getElementById('editor-gauntlet-runway-bg-speed');
const af = document.getElementById('editor-gauntlet-runway-bg-align-frac');
if (en) en.checked = !!editorGauntletRunwayBgConfig.enabled;
if (sp) sp.value = String(editorGauntletRunwayBgConfig.speedPxPerSec || 48);
if (af) {
const v = editorGauntletRunwayBgConfig.runwaySpawnAlignFrac;
af.value = String(Number.isFinite(v) ? Math.max(0.02, Math.min(0.95, v)) : 0.32);
}
syncEditorGauntletRunwayBgUploadSummary();
syncEditorGauntletRunwayBgThumbnails();
}
function syncEditorGauntletRunwayBgThumbnails() {
if (mapId !== EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID) return;
const keys = ['startImage', 'loopImage2', 'loopImage3', 'loopImage4', 'finishImage'];
const defaults = [
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_START,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP2,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP3,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP4,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_FINISH,
];
const labels = ['START', 'LOOP', 'LOOP', 'LOOP', 'FINISH'];
for (let i = 0; i < 5; i++) {
const el = document.getElementById('editor-gauntlet-runway-bg-prev-' + i);
if (!el) continue;
const custom = editorGauntletRunwayBgConfig[keys[i]];
const src = (typeof custom === 'string' && custom.length) ? custom : defaults[i];
el.alt = 'Runway ' + labels[i] + ' — slot ' + (i + 1);
el.src = src;
}
}
function describeRunwaySlotStoredValue(val, fileName) {
if (!val || typeof val !== 'string' || !val.length) {
return { th: 'ยังไม่อัปโหลด — เกมใช้พื้นสีชั่วคราว', en: 'Not set — game uses soft color strip' };
}
if (val.startsWith('data:image')) {
const fn = (fileName && String(fileName).trim()) ? ` · ${fileName.trim()}` : '';
return { th: `อัปโหลดแล้ว (เก็บในฉาก)${fn}`, en: `Saved in map (data URL)${fn}` };
}
const short = val.length > 56 ? (val.slice(0, 54) + '…') : val;
return { th: `ตั้งจากแมป/URL: ${short}`, en: `From map/URL: ${short}` };
}
function syncEditorGauntletRunwayBgUploadSummary() {
const el = document.getElementById('editor-gauntlet-runway-bg-upload-summary');
if (!el) return;
if (mapId !== EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID) {
el.textContent = '';
return;
}
const keys = ['startImage', 'loopImage2', 'loopImage3', 'loopImage4', 'finishImage'];
const labels = ['1 เริ่ม', '2 ลูป', '3 ลูป', '4 ลูป', '5 จบ'];
const parts = [];
for (let i = 0; i < keys.length; i++) {
const d = describeRunwaySlotStoredValue(editorGauntletRunwayBgConfig[keys[i]], editorGauntletRunwayBgFileNames[keys[i]]);
parts.push(`${labels[i]}: ${d.th} / ${d.en}`);
}
const tail = '\n\nพรีวิวรูปแถวด้านบน = สิ่งที่เกมจะใช้ (รวมรูปเซิร์ฟถ้าช่องว่าง)\n'
+ 'Thumbnails above = what the game uses (bundled defaults if slot empty).\n\n'
+ 'หลังเลือกไฟล์ ช่องจะกลับเป็น «No file chosen» เป็นปกติของเบราว์เซอร์\n'
+ 'After pick, the file box clears — use thumbnails + this text.\n\n'
+ 'ต้องกด บันทึกฉาก ถึงจะไปโชว์ในเกมจริง · Click Save scene for play mode.';
el.textContent = 'สถานะรูปรันเวย์ · Runway images\n' + parts.join('\n') + tail;
}
function readGauntletCrownRunwayBgForSave() {
/** บันทึกจากรหัสแมป — ไม่ผูกกับ dropdown โหมดอย่างเดียว (เดิมถ้าไม่ใช่ gauntlet จะไม่ส่ง → รีเฟรชแล้วหาย) · ส่งครบทุกคีย์เพราะ PUT แทนที่ทั้ง object */
if (mapId !== EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID) return undefined;
const en = document.getElementById('editor-gauntlet-runway-bg-enabled');
const spEl = document.getElementById('editor-gauntlet-runway-bg-speed');
const sp = Math.max(8, Math.min(400, Math.floor(Number(spEl && spEl.value)) || 48));
const s = (k) => {
const v = editorGauntletRunwayBgConfig[k];
return (typeof v === 'string' && v.length) ? v : null;
};
const afEl = document.getElementById('editor-gauntlet-runway-bg-align-frac');
const afv = Math.max(0.02, Math.min(0.95, Number(afEl && afEl.value) || 0.32));
return {
enabled: !!(en && en.checked),
speedPxPerSec: sp,
runwaySpawnAlignFrac: afv,
startImage: s('startImage'),
loopImage2: s('loopImage2'),
loopImage3: s('loopImage3'),
loopImage4: s('loopImage4'),
finishImage: s('finishImage'),
};
}
function readEditorBgScrollConfigForSave() {
if (!isEditorScrollBgMap()) return null;
const en = document.getElementById('editor-bg-scroll-enabled');
const spEl = document.getElementById('editor-bg-scroll-speed');
const sp = Math.max(8, Math.min(400, Math.floor(Number(spEl && spEl.value)) || 56));
const dirEl = document.getElementById('editor-bg-scroll-direction');
const out = {
enabled: !!(en && en.checked),
speedPxPerSec: sp,
scrollDirection: dirEl && dirEl.value === 'down' ? 'down' : 'up',
};
if (editorBgScrollConfig.introImage) out.introImage = editorBgScrollConfig.introImage;
if (editorBgScrollConfig.loopImage) out.loopImage = editorBgScrollConfig.loopImage;
return out;
}
function drawEditorScrollingBackground(cw, ch) {
const intro = editorBgScrollIntroImg;
const loop = editorBgScrollLoopImg;
if (!intro || !intro.complete || !loop || !loop.complete) return;
const cwR = Math.max(1, Math.round(cw));
const chR = Math.max(1, Math.round(ch));
const scaleI = cwR / intro.naturalWidth;
const drawHIntro = Math.round(intro.naturalHeight * scaleI);
const scaleL = cwR / loop.naturalWidth;
const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
const S = editorBgScrollPx;
const vp0 = S;
const vp1 = S + chR;
const yLimit = vp1 + drawHLoop * 2;
const flowDown = editorBgScrollConfig.scrollDirection === 'down';
function stripTopToDestY(stripTop, drawH) {
if (!flowDown) return stripTop - S;
return S + chR - (stripTop + drawH);
}
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.beginPath();
ctx.rect(0, 0, cwR, chR);
ctx.clip();
if (vp0 < 0) {
let k = 1;
if (vp0 < -drawHLoop * 4) {
k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
}
for (; k < 50000; k++) {
const y0 = -k * drawHLoop;
if (y0 >= vp1 + drawHLoop) break;
if (y0 + drawHLoop <= vp0) continue;
const dy = Math.round(stripTopToDestY(y0, drawHLoop));
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
}
}
if (drawHIntro > vp0 && vp1 > 0) {
const dy = Math.round(stripTopToDestY(0, drawHIntro));
ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
}
let y = drawHIntro;
if (y < yLimit && vp1 > drawHIntro) {
if (vp0 > drawHIntro) {
const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
y = drawHIntro + Math.max(0, n - 1) * drawHLoop;
}
while (y < yLimit) {
const dy = Math.round(stripTopToDestY(y, drawHLoop));
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
y += drawHLoop;
}
}
ctx.restore();
}
function editorBgScrollTick(ts) {
editorBgScrollRaf = null;
if (!isEditorScrollBgDrawing()) return;
if (!editorBgScrollLastTs) editorBgScrollLastTs = ts;
const dt = Math.min(64, Math.max(0, ts - editorBgScrollLastTs));
editorBgScrollLastTs = ts;
editorBgScrollPx += (editorBgScrollConfig.speedPxPerSec || 56) * (dt / 1000);
draw();
editorBgScrollRaf = requestAnimationFrame(editorBgScrollTick);
}
function startEditorBgScrollRaf() {
if (!isEditorScrollBgDrawing()) return;
stopEditorBgScrollRaf();
editorBgScrollLastTs = 0;
editorBgScrollRaf = requestAnimationFrame(editorBgScrollTick);
}
/** คลังรูป + สไปรต์ { i, x, y, w, h } — บันทึก gridImageSprites + gridImageCells (สำหรับรุ่นเก่า) */
let gridImageLibrary = [];
let gridImageSprites = [];
let gridImageBrushIndex = 0;
let pendingHeldLibIndex = -1;
const editorGridImageElByIndex = {};
const editorGridImageHeldElByIndex = {};
const GRID_IMG_MAX_WH = 24;
function gridLibIdleUrl(entry) {
if (entry == null) return '';
if (typeof entry === 'string') return entry;
if (typeof entry === 'object' && entry.idle) return String(entry.idle);
return '';
}
function gridLibHeldUrl(entry) {
if (entry == null || typeof entry === 'string') return '';
if (typeof entry === 'object' && entry.held && String(entry.held).length) return String(entry.held);
return '';
}
function normalizeGridLibEntry(raw) {
if (raw == null) return null;
if (typeof raw === 'string' && raw.length > 0) return { idle: raw, held: null };
if (typeof raw === 'object' && typeof raw.idle === 'string' && raw.idle.length > 0) {
let held = (typeof raw.held === 'string' && raw.held.length > 0) ? raw.held : null;
if (held && held === raw.idle) held = null;
return { idle: raw.idle, held };
}
return null;
}
function serializeGridLibEntry(entry) {
const idle = gridLibIdleUrl(entry);
if (!idle) return null;
const held = gridLibHeldUrl(entry);
if (held && held !== idle) return { idle, held };
return idle;
}
let isSpawnMode = false;
let editorFroggerAnimationId = null;
/** Bullet list for game-type hint (block layout; avoid one long inline line). */
function editorHintBulletList(items) {
if (!items || !items.length) return '';
return '<ul class="editor-hint-bullets">' + items.map(function (html) {
return '<li>' + html + '</li>';
}).join('') + '</ul>';
}
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 ensureStackAreas() {
if (!stackReleaseArea.length || stackReleaseArea.length !== height) {
const existingR = stackReleaseArea.slice().map(r => r && r.slice());
stackReleaseArea = [];
for (let y = 0; y < height; y++) {
const row = existingR[y] && existingR[y].length === width ? existingR[y].slice() : Array(width).fill(0);
stackReleaseArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!stackReleaseArea[y] || stackReleaseArea[y].length !== width) stackReleaseArea[y] = Array(width).fill(0);
}
}
if (!stackLandArea.length || stackLandArea.length !== height) {
const existingL = stackLandArea.slice().map(r => r && r.slice());
stackLandArea = [];
for (let y = 0; y < height; y++) {
const row = existingL[y] && existingL[y].length === width ? existingL[y].slice() : Array(width).fill(0);
stackLandArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!stackLandArea[y] || stackLandArea[y].length !== width) stackLandArea[y] = Array(width).fill(0);
}
}
}
function ensureQuizCarryAreas() {
if (!quizCarryHubArea.length || quizCarryHubArea.length !== height) {
const existingH = quizCarryHubArea.slice().map(r => r && r.slice());
quizCarryHubArea = [];
for (let y = 0; y < height; y++) {
const row = existingH[y] && existingH[y].length === width ? existingH[y].slice() : Array(width).fill(0);
quizCarryHubArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!quizCarryHubArea[y] || quizCarryHubArea[y].length !== width) quizCarryHubArea[y] = Array(width).fill(0);
}
}
if (!quizCarryOptionArea.length || quizCarryOptionArea.length !== height) {
const existingO = quizCarryOptionArea.slice().map(r => r && r.slice());
quizCarryOptionArea = [];
for (let y = 0; y < height; y++) {
const row = existingO[y] && existingO[y].length === width ? existingO[y].slice() : Array(width).fill(0);
quizCarryOptionArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!quizCarryOptionArea[y] || quizCarryOptionArea[y].length !== width) quizCarryOptionArea[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);
}
}
if (!carryEmbedCountdownArea.length || carryEmbedCountdownArea.length !== height) {
const existingCd = carryEmbedCountdownArea.slice().map(r => r && r.slice());
carryEmbedCountdownArea = [];
for (let y = 0; y < height; y++) {
const row = existingCd[y] && existingCd[y].length === width ? existingCd[y].slice() : Array(width).fill(0);
carryEmbedCountdownArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!carryEmbedCountdownArea[y] || carryEmbedCountdownArea[y].length !== width) carryEmbedCountdownArea[y] = Array(width).fill(0);
}
}
}
function ensureQuizBattleDomeArea() {
if (!quizBattleDomeArea.length || quizBattleDomeArea.length !== height) {
const existing = quizBattleDomeArea.slice().map(r => r && r.slice());
quizBattleDomeArea = [];
for (let y = 0; y < height; y++) {
const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0);
quizBattleDomeArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!quizBattleDomeArea[y] || quizBattleDomeArea[y].length !== width) quizBattleDomeArea[y] = Array(width).fill(0);
}
}
}
function ensureQuizBattlePathArea() {
if (!quizBattlePathArea.length || quizBattlePathArea.length !== height) {
const existing = quizBattlePathArea.slice().map(r => r && r.slice());
quizBattlePathArea = [];
for (let y = 0; y < height; y++) {
const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0);
quizBattlePathArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!quizBattlePathArea[y] || quizBattlePathArea[y].length !== width) quizBattlePathArea[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);
}
}
}
/** โหลดแมปแล้ว objects/ground แถวไม่ครบ → คลิกวาดพัง — เติมกริดให้ตรง width×height */
function ensureObjectsGroundGrids() {
const z = () => Array(width).fill(0);
const pad = (arr) => {
const ex = (arr && arr.slice) ? arr.map((r) => (r && r.slice ? r.slice() : null)) : [];
const out = [];
for (let y = 0; y < height; y++) {
const r = ex[y];
if (r && r.length === width) out.push(r.slice());
else {
const row = z();
if (r) for (let x = 0; x < Math.min(width, r.length); x++) if (r[x] != null) row[x] = r[x];
out.push(row);
}
}
return out;
};
objects = pad(objects);
ground = pad(ground);
}
function ensureShooterSpawnSlots() {
if (!shooterSpawnSlots.length || shooterSpawnSlots.length !== height) {
const existing = shooterSpawnSlots.slice().map((r) => r && r.slice());
shooterSpawnSlots = [];
for (let y = 0; y < height; y++) {
const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0);
shooterSpawnSlots.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!shooterSpawnSlots[y] || shooterSpawnSlots[y].length !== width) {
shooterSpawnSlots[y] = Array(width).fill(0);
}
}
}
}
function ensureBalloonBossPlayerSlots() {
if (!balloonBossPlayerSlots.length || balloonBossPlayerSlots.length !== height) {
const existing = balloonBossPlayerSlots.slice().map((r) => r && r.slice());
balloonBossPlayerSlots = [];
for (let y = 0; y < height; y++) {
const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0);
balloonBossPlayerSlots.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!balloonBossPlayerSlots[y] || balloonBossPlayerSlots[y].length !== width) {
balloonBossPlayerSlots[y] = Array(width).fill(0);
}
}
}
}
function ensureJumpSurvivePlatformArea() {
if (!jumpSurvivePlatformArea.length || jumpSurvivePlatformArea.length !== height) {
const existing = jumpSurvivePlatformArea.slice().map((r) => r && r.slice());
jumpSurvivePlatformArea = [];
for (let y = 0; y < height; y++) {
const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0);
jumpSurvivePlatformArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!jumpSurvivePlatformArea[y] || jumpSurvivePlatformArea[y].length !== width) {
jumpSurvivePlatformArea[y] = Array(width).fill(0);
}
}
}
ensureJumpSurvivePlatformVariantArea();
}
function ensureJumpSurvivePlatformVariantArea() {
if (!jumpSurvivePlatformVariantArea.length || jumpSurvivePlatformVariantArea.length !== height) {
const existing = jumpSurvivePlatformVariantArea.slice().map((r) => r && r.slice());
jumpSurvivePlatformVariantArea = [];
for (let y = 0; y < height; y++) {
const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0);
jumpSurvivePlatformVariantArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!jumpSurvivePlatformVariantArea[y] || jumpSurvivePlatformVariantArea[y].length !== width) {
jumpSurvivePlatformVariantArea[y] = Array(width).fill(0);
}
}
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (jumpSurvivePlatformArea[y] && jumpSurvivePlatformArea[y][x] === 1) {
let v = jumpSurvivePlatformVariantArea[y][x];
if (!Number.isFinite(v) || v < 1) jumpSurvivePlatformVariantArea[y][x] = 1;
else if (v > 3) jumpSurvivePlatformVariantArea[y][x] = 3;
} else {
jumpSurvivePlatformVariantArea[y][x] = 0;
}
}
}
}
function editorJumpSurviveVariantAtCell(x, y) {
if (!jumpSurvivePlatformVariantArea[y]) return 1;
let v = Math.floor(Number(jumpSurvivePlatformVariantArea[y][x]));
if (!Number.isFinite(v) || v < 1) v = 1;
if (v > 3) v = 3;
return v;
}
function ensureJumpSurviveHazardArea() {
if (!jumpSurviveHazardArea.length || jumpSurviveHazardArea.length !== height) {
const existing = jumpSurviveHazardArea.slice().map((r) => r && r.slice());
jumpSurviveHazardArea = [];
for (let y = 0; y < height; y++) {
const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0);
jumpSurviveHazardArea.push(row);
}
} else {
for (let y = 0; y < height; y++) {
if (!jumpSurviveHazardArea[y] || jumpSurviveHazardArea[y].length !== width) {
jumpSurviveHazardArea[y] = Array(width).fill(0);
}
}
}
}
function normalizeEditorJumpSurvivePlatformTileUrl(v) {
let s = String(v || '').trim().slice(0, 500);
if (!s) return '';
if (!/^https?:\/\//i.test(s) && s.charAt(0) !== '/') s = '/' + s.replace(/^\/+/, '');
return s;
}
function normalizeEditorJumpSurvivePlatformTilesFromTiming(data) {
const legacy = normalizeEditorJumpSurvivePlatformTileUrl(data.jumpSurvivePlatformTileUrl);
const arr = Array.isArray(data.jumpSurvivePlatformTiles) ? data.jumpSurvivePlatformTiles : [];
const out = [];
for (let i = 0; i < 3; i++) {
const o = arr[i] && typeof arr[i] === 'object' ? arr[i] : {};
let url = normalizeEditorJumpSurvivePlatformTileUrl(o.url);
if (i === 0 && !url && legacy) url = legacy;
let ww = Number(o.w);
let hh = Number(o.h);
if (!Number.isFinite(ww) || ww <= 0) ww = 0;
else ww = Math.max(8, Math.min(4096, Math.round(ww)));
if (!Number.isFinite(hh) || hh <= 0) hh = 0;
else hh = Math.max(8, Math.min(4096, Math.round(hh)));
out.push({ url, w: ww, h: hh });
}
return out;
}
function refreshEditorJumpSurvivePlatformTileFromTiming() {
editorJumpSurvivePlatformTimingGen += 1;
const gen = editorJumpSurvivePlatformTimingGen;
editorJumpSurvivePlatformTileImgs = [null, null, null];
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (!data || gen !== editorJumpSurvivePlatformTimingGen) return;
const gtNow = gameTypeEl ? gameTypeEl.value : gameType;
if (gtNow !== 'jump_survive') return;
editorJumpSurvivePlatformTilesCfg = normalizeEditorJumpSurvivePlatformTilesFromTiming(data);
let pending = 0;
for (let i = 0; i < 3; i++) {
const u = editorJumpSurvivePlatformTilesCfg[i].url;
if (!u) continue;
pending += 1;
const idx = i;
const im = new Image();
im.onload = function () {
if (gen !== editorJumpSurvivePlatformTimingGen) return;
editorJumpSurvivePlatformTileImgs[idx] = im;
pending -= 1;
if (pending <= 0) draw();
};
im.onerror = function () {
if (gen !== editorJumpSurvivePlatformTimingGen) return;
pending -= 1;
if (pending <= 0) draw();
};
im.src = u;
}
if (pending === 0) draw();
})
.catch(() => {});
}
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 sanitizeGauntletLaserRowsInEditor() {
if (gauntletLaserRowStart == null || gauntletLaserRowEnd == null) return;
let y0 = Math.floor(Number(gauntletLaserRowStart));
let y1 = Math.floor(Number(gauntletLaserRowEnd));
if (!Number.isFinite(y0)) y0 = 0;
if (!Number.isFinite(y1)) y1 = height - 1;
y0 = Math.max(0, Math.min(height - 1, y0));
y1 = Math.max(0, Math.min(height - 1, y1));
if (y1 < y0) { const t = y0; y0 = y1; y1 = t; }
gauntletLaserRowStart = y0;
gauntletLaserRowEnd = y1;
}
function supportsLobbySpawnPaint(gt) {
return gt === 'zep' || gt === 'lobby' || gt === 'quiz' || gt === 'quiz_carry' || gt === 'stack' || gt === 'jump_survive' || gt === 'quiz_battle';
}
function ensureLobbyPlayerSpawnsLength() {
while (lobbyPlayerSpawns.length < 6) lobbyPlayerSpawns.push(null);
lobbyPlayerSpawns.length = 6;
}
function lobbySpawnFootprintFitsInEditor(x, y) {
const fp = readCharacterFootprintInputs();
const effW = Math.max(1, Math.min(fp.cw, width - x));
const effH = Math.max(1, Math.min(fp.ch, height - y));
for (let ty = y; ty < y + effH; ty++) {
for (let tx = x; tx < x + effW; tx++) {
if (objects[ty] && objects[ty][tx] === 1) return false;
}
}
return true;
}
function sanitizeLobbyPlayerSpawnsInEditor() {
ensureLobbyPlayerSpawnsLength();
for (let i = 0; i < 6; i++) {
const s = lobbyPlayerSpawns[i];
if (!s) continue;
const x = Math.floor(Number(s.x));
const y = Math.floor(Number(s.y));
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= width || y < 0 || y >= height
|| !lobbySpawnFootprintFitsInEditor(x, y)) {
lobbyPlayerSpawns[i] = null;
} else {
lobbyPlayerSpawns[i] = { x, y };
}
}
}
function syncLobbySpawnAuxUi() {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
const wrap = document.getElementById('lobby-spawn-wrap');
const slotWrap = document.getElementById('lobby-slot-paint-wrap');
const btnClr = document.getElementById('btn-clear-lobby-spawns');
const modeEl = document.getElementById('lobby-spawn-mode');
const use = supportsLobbySpawnPaint(gt);
if (wrap) wrap.style.display = use ? 'flex' : 'none';
const slotsMode = !!(use && modeEl && modeEl.value === 'slots6');
if (slotWrap) slotWrap.style.display = slotsMode ? '' : 'none';
if (btnClr) btnClr.hidden = !slotsMode;
}
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 syncGridImageBrushInputCaps() {
const wEl = document.getElementById('grid-image-brush-w');
const hEl = document.getElementById('grid-image-brush-h');
const cap = Math.min(GRID_IMG_MAX_WH, width, height);
if (wEl) {
wEl.max = String(Math.max(1, cap));
wEl.min = '1';
const vw = parseInt(wEl.value, 10);
if (!Number.isFinite(vw) || vw < 1) wEl.value = '1';
else if (vw > cap) wEl.value = String(cap);
}
if (hEl) {
hEl.max = String(Math.max(1, cap));
hEl.min = '1';
const vh = parseInt(hEl.value, 10);
if (!Number.isFinite(vh) || vh < 1) hEl.value = '1';
else if (vh > cap) hEl.value = String(cap);
}
}
function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
function deriveGridImageCellsFromSprites() {
const cells = Array(height).fill(0).map(() => Array(width).fill(null));
gridImageSprites.forEach((s) => {
const i = s.i;
if (!Number.isFinite(i) || i < 0 || i >= gridImageLibrary.length) return;
for (let yy = s.y; yy < s.y + s.h; yy++) {
for (let xx = s.x; xx < s.x + s.w; xx++) {
if (yy >= 0 && yy < height && xx >= 0 && xx < width) cells[yy][xx] = i;
}
}
});
return cells;
}
function migrateSpritesFromMapJson(m, mapW, mapH) {
if (Array.isArray(m.gridImageSprites) && m.gridImageSprites.length) {
return m.gridImageSprites.map((raw) => {
const i = Math.max(0, Math.floor(Number(raw.i)));
const x = Math.floor(Number(raw.x));
const y = Math.floor(Number(raw.y));
const w = Math.max(1, Math.floor(Number(raw.w)) || 1);
const h = Math.max(1, Math.floor(Number(raw.h)) || 1);
const b = parseSpriteBindCarryOption(raw.bindCarryOption);
const o = { i, x, y, w, h };
if (b != null) o.bindCarryOption = b;
return o;
}).filter((s) => Number.isFinite(s.x) && Number.isFinite(s.y));
}
const libLen = (Array.isArray(m.gridImageLibrary) && m.gridImageLibrary.length) || 0;
const gic = m.gridImageCells;
const out = [];
if (!Array.isArray(gic) || !libLen) return out;
const mh = Number.isFinite(mapH) && mapH > 0 ? Math.floor(mapH) : gic.length;
const mw = Number.isFinite(mapW) && mapW > 0 ? Math.floor(mapW) : (gic[0] && gic[0].length) || 0;
for (let yy = 0; yy < Math.min(gic.length, mh); yy++) {
const row = gic[yy];
if (!row) continue;
for (let xx = 0; xx < Math.min(row.length, mw || row.length); xx++) {
if (row[xx] == null) continue;
const iv = parseInt(row[xx], 10);
if (!Number.isFinite(iv) || iv < 0 || iv >= libLen) continue;
out.push({ i: iv, x: xx, y: yy, w: 1, h: 1 });
}
}
return out;
}
function parseSpriteBindCarryOption(raw) {
if (raw == null || raw === '') return null;
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10);
if (!Number.isFinite(n) || n < 1 || n > QUIZ_CARRY_EDITOR_OPT_COUNT) return null;
return n;
}
function readSpriteCarryBindFromSelect() {
const el = document.getElementById('grid-sprite-carry-bind');
if (!el) return null;
const v = String(el.value || '').trim();
if (v === '' || v === 'auto') return null;
return parseSpriteBindCarryOption(v);
}
function clampSpriteToMap(s) {
let { i, x, y, w, h, bindCarryOption } = s;
if (x < 0) { w += x; x = 0; }
if (y < 0) { h += y; y = 0; }
if (x + w > width) w = width - x;
if (y + h > height) h = height - y;
w = Math.max(1, w);
h = Math.max(1, h);
const out = { i, x, y, w, h };
const b = parseSpriteBindCarryOption(bindCarryOption);
if (b != null) out.bindCarryOption = b;
return out;
}
function sanitizeSpritesInPlace() {
gridImageSprites = gridImageSprites.map(clampSpriteToMap).filter((sp) => {
if (!Number.isFinite(sp.i) || sp.i < 0 || sp.i >= gridImageLibrary.length) return false;
if (sp.w < 1 || sp.h < 1) return false;
return sp.x < width && sp.y < height && sp.x + sp.w > 0 && sp.y + sp.h > 0;
});
}
function removeSpritesOverlappingRect(x0, y0, w0, h0) {
gridImageSprites = gridImageSprites.filter((sp) => !rectsOverlap(x0, y0, w0, h0, sp.x, sp.y, sp.w, sp.h));
}
function findSpriteIndexAtCell(px, py) {
for (let k = gridImageSprites.length - 1; k >= 0; k--) {
const s = gridImageSprites[k];
if (px >= s.x && py >= s.y && px < s.x + s.w && py < s.y + s.h) return k;
}
return -1;
}
function canPlaceSpriteAt(anchorX, anchorY, sw, sh, libIdx) {
if (!Number.isFinite(libIdx) || libIdx < 0 || libIdx >= gridImageLibrary.length) return null;
const ax = Math.max(0, Math.min(Math.floor(anchorX), width - 1));
const ay = Math.max(0, Math.min(Math.floor(anchorY), height - 1));
const cap = Math.min(GRID_IMG_MAX_WH, width, height);
let w = Math.max(1, Math.min(Math.floor(sw) || 1, width - ax, cap));
let h = Math.max(1, Math.min(Math.floor(sh) || 1, height - ay, cap));
for (let yy = ay; yy < ay + h; yy++) {
for (let xx = ax; xx < ax + w; xx++) {
if (objects[yy][xx] === 1) return null;
}
}
const out = { i: libIdx, x: ax, y: ay, w, h };
const b = readSpriteCarryBindFromSelect();
if (b != null) out.bindCarryOption = b;
return out;
}
function rebuildGridImageGallery() {
const gal = document.getElementById('grid-cell-image-gallery');
if (!gal) return;
gal.innerHTML = '';
if (!gridImageLibrary.length) {
gal.innerHTML = '<span class="grid-cell-image-gallery-empty">ยังไม่มีรูป — อัปโหลดด้านล่าง</span>';
return;
}
if (gridImageBrushIndex >= gridImageLibrary.length) gridImageBrushIndex = gridImageLibrary.length - 1;
gridImageLibrary.forEach((entry, idx) => {
const row = document.createElement('div');
row.className = 'grid-cell-image-gallery-row';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'grid-cell-image-gallery-item' + (idx === gridImageBrushIndex ? ' is-selected' : '');
btn.title = 'รูป #' + (idx + 1) + ' (ก่อนหยิบ) — คลิกเลือกแปรง';
btn.dataset.libIndex = String(idx);
const thumb = document.createElement('img');
thumb.alt = '';
thumb.src = gridLibIdleUrl(entry);
thumb.loading = 'lazy';
btn.appendChild(thumb);
const cap = document.createElement('span');
cap.className = 'grid-cell-image-gallery-cap';
cap.textContent = '#' + (idx + 1);
btn.appendChild(cap);
btn.addEventListener('click', () => {
gridImageBrushIndex = idx;
Array.from(gal.querySelectorAll('.grid-cell-image-gallery-item')).forEach((el) => {
el.classList.toggle('is-selected', el.dataset.libIndex === String(idx));
});
});
const heldSide = document.createElement('div');
heldSide.className = 'grid-cell-image-gallery-held-side';
const heldLabel = document.createElement('span');
heldLabel.className = 'grid-cell-image-gallery-held-label';
heldLabel.textContent = 'หยิบแล้ว';
heldSide.appendChild(heldLabel);
const heldUrl = gridLibHeldUrl(entry);
if (heldUrl) {
const him = document.createElement('img');
him.className = 'grid-cell-image-gallery-held-thumb';
him.alt = '';
him.src = heldUrl;
him.loading = 'lazy';
heldSide.appendChild(him);
}
const btnHeld = document.createElement('button');
btnHeld.type = 'button';
btnHeld.className = 'grid-cell-image-gallery-held-btn';
btnHeld.textContent = heldUrl ? 'เปลี่ยนรูปถือ' : 'ใส่รูปถือ';
btnHeld.title = 'รูปบนแมปเมื่อมีคนหยิบตัวเลือกในช่องนี้ (quiz carry) จนกว่าจะปล่อย';
btnHeld.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
pendingHeldLibIndex = idx;
const inp = document.getElementById('grid-cell-image-held-upload');
if (inp) inp.click();
});
heldSide.appendChild(btnHeld);
if (heldUrl) {
const clr = document.createElement('button');
clr.type = 'button';
clr.className = 'grid-cell-image-gallery-clear-held';
clr.title = 'ลบรูปตอนถือ';
clr.textContent = '×';
clr.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
gridImageLibrary[idx] = { idle: gridLibIdleUrl(gridImageLibrary[idx]), held: null };
delete editorGridImageHeldElByIndex[idx];
rebuildGridImageGallery();
draw();
if (statusEl) statusEl.textContent = 'ลบรูปตอนถือของ #' + (idx + 1) + ' แล้ว';
});
heldSide.appendChild(clr);
}
row.appendChild(btn);
row.appendChild(heldSide);
if (heldUrl) {
const idleStr = gridLibIdleUrl(entry);
if (idleStr && heldUrl === idleStr) {
const sameHint = document.createElement('div');
sameHint.className = 'grid-cell-image-gallery-same-hint';
sameHint.textContent = 'รูปก่อนหยิบกับหยิบแล้วเป็นข้อมูลเดียวกัน — อัปโหลดภาพคนละไฟล์สำหรับ «หยิบแล้ว» จึงจะเห็นต่างในเกม';
row.appendChild(sameHint);
}
}
gal.appendChild(row);
primeEditorGridImage(idx);
primeEditorGridImageHeld(idx);
});
}
function primeEditorGridImage(idx) {
const idle = gridLibIdleUrl(gridImageLibrary[idx]);
if (editorGridImageElByIndex[idx] || !idle) return;
const im = new Image();
im.onload = () => { draw(); };
im.onerror = () => { delete editorGridImageElByIndex[idx]; };
im.src = idle;
editorGridImageElByIndex[idx] = im;
}
function primeEditorGridImageHeld(idx) {
const held = gridLibHeldUrl(gridImageLibrary[idx]);
if (!held) {
delete editorGridImageHeldElByIndex[idx];
return;
}
if (editorGridImageHeldElByIndex[idx]) return;
const im = new Image();
im.onload = () => { draw(); };
im.onerror = () => { delete editorGridImageHeldElByIndex[idx]; };
im.src = held;
editorGridImageHeldElByIndex[idx] = im;
}
function removeGridImageFromLibraryAt(index) {
const i = parseInt(index, 10);
if (!Number.isFinite(i) || i < 0 || i >= gridImageLibrary.length) return;
gridImageLibrary.splice(i, 1);
gridImageSprites = gridImageSprites
.map((s) => {
if (s.i === i) return null;
if (s.i > i) return { ...s, i: s.i - 1 };
return s;
})
.filter(Boolean);
if (gridImageBrushIndex >= gridImageLibrary.length) gridImageBrushIndex = Math.max(0, gridImageLibrary.length - 1);
Object.keys(editorGridImageElByIndex).forEach((k) => { delete editorGridImageElByIndex[k]; });
Object.keys(editorGridImageHeldElByIndex).forEach((k) => { delete editorGridImageHeldElByIndex[k]; });
sanitizeSpritesInPlace();
rebuildGridImageGallery();
syncGridImageBrushInputCaps();
draw();
if (statusEl) statusEl.textContent = 'ลบรูปออกจากคลังแล้ว';
}
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 = '<thead><tr><th>แถว (y)</th><th>ประเภท</th><th>ความเร็ว</th><th>ทิศทาง</th><th>ระยะห่าง</th></tr></thead><tbody></tbody>';
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 =
'<td>' + lane.y + '</td>' +
'<td><select class="lane-type" data-y="' + lane.y + '">' +
'<option value="spawn"' + (lane.type === 'spawn' ? ' selected' : '') + '>จุดเกิด</option>' +
'<option value="safe"' + (lane.type === 'safe' ? ' selected' : '') + '>ปลอดภัย</option>' +
'<option value="road"' + (lane.type === 'road' ? ' selected' : '') + '>ถนน (รถ)</option>' +
'<option value="water"' + (lane.type === 'water' ? ' selected' : '') + '>น้ำ (ท่อนซุง)</option>' +
'<option value="goal"' + (lane.type === 'goal' ? ' selected' : '') + '>ปลายทาง</option>' +
'</select></td>' +
'<td><input type="number" class="lane-speed" data-y="' + lane.y + '" min="0.5" max="5" step="0.5" value="' + (lane.speed != null ? lane.speed : 1) + '" ' + (isRoadOrWater ? '' : ' disabled') + '></td>' +
'<td><select class="lane-dir" data-y="' + lane.y + '" ' + (isRoadOrWater ? '' : ' disabled') + '>' +
'<option value="1"' + (lane.dir === 1 ? ' selected' : '') + '>ขวา</option>' +
'<option value="-1"' + (lane.dir === -1 ? ' selected' : '') + '>ซ้าย</option>' +
'</select></td>' +
'<td><input type="number" class="lane-spacing" data-y="' + lane.y + '" min="1.5" max="8" step="0.5" value="' + spacingVal + '" title="ระยะห่างรถ/ท่อนซุง (ยิ่งมากยิ่งห่าง)" ' + (isRoadOrWater ? '' : ' disabled') + '></td>';
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() {
syncEditorGauntletRunwayBgBlockVisibility();
syncStackTowerCamBlockVisibilityOnly();
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');
const dqStackR = document.getElementById('draw-mode-option-stack-release');
const dqStackL = document.getElementById('draw-mode-option-stack-land');
const dqCarryOpts = document.querySelectorAll('.quiz-carry-mode-opt');
const dqBattleDome = document.getElementById('draw-mode-option-quiz-battle-dome');
const dqBattlePath = document.getElementById('draw-mode-option-quiz-battle-path');
if (!froggerWrap) return;
const gt = gameTypeEl ? gameTypeEl.value : 'zep';
if (drawModeEl && drawModeEl.value === 'jumpSurvivePlatform') drawModeEl.value = 'jumpSurvivePlatform1';
if (dqTrue) dqTrue.hidden = gt !== 'quiz';
if (dqFalse) dqFalse.hidden = gt !== 'quiz';
if (dqQ) dqQ.hidden = gt !== 'quiz' && gt !== 'quiz_carry';
if (dqStackR) dqStackR.hidden = gt !== 'stack';
if (dqStackL) dqStackL.hidden = gt !== 'stack';
const showCarry = gt === 'quiz_carry';
dqCarryOpts.forEach(function (node) { node.hidden = !showCarry; });
const carryCdWrap = document.getElementById('editor-quiz-carry-countdown-wrap');
if (carryCdWrap) carryCdWrap.style.display = showCarry ? 'flex' : 'none';
if (dqBattleDome) {
dqBattleDome.hidden = gt !== 'quiz_battle';
if (gt !== 'quiz_battle' && drawModeEl && drawModeEl.value === 'quizBattleDome') drawModeEl.value = 'wall';
}
if (dqBattlePath) {
dqBattlePath.hidden = gt !== 'quiz_battle';
if (gt !== 'quiz_battle' && drawModeEl && drawModeEl.value === 'quizBattlePath') drawModeEl.value = 'wall';
}
if (hint) hint.style.removeProperty('display');
if (gt === 'frogger') {
froggerWrap.style.display = 'block';
if (legEl) legEl.innerHTML = 'แถวบน (y=0) = ปลายทาง · แถวล่าง = จุดเกิด · ถนน/น้ำ ตั้งความเร็ว ทิศทาง และ <strong>ระยะห่าง</strong> (ยิ่งมากยิ่งห่าง เกมง่ายขึ้น)';
if (hint) {
hint.innerHTML = editorHintBulletList([
'แถวบน (y=0) = <strong>ปลายทาง</strong>',
'แถวล่าง = <strong>จุดเกิด</strong>',
'ตั้งถนน/น้ำด้านล่าง — ความเร็ว ทิศทาง ระยะห่างรถหรือท่อนซุง',
]);
}
ensureLanes();
renderLanesTable();
draw();
if (editorFroggerAnimationId != null) cancelAnimationFrame(editorFroggerAnimationId);
editorFroggerAnimationId = requestAnimationFrame(editorFroggerTick);
} else if (gt === 'gauntlet') {
froggerWrap.style.display = 'none';
if (legEl) legEl.innerHTML = '<strong>พรมแดงสุดท้าย:</strong> ลำดับเกิดกำหนดแค่ <strong>แถว (y)</strong> — ในเกมทุกคนยืน <strong>คอลัมน์ x เดียวกัน</strong> (ซ้ายสุดจากจุดที่วาด) ไม่เฉียง';
if (hint) {
hint.innerHTML = '<p class="editor-hint-lead">โหมด <strong>พรมแดงสุดท้าย</strong></p>' + editorHintBulletList([
'อุปสรรคเลนเฉพาะ<strong>แถวที่มีคน</strong> + เลเซอร์ทุกแถว · ชนแล้วถอยซ้าย',
'โหมดวาด <strong>เลเซอร์: แถวต้น / แถวปลาย</strong> — กำหนดความสูงแถบเลเซอร์บนแมป (ไม่ต้องเปลี่ยนรูปจาก Admin)',
'ลำดับเกิด <strong>16</strong> หรือช่องฟ้า',
'กระโดด: <kbd>Space</kbd> · <kbd>W</kbd> · <kbd>↑</kbd>',
]);
}
if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; }
draw();
} else if (gt === 'lobby') {
froggerWrap.style.display = 'none';
if (hint) {
hint.innerHTML = editorHintBulletList([
'วาดฉาก<strong>ห้องโถง</strong> — รอผู้เล่นและตกแต่งฉาก',
'<strong>พื้นที่เริ่มเกม (ส้ม)</strong> — host ยืนในโซนนี้ก่อนกดเริ่ม',
'<strong>พื้นที่สุ่มจุดเกิด (ฟ้าอ่อน)</strong> — ผู้เล่นใหม่สุ่มเกิดในช่องที่วาด',
]);
}
if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; }
draw();
} else if (gt === 'quiz') {
froggerWrap.style.display = 'none';
if (hint) {
hint.innerHTML = editorHintBulletList([
'<strong>โซนถูก (ฟ้า)</strong> · <strong>โซนผิด (ชมพู)</strong> · <strong>พื้นที่คำถาม (ทอง)</strong>',
'<strong>พื้นที่สุ่มจุดเกิด (ฟ้าอ่อน)</strong> — ผู้เล่นเข้าห้องสุ่มยืนในโซน',
'คำถามและเวลา — ตั้งที่ <strong>Admin → คำถามเกม</strong>',
]);
}
if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; }
draw();
} else if (gt === 'stack') {
froggerWrap.style.display = 'none';
if (hint) {
hint.innerHTML = '<p class="editor-hint-lead"><strong>Stack</strong> — สลับจังหวะลงตึก</p>' + editorHintBulletList([
'<strong>จุดปล่อย (ฟ้า)</strong> — แนวสวิง crane ด้านบน',
'<strong>จุดซ้อนตึก (ชมพู)</strong> — แพลตฟอร์มรองรับบล็อก',
'ในเกมไม่มีเดิน — กด <kbd>Space</kbd> เพื่อปล่อยบล็อก',
]);
}
if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; }
draw();
} else if (gt === 'quiz_carry') {
froggerWrap.style.display = 'none';
if (hint) {
hint.innerHTML = '<p class="editor-hint-lead"><strong>หยิบมาวาง</strong> — หยิบตัวเลือกแล้วไปส่งที่โซน interactive (เขียว)</p>' + editorHintBulletList([
'<strong>โซนกลาง (ม่วง)</strong> — กำแพง · ถ้าไม่วาดโซนทองด้านล่าง ข้อความคำถามจะโชว์บนโซนม่วงในเกม',
'<strong>พื้นที่โชว์ข้อความคำถาม (ทอง)</strong> — วาดโซนทองแยกได้ · ในเกมข้อความจะอยู่ตรงโซนที่วาด',
'<strong>โซน interactive (เขียว)</strong> — ยืนบนหรือชิดขอบแล้วกด <kbd>F</kbd> ส่งคำตอบ (วาดอย่างน้อย 1 ช่อง — ถ้าไม่วาดจะใช้ชิดโซนกลางแทน)',
'<strong>ตัวเลือก 116</strong> — วาดบนกริดให้ตรงลำดับ <code>choices</code> (Admin / แมป)',
'พรีวิวเอดิเตอร์: ช่วงนับ <strong>3-2-1</strong> — เลือกตำแหน่ง (กลางจอ / บนแมปอัตโนมัติที่โซนทองหรือโซนกลาง / <strong>ตามกริด</strong> โหมดวาด «ตำแหน่งเลข 3-2-1») · ไฮไลต์ข้อถูกด้านบน · ต่อข้อ <code>countdownHighlightSlot</code> (116)',
'เล่นพร้อมกันได้ · คำถามจากแมปหรือ Admin',
]);
}
if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; }
draw();
} else if (gt === 'jump_survive') {
refreshEditorJumpSurvivePlatformTileFromTiming();
froggerWrap.style.display = 'none';
if (hint) {
hint.innerHTML = '<p class="editor-hint-lead"><strong>กระโดดให้รอด</strong> — กำแพง + แพลตฟอร์มเลื่อนขึ้นในกรอบจอ</p>' + editorHintBulletList([
'<strong>กำแพง</strong> = ขอบซ้ายขวา/บน (ชนแล้วตายหรือออกนอกจอ) · <strong>โหมดวาดแพลตฟอร์ม</strong> (ฟ้าอมเขียว) = ท่อนยืนได้ — ตอนเล่นแพลตฟอร์มจะเลื่อนขึ้นในกรอด ไม่ลากทั้งฉาก',
'<strong>โซนตาย (แดง)</strong> — วาดช่องที่ตัวละคร<strong>แตะแล้วตายทันที</strong> (ลาวา/glitch) · อยู่คงที่ในโลก ไม่เลื่อนไปกับแพลตฟอร์ม',
'ในเกม: กระโดดสูงขึ้น · แพลตฟอร์มเลื่อนขึ้นแล้ว<strong>วนกลับมาด้านล่าง</strong> (คาบ = ความสูงแมป) · กล้องตามตัว — หลุดขอบบน/ล่างจอ = กลับจุดเกิด',
'พื้นที่สุ่มเกิด (ฟ้า) + ปุ่มตั้งจุดเกิด — แนะนำสูงหลายแถวเพื่อให้มีที่กระโดด',
]);
}
if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; }
draw();
} else if (gt === 'space_shooter') {
froggerWrap.style.display = 'none';
if (hint) {
hint.innerHTML = '<p class="editor-hint-lead"><strong>ยิงยานอวกาศ</strong> — หินตก · ผู้เล่น/บอทยิงขึ้น · สูงสุด 6 คน</p>' + editorHintBulletList([
'เลือกโหมดวาด <strong>จุดเกิดยาน 16</strong> แล้วเลือกหมายเลข P ด้านขวา — คลิกซ้ายวาง · คลิกขวาลบช่อง · ช่องเดียวต่อหมายเลข (วางซ้ำจะย้ายจุด)',
'ลำดับผู้เล่นในเกม: <strong>คุณก่อน</strong> แล้วตามรหัสผู้เล่นอื่น แล้วบอททดสอบ — ตรงกับ P1, P2, …',
'แนะนำ <strong>รูปพื้นหลัง</strong> อวกาศ + ปิดโชว์กริดในเกม — เล่น: A/D หรือ ←/→ เลื่อน · W/S หรือ ↑/↓ ขึ้นลง (ไม่เกินครึ่งล่างแมป) · Space ยิง',
]);
}
if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; }
draw();
} else if (gt === 'balloon_boss') {
froggerWrap.style.display = 'none';
if (hint) {
hint.innerHTML = '<p class="editor-hint-lead"><strong>ลูกโป้งยิงบอส (Mega Virus)</strong> — สูงสุด 6 คน · บอสกลางจอ</p>' + editorHintBulletList([
'โหมด <strong>จุดเกิดผู้เล่น 16</strong> + เลข P ด้านขวา — คลิกซ้ายวาง · ขวาลบ (เหมือนยิงยาน)',
'โหมด <strong>จุดเกิดบอส</strong> — คลิกช่อง <strong>หนึ่งช่อง</strong> เป็นตำแหน่งศูนย์กลางบอส (กะโหลก + โล่หกเหลี่ยม)',
'ในเกม: คุมแบบ<strong>ยานมีแรงเฉื่อย</strong> (กดทิศแล้วค่อยเร่ง ปล่อยแล้วยังไหล มีแรงหน่วง) — ไม่สแนปทันที · Space ยิงมี<strong>ดีเลย์</strong> · บอสยิงกลับ · ลูกโป้งหมด = ตกรอบ',
'อัปโหลดรูปทาง FTP: โฟลเดอร์ <code>/Game/public/img/MegaVirus</code> — <strong>ไม่มีช่องว่าง</strong> · ชื่อ <code>Mega Virus</code> มักได้ <strong>550</strong>',
]);
}
if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; }
draw();
} else if (gt === 'quiz_battle') {
froggerWrap.style.display = 'none';
if (hint) {
hint.innerHTML = '<p class="editor-hint-lead"><strong>Quiz Battle</strong> — เส้นทาง + โดมถาม A / B / C</p>' + editorHintBulletList([
'โหมด <strong>เส้นทางเดิน (ม่วง)</strong> — วาดซิกแซกเหมือนทางในเกม · ถ้ามีอย่างน้อย 1 ช่อง ผู้เล่น<strong>เดินได้เฉพาะบนเส้นทาง</strong> · ไม่วาดเลย = เดินอิสระทั้งแมป',
'โหมด <strong>โดมคำถาม</strong> — วางบนเส้นทาง (หรือทั่วแมปถ้าไม่ใช้เส้นทาง) · ในเกมกด <kbd>E</kbd>',
'รูป <strong>พื้นหลัง</strong> เปลี่ยนทีหลังได้ — ตรรกะเส้นทาง/โดมอยู่ที่กริด ไม่ผูกกราฟิก',
'ข้อสอบจาก <strong>Admin → Quiz Battle</strong> (<code>battleQuizMcq</code>) · แนะนำ <strong>พื้นที่สุ่มจุดเกิด</strong> ให้อยู่บนเส้นทางหรือใกล้จุดเริ่ม',
]);
}
if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; }
draw();
} else {
froggerWrap.style.display = 'none';
if (hint) {
hint.innerHTML = '<p class="editor-hint-lead">เลือกโหมดด้านบน — แต่ละโหมดวาดคนละแบบ</p>' + editorHintBulletList([
'<strong>ZEP</strong> — เดินอิสระบนแผนที่',
'<strong>กบข้ามถนน</strong> — ตารางแถว / ถนน น้ำ',
'<strong>พรมแดงสุดท้าย</strong> — กระโดดหลบ (ไม่ใช่ตารางแถวแบบกบ)',
'<strong>Stack</strong> — จุดปล่อย (ฟ้า) + จุดซ้อน (ชมพู)',
'<strong>กระโดดให้รอด</strong> — แพลตฟอร์มรอดชีวิต',
'<strong>ยิงยานอวกาศ</strong> — หินตก · จุดเกิด P1–P6',
'<strong>ลูกโป้งยิงบอส</strong> — จุดเกิดผู้เล่น + บอส · ดีเลย์ยิง · ลูกโป้ง = ชีวิต',
'<strong>Quiz Battle</strong> — โดม + กด <kbd>E</kbd> ตอบ A B C',
'<strong>ห้องโถง</strong> — พื้นที่เริ่มเกม (ส้ม) · <strong>ตอบคำถาม</strong> — โซนถูก–ผิด + คำถาม',
'<strong>หยิบมาวาง</strong> — โซนกลาง + ตัวเลือก 116 · <strong>ฟ้าอ่อน</strong> = สุ่มจุดเกิด',
]);
}
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';
const glStartOpt = document.getElementById('draw-mode-option-gauntlet-laser-start');
const glEndOpt = document.getElementById('draw-mode-option-gauntlet-laser-end');
const btnClrGl = document.getElementById('btn-clear-gauntlet-laser-span');
if (glStartOpt) {
glStartOpt.hidden = gt !== 'gauntlet';
if (gt !== 'gauntlet' && drawModeEl && drawModeEl.value === 'gauntletLaserStart') drawModeEl.value = 'wall';
}
if (glEndOpt) {
glEndOpt.hidden = gt !== 'gauntlet';
if (gt !== 'gauntlet' && drawModeEl && drawModeEl.value === 'gauntletLaserEnd') drawModeEl.value = 'wall';
}
if (btnClrGl) btnClrGl.hidden = gt !== 'gauntlet';
const shOpt = document.getElementById('draw-mode-option-shooter-spawn');
const shWrap = document.getElementById('shooter-slot-paint-wrap');
const btnClrSh = document.getElementById('btn-clear-shooter-spawns');
const shJump = gt === 'space_shooter' || gt === 'jump_survive';
if (shOpt) {
shOpt.hidden = !shJump;
if (!shJump && drawModeEl && drawModeEl.value === 'shooterSpawnPaint') drawModeEl.value = 'wall';
}
if (shWrap) shWrap.style.display = shJump ? '' : 'none';
if (btnClrSh) btnClrSh.hidden = !shJump;
const bbPlayerOpt = document.getElementById('draw-mode-option-balloon-boss-player');
const bbBossOpt = document.getElementById('draw-mode-option-balloon-boss-boss');
const bbWrap = document.getElementById('balloon-boss-slot-paint-wrap');
const btnClrBb = document.getElementById('btn-clear-balloon-boss-spawns');
if (bbPlayerOpt) {
bbPlayerOpt.hidden = gt !== 'balloon_boss';
if (gt !== 'balloon_boss' && drawModeEl && drawModeEl.value === 'balloonBossPlayerPaint') drawModeEl.value = 'wall';
}
if (bbBossOpt) {
bbBossOpt.hidden = gt !== 'balloon_boss';
if (gt !== 'balloon_boss' && drawModeEl && drawModeEl.value === 'balloonBossBossPaint') drawModeEl.value = 'wall';
}
if (bbWrap) bbWrap.style.display = gt === 'balloon_boss' ? '' : 'none';
if (btnClrBb) btnClrBb.hidden = gt !== 'balloon_boss';
if (drawModeEl && drawModeEl.value === 'balloonBossPlayerPaint' && gt !== 'balloon_boss') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'balloonBossBossPaint' && gt !== 'balloon_boss') drawModeEl.value = 'wall';
if (drawModeEl && (drawModeEl.value === 'quizTrue' || drawModeEl.value === 'quizFalse') && gt !== 'quiz') {
drawModeEl.value = 'wall';
}
if (drawModeEl && drawModeEl.value === 'quizQuestion' && gt !== 'quiz' && gt !== 'quiz_carry') {
drawModeEl.value = 'wall';
}
if (drawModeEl && (drawModeEl.value === 'stackRelease' || drawModeEl.value === 'stackLand') && gt !== 'stack') {
drawModeEl.value = 'wall';
}
const carryModes = quizCarryEditorCarryModes();
if (drawModeEl && carryModes.indexOf(drawModeEl.value) >= 0 && gt !== 'quiz_carry') {
drawModeEl.value = 'wall';
}
if (drawModeEl && (drawModeEl.value === 'quizBattleDome' || drawModeEl.value === 'quizBattlePath') && gt !== 'quiz_battle') {
drawModeEl.value = 'wall';
}
const jPlat1 = document.getElementById('draw-mode-option-jump-platform-1');
const jPlat2 = document.getElementById('draw-mode-option-jump-platform-2');
const jPlat3 = document.getElementById('draw-mode-option-jump-platform-3');
const jHazOpt = document.getElementById('draw-mode-option-jump-hazard');
const showJumpPlat = gt === 'jump_survive';
if (jPlat1) jPlat1.hidden = !showJumpPlat;
if (jPlat2) jPlat2.hidden = !showJumpPlat;
if (jPlat3) jPlat3.hidden = !showJumpPlat;
if (!showJumpPlat && drawModeEl && /^jumpSurvivePlatform\d$/.test(drawModeEl.value)) drawModeEl.value = 'wall';
if (jHazOpt) {
jHazOpt.hidden = gt !== 'jump_survive';
if (gt !== 'jump_survive' && drawModeEl && drawModeEl.value === 'jumpSurviveHazard') drawModeEl.value = 'wall';
}
if (drawModeEl && drawModeEl.value === 'shooterSpawnPaint' && gt !== 'space_shooter' && gt !== 'jump_survive') drawModeEl.value = 'wall';
const lobbySpawnOpt = document.getElementById('draw-mode-option-lobby-spawn');
if (lobbySpawnOpt) {
const useLobby = supportsLobbySpawnPaint(gt);
lobbySpawnOpt.hidden = !useLobby;
if (!useLobby && drawModeEl && drawModeEl.value === 'lobbyPlayerSpawn') drawModeEl.value = 'wall';
}
syncLobbySpawnAuxUi();
}
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));
quizCarryHubArea = Array(height).fill(0).map(() => Array(width).fill(0));
quizCarryOptionArea = Array(height).fill(0).map(() => Array(width).fill(0));
carryEmbedCountdownArea = Array(height).fill(0).map(() => Array(width).fill(0));
quizBattleDomeArea = Array(height).fill(0).map(() => Array(width).fill(0));
quizBattlePathArea = Array(height).fill(0).map(() => Array(width).fill(0));
stackReleaseArea = Array(height).fill(0).map(() => Array(width).fill(0));
stackLandArea = Array(height).fill(0).map(() => Array(width).fill(0));
jumpSurvivePlatformArea = Array(height).fill(0).map(() => Array(width).fill(0));
jumpSurvivePlatformVariantArea = Array(height).fill(0).map(() => Array(width).fill(0));
jumpSurviveHazardArea = Array(height).fill(0).map(() => Array(width).fill(0));
shooterSpawnSlots = Array(height).fill(0).map(() => Array(width).fill(0));
balloonBossPlayerSlots = Array(height).fill(0).map(() => Array(width).fill(0));
balloonBossBossSpawn = null;
cellColors = Array(height).fill(0).map(() => Array(width).fill(null));
gridImageLibrary = [];
gridImageSprites = [];
gridImageBrushIndex = 0;
Object.keys(editorGridImageElByIndex).forEach((k) => { delete editorGridImageElByIndex[k]; });
Object.keys(editorGridImageHeldElByIndex).forEach((k) => { delete editorGridImageHeldElByIndex[k]; });
rebuildGridImageGallery();
syncGridImageBrushInputCaps();
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' || gameType === 'quiz_carry' || gameType === 'quiz_battle' || gameType === 'jump_survive' || gameType === 'space_shooter' || gameType === 'balloon_boss') lanes = [];
jumpSurvivePlatforms = [];
gauntletPlayerSpawns = [];
gauntletLaserRowStart = null;
gauntletLaserRowEnd = null;
lobbyPlayerSpawns = [null, null, null, null, null, null];
const lsmInit = document.getElementById('lobby-spawn-mode');
if (lsmInit) lsmInit.value = 'random';
const fpNew = readCharacterFootprintInputs();
applyCharacterFootprintInputs(fpNew.cw, fpNew.ch);
mapW.value = width; mapH.value = height; tileSizeEl.value = tileSize;
resize(); draw();
toggleFroggerUI();
}
function resize() {
canvas.width = width * tileSize;
canvas.height = height * tileSize;
sanitizeGauntletPlayerSpawns();
sanitizeLobbyPlayerSpawnsInEditor();
ensureShooterSpawnSlots();
ensureBalloonBossPlayerSlots();
sanitizeSpritesInPlace();
syncGridImageBrushInputCaps();
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 isQuizCarry = gtDraw === 'quiz_carry';
const isQuizBattle = gtDraw === 'quiz_battle';
const isJumpSurvive = gtDraw === 'jump_survive';
const isSpaceShooter = gtDraw === 'space_shooter';
const isBalloonBoss = gtDraw === 'balloon_boss';
const usesLanes = isFrogger;
if (usesLanes) ensureLanes();
ensureInteractive();
ensureStartGameArea();
ensureSpawnArea();
if (gtDraw === 'quiz') ensureQuizAreas();
if (gtDraw === 'quiz_carry') ensureQuizCarryAreas();
if (gtDraw === 'quiz_battle') {
ensureQuizBattlePathArea();
ensureQuizBattleDomeArea();
}
if (gtDraw === 'stack') ensureStackAreas();
if (gtDraw === 'jump_survive') {
ensureJumpSurvivePlatformArea();
ensureJumpSurviveHazardArea();
}
if (gtDraw === 'space_shooter') ensureShooterSpawnSlots();
if (gtDraw === 'balloon_boss') ensureBalloonBossPlayerSlots();
if (isEditorScrollBgDrawing()) {
drawEditorScrollingBackground(canvas.width, canvas.height);
} else if (isEditorGauntletRunwayBgDrawing()) {
drawEditorGauntletRunwayScrollingBackground(canvas.width, canvas.height);
} else if (isEditorStackTowerBgDrawing()) {
drawStackTowerScrollingBackground(canvas.width, canvas.height);
} else 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)';
} else if (isQuizCarry) {
rowFill = (y % 2 === 0) ? 'rgba(100, 180, 160, 0.06)' : 'rgba(80, 140, 200, 0.05)';
} else if (isQuizBattle) {
rowFill = (y % 2 === 0) ? 'rgba(120, 190, 255, 0.07)' : 'rgba(255, 120, 150, 0.06)';
} 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(25, 40, 95, 0.42)' : 'rgba(18, 28, 72, 0.48)';
} else if (isBalloonBoss) {
rowFill = (y % 2 === 0) ? 'rgba(35, 22, 68, 0.45)' : 'rgba(22, 14, 52, 0.5)';
}
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);
}
}
}
}
gridImageSprites.forEach((sp) => {
primeEditorGridImage(sp.i);
const gim = editorGridImageElByIndex[sp.i];
if (!gim || !gim.complete || !gim.naturalWidth) return;
const tx = sp.x * tileSize;
const ty = sp.y * tileSize;
const tw = sp.w * tileSize;
const th = sp.h * tileSize;
ctx.save();
ctx.beginPath();
ctx.rect(tx, ty, tw, th);
ctx.clip();
ctx.drawImage(gim, tx, ty, tw, th);
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
ctx.lineWidth = 1;
ctx.strokeRect(tx + 0.5, ty + 0.5, tw - 1, th - 1);
});
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)';
} else if (isQuizCarry) {
rowFill = (y % 2 === 0) ? 'rgba(100, 180, 160, 0.06)' : 'rgba(80, 140, 200, 0.05)';
} else if (isQuizBattle) {
rowFill = (y % 2 === 0) ? 'rgba(120, 190, 255, 0.07)' : 'rgba(255, 120, 150, 0.06)';
} 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(25, 40, 95, 0.42)' : 'rgba(18, 28, 72, 0.48)';
} else if (isBalloonBoss) {
rowFill = (y % 2 === 0) ? 'rgba(35, 22, 68, 0.45)' : 'rgba(22, 14, 52, 0.5)';
}
for (let x = 0; x < width; x++) {
const tx = x * tileSize, ty = y * 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 (gtDraw === 'stack') {
if (stackReleaseArea[y] && stackReleaseArea[y][x] === 1) {
ctx.fillStyle = 'rgba(125, 207, 255, 0.42)';
ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(122, 220, 255, 0.92)';
ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
}
if (stackLandArea[y] && stackLandArea[y][x] === 1) {
ctx.fillStyle = 'rgba(247, 118, 190, 0.42)';
ctx.fillRect(tx + 3, ty + 3, tileSize - 6, tileSize - 6);
ctx.strokeStyle = 'rgba(255, 130, 200, 0.92)';
ctx.strokeRect(tx + 3, ty + 3, tileSize - 6, tileSize - 6);
}
}
if (gtDraw === 'jump_survive' && jumpSurvivePlatformArea[y] && jumpSurvivePlatformArea[y][x] === 1) {
const vHere = editorJumpSurviveVariantAtCell(x, y);
const isRunContinuation = x > 0 && jumpSurvivePlatformArea[y][x - 1] === 1 && editorJumpSurviveVariantAtCell(x - 1, y) === vHere;
if (!isRunContinuation) {
let runLen = 1;
while (x + runLen < width && jumpSurvivePlatformArea[y][x + runLen] === 1
&& editorJumpSurviveVariantAtCell(x + runLen, y) === vHere) runLen += 1;
const cfg = editorJumpSurvivePlatformTilesCfg[vHere - 1] || { url: '', w: 0, h: 0 };
const im = editorJumpSurvivePlatformTileImgs[vHere - 1];
const segWpx = runLen * tileSize;
let dw = runLen > 1 ? segWpx : (cfg.w > 0 ? cfg.w : tileSize - 6);
let dh = cfg.h > 0 ? cfg.h : tileSize - 6;
if (runLen === 1) {
const maxDim = tileSize * 4;
if (dw > maxDim) dw = maxDim;
if (dh > maxDim) dh = maxDim;
}
const dx = tx;
const dy = ty + tileSize - dh - 2;
if (im && im.complete && im.naturalWidth > 0) {
ctx.drawImage(im, dx, dy, dw, dh);
ctx.strokeStyle = 'rgba(160, 255, 220, 0.92)';
ctx.lineWidth = 2;
ctx.strokeRect(dx, dy, dw, dh);
ctx.lineWidth = 1;
} else {
const innerW = runLen > 1 ? segWpx - 4 : tileSize - 6;
const innerH = tileSize - 6;
ctx.fillStyle = 'rgba(125, 235, 200, 0.48)';
ctx.fillRect(tx + 3, ty + 3, innerW, innerH);
ctx.strokeStyle = 'rgba(160, 255, 220, 0.92)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 3, ty + 3, innerW, innerH);
ctx.lineWidth = 1;
}
}
}
if (gtDraw === 'jump_survive' && jumpSurviveHazardArea[y] && jumpSurviveHazardArea[y][x] === 1) {
ctx.fillStyle = 'rgba(220, 40, 60, 0.52)';
ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(255, 100, 120, 0.95)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.lineWidth = 1;
ctx.fillStyle = 'rgba(40, 0, 8, 0.88)';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('!', tx + tileSize / 2, ty + tileSize / 2 + 4);
ctx.textAlign = 'left';
}
if ((gtDraw === 'space_shooter' || gtDraw === 'jump_survive') && shooterSpawnSlots[y] && shooterSpawnSlots[y][x] >= 1 && shooterSpawnSlots[y][x] <= 6) {
const sn = shooterSpawnSlots[y][x];
const hue = (sn * 52 + 180) % 360;
ctx.fillStyle = 'hsla(' + hue + ', 70%, 55%, 0.38)';
ctx.fillRect(tx + 3, ty + 3, tileSize - 6, tileSize - 6);
ctx.strokeStyle = 'hsla(' + hue + ', 85%, 65%, 0.95)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 3, ty + 3, tileSize - 6, tileSize - 6);
ctx.lineWidth = 1;
ctx.fillStyle = '#e0f7ff';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('P' + sn, tx + tileSize / 2, ty + tileSize / 2 + 4);
ctx.textAlign = 'left';
}
if (gtDraw === 'balloon_boss' && balloonBossPlayerSlots[y] && balloonBossPlayerSlots[y][x] >= 1 && balloonBossPlayerSlots[y][x] <= 6) {
const sn = balloonBossPlayerSlots[y][x];
const hue = (sn * 47 + 320) % 360;
ctx.fillStyle = 'hsla(' + hue + ', 72%, 52%, 0.4)';
ctx.fillRect(tx + 3, ty + 3, tileSize - 6, tileSize - 6);
ctx.strokeStyle = 'hsla(' + hue + ', 88%, 62%, 0.95)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 3, ty + 3, tileSize - 6, tileSize - 6);
ctx.lineWidth = 1;
ctx.fillStyle = '#ffe0ff';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('P' + sn, tx + tileSize / 2, ty + tileSize / 2 + 4);
ctx.textAlign = 'left';
}
if (gtDraw === 'balloon_boss' && balloonBossBossSpawn && balloonBossBossSpawn.x === x && balloonBossBossSpawn.y === y) {
ctx.fillStyle = 'rgba(255, 80, 120, 0.42)';
ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(255, 120, 200, 0.95)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.lineWidth = 1;
ctx.fillStyle = '#1a1b26';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('BOSS', tx + tileSize / 2, ty + tileSize / 2 + 4);
ctx.textAlign = 'left';
}
if (customizeSpot && customizeSpot.x === x && customizeSpot.y === y) {
ctx.fillStyle = 'rgba(34, 211, 238, 0.42)';
ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(120, 230, 255, 0.95)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.lineWidth = 1;
ctx.fillStyle = '#eafaff';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('แต่งตัว', tx + tileSize / 2, ty + tileSize / 2 + 4);
ctx.textAlign = 'left';
}
if (gtDraw === 'quiz_carry') {
if (quizQuestionArea[y] && quizQuestionArea[y][x] === 1) {
ctx.fillStyle = 'rgba(255, 214, 102, 0.44)';
ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(224, 185, 70, 0.96)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.lineWidth = 1;
ctx.fillStyle = '#1a1b26';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Q', tx + tileSize / 2, ty + tileSize / 2 + 3);
ctx.textAlign = 'left';
}
const ov = quizCarryOptionArea[y] && quizCarryOptionArea[y][x];
if (quizCarryHubArea[y] && quizCarryHubArea[y][x] === 1) {
ctx.fillStyle = 'rgba(187, 154, 247, 0.45)';
ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(200, 170, 255, 0.95)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.lineWidth = 1;
}
if (ov >= 1 && ov <= QUIZ_CARRY_EDITOR_OPT_COUNT) {
const pal = quizCarryEditorDrawPalette(ov);
ctx.fillStyle = pal.fill;
ctx.fillRect(tx + 3, ty + 3, tileSize - 6, tileSize - 6);
ctx.strokeStyle = pal.stroke;
ctx.strokeRect(tx + 3, ty + 3, tileSize - 6, tileSize - 6);
ctx.fillStyle = '#1a1b26';
const lab = String(ov);
ctx.font = lab.length > 1 ? 'bold 10px sans-serif' : 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(lab, tx + tileSize / 2, ty + tileSize / 2 + 4);
ctx.textAlign = 'left';
}
if (carryEmbedCountdownArea[y] && Number(carryEmbedCountdownArea[y][x]) === 1) {
ctx.fillStyle = 'rgba(100, 230, 255, 0.32)';
ctx.fillRect(tx + 4, ty + 4, tileSize - 8, tileSize - 8);
ctx.strokeStyle = 'rgba(80, 220, 255, 0.88)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 4, ty + 4, tileSize - 8, tileSize - 8);
ctx.lineWidth = 1;
ctx.fillStyle = '#1a3a44';
ctx.font = 'bold 9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('321', tx + tileSize / 2, ty + tileSize / 2 + 3);
ctx.textAlign = 'left';
}
}
if (gtDraw === 'quiz_battle' && quizBattlePathArea[y] && quizBattlePathArea[y][x] === 1) {
ctx.fillStyle = 'rgba(186, 130, 255, 0.4)';
ctx.fillRect(tx + 1, ty + 1, tileSize - 2, tileSize - 2);
ctx.strokeStyle = 'rgba(210, 170, 255, 0.72)';
ctx.lineWidth = 1;
ctx.strokeRect(tx + 1, ty + 1, tileSize - 2, tileSize - 2);
}
if (gtDraw === 'quiz_battle' && quizBattleDomeArea[y] && quizBattleDomeArea[y][x] === 1) {
ctx.fillStyle = 'rgba(100, 200, 255, 0.42)';
ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.strokeStyle = 'rgba(255, 100, 130, 0.92)';
ctx.lineWidth = 2;
ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4);
ctx.lineWidth = 1;
ctx.fillStyle = '#1a1b26';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('E', tx + tileSize / 2, ty + tileSize / 2 + 3);
ctx.textAlign = 'left';
}
}
}
/* จุดเกิด + footprint กว้าง×สูง — ต้องวาดหลังลูปกริด ไม่งั้นช่องข้างเคียงจะทับสีเขียวจนเหลือแค่มุม/ครึ่งวง */
if (spawn && spawn.x >= 0 && spawn.y >= 0 && spawn.x < width && spawn.y < height) {
const fp = readCharacterFootprintInputs();
const cw0 = fp.cw, ch0 = fp.ch;
const effW = Math.max(1, Math.min(cw0, width - spawn.x));
const effH = Math.max(1, Math.min(ch0, height - spawn.y));
const tx0 = spawn.x * tileSize;
const ty0 = spawn.y * tileSize;
const rw = effW * tileSize;
const rh = effH * tileSize;
const cx0 = tx0 + rw / 2;
const cy0 = ty0 + rh / 2;
ctx.fillStyle = 'rgba(158,206,106,0.35)';
ctx.fillRect(tx0 + 1, ty0 + 1, rw - 2, rh - 2);
ctx.strokeStyle = '#9ece6a';
ctx.lineWidth = 2;
ctx.strokeRect(tx0 + 1, ty0 + 1, rw - 2, rh - 2);
ctx.fillStyle = 'rgba(158,206,106,0.7)';
ctx.beginPath();
ctx.arc(cx0, cy0, Math.min(tileSize / 3, Math.min(rw, rh) / 4), 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#9ece6a';
ctx.stroke();
const colW0 = Math.max(1, Math.min(fp.cw, fp.colW));
const colH0 = Math.max(1, Math.min(fp.ch, fp.colH));
const colWEff = Math.max(1, Math.min(colW0, effW));
const colHEff = Math.max(1, Math.min(colH0, effH));
if (colWEff < effW || colHEff < effH) {
const ox = spawn.x + Math.floor((effW - colWEff) / 2);
const oy = spawn.y + (effH - colHEff);
const tcx = ox * tileSize;
const tcy = oy * tileSize;
const trw = colWEff * tileSize;
const trh = colHEff * tileSize;
ctx.save();
ctx.setLineDash([5, 4]);
ctx.strokeStyle = 'rgba(224, 175, 104, 0.95)';
ctx.lineWidth = 2;
ctx.strokeRect(tcx + 2, tcy + 2, trw - 4, trh - 4);
ctx.restore();
}
}
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 (gauntletLaserRowStart != null && gauntletLaserRowEnd != null) {
let y0 = Math.max(0, Math.min(height - 1, gauntletLaserRowStart));
let y1 = Math.max(0, Math.min(height - 1, gauntletLaserRowEnd));
if (y1 < y0) { const t = y0; y0 = y1; y1 = t; }
const x0 = 2;
const x1 = width * tileSize - 2;
ctx.save();
ctx.fillStyle = 'rgba(255, 80, 140, 0.12)';
ctx.fillRect(x0, y0 * tileSize, x1 - x0, (y1 - y0 + 1) * tileSize);
ctx.strokeStyle = 'rgba(255, 140, 180, 0.9)';
ctx.lineWidth = 2;
ctx.setLineDash([7, 5]);
ctx.beginPath();
ctx.moveTo(x0, y0 * tileSize + 1);
ctx.lineTo(x1, y0 * tileSize + 1);
ctx.moveTo(x0, (y1 + 1) * tileSize - 1);
ctx.lineTo(x1, (y1 + 1) * tileSize - 1);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255, 200, 220, 0.95)';
ctx.font = 'bold 11px ui-monospace, Consolas, sans-serif';
ctx.fillText('laser y=' + y0 + '…' + y1, x0 + 4, y0 * tileSize + 14);
ctx.restore();
}
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 — ฟ้า = ปล่อยบล็อก · ชมพู = แพลตฟอร์ม · บันทึกแล้วกดทดลองเล่น (Space ปล่อย)', 8, 20);
}
if (isQuizCarry) {
ctx.fillStyle = '#bb9af7';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('หยิบมาวาง — ม่วง = กลาง · 1–16 = ตัวเลือก · บันทึกแล้วทดลองเล่น', 8, 20);
}
if (isSpaceShooter) {
ctx.fillStyle = '#7df9ff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('ยิงยาน — วาดจุดเกิด P1–P6 · แนะนำพื้นหลังอวกาศ + ปิดโชว์กริดในเกม', 8, 20);
}
if (isBalloonBoss) {
ctx.fillStyle = '#ff79c6';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('ลูกโป้งยิงบอส — P1–P6 + โหมด BOSS 1 ช่อง · ปิดโชว์กริดในเกมให้เหมือนรีเฟอเรนซ์', 8, 20);
}
if (supportsLobbySpawnPaint(gtDraw)) {
ensureLobbyPlayerSpawnsLength();
let anyLobbyP = false;
for (let ii = 0; ii < 6; ii++) {
if (lobbyPlayerSpawns[ii]) { anyLobbyP = true; break; }
}
if (anyLobbyP) {
const drawLobbyPNum = (s, num) => {
if (!s || s.x < 0 || s.x >= width || s.y < 0 || s.y >= height) return;
if (objects[s.y][s.x] === 1) return;
const fpP = readCharacterFootprintInputs();
const effW = Math.max(1, Math.min(fpP.cw, width - s.x));
const effH = Math.max(1, Math.min(fpP.ch, height - s.y));
const tx0 = s.x * tileSize;
const ty0 = s.y * tileSize;
const rw = effW * tileSize;
const rh = effH * tileSize;
ctx.fillStyle = 'rgba(0, 120, 180, 0.28)';
ctx.fillRect(tx0 + 1, ty0 + 1, rw - 2, rh - 2);
ctx.strokeStyle = '#7dcfff';
ctx.lineWidth = 2;
ctx.strokeRect(tx0 + 1, ty0 + 1, rw - 2, rh - 2);
const cx = tx0 + rw / 2;
const cy = ty0 + rh - tileSize * 0.35;
ctx.fillStyle = 'rgba(0,80,120,0.55)';
ctx.beginPath();
ctx.arc(cx, cy - 3, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#b4f9f8';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('P' + String(num), cx, cy + 2);
ctx.textAlign = 'left';
};
for (let i = 0; i < 6; i++) {
const s = lobbyPlayerSpawns[i];
if (s) drawLobbyPNum(s, i + 1);
}
}
}
const fpHud = readCharacterFootprintInputs();
ctx.fillStyle = '#c0caf5';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
const colLine = (fpHud.colW < fpHud.cw || fpHud.colH < fpHud.ch)
? ' · ชนกำแพง ' + fpHud.colW + '×' + fpHud.colH + ' (เท้า/กลาง — เส้นประส้ม · เฉพาะกำแพง; โซนอื่นใช้ ' + fpHud.ch + '×' + fpHud.cw + ')'
: '';
ctx.fillText('ขนาดตัวละคร: สูง ' + fpHud.ch + ' × กว้าง ' + fpHud.cw + ' ช่อง (แนวตั้ง×แนวนอน · มุมซ้ายบนจุดเกิด)' + colLine, 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, mouseEv) {
if (drawModeEl.value === 'gauntletLaserStart' || drawModeEl.value === 'gauntletLaserEnd') {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
if (gt !== 'gauntlet') return;
if (!left) {
gauntletLaserRowStart = null;
gauntletLaserRowEnd = null;
if (statusEl) statusEl.textContent = 'ล้างช่วงแนวตั้ง laser แล้ว — ใช้เต็มความสูงแมป';
draw();
return;
}
const yy = Math.max(0, Math.min(height - 1, y));
if (drawModeEl.value === 'gauntletLaserStart') gauntletLaserRowStart = yy;
else gauntletLaserRowEnd = yy;
sanitizeGauntletLaserRowsInEditor();
if (statusEl) {
const a = gauntletLaserRowStart;
const b = gauntletLaserRowEnd;
statusEl.textContent = (a != null && b != null)
? ('เลเซอร์: แถว ' + a + '' + b + ' (แนวตั้ง · คลิกขวา = ล้างทั้งช่วง)')
: ('เลเซอร์: ตั้งแถว ' + yy + ' แล้ว — เลือกโหมดอีกแถวเพื่อกำหนดขอบอีกด้าน');
}
draw();
return;
}
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 === 'lobbyPlayerSpawn') {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
if (!supportsLobbySpawnPaint(gt)) return;
const slotEl = document.getElementById('lobby-slot-paint');
const slotOne = parseInt(slotEl && slotEl.value, 10) || 1;
const slot = Math.max(0, Math.min(5, slotOne - 1));
ensureLobbyPlayerSpawnsLength();
if (objects[y][x] === 1) {
if (statusEl) statusEl.textContent = 'ช่องกำแพง — ใช้เป็นจุดเกิดไม่ได้';
return;
}
if (left) {
for (let k = 0; k < 6; k++) {
if (k === slot) continue;
const o = lobbyPlayerSpawns[k];
if (o && o.x === x && o.y === y) lobbyPlayerSpawns[k] = null;
}
lobbyPlayerSpawns[slot] = { x, y };
if (statusEl) statusEl.textContent = 'จุดเกิด P' + (slot + 1) + ' = (' + x + ',' + y + ')';
} else {
let cleared = false;
for (let k = 0; k < 6; k++) {
const o = lobbyPlayerSpawns[k];
if (o && o.x === x && o.y === y) {
lobbyPlayerSpawns[k] = null;
cleared = true;
}
}
if (statusEl) statusEl.textContent = cleared ? 'ถอดจุด P ที่ช่องนี้แล้ว' : 'ไม่มีจุด P ที่ช่องนี้';
}
return;
}
if (drawModeEl.value === 'cellImage') {
if (objects[y][x] === 1) {
if (statusEl) statusEl.textContent = 'ช่องกำแพง — วางรูปบนกริดไม่ได้';
return;
}
const gtCell = gameTypeEl ? gameTypeEl.value : gameType;
if (left && mouseEv && mouseEv.altKey && gtCell === 'quiz_carry') {
const hit = findSpriteIndexAtCell(x, y);
if (hit >= 0) {
const bind = readSpriteCarryBindFromSelect();
if (bind == null) delete gridImageSprites[hit].bindCarryOption;
else gridImageSprites[hit].bindCarryOption = bind;
sanitizeSpritesInPlace();
if (statusEl) {
statusEl.textContent = bind == null
? 'สไปรต์: ผูกแบบอัตโนมัติ (นับจากช่องโซนตัวเลือกที่ทับสไปรต์)'
: ('ผูกสไปรต์กับตัวเลือกข้อ ' + bind + ' แล้ว (Alt+คลิกซ้ายอีกครั้งเปลี่ยนค่าในรายการ)');
}
return;
}
if (statusEl) statusEl.textContent = 'ไม่มีสไปรต์ในช่องนี้ — Alt+คลิกบนพื้นที่ที่มีรูป';
return;
}
const wEl = document.getElementById('grid-image-brush-w');
const hEl = document.getElementById('grid-image-brush-h');
const bw = Math.max(1, parseInt(wEl && wEl.value, 10) || 1);
const bh = Math.max(1, parseInt(hEl && hEl.value, 10) || 1);
if (left) {
if (!gridImageLibrary.length) {
if (statusEl) statusEl.textContent = 'อัปโหลดรูปเข้าคลังก่อน (หรือโหลดฉากที่มีคลังรูป)';
return;
}
const bi = gridImageBrushIndex;
if (bi < 0 || bi >= gridImageLibrary.length) {
if (statusEl) statusEl.textContent = 'คลิกเลือกรูปในแกลเลอรีก่อนวาง';
return;
}
const placed = canPlaceSpriteAt(x, y, bw, bh, bi);
if (!placed) {
if (statusEl) statusEl.textContent = 'วางไม่ได้ — มีกำแพงในพื้นที่ หรือลดกว้าง/สูง';
return;
}
removeSpritesOverlappingRect(placed.x, placed.y, placed.w, placed.h);
gridImageSprites.push(placed);
draw();
if (statusEl) statusEl.textContent = 'วางรูป ' + placed.w + '×' + placed.h + ' ช่องที่ (' + placed.x + ',' + placed.y + ')';
} else {
const hit = findSpriteIndexAtCell(x, y);
if (hit >= 0) {
gridImageSprites.splice(hit, 1);
draw();
if (statusEl) statusEl.textContent = 'ลบสไปรต์รูปแล้ว';
}
}
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') {
const gtq = gameTypeEl ? gameTypeEl.value : gameType;
if (gtq === 'quiz') {
ensureQuizAreas();
quizQuestionArea[y][x] = left ? 1 : 0;
} else if (gtq === 'quiz_carry') {
ensureQuizCarryAreas();
quizQuestionArea[y][x] = left ? 1 : 0;
if (left) carryEmbedCountdownArea[y][x] = 0;
} else return;
} else if (drawModeEl.value === 'stackRelease') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'stack') return;
ensureStackAreas();
stackReleaseArea[y][x] = left ? 1 : 0;
} else if (drawModeEl.value === 'stackLand') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'stack') return;
ensureStackAreas();
stackLandArea[y][x] = left ? 1 : 0;
} else if (drawModeEl.value === 'jumpSurvivePlatform1' || drawModeEl.value === 'jumpSurvivePlatform2' || drawModeEl.value === 'jumpSurvivePlatform3') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'jump_survive') return;
const slot = drawModeEl.value === 'jumpSurvivePlatform1' ? 1 : drawModeEl.value === 'jumpSurvivePlatform2' ? 2 : 3;
ensureJumpSurvivePlatformArea();
if (left) {
jumpSurvivePlatformArea[y][x] = 1;
jumpSurvivePlatformVariantArea[y][x] = slot;
} else {
jumpSurvivePlatformArea[y][x] = 0;
jumpSurvivePlatformVariantArea[y][x] = 0;
}
} else if (drawModeEl.value === 'jumpSurviveHazard') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'jump_survive') return;
ensureJumpSurviveHazardArea();
jumpSurviveHazardArea[y][x] = left ? 1 : 0;
} else if (drawModeEl.value === 'shooterSpawnPaint') {
const gtSh = (gameTypeEl ? gameTypeEl.value : gameType);
if (gtSh !== 'space_shooter' && gtSh !== 'jump_survive') return;
ensureShooterSpawnSlots();
if (objects[y][x] === 1) {
if (statusEl) statusEl.textContent = 'ช่องกำแพง — ใช้เป็นจุดเกิดยานไม่ได้';
return;
}
const slotEl = document.getElementById('shooter-slot-paint');
const slot = Math.max(1, Math.min(6, parseInt(slotEl && slotEl.value, 10) || 1));
if (left) {
for (let yy = 0; yy < height; yy++) {
for (let xx = 0; xx < width; xx++) {
if (shooterSpawnSlots[yy] && shooterSpawnSlots[yy][xx] === slot) shooterSpawnSlots[yy][xx] = 0;
}
}
shooterSpawnSlots[y][x] = slot;
if (statusEl) statusEl.textContent = (gtSh === 'jump_survive' ? 'Jump P' : 'ยาน P') + slot + ' → ช่อง (' + x + ',' + y + ')';
} else {
shooterSpawnSlots[y][x] = 0;
if (statusEl) statusEl.textContent = (gtSh === 'jump_survive' ? 'ลบ Jump P' : 'ลบจุดเกิดยาน') + ' ที่ช่อง (' + x + ',' + y + ')';
}
return;
} else if (drawModeEl.value === 'balloonBossPlayerPaint') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'balloon_boss') return;
ensureBalloonBossPlayerSlots();
if (objects[y][x] === 1) {
if (statusEl) statusEl.textContent = 'ช่องกำแพง — ใช้เป็นจุดเกิดผู้เล่นไม่ได้';
return;
}
const slotEl = document.getElementById('balloon-boss-slot-paint');
const slot = Math.max(1, Math.min(6, parseInt(slotEl && slotEl.value, 10) || 1));
if (left) {
for (let yy = 0; yy < height; yy++) {
for (let xx = 0; xx < width; xx++) {
if (balloonBossPlayerSlots[yy] && balloonBossPlayerSlots[yy][xx] === slot) balloonBossPlayerSlots[yy][xx] = 0;
}
}
balloonBossPlayerSlots[y][x] = slot;
if (statusEl) statusEl.textContent = 'ผู้เล่น P' + slot + ' → ช่อง (' + x + ',' + y + ')';
} else {
balloonBossPlayerSlots[y][x] = 0;
if (statusEl) statusEl.textContent = 'ลบจุดเกิดผู้เล่นที่ช่อง (' + x + ',' + y + ')';
}
return;
} else if (drawModeEl.value === 'balloonBossBossPaint') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'balloon_boss') return;
if (objects[y][x] === 1) {
if (statusEl) statusEl.textContent = 'ช่องกำแพง — ใช้เป็นจุดเกิดบอสไม่ได้';
return;
}
if (left) {
balloonBossBossSpawn = { x: x, y: y };
if (statusEl) statusEl.textContent = 'บอส → ช่อง (' + x + ',' + y + ')';
} else {
balloonBossBossSpawn = null;
if (statusEl) statusEl.textContent = 'ลบจุดเกิดบอส (เกมจะใช้กลางแมป)';
}
return;
} else if (drawModeEl.value === 'customizeSpot') {
if (objects[y][x] === 1) {
if (statusEl) statusEl.textContent = 'ช่องกำแพง — ใช้เป็นจุดห้องแต่งตัวไม่ได้';
return;
}
if (left) {
customizeSpot = { x: x, y: y };
if (statusEl) statusEl.textContent = 'จุดห้องแต่งตัว → ช่อง (' + x + ',' + y + ')';
} else {
customizeSpot = null;
if (statusEl) statusEl.textContent = 'ลบจุดห้องแต่งตัว';
}
return;
} else if (drawModeEl.value === 'quizBattlePath') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz_battle') return;
ensureQuizBattlePathArea();
quizBattlePathArea[y][x] = left ? 1 : 0;
} else if (drawModeEl.value === 'quizBattleDome') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz_battle') return;
ensureQuizBattleDomeArea();
quizBattleDomeArea[y][x] = left ? 1 : 0;
} else if (drawModeEl.value === 'carryEmbedCountdown') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz_carry') return;
ensureQuizCarryAreas();
if (left) {
carryEmbedCountdownArea[y][x] = 1;
quizCarryHubArea[y][x] = 0;
quizCarryOptionArea[y][x] = 0;
} else {
carryEmbedCountdownArea[y][x] = 0;
}
} else if (drawModeEl.value === 'quizCarryHub') {
if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz_carry') return;
ensureQuizCarryAreas();
quizCarryHubArea[y][x] = left ? 1 : 0;
if (left) {
quizCarryOptionArea[y][x] = 0;
carryEmbedCountdownArea[y][x] = 0;
}
} else {
const carryOptM = /^quizCarryOpt(\d+)$/.exec(drawModeEl.value);
if (carryOptM && (gameTypeEl ? gameTypeEl.value : gameType) === 'quiz_carry') {
ensureQuizCarryAreas();
const val = parseInt(carryOptM[1], 10);
if (val >= 1 && val <= QUIZ_CARRY_EDITOR_OPT_COUNT) {
if (left) {
quizCarryOptionArea[y][x] = val;
quizCarryHubArea[y][x] = 0;
carryEmbedCountdownArea[y][x] = 0;
} else {
quizCarryOptionArea[y][x] = 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, e);
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, e);
draw();
});
if (mapLoadEl) {
fetch(BASE + '/api/maps')
.then(r => r.json())
.then(list => {
mapLoadEl.innerHTML = '<option value="">— เลือกฉากเดิม —</option>';
(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 = '<option value="">— เลือกฉากเดิม —</option>'; });
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();
});
(function wireEditorBgScrollUi() {
if (!isEditorScrollBgMap()) return;
syncEditorBgScrollUiFromConfig();
const en = document.getElementById('editor-bg-scroll-enabled');
const sp = document.getElementById('editor-bg-scroll-speed');
if (en) {
en.addEventListener('change', () => {
editorBgScrollConfig.enabled = !!en.checked;
if (editorBgScrollConfig.enabled) startEditorBgScrollRaf();
else stopEditorBgScrollRaf();
draw();
});
}
if (sp) {
sp.addEventListener('change', () => {
editorBgScrollConfig.speedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(sp.value)) || 56));
});
}
const dirSel = document.getElementById('editor-bg-scroll-direction');
if (dirSel) {
dirSel.addEventListener('change', () => {
editorBgScrollConfig.scrollDirection = dirSel.value === 'down' ? 'down' : 'up';
syncEditorBgScrollInitialToBottom();
draw();
if (editorBgScrollConfig.enabled) startEditorBgScrollRaf();
});
}
const introF = document.getElementById('editor-bg-scroll-intro-file');
if (introF) {
introF.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const r = new FileReader();
r.onload = () => {
editorBgScrollConfig.introImage = r.result;
loadEditorBgScrollImagePair(editorBgScrollConfig.introImage, editorBgScrollConfig.loopImage || EDITOR_SCROLL_BG_DEFAULT_LOOP, () => {
syncEditorBgScrollInitialToBottom();
draw();
if (editorBgScrollConfig.enabled) startEditorBgScrollRaf();
});
};
r.readAsDataURL(file);
e.target.value = '';
});
}
const loopF = document.getElementById('editor-bg-scroll-loop-file');
if (loopF) {
loopF.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const r = new FileReader();
r.onload = () => {
editorBgScrollConfig.loopImage = r.result;
loadEditorBgScrollImagePair(editorBgScrollConfig.introImage || EDITOR_SCROLL_BG_DEFAULT_INTRO, editorBgScrollConfig.loopImage, () => {
syncEditorBgScrollInitialToBottom();
draw();
if (editorBgScrollConfig.enabled) startEditorBgScrollRaf();
});
};
r.readAsDataURL(file);
e.target.value = '';
});
}
const btnR = document.getElementById('editor-bg-scroll-reset-assets');
if (btnR) {
btnR.addEventListener('click', () => {
editorBgScrollConfig.introImage = null;
editorBgScrollConfig.loopImage = null;
loadEditorBgScrollImagePair(EDITOR_SCROLL_BG_DEFAULT_INTRO, EDITOR_SCROLL_BG_DEFAULT_LOOP, () => {
syncEditorBgScrollUiFromConfig();
syncEditorBgScrollInitialToBottom();
draw();
if (editorBgScrollConfig.enabled) startEditorBgScrollRaf();
});
});
}
})();
(function wireEditorGauntletRunwayBgUi() {
const en = document.getElementById('editor-gauntlet-runway-bg-enabled');
const sp = document.getElementById('editor-gauntlet-runway-bg-speed');
if (en) {
en.addEventListener('change', () => {
editorGauntletRunwayBgConfig.enabled = !!en.checked;
if (editorGauntletRunwayBgConfig.enabled) {
applyGauntletCrownRunwayBgFromMap({
gameType: 'gauntlet',
gauntletCrownRunwayBg: {
enabled: true,
speedPxPerSec: editorGauntletRunwayBgConfig.speedPxPerSec,
runwaySpawnAlignFrac: editorGauntletRunwayBgConfig.runwaySpawnAlignFrac,
startImage: editorGauntletRunwayBgConfig.startImage,
loopImage2: editorGauntletRunwayBgConfig.loopImage2,
loopImage3: editorGauntletRunwayBgConfig.loopImage3,
loopImage4: editorGauntletRunwayBgConfig.loopImage4,
finishImage: editorGauntletRunwayBgConfig.finishImage,
},
});
} else {
stopEditorGauntletRunwayBgRaf();
editorGauntletRunwayBgImgs = [null, null, null, null, null];
draw();
}
});
}
if (sp) {
sp.addEventListener('change', () => {
editorGauntletRunwayBgConfig.speedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(sp.value)) || 48));
});
}
const afIn = document.getElementById('editor-gauntlet-runway-bg-align-frac');
if (afIn) {
afIn.addEventListener('change', () => {
editorGauntletRunwayBgConfig.runwaySpawnAlignFrac = Math.max(0.02, Math.min(0.95, Number(afIn.value) || 0.32));
});
}
function wireFile(id, key) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const pickedName = file.name || '';
const r = new FileReader();
r.onload = () => {
compressRunwayDataUrlIfLarge(r.result, (finalUrl) => {
editorGauntletRunwayBgConfig[key] = finalUrl;
editorGauntletRunwayBgFileNames[key] = pickedName;
syncEditorGauntletRunwayBgThumbnails();
const s0 = editorGauntletRunwayBgConfig.startImage || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_START;
const s1 = editorGauntletRunwayBgConfig.loopImage2 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP2;
const s2 = editorGauntletRunwayBgConfig.loopImage3 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP3;
const s3 = editorGauntletRunwayBgConfig.loopImage4 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP4;
const s4 = editorGauntletRunwayBgConfig.finishImage || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_FINISH;
editorGauntletRunwayBgScrollPx = 0;
loadEditorGauntletRunwayBgImages(s0, s1, s2, s3, s4, () => {
syncEditorGauntletRunwayBgUploadSummary();
syncEditorGauntletRunwayBgThumbnails();
draw();
if (editorGauntletRunwayBgConfig.enabled) startEditorGauntletRunwayBgRaf();
});
});
};
r.readAsDataURL(file);
e.target.value = '';
});
}
wireFile('editor-gauntlet-runway-bg-start-file', 'startImage');
wireFile('editor-gauntlet-runway-bg-loop2-file', 'loopImage2');
wireFile('editor-gauntlet-runway-bg-loop3-file', 'loopImage3');
wireFile('editor-gauntlet-runway-bg-loop4-file', 'loopImage4');
wireFile('editor-gauntlet-runway-bg-finish-file', 'finishImage');
const btnR = document.getElementById('editor-gauntlet-runway-bg-reset-assets');
if (btnR) {
btnR.addEventListener('click', () => {
editorGauntletRunwayBgConfig.startImage = null;
editorGauntletRunwayBgConfig.loopImage2 = null;
editorGauntletRunwayBgConfig.loopImage3 = null;
editorGauntletRunwayBgConfig.loopImage4 = null;
editorGauntletRunwayBgConfig.finishImage = null;
editorGauntletRunwayBgFileNames = { startImage: '', loopImage2: '', loopImage3: '', loopImage4: '', finishImage: '' };
editorGauntletRunwayBgScrollPx = 0;
loadEditorGauntletRunwayBgImages(
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_START,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP2,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP3,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP4,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_FINISH,
() => {
syncEditorGauntletRunwayBgUiFromConfig();
draw();
if (editorGauntletRunwayBgConfig.enabled) startEditorGauntletRunwayBgRaf();
},
);
});
}
})();
syncEditorGauntletRunwayBgBlockVisibility();
syncEditorStackHudMock();
if (isEditorScrollBgMap()) applyEditorBgScrollFromMap({});
if (isEditorGauntletRunwayBgMap()) applyGauntletCrownRunwayBgFromMap({});
if (isEditorStackTowerMissionMap()) applyStackTowerCameraFollowFromMap({});
else stopStackTowerBgScrollRaf();
(function wireStackTowerVerticalBgUi() {
const en = document.getElementById('editor-stack-tower-vbg-enabled');
const sp = document.getElementById('editor-stack-tower-vbg-speed');
if (en) {
en.addEventListener('change', () => {
stackTowerBgScrollConfig.enabled = !!en.checked;
if (stackTowerBgScrollConfig.enabled) syncStackTowerBgScrollInitialToBottom();
draw();
});
}
if (sp) {
sp.addEventListener('change', () => {
const n = Number(sp.value);
stackTowerBgScrollConfig.speedPxPerSec = Number.isFinite(n) ? Math.max(0, Math.min(400, Math.floor(n))) : 0;
});
}
const dirSel = document.getElementById('editor-stack-tower-vbg-direction');
if (dirSel) {
dirSel.addEventListener('change', () => {
stackTowerBgScrollConfig.scrollDirection = dirSel.value === 'down' ? 'down' : 'up';
syncStackTowerBgScrollInitialToBottom();
draw();
});
}
const stL = document.getElementById('editor-stack-tower-vbg-step-layers');
if (stL) {
stL.addEventListener('change', () => {
const n = Number(stL.value);
stackTowerBgScrollConfig.stepEveryLayers = Number.isFinite(n) ? Math.max(0, Math.min(200, Math.floor(n))) : 0;
});
}
const stPx = document.getElementById('editor-stack-tower-vbg-step-px');
if (stPx) {
stPx.addEventListener('change', () => {
const n = Number(stPx.value);
stackTowerBgScrollConfig.stepScrollPx = Number.isFinite(n) ? Math.max(0, Math.min(8000, Math.floor(n))) : 0;
});
}
const stMs = document.getElementById('editor-stack-tower-vbg-step-anim-ms');
if (stMs) {
stMs.addEventListener('change', () => {
const n = Number(stMs.value);
stackTowerBgScrollConfig.stepAnimMs = Number.isFinite(n) ? Math.max(120, Math.min(4000, Math.floor(n))) : 520;
});
}
const relG = document.getElementById('editor-stack-tower-vbg-release-gap');
if (relG) {
relG.addEventListener('change', () => {
const s = String(relG.value).trim();
if (s === '') stackTowerBgScrollConfig.releaseGapWorldPx = null;
else {
const n = Number(s);
stackTowerBgScrollConfig.releaseGapWorldPx = Number.isFinite(n) && n >= 0
? Math.min(800, Math.floor(n))
: null;
}
});
}
const introF = document.getElementById('editor-stack-tower-vbg-intro-file');
if (introF) {
introF.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const r = new FileReader();
r.onload = () => {
stackTowerBgScrollConfig.introImage = r.result;
loadStackTowerBgScrollImagePair(stackTowerBgScrollConfig.introImage, stackTowerBgScrollConfig.loopImage || EDITOR_STACK_TOWER_BG_DEFAULT_LOOP, () => {
syncStackTowerBgScrollInitialToBottom();
draw();
});
};
r.readAsDataURL(file);
e.target.value = '';
});
}
const loopF = document.getElementById('editor-stack-tower-vbg-loop-file');
if (loopF) {
loopF.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const r = new FileReader();
r.onload = () => {
stackTowerBgScrollConfig.loopImage = r.result;
loadStackTowerBgScrollImagePair(stackTowerBgScrollConfig.introImage || EDITOR_STACK_TOWER_BG_DEFAULT_INTRO, stackTowerBgScrollConfig.loopImage, () => {
syncStackTowerBgScrollInitialToBottom();
draw();
});
};
r.readAsDataURL(file);
e.target.value = '';
});
}
const btnR = document.getElementById('editor-stack-tower-vbg-reset-assets');
if (btnR) {
btnR.addEventListener('click', () => {
stackTowerBgScrollConfig.introImage = null;
stackTowerBgScrollConfig.loopImage = null;
loadStackTowerBgScrollImagePair(EDITOR_STACK_TOWER_BG_DEFAULT_INTRO, EDITOR_STACK_TOWER_BG_DEFAULT_LOOP, () => {
syncStackTowerBgScrollUiFromConfig();
syncStackTowerBgScrollInitialToBottom();
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;
syncEditorGauntletRunwayBgBlockVisibility();
syncEditorStackTowerVerticalBgBlockVisibility();
if (!isEditorStackTowerMissionMap()) applyStackTowerCameraFollowFromMap({});
if (mapId === EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID) {
stopEditorGauntletRunwayBgRaf();
if (isEditorGauntletRunwayBgMap() && editorGauntletRunwayBgConfig.enabled) startEditorGauntletRunwayBgRaf();
draw();
}
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') && gameType !== 'quiz') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'quizQuestion' && gameType !== 'quiz' && gameType !== 'quiz_carry') drawModeEl.value = 'wall';
if (drawModeEl && (drawModeEl.value === 'stackRelease' || drawModeEl.value === 'stackLand') && gameType !== 'stack') drawModeEl.value = 'wall';
if (drawModeEl && quizCarryEditorCarryModes().indexOf(drawModeEl.value) >= 0 && gameType !== 'quiz_carry') drawModeEl.value = 'wall';
if (drawModeEl && /^jumpSurvivePlatform\d$/.test(drawModeEl.value) && gameType !== 'jump_survive') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'gauntletPlayerSpawn' && gameType !== 'gauntlet') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'gauntletLaserStart' && gameType !== 'gauntlet') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'gauntletLaserEnd' && gameType !== 'gauntlet') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'shooterSpawnPaint' && gameType !== 'space_shooter' && gameType !== 'jump_survive') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'balloonBossPlayerPaint' && gameType !== 'balloon_boss') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'balloonBossBossPaint' && gameType !== 'balloon_boss') drawModeEl.value = 'wall';
if (drawModeEl && (drawModeEl.value === 'quizBattleDome' || drawModeEl.value === 'quizBattlePath') && gameType !== 'quiz_battle') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'lobbyPlayerSpawn' && !supportsLobbySpawnPaint(gameType)) drawModeEl.value = 'wall';
toggleFroggerUI();
syncEditorStackHudMock();
syncGridImageBrushInputCaps();
syncDrawModeAuxUi();
});
const btnClearGauntletSpawns = document.getElementById('btn-clear-gauntlet-spawns');
if (btnClearGauntletSpawns) {
btnClearGauntletSpawns.addEventListener('click', () => {
gauntletPlayerSpawns = [];
if (statusEl) statusEl.textContent = 'ล้างลำดับเกิดพรมแดง (1–6) แล้ว';
draw();
});
}
const btnClearGauntletLaserSpan = document.getElementById('btn-clear-gauntlet-laser-span');
if (btnClearGauntletLaserSpan) {
btnClearGauntletLaserSpan.addEventListener('click', () => {
gauntletLaserRowStart = null;
gauntletLaserRowEnd = null;
if (statusEl) statusEl.textContent = 'ล้างช่วง laser — เลเซอร์ในเกมใช้เต็มความสูงแมปอีกครั้ง';
draw();
});
}
const lobbySpawnModeEl = document.getElementById('lobby-spawn-mode');
if (lobbySpawnModeEl) {
lobbySpawnModeEl.addEventListener('change', () => {
if (lobbySpawnModeEl.value !== 'slots6' && drawModeEl && drawModeEl.value === 'lobbyPlayerSpawn') drawModeEl.value = 'wall';
syncLobbySpawnAuxUi();
draw();
});
}
const btnClearLobbySpawns = document.getElementById('btn-clear-lobby-spawns');
if (btnClearLobbySpawns) {
btnClearLobbySpawns.addEventListener('click', () => {
lobbyPlayerSpawns = [null, null, null, null, null, null];
if (statusEl) statusEl.textContent = 'ล้างจุดเกิด P1–P6 แล้ว';
draw();
});
}
const lobbySlotPaintEl = document.getElementById('lobby-slot-paint');
if (lobbySlotPaintEl) {
lobbySlotPaintEl.addEventListener('change', () => { draw(); });
}
const btnClearShooterSpawns = document.getElementById('btn-clear-shooter-spawns');
if (btnClearShooterSpawns) {
btnClearShooterSpawns.addEventListener('click', () => {
ensureShooterSpawnSlots();
for (let yy = 0; yy < height; yy++) {
for (let xx = 0; xx < width; xx++) shooterSpawnSlots[yy][xx] = 0;
}
if (statusEl) statusEl.textContent = 'ล้างจุดเกิดยานทั้งหมดแล้ว';
draw();
});
}
const btnClearBalloonBoss = document.getElementById('btn-clear-balloon-boss-spawns');
if (btnClearBalloonBoss) {
btnClearBalloonBoss.addEventListener('click', () => {
ensureBalloonBossPlayerSlots();
for (let yy = 0; yy < height; yy++) {
for (let xx = 0; xx < width; xx++) balloonBossPlayerSlots[yy][xx] = 0;
}
balloonBossBossSpawn = null;
if (statusEl) statusEl.textContent = 'ล้างจุดเกิดผู้เล่น + บอสแล้ว';
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' || gameTypeEl.value === 'space_shooter' || gameTypeEl.value === 'balloon_boss')) ? 6
: (gameTypeEl && (gameTypeEl.value === 'stack' || gameTypeEl.value === 'quiz_carry' || gameTypeEl.value === 'quiz_battle' || gameTypeEl.value === 'jump_survive')) ? 8 : 4;
fetch(BASE + '/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 gt = gameTypeEl ? (gameTypeEl.value || 'zep') : 'zep';
/* โถง lobby ต้องใช้ room-lobby (มีปุ่มเริ่มเกม + โซนส้ม) — play.html ไม่มี UI host */
var page = gt === 'lobby' ? 'room-lobby.html' : 'play.html';
var q = page + '?space=' + encodeURIComponent(sid)
+ '&nick=' + encodeURIComponent(nick)
+ '&map=' + encodeURIComponent(id)
+ '&preview=1&defaultChar=1&editorEmbed=1'
+ '&_h=' + Date.now();
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();
ensureQuizCarryAreas();
ensureQuizBattleDomeArea();
ensureQuizBattlePathArea();
ensureJumpSurvivePlatformArea();
ensureJumpSurviveHazardArea();
ensureShooterSpawnSlots();
ensureBalloonBossPlayerSlots();
ensureStackAreas();
const fpSave = readCharacterFootprintInputs();
applyCharacterFootprintInputs(fpSave.cw, fpSave.ch);
const bpSepSave = readBlockPlayerSeparationWHForSave();
const showMapInGameEl = document.getElementById('show-map-in-game');
showMapInGame = showMapInGameEl ? showMapInGameEl.checked : true;
const body = {
name, gameType, width, height, tileSize,
characterCells: Math.max(fpSave.cw, fpSave.ch),
characterCellsW: fpSave.cw,
characterCellsH: fpSave.ch,
characterCollisionW: fpSave.colW,
characterCollisionH: fpSave.colH,
blockPlayerSeparationW: bpSepSave.w,
blockPlayerSeparationH: bpSepSave.h,
ground, objects, blockPlayer, interactive, startGameArea, spawnArea, spawn, cellColors, showMapInGame,
lobbySpawnMode: (() => {
ensureLobbyPlayerSpawnsLength();
sanitizeLobbyPlayerSpawnsInEditor();
let hasP = false;
for (let pi = 0; pi < 6; pi++) {
if (lobbyPlayerSpawns[pi]) { hasP = true; break; }
}
if (hasP) return 'slots6';
const el = document.getElementById('lobby-spawn-mode');
const v = el && el.value;
return (v === 'fixed' || v === 'slots6') ? v : 'random';
})(),
lobbyPlayerSpawns: (() => {
ensureLobbyPlayerSpawnsLength();
sanitizeLobbyPlayerSpawnsInEditor();
return lobbyPlayerSpawns.map((s) => (s && Number.isFinite(s.x) && Number.isFinite(s.y)
? { x: Math.floor(s.x), y: Math.floor(s.y) }
: null));
})(),
quizTrueArea, quizFalseArea, quizQuestionArea,
quizCarryHubArea: gameType === 'quiz_carry' ? quizCarryHubArea.map(r => r.slice()) : [],
quizCarryOptionArea: gameType === 'quiz_carry' ? quizCarryOptionArea.map(r => r.slice()) : [],
carryEmbedCountdownArea: gameType === 'quiz_carry' ? carryEmbedCountdownArea.map(r => r.slice()) : [],
carryEmbedCountdownHighlight: (() => {
if (gameType !== 'quiz_carry') return false;
const h = document.getElementById('carry-embed-countdown-highlight');
return !h || !!h.checked;
})(),
carryEmbedCountdownHighlightColor: (() => {
if (gameType !== 'quiz_carry') return undefined;
const el = document.getElementById('carry-embed-countdown-color');
return (el && el.value) ? el.value : '#ffb44a';
})(),
carryEmbedCountdownAnchor: (() => {
if (gameType !== 'quiz_carry') return undefined;
const el = document.getElementById('carry-embed-countdown-anchor');
const v = el && el.value;
if (v === 'grid' || v === 'map' || v === 'screen') return v;
return 'map';
})(),
quizBattleDomeArea: gameType === 'quiz_battle' ? quizBattleDomeArea.map(r => r.slice()) : [],
quizBattlePathArea: gameType === 'quiz_battle' ? quizBattlePathArea.map(r => r.slice()) : [],
stackReleaseArea: gameType === 'stack' ? stackReleaseArea.map(r => r.slice()) : [],
stackLandArea: gameType === 'stack' ? stackLandArea.map(r => r.slice()) : [],
jumpSurvivePlatforms: gameType === 'jump_survive' ? jumpSurvivePlatforms.slice() : [],
jumpSurvivePlatformArea: gameType === 'jump_survive' ? jumpSurvivePlatformArea.map((r) => r.slice()) : [],
jumpSurvivePlatformVariantArea: gameType === 'jump_survive' ? jumpSurvivePlatformVariantArea.map((r) => r.slice()) : [],
jumpSurviveHazardArea: gameType === 'jump_survive' ? jumpSurviveHazardArea.map((r) => r.slice()) : [],
shooterSpawnSlots: (gameType === 'space_shooter' || gameType === 'jump_survive') ? shooterSpawnSlots.map((r) => r.slice()) : [],
balloonBossPlayerSlots: gameType === 'balloon_boss' ? balloonBossPlayerSlots.map((r) => r.slice()) : [],
balloonBossBossSpawn: gameType === 'balloon_boss' && balloonBossBossSpawn && Number.isFinite(balloonBossBossSpawn.x)
? { x: Math.floor(balloonBossBossSpawn.x), y: Math.floor(balloonBossBossSpawn.y) }
: null,
customizeSpot: customizeSpot && Number.isFinite(customizeSpot.x)
? { x: Math.floor(customizeSpot.x), y: Math.floor(customizeSpot.y) }
: null,
gridImageLibrary: gridImageLibrary.map(serializeGridLibEntry).filter((x) => x != null),
gridImageSprites: gridImageSprites.map((s) => {
const o = { i: s.i, x: s.x, y: s.y, w: s.w, h: s.h };
if (s.bindCarryOption != null) o.bindCarryOption = s.bindCarryOption;
return o;
}),
gridImageCells: deriveGridImageCellsFromSprites(),
};
if (backgroundImage) body.backgroundImage = backgroundImage;
const ebsSave = readEditorBgScrollConfigForSave();
if (ebsSave) body.editorBgScroll = ebsSave;
const stcSave = readStackTowerCameraFollowForSave();
if (stcSave !== undefined) body.stackTowerCameraFollow = stcSave;
const stBgSave = readStackTowerBgScrollForSave();
if (stBgSave !== undefined) body.stackTowerBgScroll = stBgSave;
const gcrSave = readGauntletCrownRunwayBgForSave();
if (gcrSave !== undefined) body.gauntletCrownRunwayBg = gcrSave;
body.lanes = gameType === 'frogger' ? lanes : [];
sanitizeGauntletPlayerSpawns();
body.gauntletPlayerSpawns = gameType === 'gauntlet'
? gauntletPlayerSpawns.map((s) => ({ x: s.x, y: s.y }))
: [];
if (gameType === 'gauntlet') {
sanitizeGauntletLaserRowsInEditor();
if (gauntletLaserRowStart != null && gauntletLaserRowEnd != null) {
body.gauntletLaserRowStart = gauntletLaserRowStart;
body.gauntletLaserRowEnd = gauntletLaserRowEnd;
} else {
body.gauntletLaserRowStart = null;
body.gauntletLaserRowEnd = null;
}
}
const url = mapId ? BASE + '/api/maps/' + mapId : BASE + '/api/maps';
const method = mapId ? 'PUT' : 'POST';
let payloadStr;
try {
payloadStr = JSON.stringify(body);
} catch (err) {
if (statusEl) statusEl.textContent = 'แปลงฉากเป็น JSON ไม่ได้ — รูป/ข้อมูลใหญ่เกิน · Cannot stringify map JSON';
return;
}
const payloadBytes = (typeof TextEncoder !== 'undefined')
? new TextEncoder().encode(payloadStr).length
: new Blob([payloadStr]).size;
const maxSaveBytes = 78 * 1024 * 1024;
if (payloadBytes > maxSaveBytes) {
if (statusEl) {
statusEl.textContent = 'ข้อมูลฉาก ~' + Math.round(payloadBytes / (1024 * 1024)) + 'MB ใหญ่เกิน ' + Math.round(maxSaveBytes / (1024 * 1024))
+ 'MB — ลดรูปรันเวย์/คลังกริด หรือให้แอดมินเพิ่ม client_max_body_size · Map payload too large';
}
return;
}
fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: payloadStr })
.then((r) => {
if (!r.ok) {
return r.text().then((t) => {
const hint = r.status === 413
? ' (413 = body ใหญ่เกิน nginx — เพิ่ม client_max_body_size ใน /Game/api/ หรือลดรูป)'
: '';
throw new Error('HTTP ' + r.status + hint + (t ? ' ' + t.slice(0, 120) : ''));
});
}
return r.json();
})
.then(data => {
if (data.ok) {
const id = data.mapId || mapId;
statusEl.textContent = 'บันทึกแล้ว รหัสฉาก: ' + id + (showMapInGame ? '' : ' (ซ่อนกริดในเกม — รูปบนกริดยังแสดง) — ออกจากห้องแล้วเข้าใหม่ หรือกด F5 ในหน้าเล่น ถึงจะเห็นผล');
if (gameType === 'quiz_carry' && gridImageSprites.length) {
const used = new Set();
gridImageSprites.forEach(function (s) {
if (s && Number.isFinite(s.i) && s.i >= 0) used.add(Math.floor(s.i));
});
const noHeld = [];
used.forEach(function (idx) {
const e = gridImageLibrary[idx];
if (!gridLibHeldUrl(e)) noHeld.push(idx);
});
if (noHeld.length) {
const labels = noHeld.sort(function (a, b) { return a - b; }).map(function (i) { return '#' + (i + 1); });
statusEl.textContent += ' — เตือน (หยิบมาวาง): คลัง ' + labels.join(', ') + ' ยังไม่มีรูป «หยิบแล้ว» — ในเกมรูปบนพื้น/มือจะไม่สลับ (อัปโหลดช่องที่สองในคลัง หรือให้ held ต่างจาก idle)';
}
}
if (!mapId) window.history.replaceState({}, '', 'editor.html' + buildEditorSearch(id));
} else statusEl.textContent = data.error || 'บันทึกไม่ได้';
})
.catch((err) => {
if (statusEl) {
statusEl.textContent = 'บันทึกไม่ได้'
+ (err && err.message ? ' — ' + err.message : '')
+ ' · ถ้าเป็น 413 ให้ reload nginx หลังเพิ่ม client_max_body_size';
}
});
});
const cellColorWrap = document.getElementById('cell-color-wrap');
const gridImageToolsWrap = document.getElementById('editor-grid-image-tools');
const cellAlphaEl = document.getElementById('cell-alpha');
const cellAlphaValue = document.getElementById('cell-alpha-value');
function syncDrawModeAuxUi() {
if (!drawModeEl) return;
if (cellColorWrap) cellColorWrap.style.display = drawModeEl.value === 'color' ? 'inline' : 'none';
if (gridImageToolsWrap) gridImageToolsWrap.style.display = drawModeEl.value === 'cellImage' ? 'flex' : 'none';
const bindWrap = document.getElementById('grid-sprite-carry-bind-wrap');
if (bindWrap) {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
bindWrap.style.display = (drawModeEl.value === 'cellImage' && gt === 'quiz_carry') ? 'flex' : 'none';
}
}
if (drawModeEl) drawModeEl.addEventListener('change', () => { syncDrawModeAuxUi(); syncLobbySpawnAuxUi(); });
syncDrawModeAuxUi();
syncLobbySpawnAuxUi();
const gridCellUpload = document.getElementById('grid-cell-image-upload');
if (gridCellUpload) {
gridCellUpload.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
if (file.size > 6 * 1024 * 1024) {
if (statusEl) statusEl.textContent = 'ไฟล์ใหญ่เกิน 6MB — ลดขนาดก่อนอัปโหลด';
e.target.value = '';
return;
}
const r = new FileReader();
r.onload = () => {
const dataUrl = r.result;
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) {
e.target.value = '';
return;
}
gridImageLibrary.push({ idle: dataUrl, held: null });
gridImageBrushIndex = gridImageLibrary.length - 1;
delete editorGridImageElByIndex[gridImageBrushIndex];
primeEditorGridImage(gridImageBrushIndex);
rebuildGridImageGallery();
syncGridImageBrushInputCaps();
draw();
if (statusEl) statusEl.textContent = 'เพิ่มรูปเข้าคลังแล้ว — คลิกซ้ายบนกริดเพื่อวาง (ตั้งกว้าง×สูงก่อนได้)';
};
r.readAsDataURL(file);
e.target.value = '';
});
}
const gridHeldUpload = document.getElementById('grid-cell-image-held-upload');
if (gridHeldUpload) {
gridHeldUpload.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
const idx = pendingHeldLibIndex;
pendingHeldLibIndex = -1;
e.target.value = '';
if (!file || idx < 0 || idx >= gridImageLibrary.length) return;
if (file.size > 6 * 1024 * 1024) {
if (statusEl) statusEl.textContent = 'ไฟล์ใหญ่เกิน 6MB — ลดขนาดก่อนอัปโหลด';
return;
}
const r = new FileReader();
r.onload = () => {
const dataUrl = r.result;
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) return;
const idle = gridLibIdleUrl(gridImageLibrary[idx]);
if (dataUrl === idle) {
if (statusEl) statusEl.textContent = 'ไฟล์นี้เหมือนรูปก่อนหยิบทุกไบต์ — เลือกภาพอื่นสำหรับ «หยิบแล้ว» ถึงจะเห็นต่างในเกม';
rebuildGridImageGallery();
draw();
return;
}
gridImageLibrary[idx] = { idle, held: dataUrl };
delete editorGridImageHeldElByIndex[idx];
rebuildGridImageGallery();
draw();
if (statusEl) statusEl.textContent = 'ตั้งรูปตอนถือสำหรับ #' + (idx + 1) + ' แล้ว (quiz carry — แสดงบนแมปจนกว่าจะปล่อย)';
};
r.readAsDataURL(file);
});
}
const btnRmGridLib = document.getElementById('btn-grid-image-remove-from-lib');
if (btnRmGridLib) {
btnRmGridLib.addEventListener('click', () => {
if (!gridImageLibrary.length) return;
removeGridImageFromLibraryAt(gridImageBrushIndex);
});
}
const brushWEl0 = document.getElementById('grid-image-brush-w');
const brushHEl0 = document.getElementById('grid-image-brush-h');
if (brushWEl0) brushWEl0.addEventListener('input', () => { syncGridImageBrushInputCaps(); draw(); });
if (brushHEl0) brushHEl0.addEventListener('input', () => { syncGridImageBrushInputCaps(); draw(); });
if (cellAlphaEl && cellAlphaValue) cellAlphaEl.addEventListener('input', () => { cellAlphaValue.textContent = cellAlphaEl.value; draw(); });
mapW.addEventListener('change', resize);
mapH.addEventListener('change', resize);
tileSizeEl.addEventListener('change', resize);
if (characterCellsWEl) characterCellsWEl.addEventListener('input', () => {
const fp = readCharacterFootprintInputs();
applyCharacterFootprintInputs(fp.cw, fp.ch);
draw();
});
if (characterCellsHEl) characterCellsHEl.addEventListener('input', () => {
const fp = readCharacterFootprintInputs();
applyCharacterFootprintInputs(fp.cw, fp.ch);
draw();
});
if (characterCollisionWEl) characterCollisionWEl.addEventListener('input', () => {
const fp = readCharacterFootprintInputs();
applyCharacterFootprintInputs(fp.cw, fp.ch);
draw();
});
if (characterCollisionHEl) characterCollisionHEl.addEventListener('input', () => {
const fp = readCharacterFootprintInputs();
applyCharacterFootprintInputs(fp.cw, fp.ch);
draw();
});
function clampSepInput(el) {
if (!el) return;
let v = Math.floor(Number(el.value));
if (!Number.isFinite(v) || v < 0) v = 0;
el.value = String(Math.min(4, v));
}
if (blockPlayerSeparationWEl) {
blockPlayerSeparationWEl.addEventListener('input', () => {
clampSepInput(blockPlayerSeparationWEl);
draw();
});
}
if (blockPlayerSeparationHEl) {
blockPlayerSeparationHEl.addEventListener('input', () => {
clampSepInput(blockPlayerSeparationHEl);
draw();
});
}
if (mapId) {
fetch(BASE + '/api/maps/' + mapId)
.then(r => r.json())
.then(m => {
width = Math.max(10, Math.min(50, parseInt(m.width, 10) || 20));
height = Math.max(10, Math.min(40, parseInt(m.height, 10) || 15));
tileSize = Math.max(16, Math.min(64, parseInt(m.tileSize, 10) || 32));
const leg = Math.max(1, Math.min(4, m.characterCells || 1));
let cwL = Number(m.characterCellsW);
let chL = Number(m.characterCellsH);
cwL = Number.isFinite(cwL) ? Math.max(1, Math.min(4, Math.floor(cwL))) : leg;
chL = Number.isFinite(chL) ? Math.max(1, Math.min(4, Math.floor(chL))) : leg;
applyCharacterFootprintInputs(cwL, chL);
let colWL = Number(m.characterCollisionW);
let colHL = Number(m.characterCollisionH);
colWL = Number.isFinite(colWL) ? Math.max(1, Math.min(cwL, Math.floor(colWL))) : cwL;
colHL = Number.isFinite(colHL) ? Math.max(1, Math.min(chL, Math.floor(colHL))) : chL;
if (characterCollisionWEl) characterCollisionWEl.value = String(colWL);
if (characterCollisionHEl) characterCollisionHEl.value = String(colHL);
if (blockPlayerSeparationWEl || blockPlayerSeparationHEl) {
let wS = Math.floor(Number(m.blockPlayerSeparationW));
let hS = Math.floor(Number(m.blockPlayerSeparationH));
if (!Number.isFinite(wS) || wS < 0) {
const leg = Math.floor(Number(m.blockPlayerSeparation));
wS = Number.isFinite(leg) && leg >= 0 ? Math.min(4, leg) : 0;
} else wS = Math.min(4, wS);
if (!Number.isFinite(hS) || hS < 0) {
const leg = Math.floor(Number(m.blockPlayerSeparation));
hS = Number.isFinite(leg) && leg >= 0 ? Math.min(4, leg) : 0;
} else hS = Math.min(4, hS);
if (blockPlayerSeparationWEl) blockPlayerSeparationWEl.value = String(wS);
if (blockPlayerSeparationHEl) blockPlayerSeparationHEl.value = String(hS);
}
ground = m.ground || [];
objects = m.objects || [];
ensureObjectsGroundGrids();
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));
quizCarryHubArea = m.quizCarryHubArea && m.quizCarryHubArea.length ? m.quizCarryHubArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
quizCarryOptionArea = m.quizCarryOptionArea && m.quizCarryOptionArea.length ? m.quizCarryOptionArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
carryEmbedCountdownArea = m.carryEmbedCountdownArea && m.carryEmbedCountdownArea.length
? m.carryEmbedCountdownArea.map(r => r && r.slice())
: Array(height).fill(0).map(() => Array(width).fill(0));
quizBattleDomeArea = m.quizBattleDomeArea && m.quizBattleDomeArea.length ? m.quizBattleDomeArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
quizBattlePathArea = m.quizBattlePathArea && m.quizBattlePathArea.length ? m.quizBattlePathArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
jumpSurvivePlatforms = Array.isArray(m.jumpSurvivePlatforms) ? m.jumpSurvivePlatforms.map((p) => (p && typeof p === 'object' ? { ...p } : null)).filter(Boolean) : [];
jumpSurvivePlatformArea = m.jumpSurvivePlatformArea && m.jumpSurvivePlatformArea.length ? m.jumpSurvivePlatformArea.map((r) => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
jumpSurvivePlatformVariantArea = m.jumpSurvivePlatformVariantArea && m.jumpSurvivePlatformVariantArea.length
? m.jumpSurvivePlatformVariantArea.map((r) => r && r.slice())
: Array(height).fill(0).map(() => Array(width).fill(0));
ensureJumpSurvivePlatformArea();
jumpSurviveHazardArea = m.jumpSurviveHazardArea && m.jumpSurviveHazardArea.length ? m.jumpSurviveHazardArea.map((r) => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
ensureJumpSurviveHazardArea();
shooterSpawnSlots = m.shooterSpawnSlots && m.shooterSpawnSlots.length
? m.shooterSpawnSlots.map((r) => r && r.slice())
: Array(height).fill(0).map(() => Array(width).fill(0));
ensureShooterSpawnSlots();
balloonBossPlayerSlots = m.balloonBossPlayerSlots && m.balloonBossPlayerSlots.length
? m.balloonBossPlayerSlots.map((r) => r && r.slice())
: Array(height).fill(0).map(() => Array(width).fill(0));
ensureBalloonBossPlayerSlots();
if (m.balloonBossBossSpawn && Number.isFinite(Number(m.balloonBossBossSpawn.x)) && Number.isFinite(Number(m.balloonBossBossSpawn.y))) {
balloonBossBossSpawn = { x: Math.floor(Number(m.balloonBossBossSpawn.x)), y: Math.floor(Number(m.balloonBossBossSpawn.y)) };
} else {
balloonBossBossSpawn = null;
}
if (m.customizeSpot && Number.isFinite(Number(m.customizeSpot.x)) && Number.isFinite(Number(m.customizeSpot.y))) {
customizeSpot = { x: Math.floor(Number(m.customizeSpot.x)), y: Math.floor(Number(m.customizeSpot.y)) };
} else {
customizeSpot = null;
}
stackReleaseArea = m.stackReleaseArea && m.stackReleaseArea.length ? m.stackReleaseArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0));
stackLandArea = m.stackLandArea && m.stackLandArea.length ? m.stackLandArea.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));
gridImageLibrary = Array.isArray(m.gridImageLibrary)
? m.gridImageLibrary.map(normalizeGridLibEntry).filter((e) => e && gridLibIdleUrl(e) && gridLibIdleUrl(e).length < 25000000)
: [];
gridImageSprites = migrateSpritesFromMapJson(m, width, height);
sanitizeSpritesInPlace();
gridImageBrushIndex = Math.min(gridImageBrushIndex, Math.max(0, gridImageLibrary.length - 1));
Object.keys(editorGridImageElByIndex).forEach((k) => { delete editorGridImageElByIndex[k]; });
Object.keys(editorGridImageHeldElByIndex).forEach((k) => { delete editorGridImageHeldElByIndex[k]; });
rebuildGridImageGallery();
syncGridImageBrushInputCaps();
showMapInGame = m.showMapInGame !== false;
const showMapEl = document.getElementById('show-map-in-game');
if (showMapEl) showMapEl.checked = showMapInGame;
const cdH = document.getElementById('carry-embed-countdown-highlight');
if (cdH) cdH.checked = m.carryEmbedCountdownHighlight !== false;
const cdC = document.getElementById('carry-embed-countdown-color');
if (cdC && typeof m.carryEmbedCountdownHighlightColor === 'string') {
const hx = m.carryEmbedCountdownHighlightColor.trim();
if (/^#[0-9a-fA-F]{6}$/.test(hx)) cdC.value = hx;
}
const cdA = document.getElementById('carry-embed-countdown-anchor');
if (cdA) {
const a = m.carryEmbedCountdownAnchor;
cdA.value = (a === 'grid' || a === 'screen' || a === 'map') ? a : 'map';
}
ensureInteractive();
ensureStartGameArea();
ensureSpawnArea();
backgroundImage = m.backgroundImage || null;
if (backgroundImage) {
backgroundImageImg = new Image();
backgroundImageImg.onload = () => { draw(); };
backgroundImageImg.src = backgroundImage;
} else backgroundImageImg = null;
applyEditorBgScrollFromMap(m);
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))
: [];
const rs = m.gauntletLaserRowStart;
const re = m.gauntletLaserRowEnd;
if (rs != null && re != null && Number.isFinite(Number(rs)) && Number.isFinite(Number(re))) {
gauntletLaserRowStart = Math.floor(Number(rs));
gauntletLaserRowEnd = Math.floor(Number(re));
sanitizeGauntletLaserRowsInEditor();
} else {
gauntletLaserRowStart = null;
gauntletLaserRowEnd = null;
}
lobbyPlayerSpawns = [null, null, null, null, null, null];
if (Array.isArray(m.lobbyPlayerSpawns)) {
for (let li = 0; li < 6 && li < m.lobbyPlayerSpawns.length; li++) {
const c = m.lobbyPlayerSpawns[li];
if (c && c.x != null && c.y != null) {
lobbyPlayerSpawns[li] = { x: Math.floor(Number(c.x)), y: Math.floor(Number(c.y)) };
}
}
}
sanitizeLobbyPlayerSpawnsInEditor();
const lsmLoad = document.getElementById('lobby-spawn-mode');
if (lsmLoad) {
const mv = m.lobbySpawnMode;
lsmLoad.value = (mv === 'fixed' || mv === 'slots6') ? mv : 'random';
}
gameType = m.gameType || 'zep';
if (gameTypeEl) gameTypeEl.value = gameType;
syncEditorGauntletRunwayBgBlockVisibility();
syncEditorStackHudMock();
applyGauntletCrownRunwayBgFromMap(m);
applyStackTowerCameraFollowFromMap(m);
lanes = (m.lanes && m.lanes.length) ? m.lanes.map(l => ({ ...l })) : [];
if (gameType === 'gauntlet' || gameType === 'stack' || gameType === 'quiz_carry' || gameType === 'quiz_battle' || gameType === 'jump_survive' || gameType === 'space_shooter' || gameType === 'balloon_boss') lanes = [];
if (gameType === 'frogger') {
ensureLanes();
if (lanes.length !== height) ensureLanes();
}
mapName.value = m.name || ''; mapW.value = width; mapH.value = height; tileSizeEl.value = tileSize;
sanitizeGauntletPlayerSpawns();
resize(); draw();
toggleFroggerUI();
})
.catch(() => initGrid());
} else { initGrid(); toggleFroggerUI(); syncEditorStackHudMock(); }
})();