push image to editor

This commit is contained in:
2026-04-26 12:27:55 +00:00
parent f70e13a8aa
commit 8597578a28
10 changed files with 668 additions and 16 deletions
File diff suppressed because one or more lines are too long
+85
View File
@@ -265,6 +265,91 @@ button { background: #7aa2f7; color: #1a1b26; cursor: pointer; border: none; }
align-items: center;
gap: 0.35rem 0.75rem;
}
.editor-grid-image-tools {
display: none;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
max-width: 100%;
width: min(100%, 520px);
}
.editor-grid-image-size-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
align-items: center;
}
.editor-grid-image-size-row input[type="number"] {
width: 3.2rem;
margin-left: 0.25rem;
}
.editor-grid-image-gallery-wrap {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.editor-grid-image-gallery-label {
font-size: 0.72rem;
color: #a9b1d6;
font-weight: 600;
}
.grid-cell-image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
gap: 0.4rem;
max-height: 200px;
overflow-y: auto;
padding: 0.35rem;
border-radius: 8px;
border: 1px solid rgba(65, 72, 104, 0.85);
background: rgba(15, 16, 24, 0.6);
}
.grid-cell-image-gallery-empty {
font-size: 0.72rem;
color: #787c99;
padding: 0.35rem;
}
.grid-cell-image-gallery-item {
position: relative;
padding: 0;
margin: 0;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #24283b;
line-height: 0;
}
.grid-cell-image-gallery-item.is-selected {
border-color: #9ece6a;
box-shadow: 0 0 0 1px rgba(158, 206, 106, 0.35);
}
.grid-cell-image-gallery-item img {
display: block;
width: 100%;
height: 56px;
object-fit: cover;
}
.grid-cell-image-gallery-cap {
display: block;
font-size: 0.65rem;
text-align: center;
padding: 0.15rem 0;
color: #c0caf5;
background: rgba(0, 0, 0, 0.35);
}
.editor-grid-image-upload-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
body.editor-embed-admin .grid-cell-image-gallery {
max-height: 160px;
}
body.editor-embed-admin .grid-cell-image-gallery-item img {
height: 48px;
}
.editor-btn-primary {
font-weight: 600;
}
+23 -4
View File
@@ -3,8 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<title>Editor ฉาก — Game</title>
<link rel="stylesheet" href="css/style.css?v=0.0148">
<link rel="stylesheet" href="css/style.css?v=20260427-gallery">
</head>
<body>
<div class="container editor-page">
@@ -56,12 +58,14 @@
<section class="editor-card editor-card--wide" aria-labelledby="editor-card-draw-label">
<h2 class="editor-card__title" id="editor-card-draw-label">เครื่องมือวาด</h2>
<p class="editor-draw-build-hint" style="margin:0 0 0.4rem;font-size:0.74rem;color:#9ece6a;line-height:1.35;">มีโหมด <strong>รูปบนกริด</strong> ในเมนู «โหมดวาด» — ถ้าไม่เห็นข้อความนี้หรือไม่มีเมนูนั้น = หน้าเว็บยังเป็นไฟล์เก่า (ลอง <strong>Ctrl+F5</strong> หรืออัปโหลด <code>editor.html</code> / <code>js/editor.js</code> ขึ้นเซิร์ฟใหม่)</p>
<div class="editor-card__row editor-card__row--wrap">
<label class="editor-field-drawmode">โหมดวาด
<select id="draw-mode" title="จัดกลุ่มตามประเภท — ตัวเลือกที่ไม่ใช้กับโหมดเกมจะถูกซ่อน">
<optgroup label="พื้นฐาน / ทุกโหมด">
<option value="wall">กำแพง (เดินไม่ได้)</option>
<option value="noOverlap">โซนห้ามซ้อนผู้เล่น</option>
<option value="cellImage">รูปบนกริด (อัปโหลด / เลือกจากคลัง)</option>
<option value="interactive">โซน interactive (จุดกด/ใช้ได้)</option>
<option value="spawnArea" id="draw-mode-option-spawn-area">พื้นที่สุ่มจุดเกิด (ฟ้า — ผู้เล่นใหม่สุ่มในช่องนี้)</option>
<option value="startGame" id="draw-mode-option-start-game">พื้นที่เริ่มเกม (host ยืนแล้วกดเริ่มได้)</option>
@@ -118,7 +122,21 @@
<label>สี <input type="color" id="cell-color" value="#24283b" title="สี"></label>
<label>Alpha <input type="range" id="cell-alpha" min="0" max="100" value="100" title="ความโปร่งใส 0=ใส 100=ทึบ"> <span id="cell-alpha-value">100</span>%</label>
</span>
<label class="editor-toggle"><input type="checkbox" id="show-map-in-game" checked> โชว์กริดในเกม</label>
<div id="editor-grid-image-tools" class="editor-grid-image-tools" style="display:none;">
<div class="editor-grid-image-size-row">
<label>กว้าง (ช่อง) <input type="number" id="grid-image-brush-w" value="1" min="1" max="24" title="จำนวนช่องกริดแนวนอน"></label>
<label>สูง (ช่อง) <input type="number" id="grid-image-brush-h" value="1" min="1" max="24" title="จำนวนช่องกริดแนวตั้ง"></label>
</div>
<div class="editor-grid-image-gallery-wrap">
<span class="editor-grid-image-gallery-label">คลังรูป (คลิกเลือก)</span>
<div id="grid-cell-image-gallery" class="grid-cell-image-gallery" role="listbox" aria-label="คลังรูปบนกริด"></div>
</div>
<div class="editor-grid-image-upload-row">
<label>อัปโหลดเข้าคลัง <input type="file" id="grid-cell-image-upload" accept="image/*"></label>
<button type="button" id="btn-grid-image-remove-from-lib" title="ลบรูปที่เลือกในคลัง (สไปรต์ที่ใช้รูปนี้จะหาย)">ลบจากคลัง</button>
</div>
</div>
<label class="editor-toggle" title="ปิด = ไม่วาดกริด/ช่องฉาก แต่ยังแสดงรูปที่วาดบนกริด · Off = hide tile grid, grid images still show"><input type="checkbox" id="show-map-in-game" checked> โชว์กริดในเกม</label>
<button type="button" id="btn-clear-gauntlet-spawns" hidden title="ล้างรายการจุดเกิดพรมแดงที่กำหนดเอง">ล้างลำดับ 16</button>
<label id="shooter-slot-paint-wrap" class="editor-field-grow" style="display:none;" title="ช่องผู้เล่นลำดับที่ 1–6 ตรงกับคนในเกม (คุณ = ลำดับแรกจาก join)">ช่องเกิดยาน P
<select id="shooter-slot-paint">
@@ -178,6 +196,7 @@
<li><strong>กระโดดให้รอด</strong>: โหมด <strong>แพลตฟอร์ม</strong> (ฟ้าอมเขียว) · กำแพง = ขอบซ้ายขวา/บน · Space / W = กระโดด</li>
<li><strong>ลูกโป้งยิงบอส</strong>: จุดเกิดผู้เล่น P1–P6 + <strong>จุดเกิดบอส</strong> 1 ช่อง · ในเกมเดินช้า ยิงมีดีเลย์ · ลูกโป้งหมด = ตกรอบ</li>
<li><strong>สีช่อง</strong>: เลือกโหมดสี + Alpha แล้วคลิกบนกริด</li>
<li><strong>รูปบนกริด</strong>: โหมด «รูปบนกริด» — ตั้งกว้าง×สูง (ช่อง) · เลือกรูปจากแกลเลอรี · คลิกซ้ายวาง (ทับสไปรต์ที่ทับกัน) · คลิกขาวางลบทั้งแผ่น · บันทึกฉากเก็บเป็น <code>gridImageSprites</code> (ไฟล์ใหญ่ = JSON ใหญ่)</li>
<li>ปิด «โชว์กริดในเกม» = ซ่อนกริด/กำแพงในเกม (รูปพื้นหลังยังแสดง)</li>
</ul>
</section>
@@ -187,8 +206,8 @@
<p class="legend" id="editor-status">บันทึกแล้วจะได้รหัสฉาก ใช้รหัสนี้ตอนสร้างห้องในล็อบบี้</p>
</div>
</div>
<script src="js/version.js?v=0.0104"></script>
<script src="js/editor.js?v=0.040"></script>
<script src="js/version.js?v=0.0169"></script>
<script src="js/editor.js?v=20260428-zones-over-grid"></script>
<div class="version-tag">v —</div>
</body>
</html>
+336 -4
View File
@@ -86,6 +86,12 @@
let showMapInGame = true;
let backgroundImage = null;
let backgroundImageImg = null;
/** คลังรูป + สไปรต์ { i, x, y, w, h } — บันทึก gridImageSprites + gridImageCells (สำหรับรุ่นเก่า) */
let gridImageLibrary = [];
let gridImageSprites = [];
let gridImageBrushIndex = 0;
const editorGridImageElByIndex = {};
const GRID_IMG_MAX_WH = 24;
let isSpawnMode = false;
let editorFroggerAnimationId = null;
@@ -358,6 +364,184 @@
}
}
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);
return { i, x, y, w, h };
}).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 clampSpriteToMap(s) {
let { i, x, y, w, h } = 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);
return { i, x, y, w, h };
}
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;
}
}
return { i: libIdx, x: ax, y: ay, w, h };
}
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((url, idx) => {
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 = url;
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));
});
});
gal.appendChild(btn);
primeEditorGridImage(idx);
});
}
function primeEditorGridImage(idx) {
if (editorGridImageElByIndex[idx] || !gridImageLibrary[idx]) return;
const im = new Image();
im.onload = () => { draw(); };
im.onerror = () => { delete editorGridImageElByIndex[idx]; };
im.src = gridImageLibrary[idx];
editorGridImageElByIndex[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]; });
sanitizeSpritesInPlace();
rebuildGridImageGallery();
syncGridImageBrushInputCaps();
draw();
if (statusEl) statusEl.textContent = 'ลบรูปออกจากคลังแล้ว';
}
function buildDefaultLanes() {
const arr = [];
for (let y = 0; y < height; y++) {
@@ -706,6 +890,12 @@
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]; });
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 };
@@ -726,6 +916,8 @@
sanitizeGauntletPlayerSpawns();
ensureShooterSpawnSlots();
ensureBalloonBossPlayerSlots();
sanitizeSpritesInPlace();
syncGridImageBrushInputCaps();
draw();
}
@@ -805,6 +997,50 @@
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);
@@ -1148,6 +1384,44 @@
}
return;
}
if (drawModeEl.value === 'cellImage') {
if (objects[y][x] === 1) {
if (statusEl) statusEl.textContent = 'ช่องกำแพง — วางรูปบนกริดไม่ได้';
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') {
@@ -1369,6 +1643,7 @@
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';
toggleFroggerUI();
syncGridImageBrushInputCaps();
});
const btnClearGauntletSpawns = document.getElementById('btn-clear-gauntlet-spawns');
@@ -1487,6 +1762,9 @@
balloonBossBossSpawn: gameType === 'balloon_boss' && balloonBossBossSpawn && Number.isFinite(balloonBossBossSpawn.x)
? { x: Math.floor(balloonBossBossSpawn.x), y: Math.floor(balloonBossBossSpawn.y) }
: null,
gridImageLibrary: gridImageLibrary.slice(),
gridImageSprites: gridImageSprites.map((s) => ({ i: s.i, x: s.x, y: s.y, w: s.w, h: s.h })),
gridImageCells: deriveGridImageCellsFromSprites(),
};
if (backgroundImage) body.backgroundImage = backgroundImage;
body.lanes = gameType === 'frogger' ? lanes : [];
@@ -1501,7 +1779,7 @@
.then(data => {
if (data.ok) {
const id = data.mapId || mapId;
statusEl.textContent = 'บันทึกแล้ว รหัสฉาก: ' + id + (showMapInGame ? '' : ' (ไม่โชว์ฉาก) — ออกจากห้องแล้วเข้าใหม่ หรือกด F5 ในหน้าเล่น ถึงจะเห็นผล');
statusEl.textContent = 'บันทึกแล้ว รหัสฉาก: ' + id + (showMapInGame ? '' : ' (ซ่อนกริดในเกม — รูปบนกริดยังแสดง) — ออกจากห้องแล้วเข้าใหม่ หรือกด F5 ในหน้าเล่น ถึงจะเห็นผล');
if (!mapId) window.history.replaceState({}, '', 'editor.html' + buildEditorSearch(id));
} else statusEl.textContent = data.error || 'บันทึกไม่ได้';
})
@@ -1509,12 +1787,57 @@
});
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');
if (drawModeEl) drawModeEl.addEventListener('change', () => {
function syncDrawModeAuxUi() {
if (!drawModeEl) return;
if (cellColorWrap) cellColorWrap.style.display = drawModeEl.value === 'color' ? 'inline' : 'none';
});
if (cellColorWrap && drawModeEl) cellColorWrap.style.display = drawModeEl.value === 'color' ? 'inline' : 'none';
if (gridImageToolsWrap) gridImageToolsWrap.style.display = drawModeEl.value === 'cellImage' ? 'flex' : 'none';
}
if (drawModeEl) drawModeEl.addEventListener('change', () => { syncDrawModeAuxUi(); });
syncDrawModeAuxUi();
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(dataUrl);
gridImageBrushIndex = gridImageLibrary.length - 1;
delete editorGridImageElByIndex[gridImageBrushIndex];
primeEditorGridImage(gridImageBrushIndex);
rebuildGridImageGallery();
syncGridImageBrushInputCaps();
draw();
if (statusEl) statusEl.textContent = 'เพิ่มรูปเข้าคลังแล้ว — คลิกซ้ายบนกริดเพื่อวาง (ตั้งกว้าง×สูงก่อนได้)';
};
r.readAsDataURL(file);
e.target.value = '';
});
}
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);
@@ -1569,6 +1892,15 @@
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.filter((s) => typeof s === 'string' && s.length > 0 && s.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]; });
rebuildGridImageGallery();
syncGridImageBrushInputCaps();
showMapInGame = m.showMapInGame !== false;
const showMapEl = document.getElementById('show-map-in-game');
if (showMapEl) showMapEl.checked = showMapInGame;
+114 -4
View File
@@ -83,6 +83,8 @@
let stackFall = null;
let lastStackTickMs = performance.now();
let mapBackgroundImg = null;
/** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */
let mapGridImageImgs = [];
let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, tx: null, ty: null, quizCarryHeld: null };
const others = new Map();
/** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */
@@ -5299,6 +5301,73 @@
}
}
function normalizeGridImageCellsOnMap(md) {
if (!md) return;
const w = md.width || 20, h = md.height || 15;
if (!Array.isArray(md.gridImageLibrary)) md.gridImageLibrary = [];
md.gridImageLibrary = md.gridImageLibrary.filter((s) => typeof s === 'string' && s.length > 0 && s.length < 30000000);
const libLen = md.gridImageLibrary.length;
let placements = [];
if (Array.isArray(md.gridImageSprites) && md.gridImageSprites.length) {
placements = md.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 ww = Math.max(1, Math.floor(Number(raw.w)) || 1);
const hh = Math.max(1, Math.floor(Number(raw.h)) || 1);
return { i, x, y, w: ww, h: hh };
}).filter((s) => Number.isFinite(s.i) && s.i >= 0 && s.i < libLen &&
Number.isFinite(s.x) && Number.isFinite(s.y) && s.w >= 1 && s.h >= 1 &&
s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
placements = placements.map((s) => {
let x = s.x, y = s.y, ww = s.w, hh = s.h;
if (x < 0) { ww += x; x = 0; }
if (y < 0) { hh += y; y = 0; }
if (x + ww > w) ww = w - x;
if (y + hh > h) hh = h - y;
return { i: s.i, x, y, w: Math.max(1, ww), h: Math.max(1, hh) };
}).filter((s) => s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
} else {
const cells = md.gridImageCells;
if (Array.isArray(cells) && cells.length === h && libLen > 0) {
for (let yy = 0; yy < h; yy++) {
const row = cells[yy];
if (!row) continue;
for (let xx = 0; xx < w; xx++) {
if (row[xx] == null) continue;
const iv = parseInt(row[xx], 10);
if (!Number.isFinite(iv) || iv < 0 || iv >= libLen) continue;
placements.push({ i: iv, x: xx, y: yy, w: 1, h: 1 });
}
}
}
}
md.gridImagePlacements = placements;
const outCells = Array(h).fill(0).map(() => Array(w).fill(null));
placements.forEach((sp) => {
for (let yy = sp.y; yy < sp.y + sp.h; yy++) {
for (let xx = sp.x; xx < sp.x + sp.w; xx++) {
if (yy >= 0 && yy < h && xx >= 0 && xx < w) outCells[yy][xx] = sp.i;
}
}
});
md.gridImageCells = outCells;
}
function loadMapGridImages() {
mapGridImageImgs = [];
if (!mapData || !Array.isArray(mapData.gridImageLibrary) || !mapData.gridImageLibrary.length) return;
mapData.gridImageLibrary.forEach((src, i) => {
mapGridImageImgs[i] = null;
if (!src || typeof src !== 'string') return;
const im = new Image();
im.onload = () => { try { draw(); } catch (e) {} };
im.onerror = () => {};
im.src = src;
mapGridImageImgs[i] = im;
});
}
function applyMapAndStart(gameMapData, res) {
const prevWasSpaceShooter = mapData && mapData.gameType === 'space_shooter';
const prevWasBalloonBoss = mapData && mapData.gameType === 'balloon_boss';
@@ -5348,6 +5417,8 @@
mapBackgroundImg = new Image();
mapBackgroundImg.src = mapData.backgroundImage;
}
normalizeGridImageCellsOnMap(mapData);
loadMapGridImages();
var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
: (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
: (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
@@ -5871,6 +5942,8 @@
mapBackgroundImg = new Image();
mapBackgroundImg.src = mapData.backgroundImage;
}
normalizeGridImageCellsOnMap(mapData);
loadMapGridImages();
if (mapData.gameType === 'gauntlet' && ev.peersSnap && Array.isArray(ev.peersSnap)) {
ev.peersSnap.forEach((p) => {
if (p.id != null && myId != null && String(p.id) === String(myId)) {
@@ -7185,11 +7258,38 @@
return [sx, sy];
}
function drawGridImagePlacementsLayer() {
if (!mapData.gridImagePlacements || !mapData.gridImagePlacements.length || !mapData.gridImageLibrary || !mapData.gridImageLibrary.length) return;
for (let gi = 0; gi < mapData.gridImagePlacements.length; gi++) {
const sp = mapData.gridImagePlacements[gi];
const wx0 = sp.x * tileSize;
const wy0 = sp.y * tileSize;
const ww = sp.w * tileSize;
const wh = sp.h * tileSize;
if (wx0 + ww <= worldMinX || wx0 >= worldMaxX || wy0 + wh <= worldMinY || wy0 >= worldMaxY) continue;
const gim = mapGridImageImgs[sp.i];
if (!gim || !gim.complete || !gim.naturalWidth) continue;
const sx0 = (wx0 - camX) * zDraw + canvas.width / 2;
const sy0 = (wy0 - camY) * zDraw + canvas.height / 2;
const sw = ww * zDraw;
const sh = wh * zDraw;
ctx.save();
ctx.beginPath();
ctx.rect(sx0, sy0, sw, sh);
ctx.clip();
ctx.drawImage(gim, sx0, sy0, sw, sh);
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.strokeRect(sx0 + 0.5, sy0 + 0.5, sw - 1, sh - 1);
}
}
const startTileX = Math.max(0, Math.floor(worldMinX / tileSize));
const endTileX = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
const startTileY = Math.max(0, Math.floor(worldMinY / tileSize));
const endTileY = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
if (showGrid) {
const startTileX = Math.max(0, Math.floor(worldMinX / tileSize));
const endTileX = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
const startTileY = Math.max(0, Math.floor(worldMinY / tileSize));
const endTileY = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
for (let y = startTileY; y <= endTileY; y++) {
const lane = isFrogger() ? getLane(y) : null;
let rowFill = null;
@@ -7230,6 +7330,16 @@
ctx.fillRect(sx, sy, size, size);
}
}
}
}
}
drawGridImagePlacementsLayer();
if (showGrid) {
for (let y = startTileY; y <= endTileY; y++) {
for (let x = startTileX; x <= endTileX; x++) {
const wx = x * tileSize, wy = y * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const size = tileSize * zDraw;
const isInter = mapData.interactive && mapData.interactive[y] && mapData.interactive[y][x] === 1;
if (isInter) {
ctx.fillStyle = 'rgba(158,206,106,0.35)';
+103
View File
@@ -137,7 +137,78 @@
/** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */
let serverSuspectPhaseActive = false;
let mapBackgroundImg = null;
/** รูปต่อเซลล์ (gridImageLibrary / gridImageCells) — โหลดคู่กับ mapData */
let mapLobbyGridImgs = [];
let hostIconImg = null;
function normalizeLobbyGridImageCells(md) {
if (!md) return;
const w = md.width || 20, h = md.height || 15;
if (!Array.isArray(md.gridImageLibrary)) md.gridImageLibrary = [];
md.gridImageLibrary = md.gridImageLibrary.filter((s) => typeof s === 'string' && s.length > 0 && s.length < 30000000);
const libLen = md.gridImageLibrary.length;
let placements = [];
if (Array.isArray(md.gridImageSprites) && md.gridImageSprites.length) {
placements = md.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 ww = Math.max(1, Math.floor(Number(raw.w)) || 1);
const hh = Math.max(1, Math.floor(Number(raw.h)) || 1);
return { i, x, y, w: ww, h: hh };
}).filter((s) => Number.isFinite(s.i) && s.i >= 0 && s.i < libLen &&
Number.isFinite(s.x) && Number.isFinite(s.y) && s.w >= 1 && s.h >= 1 &&
s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
placements = placements.map((s) => {
let x = s.x, y = s.y, ww = s.w, hh = s.h;
if (x < 0) { ww += x; x = 0; }
if (y < 0) { hh += y; y = 0; }
if (x + ww > w) ww = w - x;
if (y + hh > h) hh = h - y;
return { i: s.i, x, y, w: Math.max(1, ww), h: Math.max(1, hh) };
}).filter((s) => s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
} else {
const cells = md.gridImageCells;
if (Array.isArray(cells) && cells.length === h && libLen > 0) {
for (let yy = 0; yy < h; yy++) {
const row = cells[yy];
if (!row) continue;
for (let xx = 0; xx < w; xx++) {
if (row[xx] == null) continue;
const iv = parseInt(row[xx], 10);
if (!Number.isFinite(iv) || iv < 0 || iv >= libLen) continue;
placements.push({ i: iv, x: xx, y: yy, w: 1, h: 1 });
}
}
}
}
md.gridImagePlacements = placements;
const outCells = Array(h).fill(0).map(() => Array(w).fill(null));
placements.forEach((sp) => {
for (let yy = sp.y; yy < sp.y + sp.h; yy++) {
for (let xx = sp.x; xx < sp.x + sp.w; xx++) {
if (yy >= 0 && yy < h && xx >= 0 && xx < w) outCells[yy][xx] = sp.i;
}
}
});
md.gridImageCells = outCells;
}
function refreshLobbyGridImages() {
mapLobbyGridImgs = [];
if (!mapData) return;
normalizeLobbyGridImageCells(mapData);
if (!mapData.gridImageLibrary || !mapData.gridImageLibrary.length) return;
mapData.gridImageLibrary.forEach((src, i) => {
mapLobbyGridImgs[i] = null;
if (!src || typeof src !== 'string') return;
const im = new Image();
im.onload = function () { try { resizeAndDraw(); } catch (e) {} };
im.onerror = function () {};
im.src = src;
mapLobbyGridImgs[i] = im;
});
}
const peers = new Map();
/** รองรับ x/y string จาก Socket — ไม่งั้นตำแหน่งจากสุ่มจุดเกิดจะถูกทิ้ง */
function normalizeLobbyPeerFromServer(p, mapRef) {
@@ -410,6 +481,26 @@
const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false';
const timeMs = Date.now();
function drawLobbyGridImagePlacementsLayer() {
if (!mapData.gridImagePlacements || !mapData.gridImagePlacements.length || !mapData.gridImageLibrary || !mapData.gridImageLibrary.length) return;
for (let gi = 0; gi < mapData.gridImagePlacements.length; gi++) {
const sp = mapData.gridImagePlacements[gi];
const sx0 = sp.x * tileSize;
const sy0 = sp.y * tileSize;
const sw0 = sp.w * tileSize;
const sh0 = sp.h * tileSize;
const gim = mapLobbyGridImgs[sp.i];
if (!gim || !gim.complete || !gim.naturalWidth) continue;
ctx.save();
ctx.beginPath();
ctx.rect(sx0, sy0, sw0, sh0);
ctx.clip();
ctx.drawImage(gim, sx0, sy0, sw0, sh0);
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.strokeRect(sx0 + 0.5, sy0 + 0.5, sw0 - 1, sh0 - 1);
}
}
if (showGrid) {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
@@ -429,6 +520,15 @@
ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335';
ctx.fillRect(sx, sy, tileSize, tileSize);
}
}
}
}
drawLobbyGridImagePlacementsLayer();
if (showGrid) {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const sx = x * tileSize;
const sy = y * tileSize;
const isInter = mapData.interactive && mapData.interactive[y] && mapData.interactive[y][x] === 1;
if (isInter) {
ctx.fillStyle = 'rgba(158,206,106,0.35)';
@@ -1227,6 +1327,7 @@
mapBackgroundImg.src = mapData.backgroundImage;
mapBackgroundImg.onload = () => { resizeAndDraw(); };
}
refreshLobbyGridImages();
if (!mapData.interactive) mapData.interactive = [];
if (!mapData.startGameArea) mapData.startGameArea = [];
if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
@@ -2711,6 +2812,7 @@
} else {
mapBackgroundImg = null;
}
refreshLobbyGridImages();
lobbyPath = [];
try {
window.__detectiveLobbyMeta = {
@@ -2812,6 +2914,7 @@
} else {
mapBackgroundImg = null;
}
refreshLobbyGridImages();
syncLobbyBUiChrome();
try {
var u = new URL(window.location.href);
+1 -1
View File
@@ -1,6 +1,6 @@
// ทุกครั้งที่มีการเพิ่มหรือเปลี่ยน ให้เพิ่ม v +0.0001
// หลังแก้ค่าแล้วต้อง copy ไป path ที่ Nginx ชี้ (หรือรัน copy-frogger-files-only.sh) ถึงจะเห็นบนเว็บ
window.APP_VERSION = '0.0168';
window.APP_VERSION = '0.0169';
document.addEventListener('DOMContentLoaded', function () {
var t = document.querySelector('.version-tag');
if (t) t.textContent = 'v ' + window.APP_VERSION;
+1 -1
View File
@@ -902,7 +902,7 @@
</div>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0166"></script>
<script src="js/play.js?v=0.100"></script>
<script src="js/play.js?v=0.104"></script>
<div class="version-tag">v —</div>
</body>
</html>
+1 -1
View File
@@ -1015,7 +1015,7 @@
</div>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0122"></script>
<script src="js/room-lobby.js?v=0.0176"></script>
<script src="js/room-lobby.js?v=0.0180"></script>
<div class="version-tag">v —</div>
</body>
</html>
+3
View File
@@ -62,6 +62,9 @@ const defaultMap = () => ({
width: 20, height: 15, tileSize: 32, gameType: 'zep',
characterCells: 1, characterCellsW: 1, characterCellsH: 1,
ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 },
gridImageLibrary: [],
gridImageSprites: [],
gridImageCells: [],
quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [],
quizCarryHubArea: [], quizCarryOptionArea: [],
/** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */