415 lines
16 KiB
JavaScript
415 lines
16 KiB
JavaScript
(function () {
|
|
const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game';
|
|
const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/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 = SERVER + '/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');
|
|
});
|
|
|
|
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 = {};
|
|
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) {
|
|
// ข้ามเลเยอร์นี้เฟรมนี้
|
|
}
|
|
}
|
|
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 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;
|
|
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;
|
|
}
|
|
imagePromises.push(readImageFromFile(file).catch(() => 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(SERVER + '/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 = SERVER + '/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;
|
|
// แสดง preview รูปปัจจุบันในส่วนอัปโหลด
|
|
const ts = '?v=' + Date.now();
|
|
dirs.forEach(d => {
|
|
const prev = previews[d];
|
|
if (!prev) return;
|
|
const url = SERVER + '/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;
|
|
});
|
|
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' });
|
|
});
|
|
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(SERVER + '/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(SERVER + '/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 || '');
|
|
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;
|
|
});
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
})();
|