update charator
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -38,6 +38,13 @@
|
||||
.char-upload-cell { text-align: center; min-width: 0; }
|
||||
.char-upload-cell label { display: block; font-size: 0.85rem; color: #a9b1d6; margin-bottom: 0.25rem; }
|
||||
.char-upload-cell input[type="file"] { font-size: 0.8rem; max-width: 100%; }
|
||||
.char-layer-seq-strip {
|
||||
display: flex; flex-wrap: wrap; gap: 3px; margin: 0.2rem 0 0.35rem; max-width: 100%; justify-content: center;
|
||||
min-height: 0;
|
||||
}
|
||||
.char-layer-seq-strip img {
|
||||
width: 28px; height: 28px; object-fit: contain; background: #1a1b26; border-radius: 3px; border: 1px solid rgba(122,162,247,0.25);
|
||||
}
|
||||
.char-upload-cell .preview { width: 56px; height: 56px; margin: 0.25rem auto; background: #1a1b26; border-radius: 6px; object-fit: contain; display: block; }
|
||||
.char-upload-actions { margin-top: 1rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.character-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 1rem; margin: 1rem 0; }
|
||||
@@ -210,7 +217,7 @@
|
||||
<p id="char-hint" style="display:none; color: #a9b1d6;">ยังไม่มีตัวละคร — อัปโหลดด้านบน</p>
|
||||
</div>
|
||||
<script src="js/version.js?v=0.0112"></script>
|
||||
<script src="js/character.js"></script>
|
||||
<script src="js/character.js?v=0.013"></script>
|
||||
<div class="version-tag">v —</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
(function () {
|
||||
const BASE = '/Game';
|
||||
const STORAGE_KEY = 'gameCharacterId';
|
||||
const grid = document.getElementById('character-grid');
|
||||
const status = document.getElementById('char-status');
|
||||
const hint = document.getElementById('char-hint');
|
||||
const uploadStatus = document.getElementById('upload-status');
|
||||
const dirs = ['up', 'down', 'left', 'right'];
|
||||
// ลำดับเลเยอร์จากหลังมาหน้า: shadow → body → head → hair → face (เงาอยู่ล่างสุด)
|
||||
const layers = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
|
||||
const DEFAULT_SHADOW_URL = BASE + '/img/default-shadow-';
|
||||
const fileInputs = {};
|
||||
const previews = {};
|
||||
const layerFileInputs = {};
|
||||
const layerPreviews = {};
|
||||
dirs.forEach(d => {
|
||||
fileInputs[d] = document.getElementById('file-' + d);
|
||||
previews[d] = document.getElementById('preview-' + d);
|
||||
layerFileInputs[d] = {};
|
||||
layers.forEach(layer => {
|
||||
const inputId = 'layer-' + d + '-' + layer;
|
||||
const el = document.getElementById(inputId);
|
||||
if (el) layerFileInputs[d][layer] = el;
|
||||
});
|
||||
layerPreviews[d] = document.getElementById('preview-' + d + '-layer');
|
||||
});
|
||||
|
||||
/** จาก GET /api/characters/:id/layer-manifest — ใช้ตอนกดแก้ไขให้ preview / รวมเลเยอร์ก่อนอัปโหลดซ้ำ */
|
||||
let editLayerManifest = null;
|
||||
const LAYER_EDIT_FLOW_DONE = {};
|
||||
|
||||
function clearLayerThumbStrips() {
|
||||
document.querySelectorAll('.char-layer-seq-strip').forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
function manifestFrameAt(dir, frameIndex) {
|
||||
const e = editLayerManifest && editLayerManifest.byDir && editLayerManifest.byDir[dir];
|
||||
if (!e || !e.frames || !e.frames[frameIndex]) return null;
|
||||
return e.frames[frameIndex];
|
||||
}
|
||||
|
||||
function characterLayerFileUrl(fname) {
|
||||
if (!fname || typeof fname !== 'string') return '';
|
||||
return BASE + '/img/characters/' + encodeURIComponent(fname);
|
||||
}
|
||||
|
||||
/** โหลด manifest ไม่ได้ (Node เก่า / proxy) — โปรบ URL รูปเลเยอร์จริงบนดิสก์ */
|
||||
function probeCharacterImageUrl(url) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const t = setTimeout(() => {
|
||||
try { img.src = ''; } catch (e) { /* ignore */ }
|
||||
resolve(false);
|
||||
}, 2200);
|
||||
img.onload = () => {
|
||||
clearTimeout(t);
|
||||
try { img.src = ''; } catch (e) { /* ignore */ }
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
clearTimeout(t);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = url + (url.indexOf('?') >= 0 ? '&' : '?') + '_probe=' + Date.now();
|
||||
});
|
||||
}
|
||||
|
||||
async function buildLayerManifestByProbing(charId) {
|
||||
const byDir = {};
|
||||
const enc = (s) => encodeURIComponent(s);
|
||||
for (let di = 0; di < dirs.length; di++) {
|
||||
const d = dirs[di];
|
||||
const r0 = await Promise.all(layers.map((layer) => {
|
||||
const url = BASE + '/img/characters/' + enc(charId) + '_' + d + '_0_layer_' + layer + '.png';
|
||||
return probeCharacterImageUrl(url).then((ok) => (ok ? layer : null));
|
||||
}));
|
||||
const multi = r0.some(Boolean);
|
||||
if (multi) {
|
||||
const frames = [];
|
||||
for (let fi = 0; fi < 64; fi++) {
|
||||
const results = await Promise.all(layers.map((layer) => {
|
||||
const fname = charId + '_' + d + '_' + fi + '_layer_' + layer + '.png';
|
||||
const url = BASE + '/img/characters/' + enc(fname);
|
||||
return probeCharacterImageUrl(url).then((ok) => (ok ? { layer, fname } : null));
|
||||
}));
|
||||
const frameObj = {};
|
||||
results.forEach((x) => { if (x) frameObj[x.layer] = x.fname; });
|
||||
if (Object.keys(frameObj).length === 0) break;
|
||||
frames.push(frameObj);
|
||||
}
|
||||
if (frames.length) byDir[d] = { multi: true, frames };
|
||||
} else {
|
||||
const results = await Promise.all(layers.map((layer) => {
|
||||
const fname = charId + '_' + d + '_layer_' + layer + '.png';
|
||||
const url = BASE + '/img/characters/' + enc(fname);
|
||||
return probeCharacterImageUrl(url).then((ok) => (ok ? { layer, fname } : null));
|
||||
}));
|
||||
const frame0 = {};
|
||||
results.forEach((x) => { if (x) frame0[x.layer] = x.fname; });
|
||||
if (Object.keys(frame0).length) byDir[d] = { multi: false, frames: [frame0] };
|
||||
}
|
||||
}
|
||||
return { ok: true, id: charId, byDir };
|
||||
}
|
||||
|
||||
function applyEditLayerManifest(charId, man) {
|
||||
if (!man || !man.byDir || typeof man.byDir !== 'object' || !Object.keys(man.byDir).length) {
|
||||
return false;
|
||||
}
|
||||
editLayerManifest = { ok: true, id: charId, byDir: man.byDir };
|
||||
renderLayerThumbStripsFromManifest(charId);
|
||||
dirs.forEach((dd) => { refreshLayerPreviewForDir(dd); });
|
||||
return true;
|
||||
}
|
||||
|
||||
function fetchUrlAsDataUrl(fullUrl) {
|
||||
return fetch(fullUrl, { credentials: 'same-origin' })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('http');
|
||||
return r.blob();
|
||||
})
|
||||
.then((blob) => new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(fr.result);
|
||||
fr.onerror = () => reject(new Error('read blob'));
|
||||
fr.readAsDataURL(blob);
|
||||
}));
|
||||
}
|
||||
|
||||
function renderLayerThumbStripsFromManifest(charId) {
|
||||
clearLayerThumbStrips();
|
||||
if (!editLayerManifest || !editLayerManifest.byDir) return;
|
||||
dirs.forEach((d) => {
|
||||
const entry = editLayerManifest.byDir[d];
|
||||
if (!entry || !entry.frames || !entry.frames.length) return;
|
||||
layers.forEach((layer) => {
|
||||
const input = layerFileInputs[d] && layerFileInputs[d][layer];
|
||||
if (!input || !input.parentElement) return;
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'char-layer-seq-strip';
|
||||
strip.setAttribute('data-strip-for', d + '-' + layer);
|
||||
const maxF = entry.frames.length;
|
||||
for (let fi = 0; fi < maxF; fi++) {
|
||||
const fr = entry.frames[fi];
|
||||
const fname = fr && fr[layer];
|
||||
if (!fname) continue;
|
||||
const im = document.createElement('img');
|
||||
im.src = characterLayerFileUrl(fname) + '?v=' + Date.now();
|
||||
im.alt = charId + ' ' + d + ' ' + layer + ' f' + fi;
|
||||
im.title = 'เฟรม ' + (fi + 1);
|
||||
im.onerror = function () { this.style.opacity = '0.25'; };
|
||||
strip.appendChild(im);
|
||||
}
|
||||
if (strip.childNodes.length) input.insertAdjacentElement('afterend', strip);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getSelected() {
|
||||
try { return localStorage.getItem(STORAGE_KEY) || ''; } catch (e) { return ''; }
|
||||
}
|
||||
function setSelected(id) {
|
||||
try {
|
||||
if (id) localStorage.setItem(STORAGE_KEY, id);
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
dirs.forEach(d => {
|
||||
const input = fileInputs[d];
|
||||
const prev = previews[d];
|
||||
if (input && prev) {
|
||||
input.addEventListener('change', () => {
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
const f = files[0];
|
||||
if (!f) { prev.src = ''; prev.title = ''; return; }
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
prev.src = r.result;
|
||||
prev.title = files.length > 1 ? (files.length + ' เฟรม') : '';
|
||||
};
|
||||
r.readAsDataURL(f);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function readImageFromFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('load image fail'));
|
||||
img.src = r.result;
|
||||
};
|
||||
r.onerror = () => reject(new Error('read file fail'));
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(r.result);
|
||||
r.onerror = () => reject(new Error('read file fail'));
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** ส่งเลเยอร์ดิบต่อเฟรมให้เซิร์ฟเวอร์บันทึก — หน้า play จะย้อมสีแยก body / hair / head ได้ */
|
||||
async function collectLayerFramesForDirection(dir, maxFrames) {
|
||||
const inputs = layerFileInputs[dir];
|
||||
if (!inputs || maxFrames < 1) return null;
|
||||
const frames = [];
|
||||
for (let i = 0; i < maxFrames; i++) {
|
||||
const layerObj = {};
|
||||
const mf = manifestFrameAt(dir, i);
|
||||
for (const layer of layers) {
|
||||
const input = inputs[layer];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
if (files.length) {
|
||||
const file = files[Math.min(i, files.length - 1)];
|
||||
try {
|
||||
layerObj[layer] = await readFileAsDataUrl(file);
|
||||
} catch (e) { /* skip */ }
|
||||
continue;
|
||||
}
|
||||
const fname = mf && mf[layer];
|
||||
if (fname) {
|
||||
try {
|
||||
layerObj[layer] = await fetchUrlAsDataUrl(characterLayerFileUrl(fname));
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
}
|
||||
frames.push(layerObj);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
function loadImageFromUrl(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('load url fail'));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function composeLayeredFrame(dir, frameIndex) {
|
||||
const inputs = layerFileInputs[dir] || {};
|
||||
const mf = manifestFrameAt(dir, frameIndex);
|
||||
const imagePromises = [];
|
||||
layers.forEach(layer => {
|
||||
const input = inputs[layer];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
const hasFile = files.length > 0;
|
||||
const file = hasFile ? files[Math.min(frameIndex, files.length - 1)] : null;
|
||||
const fname = mf && mf[layer] ? mf[layer] : null;
|
||||
const urlFromDisk = fname ? characterLayerFileUrl(fname) : '';
|
||||
if (layer === 'shadow') {
|
||||
if (file) {
|
||||
imagePromises.push(readImageFromFile(file));
|
||||
} else if (urlFromDisk) {
|
||||
imagePromises.push(loadImageFromUrl(urlFromDisk).catch(() => null));
|
||||
} else {
|
||||
imagePromises.push(loadImageFromUrl(DEFAULT_SHADOW_URL + dir + '.png').catch(() => null));
|
||||
}
|
||||
} else {
|
||||
if (file) {
|
||||
imagePromises.push(readImageFromFile(file).catch(() => null));
|
||||
} else if (urlFromDisk) {
|
||||
imagePromises.push(loadImageFromUrl(urlFromDisk).catch(() => null));
|
||||
} else {
|
||||
imagePromises.push(Promise.resolve(null));
|
||||
}
|
||||
}
|
||||
});
|
||||
const resolved = await Promise.all(imagePromises);
|
||||
const images = resolved.filter(Boolean);
|
||||
if (!images.length) return null;
|
||||
const base = images[0];
|
||||
const w = base.width || 64;
|
||||
const h = base.height || 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
resolved.forEach(img => {
|
||||
if (img) ctx.drawImage(img, 0, 0, w, h);
|
||||
});
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function refreshLayerPreviewForDir(dir) {
|
||||
const prev = layerPreviews[dir];
|
||||
if (!prev) return;
|
||||
// ใช้เฟรมแรกเป็นตัวอย่าง
|
||||
composeLayeredFrame(dir, 0).then(dataUrl => {
|
||||
if (dataUrl) {
|
||||
prev.src = dataUrl;
|
||||
prev.title = 'ตัวอย่างเลเยอร์เฟรมที่ 1';
|
||||
} else {
|
||||
prev.src = '';
|
||||
prev.title = '';
|
||||
}
|
||||
}).catch(() => {
|
||||
prev.src = '';
|
||||
prev.title = '';
|
||||
});
|
||||
}
|
||||
|
||||
// เมื่อมีการเปลี่ยนไฟล์เลเยอร์ใด ๆ ให้รีเฟรช preview ของทิศนั้น
|
||||
dirs.forEach(d => {
|
||||
const inputs = layerFileInputs[d];
|
||||
if (!inputs) return;
|
||||
layers.forEach(layer => {
|
||||
const input = inputs[layer];
|
||||
if (!input) return;
|
||||
input.addEventListener('change', () => {
|
||||
refreshLayerPreviewForDir(d);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function refreshList() {
|
||||
if (status) status.textContent = 'โหลดรายการตัวละคร...';
|
||||
fetch(BASE + '/api/characters')
|
||||
.then(r => r.json())
|
||||
.then(list => {
|
||||
if (!Array.isArray(list)) throw new Error('ไม่ใช่ array');
|
||||
if (status) status.textContent = '';
|
||||
if (hint) hint.style.display = list.length === 0 ? 'block' : 'none';
|
||||
const selected = getSelected();
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
list.forEach(char => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'character-item' + (selected === char.id ? ' selected' : '');
|
||||
div.setAttribute('data-id', char.id);
|
||||
|
||||
const previewsDiv = document.createElement('div');
|
||||
previewsDiv.className = 'previews';
|
||||
dirs.forEach(d => {
|
||||
const img = document.createElement('img');
|
||||
img.src = BASE + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png';
|
||||
img.alt = d;
|
||||
img.onerror = () => { img.style.opacity = '0.4'; };
|
||||
previewsDiv.appendChild(img);
|
||||
});
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'name';
|
||||
name.textContent = char.name || char.id;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'character-actions';
|
||||
const btnEdit = document.createElement('button');
|
||||
btnEdit.type = 'button';
|
||||
btnEdit.textContent = 'แก้ไข';
|
||||
btnEdit.className = 'char-edit-btn';
|
||||
btnEdit.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (charName) charName.value = char.id;
|
||||
const ts = '?v=' + Date.now();
|
||||
dirs.forEach(d => {
|
||||
const prev = previews[d];
|
||||
if (!prev) return;
|
||||
const url = BASE + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png' + ts;
|
||||
prev.onerror = function () { prev.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56"><rect fill="%2324283b" width="56" height="56"/><text x="28" y="30" fill="%23a9b1d6" font-size="10" text-anchor="middle">ไม่มี</text></svg>'); prev.onerror = null; };
|
||||
prev.src = url;
|
||||
});
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
const adv = document.getElementById('char-upload-advanced');
|
||||
const scrollTarget = char.hasLayerFiles && adv ? adv : document.querySelector('.char-upload-simple');
|
||||
if (char.hasLayerFiles) {
|
||||
const setMergedLayerPreviewsOnly = () => {
|
||||
dirs.forEach((d) => {
|
||||
const lp = layerPreviews[d];
|
||||
if (lp) {
|
||||
lp.src = BASE + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png' + ts;
|
||||
lp.onerror = function () { lp.src = ''; lp.onerror = null; };
|
||||
}
|
||||
});
|
||||
};
|
||||
const finishEdit = (fromLayers) => {
|
||||
if (uploadStatus) {
|
||||
uploadStatus.textContent = fromLayers
|
||||
? ('แก้ไข "' + (char.name || char.id) + '" — ดูลำดับ PNG ต่อเลเยอร์ด้านล่าง · เลือกไฟล์ใหม่แล้วกดอัปโหลดเพื่ออัปเดต')
|
||||
: ('แก้ไข "' + (char.name || char.id) + '" — แสดงรูปรวมในตัวอย่างเลเยอร์ (ถ้าไม่มีแถบรูป: รีสตาร์ท Node แล้วรีเฟรช)');
|
||||
}
|
||||
if (scrollTarget) scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
if (applyEditLayerManifest(char.id, char.layerManifest)) {
|
||||
finishEdit(true);
|
||||
} else {
|
||||
fetch(BASE + '/api/characters/' + encodeURIComponent(char.id) + '/layer-manifest')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('http ' + r.status))))
|
||||
.then((man) => {
|
||||
if (applyEditLayerManifest(char.id, man)) {
|
||||
finishEdit(true);
|
||||
return LAYER_EDIT_FLOW_DONE;
|
||||
}
|
||||
return buildLayerManifestByProbing(char.id);
|
||||
})
|
||||
.then((man2) => {
|
||||
if (man2 === LAYER_EDIT_FLOW_DONE) return;
|
||||
if (man2 && applyEditLayerManifest(char.id, man2)) finishEdit(true);
|
||||
else {
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
setMergedLayerPreviewsOnly();
|
||||
finishEdit(false);
|
||||
}
|
||||
})
|
||||
.catch(() => buildLayerManifestByProbing(char.id)
|
||||
.then((man3) => {
|
||||
if (man3 && applyEditLayerManifest(char.id, man3)) finishEdit(true);
|
||||
else {
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
setMergedLayerPreviewsOnly();
|
||||
finishEdit(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
setMergedLayerPreviewsOnly();
|
||||
finishEdit(false);
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
dirs.forEach((d) => {
|
||||
const lp = layerPreviews[d];
|
||||
if (lp) {
|
||||
lp.src = BASE + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png' + ts;
|
||||
lp.onerror = function () { lp.src = ''; lp.onerror = null; };
|
||||
}
|
||||
});
|
||||
if (uploadStatus) uploadStatus.textContent = 'เลือกรูปใหม่เพื่ออัปเดต "' + (char.name || char.id) + '" แล้วกดอัปโหลด';
|
||||
if (scrollTarget) scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
const btnDelete = document.createElement('button');
|
||||
btnDelete.type = 'button';
|
||||
btnDelete.textContent = 'ลบ';
|
||||
btnDelete.className = 'char-delete-btn';
|
||||
btnDelete.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const ok = window.confirm('ต้องการลบตัวละคร "' + (char.name || char.id) + '" จริงหรือไม่?');
|
||||
if (!ok) return;
|
||||
if (uploadStatus) uploadStatus.textContent = 'กำลังลบตัวละคร...';
|
||||
fetch(BASE + '/api/characters/' + encodeURIComponent(char.id), {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(async (r) => {
|
||||
const text = await r.text();
|
||||
let data;
|
||||
try { data = JSON.parse(text); } catch (e) { data = { ok: false, error: text || r.status }; }
|
||||
if (!r.ok || !data.ok) {
|
||||
if (uploadStatus) uploadStatus.textContent = data.error || ('ลบไม่สำเร็จ (' + r.status + ')');
|
||||
return;
|
||||
}
|
||||
if (uploadStatus) uploadStatus.textContent = 'ลบตัวละครแล้ว';
|
||||
const current = getSelected();
|
||||
if (current === char.id) {
|
||||
setSelected('');
|
||||
}
|
||||
refreshList();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Delete character failed', err);
|
||||
if (uploadStatus) uploadStatus.textContent = 'ลบไม่สำเร็จ: ' + (err.message || '');
|
||||
});
|
||||
});
|
||||
actions.appendChild(btnEdit);
|
||||
actions.appendChild(btnDelete);
|
||||
|
||||
div.appendChild(previewsDiv);
|
||||
div.appendChild(name);
|
||||
div.appendChild(actions);
|
||||
|
||||
div.addEventListener('click', () => {
|
||||
setSelected(char.id);
|
||||
grid.querySelectorAll('.character-item').forEach(el => el.classList.remove('selected'));
|
||||
div.classList.add('selected');
|
||||
});
|
||||
grid.appendChild(div);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (status) status.textContent = 'โหลดรายการตัวละครไม่ได้';
|
||||
if (hint) hint.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
refreshList();
|
||||
|
||||
const btnUpload = document.getElementById('btn-upload');
|
||||
const charName = document.getElementById('char-name');
|
||||
const btnUploadLayered = document.getElementById('btn-upload-layered');
|
||||
|
||||
function uploadCharacterPayload(payload) {
|
||||
// ถ้าทิศไหนมีแค่ 1 เฟรม ให้ส่งเป็น string เพื่อให้เข้ากับ server แบบเก่า
|
||||
dirs.forEach(d => {
|
||||
if (Array.isArray(payload[d]) && payload[d].length === 1) {
|
||||
payload[d] = payload[d][0];
|
||||
}
|
||||
if (Array.isArray(payload[d]) && payload[d].length === 0) {
|
||||
delete payload[d];
|
||||
}
|
||||
});
|
||||
if (uploadStatus) uploadStatus.textContent = 'กำลังอัปโหลด...';
|
||||
return fetch(BASE + '/api/characters/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(async (r) => {
|
||||
const text = await r.text();
|
||||
let data;
|
||||
try { data = JSON.parse(text); } catch (e) { data = { ok: false, error: text || r.status }; }
|
||||
if (!r.ok) {
|
||||
console.error('Upload error', r.status, data.error || text);
|
||||
if (uploadStatus) uploadStatus.textContent = data.error || 'ข้อผิดพลาด ' + r.status;
|
||||
return;
|
||||
}
|
||||
if (data.ok) {
|
||||
if (uploadStatus) uploadStatus.textContent = 'อัปโหลดสำเร็จ: ' + (data.characterId || '');
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
dirs.forEach(d => {
|
||||
if (fileInputs[d]) fileInputs[d].value = '';
|
||||
if (previews[d]) previews[d].src = '';
|
||||
const inputs = layerFileInputs[d];
|
||||
if (inputs) {
|
||||
layers.forEach(layer => {
|
||||
if (inputs[layer]) inputs[layer].value = '';
|
||||
});
|
||||
}
|
||||
if (layerPreviews[d]) layerPreviews[d].src = '';
|
||||
});
|
||||
if (charName) charName.value = '';
|
||||
refreshList();
|
||||
} else {
|
||||
if (uploadStatus) uploadStatus.textContent = data.error || 'อัปโหลดไม่สำเร็จ';
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Upload failed', err);
|
||||
if (uploadStatus) uploadStatus.textContent = 'อัปโหลดไม่สำเร็จ: ' + (err.message || '');
|
||||
});
|
||||
}
|
||||
|
||||
if (btnUpload) {
|
||||
btnUpload.addEventListener('click', () => {
|
||||
const payload = { name: (charName && charName.value || '').trim() };
|
||||
let hasAny = false;
|
||||
const readers = [];
|
||||
dirs.forEach(d => {
|
||||
const input = fileInputs[d];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
if (!files.length) return;
|
||||
hasAny = true;
|
||||
payload[d] = [];
|
||||
files.forEach((f, idx) => {
|
||||
readers.push(new Promise(resolve => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => { payload[d][idx] = r.result; resolve(); };
|
||||
r.readAsDataURL(f);
|
||||
}));
|
||||
});
|
||||
});
|
||||
if (!hasAny) {
|
||||
if (uploadStatus) uploadStatus.textContent = 'กรุณาเลือกรูปอย่างน้อย 1 ทิศ';
|
||||
return;
|
||||
}
|
||||
Promise.all(readers).then(() => {
|
||||
uploadCharacterPayload(payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (btnUploadLayered) {
|
||||
btnUploadLayered.addEventListener('click', () => {
|
||||
const payload = { name: (charName && charName.value || '').trim() };
|
||||
let hasAny = false;
|
||||
const tasks = [];
|
||||
const layerCollectTasks = [];
|
||||
dirs.forEach(d => {
|
||||
const inputs = layerFileInputs[d];
|
||||
if (!inputs) return;
|
||||
let maxFrames = 0;
|
||||
layers.forEach(layer => {
|
||||
const input = inputs[layer];
|
||||
const len = input && input.files ? input.files.length : 0;
|
||||
if (len > maxFrames) maxFrames = len;
|
||||
});
|
||||
const manEnt = editLayerManifest && editLayerManifest.byDir && editLayerManifest.byDir[d];
|
||||
if (manEnt && Array.isArray(manEnt.frames) && manEnt.frames.length > maxFrames) {
|
||||
maxFrames = manEnt.frames.length;
|
||||
}
|
||||
if (maxFrames === 0) return;
|
||||
hasAny = true;
|
||||
payload[d] = [];
|
||||
layerCollectTasks.push(
|
||||
collectLayerFramesForDirection(d, maxFrames).then((lf) => {
|
||||
if (lf && lf.some(fr => fr && Object.keys(fr).length > 0)) {
|
||||
payload.layerFrames = payload.layerFrames || {};
|
||||
payload.layerFrames[d] = lf;
|
||||
}
|
||||
})
|
||||
);
|
||||
for (let i = 0; i < maxFrames; i++) {
|
||||
tasks.push(
|
||||
composeLayeredFrame(d, i).then(dataUrl => {
|
||||
if (dataUrl) {
|
||||
payload[d][i] = dataUrl;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!hasAny) {
|
||||
if (uploadStatus) uploadStatus.textContent = 'กรุณาเลือกรูปเลเยอร์อย่างน้อย 1 ทิศ';
|
||||
return;
|
||||
}
|
||||
if (uploadStatus) uploadStatus.textContent = 'กำลังรวมเลเยอร์และอัปโหลด...';
|
||||
Promise.all([...tasks, ...layerCollectTasks]).then(() => {
|
||||
uploadCharacterPayload(payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -240,6 +240,44 @@ button { background: #7aa2f7; color: #1a1b26; cursor: pointer; border: none; }
|
||||
border-left: 3px solid #7aa2f7;
|
||||
}
|
||||
.editor-card label { margin: 0; }
|
||||
/* กลุ่มกล่องตรวจ: ชนกำแพง vs โซนห้ามซ้อน — แยกมองชัดจากแถว label เปล่า ๆ */
|
||||
.editor-footprint-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem 0.85rem;
|
||||
width: 100%;
|
||||
padding: 0.42rem 0.55rem 0.48rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(122, 162, 247, 0.38);
|
||||
background: rgba(20, 22, 34, 0.72);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.editor-footprint-row--wall {
|
||||
border-color: rgba(122, 162, 247, 0.45);
|
||||
background: rgba(26, 32, 52, 0.65);
|
||||
}
|
||||
.editor-footprint-row--no-overlap {
|
||||
border-color: rgba(158, 206, 106, 0.5);
|
||||
background: rgba(22, 34, 26, 0.55);
|
||||
}
|
||||
.editor-footprint-row__head {
|
||||
flex: 0 0 100%;
|
||||
margin: 0 0 0.08rem 0;
|
||||
padding: 0;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #7aa2f7;
|
||||
}
|
||||
.editor-footprint-row--no-overlap .editor-footprint-row__head {
|
||||
color: #9ece6a;
|
||||
}
|
||||
.editor-footprint-row .editor-footprint-row__field input[type="number"] {
|
||||
width: 3.1rem;
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
.editor-field-grow {
|
||||
flex: 1 1 180px;
|
||||
min-width: min(100%, 200px);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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=20260427-gallery">
|
||||
<link rel="stylesheet" href="css/style.css?v=20260430-footprint-groups">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container editor-page">
|
||||
@@ -68,13 +68,21 @@
|
||||
<label>ขนาดไทล์ <input type="number" id="tile-size" value="32" min="16" max="64"></label>
|
||||
<label title="ลงจากมุมซ้ายบนของจุดเกิด — เน้นขยายแนวตั้งก่อน (เช่น 4 = สูง 4 ช่อง)">ตัวละคร สูง (แนวตั้ง) <input type="number" id="character-cells-h" value="1" min="1" max="4"></label>
|
||||
<label title="ไปทางขวาจากมุมซ้ายบน — แนวนอน">× กว้าง (แนวนอน) <input type="number" id="character-cells-w" value="1" min="1" max="4"></label>
|
||||
<label title="เฉพาะกำแพง (โหมดวาดกำแพง / objects = เดินไม่ได้) — แนวนอนกลางใต้ร่าง · เล็กกว่าตัว = หัว/ลำตัวทับสไปรต์กำแพงได้ · โซน interactive / hub / อื่น ๆ ใช้ขนาดตัวด้านบน" aria-describedby="editor-character-collision-hint">ชนกำแพง กว้าง <input type="number" id="character-collision-w" value="1" min="1" max="4"></label>
|
||||
<label title="เฉพาะกำแพง — สูงจากเท้าขึ้นมา · น้อยกว่าสูงตัว = ส่วนบนไม่ชนกำแพง · โซนอื่นใช้สูงตัวด้านบน" aria-describedby="editor-character-collision-hint">× สูง <input type="number" id="character-collision-h" value="1" min="1" max="4"></label>
|
||||
<div class="editor-footprint-row editor-footprint-row--wall" role="group" aria-labelledby="editor-fp-wall-head">
|
||||
<div id="editor-fp-wall-head" class="editor-footprint-row__head">ชนกำแพง · กล่องตรวจ</div>
|
||||
<label class="editor-footprint-row__field" title="เฉพาะกำแพง (โหมดวาดกำแพง / objects = เดินไม่ได้) — แนวนอนกลางใต้ร่าง · เล็กกว่าตัว = หัว/ลำตัวทับสไปรต์กำแพงได้ · โซน interactive / hub / อื่น ๆ ใช้ขนาดตัวด้านบน" aria-describedby="editor-character-collision-hint">กว้าง <input type="number" id="character-collision-w" value="1" min="1" max="4" aria-label="ชนกำแพง กว้าง (ช่องกริด)"></label>
|
||||
<label class="editor-footprint-row__field" title="เฉพาะกำแพง — สูงจากเท้าขึ้นมา · น้อยกว่าสูงตัว = ส่วนบนไม่ชนกำแพง · โซนอื่นใช้สูงตัวด้านบน" aria-describedby="editor-character-collision-hint">× สูง <input type="number" id="character-collision-h" value="1" min="1" max="4" aria-label="ชนกำแพง สูงจากเท้า (ช่องกริด)"></label>
|
||||
</div>
|
||||
<div class="editor-footprint-row editor-footprint-row--no-overlap" role="group" aria-labelledby="editor-fp-nooverlap-head">
|
||||
<div id="editor-fp-nooverlap-head" class="editor-footprint-row__head">โซนห้ามซ้อน · กล่องตรวจ</div>
|
||||
<label class="editor-footprint-row__field" title="กล่องตรวจทับโซน «ห้ามซ้อนผู้เล่น» (เทียบชนกำแพง) — แนวนอนกลางใต้ร่าง · 0 = เต็มความกว้างตัว" aria-describedby="editor-character-collision-hint">กว้าง <input type="number" id="block-player-separation-w" value="0" min="0" max="4" step="1" aria-label="โซนห้ามซ้อน กว้างกล่องตรวจ (ช่องกริด)"></label>
|
||||
<label class="editor-footprint-row__field" title="กล่องตรวจทับโซน «ห้ามซ้อนผู้เล่น» — สูงจากเท้าขึ้นมา · 0 = เต็มสูงตัว" aria-describedby="editor-character-collision-hint">× สูง <input type="number" id="block-player-separation-h" value="0" min="0" max="4" step="1" aria-label="โซนห้ามซ้อน สูงกล่องตรวจจากเท้า (ช่องกริด)"></label>
|
||||
</div>
|
||||
<button type="button" id="btn-new">สร้างกริดใหม่</button>
|
||||
<p id="editor-character-collision-hint" class="editor-card__hint" style="margin:0.4rem 0 0;width:100%;max-width:44rem;line-height:1.45;">
|
||||
<strong>ชนกำแพง กว้าง×สูง</strong> ใช้แค่ตรวจชน <strong>กำแพง</strong> (ช่องวาด «กำแพง») เท่านั้น
|
||||
· <strong>interactive</strong> · <strong>โซนห้ามซ้อน</strong> · <strong>hub / ส่งคำตอบ quiz carry</strong> · โซนคำถาม/ตัวเลือก ฯลฯ ยังใช้พื้นที่ตัวละครตาม
|
||||
<strong>สูง (แนวตั้ง) × กว้าง (แนวนอน)</strong> เหมือนเดิม — ในเกม (<code>play.js</code>) แยกฟังก์ชันชนกำแพงกับ footprint โซนแล้ว
|
||||
<strong>สูง (แนวตั้ง) × กว้าง (แนวนอน)</strong> เหมือนเดิม — ในเกม (<code>play.js</code>) แยกฟังก์ชันชนกำแพงกับ footprint โซนแล้ว · <strong>ห้ามซ้อน กว้าง×สูง</strong> = กำหนดกล่องตรวจทับโซนห้ามซ้อน (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) ไม่ใช่การขยายรอบร่าง
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -278,7 +286,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="js/version.js?v=0.0169"></script>
|
||||
<script src="js/editor.js?v=20260429-collision-hint-ui"></script>
|
||||
<script src="js/editor.js?v=20260430-editor-footprint-groups"></script>
|
||||
<div class="version-tag">v —</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -25,6 +25,67 @@
|
||||
layerPreviews[d] = document.getElementById('preview-' + d + '-layer');
|
||||
});
|
||||
|
||||
/** จาก GET /api/characters/:id/layer-manifest — ใช้ตอนกดแก้ไขให้ preview / รวมเลเยอร์ก่อนอัปโหลดซ้ำ */
|
||||
let editLayerManifest = null;
|
||||
|
||||
function clearLayerThumbStrips() {
|
||||
document.querySelectorAll('.char-layer-seq-strip').forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
function manifestFrameAt(dir, frameIndex) {
|
||||
const e = editLayerManifest && editLayerManifest.byDir && editLayerManifest.byDir[dir];
|
||||
if (!e || !e.frames || !e.frames[frameIndex]) return null;
|
||||
return e.frames[frameIndex];
|
||||
}
|
||||
|
||||
function characterLayerFileUrl(fname) {
|
||||
if (!fname || typeof fname !== 'string') return '';
|
||||
return BASE + '/img/characters/' + encodeURIComponent(fname);
|
||||
}
|
||||
|
||||
function fetchUrlAsDataUrl(fullUrl) {
|
||||
return fetch(fullUrl, { credentials: 'same-origin' })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('http');
|
||||
return r.blob();
|
||||
})
|
||||
.then((blob) => new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(fr.result);
|
||||
fr.onerror = () => reject(new Error('read blob'));
|
||||
fr.readAsDataURL(blob);
|
||||
}));
|
||||
}
|
||||
|
||||
function renderLayerThumbStripsFromManifest(charId) {
|
||||
clearLayerThumbStrips();
|
||||
if (!editLayerManifest || !editLayerManifest.byDir) return;
|
||||
dirs.forEach((d) => {
|
||||
const entry = editLayerManifest.byDir[d];
|
||||
if (!entry || !entry.frames || !entry.frames.length) return;
|
||||
layers.forEach((layer) => {
|
||||
const input = layerFileInputs[d] && layerFileInputs[d][layer];
|
||||
if (!input || !input.parentElement) return;
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'char-layer-seq-strip';
|
||||
strip.setAttribute('data-strip-for', d + '-' + layer);
|
||||
const maxF = entry.frames.length;
|
||||
for (let fi = 0; fi < maxF; fi++) {
|
||||
const fr = entry.frames[fi];
|
||||
const fname = fr && fr[layer];
|
||||
if (!fname) continue;
|
||||
const im = document.createElement('img');
|
||||
im.src = characterLayerFileUrl(fname) + '?v=' + Date.now();
|
||||
im.alt = charId + ' ' + d + ' ' + layer + ' f' + fi;
|
||||
im.title = 'เฟรม ' + (fi + 1);
|
||||
im.onerror = function () { this.style.opacity = '0.25'; };
|
||||
strip.appendChild(im);
|
||||
}
|
||||
if (strip.childNodes.length) input.insertAdjacentElement('afterend', strip);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getSelected() {
|
||||
try { return localStorage.getItem(STORAGE_KEY) || ''; } catch (e) { return ''; }
|
||||
}
|
||||
@@ -83,15 +144,22 @@
|
||||
const frames = [];
|
||||
for (let i = 0; i < maxFrames; i++) {
|
||||
const layerObj = {};
|
||||
const mf = manifestFrameAt(dir, i);
|
||||
for (const layer of layers) {
|
||||
const input = inputs[layer];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
if (!files.length) continue;
|
||||
const file = files[Math.min(i, files.length - 1)];
|
||||
try {
|
||||
layerObj[layer] = await readFileAsDataUrl(file);
|
||||
} catch (e) {
|
||||
// ข้ามเลเยอร์นี้เฟรมนี้
|
||||
if (files.length) {
|
||||
const file = files[Math.min(i, files.length - 1)];
|
||||
try {
|
||||
layerObj[layer] = await readFileAsDataUrl(file);
|
||||
} catch (e) { /* skip */ }
|
||||
continue;
|
||||
}
|
||||
const fname = mf && mf[layer];
|
||||
if (fname) {
|
||||
try {
|
||||
layerObj[layer] = await fetchUrlAsDataUrl(characterLayerFileUrl(fname));
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
}
|
||||
frames.push(layerObj);
|
||||
@@ -111,20 +179,31 @@
|
||||
|
||||
async function composeLayeredFrame(dir, frameIndex) {
|
||||
const inputs = layerFileInputs[dir] || {};
|
||||
const mf = manifestFrameAt(dir, frameIndex);
|
||||
const imagePromises = [];
|
||||
layers.forEach(layer => {
|
||||
const input = inputs[layer];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
const hasFile = files.length > 0;
|
||||
const file = hasFile ? files[Math.min(frameIndex, files.length - 1)] : null;
|
||||
const fname = mf && mf[layer] ? mf[layer] : null;
|
||||
const urlFromDisk = fname ? characterLayerFileUrl(fname) : '';
|
||||
if (layer === 'shadow') {
|
||||
imagePromises.push(file ? readImageFromFile(file) : loadImageFromUrl(DEFAULT_SHADOW_URL + dir + '.png').catch(() => null));
|
||||
} else {
|
||||
if (!file) {
|
||||
imagePromises.push(Promise.resolve(null));
|
||||
return;
|
||||
if (file) {
|
||||
imagePromises.push(readImageFromFile(file));
|
||||
} else if (urlFromDisk) {
|
||||
imagePromises.push(loadImageFromUrl(urlFromDisk).catch(() => null));
|
||||
} else {
|
||||
imagePromises.push(loadImageFromUrl(DEFAULT_SHADOW_URL + dir + '.png').catch(() => null));
|
||||
}
|
||||
} else {
|
||||
if (file) {
|
||||
imagePromises.push(readImageFromFile(file).catch(() => null));
|
||||
} else if (urlFromDisk) {
|
||||
imagePromises.push(loadImageFromUrl(urlFromDisk).catch(() => null));
|
||||
} else {
|
||||
imagePromises.push(Promise.resolve(null));
|
||||
}
|
||||
imagePromises.push(readImageFromFile(file).catch(() => null));
|
||||
}
|
||||
});
|
||||
const resolved = await Promise.all(imagePromises);
|
||||
@@ -213,7 +292,6 @@
|
||||
btnEdit.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (charName) charName.value = char.id;
|
||||
// แสดง preview รูปปัจจุบันในส่วนอัปโหลด
|
||||
const ts = '?v=' + Date.now();
|
||||
dirs.forEach(d => {
|
||||
const prev = previews[d];
|
||||
@@ -222,9 +300,47 @@
|
||||
prev.onerror = function () { prev.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56"><rect fill="%2324283b" width="56" height="56"/><text x="28" y="30" fill="%23a9b1d6" font-size="10" text-anchor="middle">ไม่มี</text></svg>'); prev.onerror = null; };
|
||||
prev.src = url;
|
||||
});
|
||||
if (uploadStatus) uploadStatus.textContent = 'เลือกรูปใหม่เพื่ออัปเดต "' + (char.name || char.id) + '" แล้วกดอัปโหลด';
|
||||
const firstSection = document.querySelector('.char-upload-simple, .char-upload');
|
||||
if (firstSection) firstSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
const adv = document.getElementById('char-upload-advanced');
|
||||
const scrollTarget = char.hasLayerFiles && adv ? adv : document.querySelector('.char-upload-simple');
|
||||
if (char.hasLayerFiles) {
|
||||
fetch(BASE + '/api/characters/' + encodeURIComponent(char.id) + '/layer-manifest')
|
||||
.then((r) => r.json())
|
||||
.then((man) => {
|
||||
if (man && man.ok && man.byDir) {
|
||||
editLayerManifest = man;
|
||||
renderLayerThumbStripsFromManifest(char.id);
|
||||
dirs.forEach((d) => { refreshLayerPreviewForDir(d); });
|
||||
} else {
|
||||
dirs.forEach((d) => {
|
||||
const lp = layerPreviews[d];
|
||||
if (lp) {
|
||||
lp.src = BASE + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png' + ts;
|
||||
lp.onerror = function () { lp.src = ''; lp.onerror = null; };
|
||||
}
|
||||
});
|
||||
}
|
||||
if (uploadStatus) {
|
||||
uploadStatus.textContent = 'แก้ไข "' + (char.name || char.id) + '" — ดูลำดับ PNG ต่อเลเยอร์ด้านล่าง · เลือกไฟล์ใหม่แล้วกดอัปโหลดเพื่ออัปเดต';
|
||||
}
|
||||
if (scrollTarget) scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
})
|
||||
.catch(() => {
|
||||
if (uploadStatus) uploadStatus.textContent = 'โหลดรายการเลเยอร์ไม่ได้ — แสดงเฉพาะรูปรวมด้านบน';
|
||||
if (scrollTarget) scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
} else {
|
||||
dirs.forEach((d) => {
|
||||
const lp = layerPreviews[d];
|
||||
if (lp) {
|
||||
lp.src = BASE + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png' + ts;
|
||||
lp.onerror = function () { lp.src = ''; lp.onerror = null; };
|
||||
}
|
||||
});
|
||||
if (uploadStatus) uploadStatus.textContent = 'เลือกรูปใหม่เพื่ออัปเดต "' + (char.name || char.id) + '" แล้วกดอัปโหลด';
|
||||
if (scrollTarget) scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
const btnDelete = document.createElement('button');
|
||||
btnDelete.type = 'button';
|
||||
@@ -312,6 +428,8 @@
|
||||
}
|
||||
if (data.ok) {
|
||||
if (uploadStatus) uploadStatus.textContent = 'อัปโหลดสำเร็จ: ' + (data.characterId || '');
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
dirs.forEach(d => {
|
||||
if (fileInputs[d]) fileInputs[d].value = '';
|
||||
if (previews[d]) previews[d].src = '';
|
||||
@@ -379,6 +497,10 @@
|
||||
const len = input && input.files ? input.files.length : 0;
|
||||
if (len > maxFrames) maxFrames = len;
|
||||
});
|
||||
const manEnt = editLayerManifest && editLayerManifest.byDir && editLayerManifest.byDir[d];
|
||||
if (manEnt && Array.isArray(manEnt.frames) && manEnt.frames.length > maxFrames) {
|
||||
maxFrames = manEnt.frames.length;
|
||||
}
|
||||
if (maxFrames === 0) return;
|
||||
hasAny = true;
|
||||
payload[d] = [];
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
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');
|
||||
@@ -55,6 +57,14 @@
|
||||
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;
|
||||
@@ -2109,6 +2119,7 @@
|
||||
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 = {
|
||||
@@ -2118,6 +2129,8 @@
|
||||
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: (() => {
|
||||
const el = document.getElementById('lobby-spawn-mode');
|
||||
@@ -2321,6 +2334,24 @@
|
||||
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)
|
||||
@@ -2341,6 +2372,20 @@
|
||||
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();
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
let previewQuizStep = 'read';
|
||||
let previewQuizCurrent = null;
|
||||
let playLiveQuizScores = {};
|
||||
/** คะแนนต่อคำตอบที่ถูกเมื่อส่งป้ายที่ฮับ/โซนส่ง — quiz_carry เท่านั้น */
|
||||
const QUIZ_CARRY_POINTS_PER_CORRECT = 10;
|
||||
/** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */
|
||||
let quizCarryPool = [];
|
||||
let quizCarryCurrent = null;
|
||||
@@ -3308,13 +3310,14 @@
|
||||
ent.quizCarryHeld = null;
|
||||
const ok = held === correct;
|
||||
if (ok) {
|
||||
playLiveQuizScores[actorId] = (playLiveQuizScores[actorId] || 0) + 1;
|
||||
playLiveQuizScores[actorId] = (playLiveQuizScores[actorId] || 0) + QUIZ_CARRY_POINTS_PER_CORRECT;
|
||||
const lim = previewMode && isQuizCarry() ? quizCarrySessionLength : 0;
|
||||
const willEnd = lim > 0 && (quizCarryRoundsCompleted + 1) >= lim;
|
||||
const showMapFb = !opts.silent && actorId === myId;
|
||||
if (showMapFb) showQuizMapQuestionCarryFeedback(true);
|
||||
if (!opts.silent) {
|
||||
showQuizCarryToast(willEnd ? 'ถูกต้อง +1 แต้ม — ครบชุดคำถาม' : 'ถูกต้อง +1 แต้ม — สุ่มคำถามใหม่', true);
|
||||
const pts = QUIZ_CARRY_POINTS_PER_CORRECT;
|
||||
showQuizCarryToast(willEnd ? ('ถูกต้อง +' + pts + ' แต้ม — ครบชุดคำถาม') : ('ถูกต้อง +' + pts + ' แต้ม — สุ่มคำถามใหม่'), true);
|
||||
}
|
||||
me.quizCarryHeld = null;
|
||||
others.forEach((o) => { if (o) o.quizCarryHeld = null; });
|
||||
@@ -7662,6 +7665,55 @@
|
||||
return quizTilesFootprintPlay(px, py).has(tx + ',' + ty);
|
||||
}
|
||||
|
||||
/**
|
||||
* กล่องตรวจโซนห้ามซ้อน (blockPlayer) — วางเหมือนชนกำแพง: กลางแนวนอน + ชิดเท้า
|
||||
* blockPlayerSeparationW/H: 0 = ใช้เต็มความกว้าง/สูงตัว · ค่าอื่น = จำกัด 1..cw / 1..ch
|
||||
* รองรับ legacy blockPlayerSeparation เมื่อยังไม่มี W/H ใน JSON
|
||||
*/
|
||||
function resolveBlockPlayerNoOverlapBoxWH(md) {
|
||||
const m = md || mapData;
|
||||
if (!m) return { cw: 1, ch: 1, boxW: 1, boxH: 1 };
|
||||
const { cw, ch } = getCharacterFootprintWH(m);
|
||||
let w = m.blockPlayerSeparationW != null && m.blockPlayerSeparationW !== '' ? Math.floor(Number(m.blockPlayerSeparationW)) : NaN;
|
||||
let h = m.blockPlayerSeparationH != null && m.blockPlayerSeparationH !== '' ? Math.floor(Number(m.blockPlayerSeparationH)) : NaN;
|
||||
const leg = m.blockPlayerSeparation != null && m.blockPlayerSeparation !== '' ? Math.floor(Number(m.blockPlayerSeparation)) : NaN;
|
||||
const hasW = Number.isFinite(w) && w >= 0;
|
||||
const hasH = Number.isFinite(h) && h >= 0;
|
||||
if (!hasW && !hasH && Number.isFinite(leg) && leg >= 1) {
|
||||
w = Math.min(leg, cw);
|
||||
h = Math.min(leg, ch);
|
||||
} else {
|
||||
if (!hasW) w = 0;
|
||||
if (!hasH) h = 0;
|
||||
}
|
||||
const boxW = !w || w <= 0 ? cw : Math.max(1, Math.min(cw, w));
|
||||
const boxH = !h || h <= 0 ? ch : Math.max(1, Math.min(ch, h));
|
||||
return { cw, ch, boxW, boxH };
|
||||
}
|
||||
|
||||
/** ช่องกริดที่ถือว่า “ผู้เล่นที่ (px,py) กินพื้นที่” สำหรับตรวจโซน blockPlayer — กล่องเดียวกับแนวชนกำแพง */
|
||||
function quizTilesBlockPlayerPeerFootprintPlay(px, py) {
|
||||
const s = new Set();
|
||||
if (!mapData) return s;
|
||||
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
|
||||
const { cw, ch, boxW, boxH } = resolveBlockPlayerNoOverlapBoxWH(mapData);
|
||||
const w = mapData.width || 20, h = mapData.height || 15;
|
||||
const minTx = Math.floor(px);
|
||||
const minTy = Math.floor(py);
|
||||
const offX = minTx + Math.floor((cw - boxW) / 2);
|
||||
const offY = minTy + (ch - boxH);
|
||||
for (let ty = offY; ty < offY + boxH; ty++) {
|
||||
for (let tx = offX; tx < offX + boxW; tx++) {
|
||||
if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function playPeerBlockPlayerOccupiesTile(px, py, tx, ty) {
|
||||
return quizTilesBlockPlayerPeerFootprintPlay(px, py).has(tx + ',' + ty);
|
||||
}
|
||||
|
||||
/** Footprint ชนกำแพง (objects=1) เท่านั้น — hub / interactive / blockPlayer / quiz ใช้ quizTilesFootprintPlay = เต็ม characterCells */
|
||||
function quizTilesWallCollisionFootprintPlay(px, py) {
|
||||
const s = new Set();
|
||||
@@ -7772,7 +7824,7 @@
|
||||
const tx = +p[0], ty = +p[1];
|
||||
if (!bp[ty] || bp[ty][tx] !== 1) continue;
|
||||
for (const [, o] of others) {
|
||||
if (playEntityFootprintContainsTile(o.x, o.y, tx, ty)) return false;
|
||||
if (playPeerBlockPlayerOccupiesTile(o.x, o.y, tx, ty)) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7812,9 +7864,9 @@
|
||||
if (!bp[ty] || bp[ty][tx] !== 1) continue;
|
||||
for (const [, peer] of others) {
|
||||
if (o && peer === o) continue;
|
||||
if (playEntityFootprintContainsTile(peer.x, peer.y, tx, ty)) return false;
|
||||
if (playPeerBlockPlayerOccupiesTile(peer.x, peer.y, tx, ty)) return false;
|
||||
}
|
||||
if (o && playEntityFootprintContainsTile(me.x, me.y, tx, ty)) return false;
|
||||
if (o && playPeerBlockPlayerOccupiesTile(me.x, me.y, tx, ty)) return false;
|
||||
}
|
||||
}
|
||||
if (isQuiz()) {
|
||||
@@ -8414,7 +8466,7 @@
|
||||
return !!(mapData && (isJumpSurvive() || isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()));
|
||||
}
|
||||
|
||||
/** Cyber HUD: quiz_carry ใช้ playLiveQuizScores (ถูก +1 ตอนส่งป้ายถูกที่ฮับ) */
|
||||
/** Cyber HUD: quiz_carry ใช้ playLiveQuizScores (ถูก +QUIZ_CARRY_POINTS_PER_CORRECT ตอนส่งป้ายถูกที่ฮับ) */
|
||||
function cyberHudQuizCarryPeerScore(peerId) {
|
||||
if (peerId == null) return 0;
|
||||
return Math.max(0, Number(playLiveQuizScores[peerId]) || 0);
|
||||
@@ -8520,7 +8572,7 @@
|
||||
if (isQuizCarry()) {
|
||||
hintEl.textContent = quizCarrySessionEnded
|
||||
? 'ชุดคำถามจบแล้ว · Session ended — ดูคะแนนด้าน SCORE'
|
||||
: 'ยืนโซนตัวเลือกแล้วกด F/Grab หยิบ · ถือป้ายถูกแล้วไปฮับส่ง — ถูก +1 แต้ม (correct answer +1 on hub)';
|
||||
: ('ยืนโซนตัวเลือกแล้วกด F/Grab หยิบ · ถือป้ายถูกแล้วไปฮับส่ง — ถูก +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' แต้ม (correct +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' on hub)');
|
||||
} else if (isJumpSurvive()) {
|
||||
hintEl.textContent = jumpSurviveEliminated
|
||||
? 'Spectate remaining nodes · Exit room to reset session'
|
||||
|
||||
@@ -856,6 +856,49 @@
|
||||
return s;
|
||||
}
|
||||
|
||||
/** กล่องตรวจโซนห้ามซ้อน — เหมือน play.js (ชิดเท้า + กลางแนวนอน) */
|
||||
function resolveBlockPlayerNoOverlapBoxWHLobby(md) {
|
||||
if (!md) return { cw: 1, ch: 1, boxW: 1, boxH: 1 };
|
||||
const { cw, ch } = getCharacterFootprintWH(md);
|
||||
let w = md.blockPlayerSeparationW != null && md.blockPlayerSeparationW !== '' ? Math.floor(Number(md.blockPlayerSeparationW)) : NaN;
|
||||
let h = md.blockPlayerSeparationH != null && md.blockPlayerSeparationH !== '' ? Math.floor(Number(md.blockPlayerSeparationH)) : NaN;
|
||||
const leg = md.blockPlayerSeparation != null && md.blockPlayerSeparation !== '' ? Math.floor(Number(md.blockPlayerSeparation)) : NaN;
|
||||
const hasW = Number.isFinite(w) && w >= 0;
|
||||
const hasH = Number.isFinite(h) && h >= 0;
|
||||
if (!hasW && !hasH && Number.isFinite(leg) && leg >= 1) {
|
||||
w = Math.min(leg, cw);
|
||||
h = Math.min(leg, ch);
|
||||
} else {
|
||||
if (!hasW) w = 0;
|
||||
if (!hasH) h = 0;
|
||||
}
|
||||
const boxW = !w || w <= 0 ? cw : Math.max(1, Math.min(cw, w));
|
||||
const boxH = !h || h <= 0 ? ch : Math.max(1, Math.min(ch, h));
|
||||
return { cw, ch, boxW, boxH };
|
||||
}
|
||||
|
||||
function quizTilesBlockPlayerPeerFootprintLobby(px, py) {
|
||||
const s = new Set();
|
||||
if (!mapData) return s;
|
||||
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
|
||||
const { cw, ch, boxW, boxH } = resolveBlockPlayerNoOverlapBoxWHLobby(mapData);
|
||||
const w = mapData.width || 20, h = mapData.height || 15;
|
||||
const minTx = Math.floor(px);
|
||||
const minTy = Math.floor(py);
|
||||
const offX = minTx + Math.floor((cw - boxW) / 2);
|
||||
const offY = minTy + (ch - boxH);
|
||||
for (let ty = offY; ty < offY + boxH; ty++) {
|
||||
for (let tx = offX; tx < offX + boxW; tx++) {
|
||||
if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function lobbyPeerBlockPlayerOccupiesTile(px, py, txi, tyi) {
|
||||
return quizTilesBlockPlayerPeerFootprintLobby(px, py).has(txi + ',' + tyi);
|
||||
}
|
||||
|
||||
function quizAnswerTileForbiddenLobby(tx, ty) {
|
||||
if (!quizPlayerLocal || quizPlayerLocal.eliminated) return false;
|
||||
if (quizPlayerLocal.cannotTrue && quizCellOnLobby(mapData.quizTrueArea, tx, ty)) return true;
|
||||
@@ -897,7 +940,7 @@
|
||||
if (bp && bp[ty] && bp[ty][tx] === 1) {
|
||||
for (const [id, p] of peers) {
|
||||
if (id === socket.id) continue;
|
||||
if (quizTilesFootprintLobby(p.x, p.y).has(tx + ',' + ty)) return false;
|
||||
if (lobbyPeerBlockPlayerOccupiesTile(p.x, p.y, tx, ty)) return false;
|
||||
}
|
||||
}
|
||||
if (mapData.gameType === 'quiz' && quizModeActive && quizPlayerLocal && !quizPlayerLocal.eliminated) {
|
||||
|
||||
@@ -1059,7 +1059,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.160"></script>
|
||||
<script src="js/play.js?v=0.165"></script>
|
||||
<div class="version-tag">v —</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1043,7 +1043,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.0182"></script>
|
||||
<script src="js/room-lobby.js?v=0.0185"></script>
|
||||
<div class="version-tag">v —</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+73
-1
@@ -51,6 +51,50 @@ function getAdminToken(cookieHeader) {
|
||||
|
||||
const MAPS_DIR = path.join(__dirname, 'data', 'maps');
|
||||
const CHARACTERS_DIR = path.join(__dirname, 'data', 'characters');
|
||||
/** ชื่อเลเยอร์ตรงกับ character.html / อัปโหลด (ลำดับวาดใน client) */
|
||||
const CHAR_LAYER_MANIFEST_KEYS = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
|
||||
const CHAR_MANIFEST_DIRS = ['up', 'down', 'left', 'right'];
|
||||
|
||||
/** รายการไฟล์เลเยอร์ต่อทิศ/เฟรม — ให้หน้า character แก้ไขโหลด preview ได้ */
|
||||
function buildCharacterLayerManifest(id, files) {
|
||||
if (!id || typeof id !== 'string') return { ok: false, error: 'bad id', byDir: {} };
|
||||
const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
|
||||
const layerAlt = CHAR_LAYER_MANIFEST_KEYS.join('|');
|
||||
const byDir = {};
|
||||
for (const dir of CHAR_MANIFEST_DIRS) {
|
||||
const multiRe = new RegExp('^' + esc + '_' + dir + '_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||||
const singleRe = new RegExp('^' + esc + '_' + dir + '_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||||
const multiHits = files.filter((f) => multiRe.test(f));
|
||||
if (multiHits.length) {
|
||||
let maxFi = -1;
|
||||
const perFrame = new Map();
|
||||
multiHits.forEach((f) => {
|
||||
const m = f.match(multiRe);
|
||||
if (!m) return;
|
||||
const fi = parseInt(m[1], 10);
|
||||
if (!Number.isFinite(fi) || fi < 0 || fi > 200) return;
|
||||
const layer = m[2];
|
||||
if (fi > maxFi) maxFi = fi;
|
||||
if (!perFrame.has(fi)) perFrame.set(fi, {});
|
||||
perFrame.get(fi)[layer] = f;
|
||||
});
|
||||
const frames = [];
|
||||
for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {});
|
||||
byDir[dir] = { multi: true, frames };
|
||||
} else {
|
||||
const frame0 = {};
|
||||
files.forEach((f) => {
|
||||
if (!f.startsWith(id + '_' + dir + '_layer_') || !ext.test(f)) return;
|
||||
const m = f.match(singleRe);
|
||||
if (m) frame0[m[1]] = f;
|
||||
});
|
||||
if (Object.keys(frame0).length) byDir[dir] = { multi: false, frames: [frame0] };
|
||||
}
|
||||
}
|
||||
return { ok: true, id, byDir };
|
||||
}
|
||||
|
||||
const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'data', 'gauntlet-assets');
|
||||
const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json');
|
||||
/** ต้องอยู่ใต้ public/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟรูปได้ — อย่าใช้ data/ */
|
||||
@@ -67,6 +111,9 @@ const maps = new Map();
|
||||
const defaultMap = () => ({
|
||||
width: 20, height: 15, tileSize: 32, gameType: 'zep',
|
||||
characterCells: 1, characterCellsW: 1, characterCellsH: 1,
|
||||
/** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */
|
||||
blockPlayerSeparationW: 0,
|
||||
blockPlayerSeparationH: 0,
|
||||
ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 },
|
||||
gridImageLibrary: [],
|
||||
gridImageSprites: [],
|
||||
@@ -1809,7 +1856,8 @@ const server = http.createServer((req, res) => {
|
||||
});
|
||||
}
|
||||
const layers = layerNameSet.size ? [...layerNameSet].sort() : [];
|
||||
return { id, name: id, hasLayerFiles, layers };
|
||||
const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null;
|
||||
return { id, name: id, hasLayerFiles, layers, layerManifest };
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify(list));
|
||||
@@ -1819,7 +1867,31 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
}
|
||||
const urlPath = url.split('?')[0];
|
||||
{
|
||||
const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$'));
|
||||
if (lm && req.method === 'GET') {
|
||||
const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, '');
|
||||
if (!id) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {} }));
|
||||
}
|
||||
try {
|
||||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||||
const files = fs.readdirSync(CHARACTERS_DIR);
|
||||
const manifest = buildCharacterLayerManifest(id, files);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify(manifest));
|
||||
} catch (e) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {} }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') {
|
||||
if (/\/layer-manifest$/i.test(urlPath)) {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' }));
|
||||
}
|
||||
const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length));
|
||||
const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, '');
|
||||
if (!id) {
|
||||
|
||||
Reference in New Issue
Block a user