1318 lines
54 KiB
JavaScript
1318 lines
54 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
var script = document.currentScript;
|
|
var assetBase = (script && script.getAttribute('data-customize-asset-base')) || 'img/03-5-Customize';
|
|
var triggerSelector = (script && script.getAttribute('data-customize-triggers')) || '#btn-customize,#btn-cloth';
|
|
var triggerSelectors = triggerSelector.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
|
var storageKey = 'customizePopupStateV1';
|
|
var appliedStorageKey = 'customizePopupAppliedV1';
|
|
function isRoomLobbyPage() {
|
|
return /room-lobby\.html$/i.test(String(window.location.pathname || ''));
|
|
}
|
|
|
|
var overlayId = isRoomLobbyPage() ? 'room-cz-overlay' : 'customize-popup-overlay';
|
|
var LOBBY_IDLE_DOWN_PREFIX = 'jdCharLobbyIdleDown:';
|
|
var PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
|
|
var PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' };
|
|
/** ธีมสี 8 ช่อง — default ช่อง 8 = #fec1fe */
|
|
var THEME_SWATCH_HEX = [
|
|
'#d72520', '#ef8508', '#efe237', '#5bb443',
|
|
'#2585cb', '#3f4ead', '#b53fd6', '#fec1fe'
|
|
];
|
|
/** สีผิว 3 ช่อง — default ช่อง 1 = #eaa78a */
|
|
var SKIN_SWATCH_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1'];
|
|
var PLAY_TINT_HEAD = SKIN_SWATCH_HEX.slice();
|
|
var PLAY_TINT_HAIR = THEME_SWATCH_HEX.slice();
|
|
var PLAY_TINT_BODY = THEME_SWATCH_HEX.slice();
|
|
var DEFAULT_ACTIVE_COLOR = 8;
|
|
var DEFAULT_ACTIVE_SKIN = 1;
|
|
var previewImagePromiseCache = Object.create(null);
|
|
var previewCompositeCache = Object.create(null);
|
|
var previewLayerManifestPromiseCache = Object.create(null);
|
|
var previewRenderToken = 0;
|
|
|
|
function resolveAssetBase(raw) {
|
|
var base = String(raw || 'img/03-5-Customize').replace(/\/$/, '');
|
|
if (/^\/(Game|img)\//i.test(base)) {
|
|
return typeof window.appPath === 'function' ? window.appPath(base) : base;
|
|
}
|
|
try {
|
|
return new URL(base + '/', window.location.href).pathname.replace(/\/$/, '');
|
|
} catch (e) {
|
|
return base;
|
|
}
|
|
}
|
|
|
|
var resolvedAssetBase = resolveAssetBase(assetBase);
|
|
|
|
function asset(name) {
|
|
return resolvedAssetBase + '/' + name;
|
|
}
|
|
|
|
function qs(sel) { return document.querySelector(sel); }
|
|
|
|
function readInitialState() {
|
|
var state = {
|
|
activeTab: 1,
|
|
activeColor: DEFAULT_ACTIVE_COLOR,
|
|
activeSkin: DEFAULT_ACTIVE_SKIN,
|
|
activeItem: 1,
|
|
coins: '150'
|
|
};
|
|
try {
|
|
var raw = localStorage.getItem(storageKey);
|
|
if (raw) {
|
|
var parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object') {
|
|
if (parsed.activeTab >= 1 && parsed.activeTab <= 3) state.activeTab = parsed.activeTab;
|
|
if (parsed.activeColor >= 1 && parsed.activeColor <= 8) state.activeColor = parsed.activeColor;
|
|
if (parsed.activeSkin >= 1 && parsed.activeSkin <= 3) state.activeSkin = parsed.activeSkin;
|
|
if (parsed.activeItem >= 1 && parsed.activeItem <= 8) state.activeItem = parsed.activeItem;
|
|
}
|
|
}
|
|
var appliedRaw = localStorage.getItem(appliedStorageKey);
|
|
if (appliedRaw) {
|
|
var applied = JSON.parse(appliedRaw);
|
|
if (applied && typeof applied === 'object') {
|
|
if (applied.activeTab >= 1 && applied.activeTab <= 3) state.activeTab = applied.activeTab;
|
|
if (applied.activeColor >= 1 && applied.activeColor <= 8) state.activeColor = applied.activeColor;
|
|
if (applied.activeSkin >= 1 && applied.activeSkin <= 3) state.activeSkin = applied.activeSkin;
|
|
if (applied.activeItem >= 1 && applied.activeItem <= 8) state.activeItem = applied.activeItem;
|
|
}
|
|
}
|
|
var c = localStorage.getItem('jdCoins');
|
|
if (c != null && c !== '') state.coins = String(Math.max(0, parseInt(c, 10) || 0));
|
|
} catch (e) { /* ignore */ }
|
|
return state;
|
|
}
|
|
|
|
var state = readInitialState();
|
|
var refs = {
|
|
overlay: null,
|
|
dialog: null,
|
|
panelShell: null,
|
|
closeBtn: null,
|
|
confirmBtn: null,
|
|
previewFace: null,
|
|
nameEl: null,
|
|
coinVal: null,
|
|
colorRow: null,
|
|
skinRow: null,
|
|
tabRow: null,
|
|
itemsEl: null,
|
|
scrollEl: null,
|
|
scrollTrack: null,
|
|
scrollThumb: null
|
|
};
|
|
var scrollbarDrag = {
|
|
active: false,
|
|
pointerOffsetY: 0
|
|
};
|
|
|
|
function resetStateToConfirmed() {
|
|
var confirmed = readInitialState();
|
|
state.activeTab = confirmed.activeTab;
|
|
state.activeColor = confirmed.activeColor;
|
|
state.activeSkin = confirmed.activeSkin;
|
|
state.activeItem = confirmed.activeItem;
|
|
state.coins = confirmed.coins;
|
|
}
|
|
|
|
function persistConfirmedState() {
|
|
try {
|
|
localStorage.setItem(storageKey, JSON.stringify({
|
|
activeTab: clampIndex(state.activeTab, 1, 3),
|
|
activeColor: clampIndex(state.activeColor, 1, 8),
|
|
activeSkin: clampIndex(state.activeSkin, 1, 3),
|
|
activeItem: clampIndex(state.activeItem, 1, 8)
|
|
}));
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
function clampIndex(n, min, max) {
|
|
var v = parseInt(n, 10);
|
|
if (isNaN(v)) v = min;
|
|
return Math.max(min, Math.min(max, v));
|
|
}
|
|
|
|
function getStoredCharacterId() {
|
|
try {
|
|
return (localStorage.getItem('gameCharacterId') || '').trim();
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* หา characterId สำหรับ apply — ถ้า localStorage ว่าง ให้ดึงจาก API เหมือน lobby.js
|
|
* (กันเคสเครื่องที่ไม่เคยเซฟ gameCharacterId → กดยืนยันแล้วตัวใหญ่ไม่อัปเดต)
|
|
*/
|
|
var resolvedDefaultCharacterId = '';
|
|
function resolveApplyCharacterId(cb) {
|
|
var id = getStoredCharacterId();
|
|
if (id) { cb(id); return; }
|
|
if (resolvedDefaultCharacterId) { cb(resolvedDefaultCharacterId); return; }
|
|
try {
|
|
fetch(projectPath('/Game/api/characters'), { credentials: 'same-origin', cache: 'no-store' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (list) {
|
|
if (!Array.isArray(list) || !list.length) { cb(''); return; }
|
|
// เลือกตัวที่มี layer files ก่อน (composite สีได้จริง) ไม่งั้นใช้ตัวล่าสุด
|
|
var pick = null;
|
|
for (var i = 0; i < list.length; i += 1) {
|
|
if (list[i] && list[i].hasLayerFiles) { pick = list[i]; break; }
|
|
}
|
|
if (!pick) pick = list[list.length - 1];
|
|
resolvedDefaultCharacterId = pick && pick.id ? String(pick.id).trim() : '';
|
|
cb(resolvedDefaultCharacterId);
|
|
})
|
|
.catch(function () { cb(''); });
|
|
} catch (e) { cb(''); }
|
|
}
|
|
|
|
/** key เดียวกับ lobby.js (ensurePlayerKey) — ใช้ผูกสีกับบัญชี guest ฝั่ง server */
|
|
function getPlayerKey() {
|
|
try {
|
|
return (localStorage.getItem('jdPlayerKey') || '').trim();
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/** บันทึกสีที่เลือกไปเซิร์ฟเวอร์ (cross-device) — fire-and-forget, ไม่บล็อก UI */
|
|
function postLobbyStyleToServer(themeIndex, skinIndex) {
|
|
var key = getPlayerKey();
|
|
if (!key) return;
|
|
try {
|
|
fetch(projectPath('/Admin/api/player-lobby-style.php'), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'omit',
|
|
keepalive: true,
|
|
body: JSON.stringify({
|
|
playerKey: key,
|
|
colorThemeIndex: clampIndex(themeIndex, 1, 8),
|
|
skinToneIndex: clampIndex(skinIndex, 1, 3)
|
|
})
|
|
}).catch(function () { /* ออฟไลน์ — local ยังเก็บไว้ */ });
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
function projectPath(path) {
|
|
if (typeof window.appPath === 'function') return window.appPath(path);
|
|
return path;
|
|
}
|
|
|
|
function characterAssetsBasePath() {
|
|
var p = projectPath('/Game/img/characters/');
|
|
return p.replace(/\/?$/, '/');
|
|
}
|
|
|
|
function defaultShadowPath(dir) {
|
|
return projectPath('/Game/img/default-shadow-' + dir + '.png');
|
|
}
|
|
|
|
function characterLayerManifestUrl(characterId) {
|
|
return projectPath('/Game/api/characters/' + encodeURIComponent(characterId) + '/layer-manifest');
|
|
}
|
|
|
|
function fetchCharacterLayerManifest(characterId) {
|
|
if (!characterId) return Promise.resolve(null);
|
|
if (previewLayerManifestPromiseCache[characterId]) return previewLayerManifestPromiseCache[characterId];
|
|
previewLayerManifestPromiseCache[characterId] = fetch(characterLayerManifestUrl(characterId), { credentials: 'same-origin' })
|
|
.then(function (res) {
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
})
|
|
.then(function (json) {
|
|
if (!json || json.ok !== true) return null;
|
|
return json;
|
|
})
|
|
.catch(function () { return null; });
|
|
return previewLayerManifestPromiseCache[characterId];
|
|
}
|
|
|
|
function firstManifestFrame(manifest, dir) {
|
|
if (!manifest) return null;
|
|
var idleDir = manifest.byDirIdle && manifest.byDirIdle[dir];
|
|
if (idleDir && Array.isArray(idleDir.frames) && idleDir.frames.length) return idleDir.frames[0] || null;
|
|
var walkDir = manifest.byDir && manifest.byDir[dir];
|
|
if (walkDir && Array.isArray(walkDir.frames) && walkDir.frames.length) return walkDir.frames[0] || null;
|
|
return null;
|
|
}
|
|
|
|
function characterLayerFileUrl(fileName) {
|
|
if (!fileName) return '';
|
|
return characterAssetsBasePath() + encodeURIComponent(fileName);
|
|
}
|
|
|
|
function hexToRgb01(hex) {
|
|
var h = String(hex || '').replace('#', '').trim();
|
|
if (h.length !== 6) return [1, 1, 1];
|
|
return [
|
|
parseInt(h.slice(0, 2), 16) / 255,
|
|
parseInt(h.slice(2, 4), 16) / 255,
|
|
parseInt(h.slice(4, 6), 16) / 255
|
|
];
|
|
}
|
|
|
|
function tintImageData(imageData, tintHex) {
|
|
var rgb = hexToRgb01(tintHex);
|
|
var tr = rgb[0], tg = rgb[1], tb = rgb[2];
|
|
var d = imageData.data;
|
|
for (var i = 0; i < d.length; i += 4) {
|
|
if (d[i + 3] < 12) continue;
|
|
// Force exact tint color for color layers (bodyColor/headColor/hairColor).
|
|
d[i] = Math.min(255, Math.round(tr * 255));
|
|
d[i + 1] = Math.min(255, Math.round(tg * 255));
|
|
d[i + 2] = Math.min(255, Math.round(tb * 255));
|
|
}
|
|
}
|
|
|
|
function drawTintedLayer(ctx, w, h, img, tintHex) {
|
|
if (!tintHex) {
|
|
ctx.drawImage(img, 0, 0, w, h);
|
|
return;
|
|
}
|
|
var c = document.createElement('canvas');
|
|
c.width = w;
|
|
c.height = h;
|
|
var x = c.getContext('2d');
|
|
x.drawImage(img, 0, 0, w, h);
|
|
try {
|
|
var idata = x.getImageData(0, 0, w, h);
|
|
tintImageData(idata, tintHex);
|
|
x.putImageData(idata, 0, 0);
|
|
} catch (e) {
|
|
x.clearRect(0, 0, w, h);
|
|
x.drawImage(img, 0, 0, w, h);
|
|
}
|
|
ctx.drawImage(c, 0, 0, w, h);
|
|
}
|
|
|
|
function rgbToHsl01(r, g, b) {
|
|
var rn = r / 255;
|
|
var gn = g / 255;
|
|
var bn = b / 255;
|
|
var max = Math.max(rn, gn, bn);
|
|
var min = Math.min(rn, gn, bn);
|
|
var h = 0;
|
|
var s = 0;
|
|
var l = (max + min) / 2;
|
|
if (max !== min) {
|
|
var d = max - min;
|
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
switch (max) {
|
|
case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break;
|
|
case gn: h = (bn - rn) / d + 2; break;
|
|
default: h = (rn - gn) / d + 4; break;
|
|
}
|
|
h /= 6;
|
|
}
|
|
return [h, s, l];
|
|
}
|
|
|
|
function tintRgbByLuma(r, g, b, tintHex) {
|
|
var rgb = hexToRgb01(tintHex);
|
|
return [
|
|
Math.min(255, Math.round(rgb[0] * 255)),
|
|
Math.min(255, Math.round(rgb[1] * 255)),
|
|
Math.min(255, Math.round(rgb[2] * 255))
|
|
];
|
|
}
|
|
|
|
function composeHeuristicTintFromSource(sourceSrc, tint) {
|
|
if (!sourceSrc || !tint) return Promise.resolve(null);
|
|
var cacheKey = ['heuristic', sourceSrc, tint.head, tint.hair, tint.body].join('|');
|
|
if (Object.prototype.hasOwnProperty.call(previewCompositeCache, cacheKey)) {
|
|
return Promise.resolve(previewCompositeCache[cacheKey] || null);
|
|
}
|
|
return loadImageCached(sourceSrc).then(function (img) {
|
|
if (!img || !img.naturalWidth || !img.naturalHeight) return null;
|
|
var w = img.naturalWidth;
|
|
var h = img.naturalHeight;
|
|
var c = document.createElement('canvas');
|
|
c.width = w;
|
|
c.height = h;
|
|
var ctx = c.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, w, h);
|
|
try {
|
|
var idata = ctx.getImageData(0, 0, w, h);
|
|
var d = idata.data;
|
|
function alphaAt(x, y) {
|
|
if (x < 0 || y < 0 || x >= w || y >= h) return 0;
|
|
return d[((y * w + x) * 4) + 3];
|
|
}
|
|
function lumaAt(x, y) {
|
|
if (x < 0 || y < 0 || x >= w || y >= h) return 0;
|
|
var bi = (y * w + x) * 4;
|
|
var aa = d[bi + 3];
|
|
if (aa < 12) return 0;
|
|
var rr = d[bi];
|
|
var gg = d[bi + 1];
|
|
var bb = d[bi + 2];
|
|
return (0.299 * rr + 0.587 * gg + 0.114 * bb) / 255;
|
|
}
|
|
function isOutlinePixel(x, y, a) {
|
|
if (a < 12) return false;
|
|
// Keep outer contour/stroke untouched:
|
|
// pixels touching transparency are treated as outline.
|
|
if (alphaAt(x - 1, y) < 12) return true;
|
|
if (alphaAt(x + 1, y) < 12) return true;
|
|
if (alphaAt(x, y - 1) < 12) return true;
|
|
if (alphaAt(x, y + 1) < 12) return true;
|
|
return false;
|
|
}
|
|
function isNearOuterContour(x, y) {
|
|
for (var oy = -2; oy <= 2; oy += 1) {
|
|
for (var ox = -2; ox <= 2; ox += 1) {
|
|
if (ox === 0 && oy === 0) continue;
|
|
if (alphaAt(x + ox, y + oy) < 12) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
function isStrokeLikeByContrast(x, y, lig, sat) {
|
|
if (sat < 0.18 && lig < 0.68) return true;
|
|
var c = lumaAt(x, y);
|
|
var d1 = Math.abs(c - lumaAt(x - 1, y));
|
|
var d2 = Math.abs(c - lumaAt(x + 1, y));
|
|
var d3 = Math.abs(c - lumaAt(x, y - 1));
|
|
var d4 = Math.abs(c - lumaAt(x, y + 1));
|
|
var maxDiff = Math.max(d1, d2, d3, d4);
|
|
if (maxDiff >= 0.24 && lig < 0.78) return true;
|
|
return false;
|
|
}
|
|
for (var i = 0; i < d.length; i += 4) {
|
|
var a = d[i + 3];
|
|
if (a < 12) continue;
|
|
var px = (i / 4) % w;
|
|
var py = Math.floor((i / 4) / w);
|
|
if (isOutlinePixel(px, py, a)) continue;
|
|
if (isNearOuterContour(px, py)) continue;
|
|
var r = d[i];
|
|
var g = d[i + 1];
|
|
var b = d[i + 2];
|
|
var hsl = rgbToHsl01(r, g, b);
|
|
var hueDeg = hsl[0] * 360;
|
|
var sat = hsl[1];
|
|
var lig = hsl[2];
|
|
|
|
// Preserve dark lines/stroke and tiny details.
|
|
if (lig < 0.23) continue;
|
|
if (isStrokeLikeByContrast(px, py, lig, sat)) continue;
|
|
|
|
// Skin region: warm hue + upper-center body area
|
|
var inHeadArea = py <= h * 0.62 && px >= w * 0.18 && px <= w * 0.82;
|
|
var isSkinLike = inHeadArea && hueDeg >= 8 && hueDeg <= 45 && sat >= 0.14 && sat <= 0.78 && lig >= 0.24 && lig <= 0.95;
|
|
if (isSkinLike) {
|
|
var skinRgb = tintRgbByLuma(r, g, b, tint.head);
|
|
d[i] = skinRgb[0];
|
|
d[i + 1] = skinRgb[1];
|
|
d[i + 2] = skinRgb[2];
|
|
continue;
|
|
}
|
|
|
|
// รักษาเส้น tech สีฟ้า/cyan accent — อย่าย้อมให้เป็นสีธีม (เคสที่ user รายงาน)
|
|
if (hueDeg >= 150 && hueDeg <= 265 && sat >= 0.22) continue;
|
|
// Theme region: colored non-gray pixels (exclude very dark/very bright)
|
|
var isThemeCandidate = sat >= 0.2 && lig >= 0.3 && lig <= 0.95;
|
|
if (!isThemeCandidate) continue;
|
|
var themeHex = py <= h * 0.52 ? tint.hair : tint.body;
|
|
var themeRgb = tintRgbByLuma(r, g, b, themeHex);
|
|
d[i] = themeRgb[0];
|
|
d[i + 1] = themeRgb[1];
|
|
d[i + 2] = themeRgb[2];
|
|
}
|
|
ctx.putImageData(idata, 0, 0);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
var out = c.toDataURL('image/png');
|
|
previewCompositeCache[cacheKey] = out;
|
|
return out;
|
|
});
|
|
}
|
|
|
|
function loadImageOnce(url, crossOrigin) {
|
|
return new Promise(function (resolve) {
|
|
var img = new Image();
|
|
img.decoding = 'async';
|
|
if (crossOrigin) { try { img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ } }
|
|
img.onload = function () { resolve(img.naturalWidth > 0 ? img : null); };
|
|
img.onerror = function () { resolve(null); };
|
|
// cache-bust เฉพาะตอน retry กันเบราว์เซอร์คืน error response ที่ cache ไว้
|
|
img.src = url;
|
|
if (img.complete && img.naturalWidth > 0) resolve(img);
|
|
});
|
|
}
|
|
|
|
function loadImageCached(url) {
|
|
if (!url) return Promise.resolve(null);
|
|
if (previewImagePromiseCache[url]) return previewImagePromiseCache[url];
|
|
// same-origin: อย่าตั้ง crossOrigin (บาง nginx ทำรูปโหลดล้ม) — ตั้งเฉพาะข้ามโดเมนเพื่อกัน canvas taint
|
|
var crossOrigin = false;
|
|
try {
|
|
var u = new URL(url, window.location.href);
|
|
crossOrigin = (u.origin !== window.location.origin);
|
|
} catch (e) { /* ignore */ }
|
|
var p = loadImageOnce(url, crossOrigin).then(function (img) {
|
|
if (img) return img;
|
|
// retry 1 ครั้ง (กันรูป layer หลุดชั่วคราวตอน burst โหลดหน้าแรก) แนบ cache-bust กัน error response ที่ cache
|
|
var bust = url + (url.indexOf('?') === -1 ? '?' : '&') + 'r=' + Date.now();
|
|
return loadImageOnce(bust, crossOrigin);
|
|
}).then(function (img) {
|
|
// อย่า cache ผลที่ล้ม → รอบหน้า (เปลี่ยนสี/เปิดใหม่) จะลองโหลดอีกครั้ง ไม่ค้าง null ทั้ง session
|
|
if (!img && previewImagePromiseCache[url] === p) delete previewImagePromiseCache[url];
|
|
return img;
|
|
});
|
|
previewImagePromiseCache[url] = p;
|
|
return p;
|
|
}
|
|
|
|
function resolveFirstImage(urls, idx) {
|
|
var index = idx || 0;
|
|
if (!urls || index >= urls.length) return Promise.resolve(null);
|
|
return loadImageCached(urls[index]).then(function (img) {
|
|
if (img) return img;
|
|
return resolveFirstImage(urls, index + 1);
|
|
});
|
|
}
|
|
|
|
function layerUrlCandidates(id, dir, layerName, frameIndex) {
|
|
var enc = encodeURIComponent(id);
|
|
var base = characterAssetsBasePath() + enc + '_' + dir;
|
|
var out = [];
|
|
function add(u) {
|
|
if (out.indexOf(u) === -1) out.push(u);
|
|
}
|
|
add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
|
|
if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
|
|
add(base + '_layer_' + layerName + '.png');
|
|
return out;
|
|
}
|
|
|
|
function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) {
|
|
var enc = encodeURIComponent(id);
|
|
var base = characterAssetsBasePath() + enc + '_' + dir + '_idle';
|
|
var out = [];
|
|
function add(u) {
|
|
if (out.indexOf(u) === -1) out.push(u);
|
|
}
|
|
add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
|
|
if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
|
|
add(base + '_layer_' + layerName + '.png');
|
|
return out;
|
|
}
|
|
|
|
function getSelectedTint() {
|
|
var skinIdx = clampIndex(state.activeSkin, 1, 3) - 1;
|
|
var colorIdx = clampIndex(state.activeColor, 1, 8) - 1;
|
|
return {
|
|
head: PLAY_TINT_HEAD[skinIdx],
|
|
hair: PLAY_TINT_HAIR[colorIdx],
|
|
body: PLAY_TINT_BODY[colorIdx]
|
|
};
|
|
}
|
|
|
|
function composeTintedCharacterPreview(characterId, tint) {
|
|
if (!characterId || !tint) return Promise.resolve(null);
|
|
var dir = 'down';
|
|
var frameIndex = 0;
|
|
var cacheKey = [characterId, dir, frameIndex, tint.head, tint.hair, tint.body, 'customize'].join('|');
|
|
if (Object.prototype.hasOwnProperty.call(previewCompositeCache, cacheKey)) {
|
|
return Promise.resolve(previewCompositeCache[cacheKey] || null);
|
|
}
|
|
|
|
function composeFromLayers(baseW, baseH, layers) {
|
|
var c = document.createElement('canvas');
|
|
c.width = baseW;
|
|
c.height = baseH;
|
|
var ctx = c.getContext('2d');
|
|
var anyTintColorLayer = false;
|
|
for (var li = 0; li < PLAY_LAYER_ORDER.length; li += 1) {
|
|
var layerName = PLAY_LAYER_ORDER[li];
|
|
var img = layers[li];
|
|
if (!img) continue;
|
|
var tintKey = PLAY_LAYER_TINT_KEY[layerName];
|
|
var tintHex = tintKey ? tint[tintKey] : null;
|
|
if (tintKey) anyTintColorLayer = true;
|
|
drawTintedLayer(ctx, c.width, c.height, img, tintHex);
|
|
}
|
|
// ต้องมีอย่างน้อย 1 layer สี (bodyColor/headColor/hairColor) ที่โหลดได้ ไม่งั้นถือว่าไม่สำเร็จ
|
|
if (!anyTintColorLayer) return null;
|
|
var out = c.toDataURL('image/png');
|
|
previewCompositeCache[cacheKey] = out;
|
|
return out;
|
|
}
|
|
|
|
function tryManifest() {
|
|
return fetchCharacterLayerManifest(characterId).then(function (manifest) {
|
|
var manifestFrame = firstManifestFrame(manifest, dir);
|
|
if (!manifestFrame) return null;
|
|
var manifestLayerPromises = PLAY_LAYER_ORDER.map(function (layerName) {
|
|
var urls = [];
|
|
var fileName = manifestFrame[layerName];
|
|
if (fileName) urls.push(characterLayerFileUrl(fileName));
|
|
if (layerName === 'shadow') urls.push(defaultShadowPath(dir));
|
|
return resolveFirstImage(urls);
|
|
});
|
|
return Promise.all(manifestLayerPromises).then(function (layers) {
|
|
var baseImg = null;
|
|
for (var bi = 0; bi < layers.length; bi += 1) {
|
|
if (layers[bi] && layers[bi].naturalWidth > 0 && layers[bi].naturalHeight > 0) {
|
|
baseImg = layers[bi];
|
|
break;
|
|
}
|
|
}
|
|
if (!baseImg) return null;
|
|
return composeFromLayers(baseImg.naturalWidth, baseImg.naturalHeight, layers);
|
|
});
|
|
});
|
|
}
|
|
|
|
function tryRawLayers() {
|
|
var rawCandidates = [
|
|
characterAssetsBasePath() + encodeURIComponent(characterId) + '_' + dir + '_idle.png',
|
|
characterAssetsBasePath() + encodeURIComponent(characterId) + '_' + dir + '.png',
|
|
characterAssetsBasePath() + encodeURIComponent(characterId) + '_' + dir + '_0.png'
|
|
];
|
|
return resolveFirstImage(rawCandidates).then(function (rawImg) {
|
|
if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null;
|
|
var layerPromises = PLAY_LAYER_ORDER.map(function (layerName) {
|
|
var urls = layerUrlCandidatesIdle(characterId, dir, layerName, frameIndex)
|
|
.concat(layerUrlCandidates(characterId, dir, layerName, frameIndex));
|
|
if (layerName === 'shadow') urls.push(defaultShadowPath(dir));
|
|
return resolveFirstImage(urls);
|
|
});
|
|
return Promise.all(layerPromises).then(function (layers) {
|
|
return composeFromLayers(rawImg.naturalWidth, rawImg.naturalHeight, layers);
|
|
});
|
|
});
|
|
}
|
|
|
|
// exact: ลอง manifest ก่อน → ถ้าไม่สำเร็จ (layer สีโหลดไม่ครบ) ลอง raw layer แบบ convention ต่อ
|
|
// กันเคส "บาง browser/PC" ที่ตกไปใช้ heuristic แล้วย้อมทั้งรูป (เส้นฟ้า/ถุงมือกลายเป็นสีธีม)
|
|
return tryManifest().then(function (out) {
|
|
return out || tryRawLayers();
|
|
}).catch(function () { return null; });
|
|
}
|
|
|
|
/** สร้าง tint จาก index (ใช้ palette hardcoded — deterministic ทุก browser) */
|
|
function tintFromIndices(themeIndex, skinIndex) {
|
|
var skinIdx = clampIndex(skinIndex, 1, 3) - 1;
|
|
var colorIdx = clampIndex(themeIndex, 1, 8) - 1;
|
|
return {
|
|
head: PLAY_TINT_HEAD[skinIdx],
|
|
hair: PLAY_TINT_HAIR[colorIdx],
|
|
body: PLAY_TINT_BODY[colorIdx]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Public API — ให้ lobby.js เรียก recompute สีตัวละครจาก index ได้ทุกเครื่อง
|
|
* ใช้เส้น exact (composite layer) ก่อน, ถ้าไม่มีเลเยอร์ค่อย fallback heuristic จากรูปดิบฝั่ง server
|
|
* @returns {Promise<string|null>} data URL ของ idle ทิศ down หรือ null
|
|
*/
|
|
function composeTintedCharacterByIndex(characterId, themeIndex, skinIndex) {
|
|
if (!characterId) return Promise.resolve(null);
|
|
var tint = tintFromIndices(themeIndex, skinIndex);
|
|
return composeTintedCharacterPreview(characterId, tint).then(function (out) {
|
|
if (out) return out;
|
|
var enc = encodeURIComponent(characterId);
|
|
var rawCandidates = [
|
|
characterAssetsBasePath() + enc + '_down_idle.png',
|
|
characterAssetsBasePath() + enc + '_down.png',
|
|
characterAssetsBasePath() + enc + '_down_0.png'
|
|
];
|
|
return resolveFirstImage(rawCandidates).then(function (img) {
|
|
if (!img || !img.src) return null;
|
|
return composeHeuristicTintFromSource(img.src, tint);
|
|
});
|
|
}).catch(function () { return null; });
|
|
}
|
|
|
|
function resolveDomAvatarSource() {
|
|
var avatarSrc = '';
|
|
var avatarAlt = 'ตัวละครของผู้เล่น';
|
|
var candidates = [
|
|
'#lobby-character-img',
|
|
'#room-lobby-profile-avatar',
|
|
'#room-lobby-profile-overlay-avatar',
|
|
'#lobby-profile-avatar',
|
|
'#lobby-avatar-wrap img'
|
|
];
|
|
for (var i = 0; i < candidates.length; i += 1) {
|
|
var el = document.querySelector(candidates[i]);
|
|
if (!el) continue;
|
|
var src = (el.getAttribute('src') || '').trim();
|
|
if (!src) continue;
|
|
avatarSrc = src;
|
|
avatarAlt = (el.getAttribute('alt') || '').trim() || avatarAlt;
|
|
break;
|
|
}
|
|
return { src: avatarSrc, alt: avatarAlt };
|
|
}
|
|
|
|
function syncLiveAvatarElements(src, alt) {
|
|
if (!src) return;
|
|
var selectors = [
|
|
'#lobby-profile-avatar',
|
|
'#room-lobby-profile-avatar',
|
|
'#room-lobby-profile-overlay-avatar',
|
|
'#lobby-character-img'
|
|
];
|
|
selectors.forEach(function (sel) {
|
|
var el = document.querySelector(sel);
|
|
if (!el || el.tagName !== 'IMG') return;
|
|
try {
|
|
el.setAttribute('src', src);
|
|
if (alt) el.setAttribute('alt', alt);
|
|
} catch (e) { /* ignore */ }
|
|
});
|
|
}
|
|
|
|
function applyCustomizeSelection() {
|
|
return new Promise(function (resolve) {
|
|
resolveApplyCharacterId(function (characterId) {
|
|
function runApply() {
|
|
// ไม่มีตัวละคร (เครื่องที่ยังไม่ได้เลือกตัวละคร) — ยังต้องซิงก์สีไปเซิร์ฟเวอร์
|
|
// ให้คนอื่นเห็น แต่ข้ามการ composite รูป preview เพราะไม่มี characterId
|
|
if (!characterId) {
|
|
try {
|
|
persistConfirmedState();
|
|
syncLobbyTintStorageKeys();
|
|
postLobbyStyleToServer(state.activeColor, state.activeSkin);
|
|
} catch (e0) { /* ignore */ }
|
|
try {
|
|
window.dispatchEvent(new CustomEvent('customize-popup:applied', {
|
|
detail: {
|
|
characterId: '',
|
|
activeColor: clampIndex(state.activeColor, 1, 8),
|
|
activeSkin: clampIndex(state.activeSkin, 1, 3),
|
|
avatarSrc: ''
|
|
}
|
|
}));
|
|
} catch (e1) { /* ignore */ }
|
|
resolve(null);
|
|
return;
|
|
}
|
|
var tint = getSelectedTint();
|
|
var domAvatar = resolveDomAvatarSource();
|
|
var fallbackSrc = domAvatar.src || '';
|
|
composeTintedCharacterPreview(characterId, tint)
|
|
.then(function (outSrc) {
|
|
if (outSrc) return outSrc;
|
|
return composeHeuristicTintFromSource(fallbackSrc, tint);
|
|
})
|
|
.then(function (finalSrc) {
|
|
if (!finalSrc || typeof finalSrc !== 'string' || finalSrc.indexOf('data:image/') !== 0) return null;
|
|
try {
|
|
localStorage.setItem(LOBBY_IDLE_DOWN_PREFIX + characterId, finalSrc);
|
|
localStorage.setItem(appliedStorageKey, JSON.stringify({
|
|
characterId: characterId,
|
|
activeTab: clampIndex(state.activeTab, 1, 3),
|
|
activeColor: clampIndex(state.activeColor, 1, 8),
|
|
activeSkin: clampIndex(state.activeSkin, 1, 3),
|
|
activeItem: clampIndex(state.activeItem, 1, 8),
|
|
appliedAt: Date.now()
|
|
}));
|
|
persistConfirmedState();
|
|
syncLobbyTintStorageKeys();
|
|
postLobbyStyleToServer(state.activeColor, state.activeSkin);
|
|
} catch (e) { /* ignore */ }
|
|
syncLiveAvatarElements(finalSrc, 'ตัวละครที่ปรับแต่งแล้ว');
|
|
try {
|
|
window.dispatchEvent(new CustomEvent('customize-popup:applied', {
|
|
detail: {
|
|
characterId: characterId,
|
|
activeColor: clampIndex(state.activeColor, 1, 8),
|
|
activeSkin: clampIndex(state.activeSkin, 1, 3),
|
|
avatarSrc: finalSrc
|
|
}
|
|
}));
|
|
} catch (e2) { /* ignore */ }
|
|
return finalSrc;
|
|
})
|
|
.catch(function () { return null; })
|
|
.then(resolve);
|
|
}
|
|
if (isRoomLobbyPage() && typeof window.validateLobbyThemeColorChange === 'function') {
|
|
window.validateLobbyThemeColorChange(
|
|
clampIndex(state.activeColor, 1, 8),
|
|
clampIndex(state.activeSkin, 1, 3),
|
|
function (ok, err) {
|
|
if (!ok) {
|
|
if (err) try { alert(err); } catch (e) { /* ignore */ }
|
|
resolve(null);
|
|
return;
|
|
}
|
|
runApply();
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
runApply();
|
|
});
|
|
});
|
|
}
|
|
|
|
function removeLegacyCustomizeOverlays() {
|
|
if (!isRoomLobbyPage()) return;
|
|
var wrong = document.getElementById('customize-popup-overlay');
|
|
if (wrong && wrong.parentNode) wrong.parentNode.removeChild(wrong);
|
|
var old = document.getElementById('room-cz-overlay');
|
|
if (old && !old.querySelector('.customize-popup-dialog') && old.parentNode) {
|
|
old.parentNode.removeChild(old);
|
|
}
|
|
var legacyStyle = document.getElementById('room-cz-style');
|
|
if (legacyStyle && legacyStyle.parentNode) legacyStyle.parentNode.removeChild(legacyStyle);
|
|
}
|
|
|
|
function createPopupIfNeeded() {
|
|
removeLegacyCustomizeOverlays();
|
|
var existing = document.getElementById(overlayId);
|
|
if (existing && existing.querySelector('.customize-popup-dialog')) {
|
|
bindRefs(existing);
|
|
return;
|
|
}
|
|
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
|
|
|
var overlay = document.createElement('div');
|
|
overlay.id = overlayId;
|
|
overlay.className = 'customize-popup-overlay is-hidden';
|
|
overlay.setAttribute('role', 'dialog');
|
|
overlay.setAttribute('aria-modal', 'true');
|
|
overlay.setAttribute('aria-label', 'ห้องแต่งตัว');
|
|
overlay.setAttribute('aria-hidden', 'true');
|
|
overlay.innerHTML = '' +
|
|
'<div class="customize-popup-dialog" role="dialog" aria-modal="true" aria-label="ห้องแต่งตัว">' +
|
|
' <div class="customize-popup-coin-wrap">' +
|
|
' <img class="customize-popup-coin-bg" src="' + asset('coin-bg.png') + '" alt="" decoding="async">' +
|
|
' <span class="customize-popup-coin-val">150</span>' +
|
|
' </div>' +
|
|
' <div class="customize-popup-content">' +
|
|
' <div class="customize-popup-char-col">' +
|
|
' <div class="customize-popup-player-name" id="customize-popup-player-name">MONE</div>' +
|
|
' <img class="customize-popup-char-bg" src="' + asset('char-bg.png') + '" alt="" decoding="async">' +
|
|
' <img class="customize-popup-char-preview" src="' + asset('face-1.png') + '" alt="ตัวอย่างตัวละคร" decoding="async">' +
|
|
' </div>' +
|
|
' <div class="customize-popup-panel-shell">' +
|
|
' <img class="customize-popup-cloth-bg" src="' + asset('cloth-bg.png') + '" alt="" decoding="async">' +
|
|
' <button type="button" class="customize-popup-close" aria-label="ปิดห้องแต่งตัว"></button>' +
|
|
' <div class="customize-popup-panel">' +
|
|
' <div class="customize-popup-theme-row">' +
|
|
' <img class="customize-popup-theme-label" src="' + asset('theme-color-txt.png') + '" alt="ธีมสี" decoding="async">' +
|
|
' <div class="customize-popup-theme-frame">' +
|
|
' <img src="' + asset('color-frame-session.png') + '" alt="" decoding="async">' +
|
|
' <div class="customize-popup-color-list customize-popup-color-row"></div>' +
|
|
' </div>' +
|
|
' </div>' +
|
|
' <div class="customize-popup-theme-row">' +
|
|
' <img class="customize-popup-theme-label" src="' + asset('theme-skin-txt.png') + '" alt="สีผิว" decoding="async">' +
|
|
' <div class="customize-popup-theme-frame">' +
|
|
' <img src="' + asset('color-frame-session.png') + '" alt="" decoding="async">' +
|
|
' <div class="customize-popup-color-list customize-popup-skin-row"></div>' +
|
|
' </div>' +
|
|
' </div>' +
|
|
' <div class="customize-popup-tabs"></div>' +
|
|
' <div class="customize-popup-items-wrap">' +
|
|
' <ul class="customize-popup-items"></ul>' +
|
|
' <div class="customize-popup-scroll is-hidden">' +
|
|
' <div class="customize-popup-scroll-track"></div>' +
|
|
' <div class="customize-popup-scroll-thumb"></div>' +
|
|
' </div>' +
|
|
' </div>' +
|
|
' <button type="button" class="customize-popup-confirm" aria-label="ยืนยัน">' +
|
|
' <img src="' + asset('btn-cf.png') + '" alt="ยืนยัน" decoding="async">' +
|
|
' </button>' +
|
|
' </div>' +
|
|
' </div>' +
|
|
' </div>' +
|
|
'</div>';
|
|
|
|
document.body.appendChild(overlay);
|
|
bindRefs(overlay);
|
|
bindPopupEvents();
|
|
renderAll();
|
|
}
|
|
|
|
function bindRefs(root) {
|
|
refs.overlay = root;
|
|
refs.dialog = root.querySelector('.customize-popup-dialog');
|
|
refs.panelShell = root.querySelector('.customize-popup-panel-shell');
|
|
refs.closeBtn = root.querySelector('.customize-popup-close');
|
|
refs.confirmBtn = root.querySelector('.customize-popup-confirm');
|
|
refs.previewFace = root.querySelector('.customize-popup-char-preview');
|
|
refs.nameEl = root.querySelector('#customize-popup-player-name');
|
|
refs.coinVal = root.querySelector('.customize-popup-coin-val');
|
|
refs.colorRow = root.querySelector('.customize-popup-color-row');
|
|
refs.skinRow = root.querySelector('.customize-popup-skin-row');
|
|
refs.tabRow = root.querySelector('.customize-popup-tabs');
|
|
refs.itemsEl = root.querySelector('.customize-popup-items');
|
|
refs.scrollEl = root.querySelector('.customize-popup-scroll');
|
|
refs.scrollTrack = root.querySelector('.customize-popup-scroll-track');
|
|
refs.scrollThumb = root.querySelector('.customize-popup-scroll-thumb');
|
|
}
|
|
|
|
function syncScale() {
|
|
if (!refs.dialog) return;
|
|
var w = refs.dialog.clientWidth || 0;
|
|
var h = refs.dialog.clientHeight || 0;
|
|
if (!w || !h) return;
|
|
// Scale from full popup canvas, not right panel only.
|
|
// This prevents left character column from shifting when right panel is tweaked.
|
|
var scaleW = w / 1600;
|
|
var scaleH = h / 951;
|
|
var vw = window.innerWidth || 0;
|
|
var vh = window.innerHeight || 0;
|
|
var minScale = (vw <= 1200 || vh <= 820) ? 0.32 : 0.45;
|
|
var scale = Math.max(minScale, Math.min(1, Math.min(scaleW, scaleH)));
|
|
refs.dialog.style.setProperty('--cc-scale', String(scale.toFixed(4)));
|
|
if (refs.panelShell) {
|
|
var shellW = refs.panelShell.clientWidth || 0;
|
|
var shellH = refs.panelShell.clientHeight || 0;
|
|
if (shellW && shellH) {
|
|
var shellScaleW = shellW / 990;
|
|
var shellScaleH = shellH / 951;
|
|
var shellScale = Math.max(0.28, Math.min(1, Math.min(shellScaleW, shellScaleH)));
|
|
refs.panelShell.style.setProperty('--cc-shell-scale', String(shellScale.toFixed(4)));
|
|
}
|
|
}
|
|
}
|
|
|
|
function getPlayerName() {
|
|
if (typeof window.getProfileDisplayName === 'function') {
|
|
var fromLobby = String(window.getProfileDisplayName() || '').trim();
|
|
if (fromLobby) return fromLobby;
|
|
}
|
|
var name = '';
|
|
try {
|
|
name = (localStorage.getItem('roomLobbyDisplayName') || '').trim() ||
|
|
(localStorage.getItem('playerName') || '').trim() || '';
|
|
} catch (e) { /* ignore */ }
|
|
return name || 'MONE';
|
|
}
|
|
|
|
function syncLobbyTintStorageKeys() {
|
|
try {
|
|
localStorage.setItem('lobbyThemeColor', String(clampIndex(state.activeColor, 1, 8)));
|
|
localStorage.setItem('lobbySkinTone', String(clampIndex(state.activeSkin, 1, 3)));
|
|
if (clampIndex(state.activeTab, 1, 3) === 1) {
|
|
localStorage.setItem('lobbyItem_face', String(clampIndex(state.activeItem, 1, 8)));
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
function readLobbyTintIntoState() {
|
|
try {
|
|
var c = localStorage.getItem('lobbyThemeColor');
|
|
if (c) state.activeColor = clampIndex(c, 1, 8);
|
|
var s = localStorage.getItem('lobbySkinTone');
|
|
if (s) state.activeSkin = clampIndex(s, 1, 3);
|
|
var f = localStorage.getItem('lobbyItem_face');
|
|
if (f) state.activeItem = clampIndex(f, 1, 8);
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
function swatchButtonHtml(hex, isActive) {
|
|
return '' +
|
|
'<span class="customize-popup-color-swatch customize-popup-color-swatch--hex" style="background-color:' + hex + '"></span>' +
|
|
'<img class="customize-popup-color-highlight" src="' + asset('color-select.png') + '" alt="" decoding="async">';
|
|
}
|
|
|
|
function renderColorRow() {
|
|
if (!refs.colorRow) return;
|
|
refs.colorRow.setAttribute('role', 'radiogroup');
|
|
refs.colorRow.setAttribute('aria-label', 'ธีมสี');
|
|
refs.colorRow.textContent = '';
|
|
for (var i = 1; i <= 8; i += 1) {
|
|
var hex = THEME_SWATCH_HEX[i - 1] || '#ffffff';
|
|
var taken = isRoomLobbyPage() && typeof window.isLobbyThemeIndexAvailable === 'function'
|
|
&& !window.isLobbyThemeIndexAvailable(i, true);
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
var cls = 'customize-popup-color-btn';
|
|
if (state.activeColor === i) cls += ' is-active';
|
|
if (taken) cls += ' is-taken';
|
|
btn.className = cls;
|
|
btn.setAttribute('aria-label', taken ? ('สีที่ ' + i + ' มีคนใช้แล้ว') : ('เลือกสีที่ ' + i));
|
|
btn.setAttribute('aria-pressed', state.activeColor === i ? 'true' : 'false');
|
|
if (taken) btn.setAttribute('aria-disabled', 'true');
|
|
btn.innerHTML = swatchButtonHtml(hex, state.activeColor === i);
|
|
(function (idx, isTaken) {
|
|
btn.addEventListener('click', function () {
|
|
if (isRoomLobbyPage() && typeof window.isLobbyThemeIndexAvailable === 'function') {
|
|
if (!window.isLobbyThemeIndexAvailable(idx, true)) return;
|
|
}
|
|
state.activeColor = idx;
|
|
renderColorRow();
|
|
renderPreview();
|
|
});
|
|
})(i, taken);
|
|
refs.colorRow.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
function renderSkinRow() {
|
|
if (!refs.skinRow) return;
|
|
refs.skinRow.setAttribute('role', 'radiogroup');
|
|
refs.skinRow.setAttribute('aria-label', 'สีผิว');
|
|
refs.skinRow.textContent = '';
|
|
for (var i = 1; i <= 3; i += 1) {
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'customize-popup-color-btn' + (state.activeSkin === i ? ' is-active' : '');
|
|
btn.setAttribute('aria-label', 'เลือกสีผิวที่ ' + i);
|
|
btn.setAttribute('aria-pressed', state.activeSkin === i ? 'true' : 'false');
|
|
var skinHex = SKIN_SWATCH_HEX[i - 1] || '#eaa78a';
|
|
btn.innerHTML = swatchButtonHtml(skinHex, state.activeSkin === i);
|
|
(function (idx) {
|
|
btn.addEventListener('click', function () {
|
|
state.activeSkin = idx;
|
|
renderSkinRow();
|
|
renderPreview();
|
|
});
|
|
})(i);
|
|
refs.skinRow.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
/** ใช้รูปปุ่มเดี่ยว 256×100 (-a.png) ทุก state — ไฟล์ tab-N.png เป็นแถบ 3 ปุ่มของ mockup ใช้เป็น sprite ปุ่มเดี่ยวไม่ได้ */
|
|
function tabImageName(index) {
|
|
if (index === 1) return 'tab-1-face-a.png';
|
|
if (index === 2) return 'tab-2-hair-a.png';
|
|
return 'tab-3-cloth-a.png';
|
|
}
|
|
|
|
function renderTabs() {
|
|
if (!refs.tabRow) return;
|
|
if (state.activeTab === 1) {
|
|
refs.tabRow.style.setProperty('--customize-tab-mid-line-shift-x', 'calc(-27px * var(--cc-scale))');
|
|
} else if (state.activeTab === 3) {
|
|
refs.tabRow.style.setProperty('--customize-tab-mid-line-shift-x', 'calc(0px * var(--cc-scale))');
|
|
} else {
|
|
refs.tabRow.style.setProperty('--customize-tab-mid-line-shift-x', 'calc(0px * var(--cc-scale))');
|
|
}
|
|
refs.tabRow.textContent = '';
|
|
for (var i = 1; i <= 3; i += 1) {
|
|
var active = state.activeTab === i;
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'customize-popup-tab-btn' + (active ? ' is-active' : '');
|
|
btn.setAttribute('aria-label', 'หมวด ' + i);
|
|
btn.innerHTML = '' +
|
|
'<img class="customize-popup-tab-img" src="' + asset(tabImageName(i)) + '" alt="" decoding="async">' +
|
|
'<img class="customize-popup-tab-btn-line" src="' + asset('tab-line.png') + '" alt="" decoding="async">';
|
|
(function (idx) {
|
|
btn.addEventListener('click', function () {
|
|
state.activeTab = idx;
|
|
renderTabs();
|
|
renderItems();
|
|
});
|
|
})(i);
|
|
refs.tabRow.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
function renderItems() {
|
|
if (!refs.itemsEl) return;
|
|
refs.itemsEl.setAttribute('role', 'radiogroup');
|
|
refs.itemsEl.setAttribute('aria-label', 'รายการปรับแต่งตัวละคร');
|
|
refs.itemsEl.textContent = '';
|
|
for (var i = 1; i <= 8; i += 1) {
|
|
var li = document.createElement('li');
|
|
li.className = 'customize-popup-item' + (state.activeItem === i ? ' is-active' : '');
|
|
li.setAttribute('role', 'radio');
|
|
li.setAttribute('aria-checked', state.activeItem === i ? 'true' : 'false');
|
|
li.setAttribute('aria-label', 'ไอเท็มที่ ' + i);
|
|
li.tabIndex = 0;
|
|
var price = (state.activeTab === 1 ? (i <= 2 ? 50 : i <= 5 ? 100 : 200) : (i <= 3 ? 120 : 220));
|
|
li.innerHTML = '' +
|
|
'<img class="customize-popup-item-bg" src="' + asset('item-bg.png') + '" alt="" decoding="async">' +
|
|
'<img class="customize-popup-item-face" src="' + asset('face-' + i + '.png') + '" alt="" decoding="async">' +
|
|
'<img class="customize-popup-item-price" src="' + asset('price-frame.png') + '" alt="" decoding="async">' +
|
|
'<span class="customize-popup-item-price-text">' + price + '</span>' +
|
|
'<img class="customize-popup-item-highlight" src="' + asset('item-select.png') + '" alt="" decoding="async">';
|
|
(function (idx) {
|
|
function selectItem() {
|
|
state.activeItem = idx;
|
|
renderItems();
|
|
renderPreview();
|
|
}
|
|
li.addEventListener('click', function () {
|
|
selectItem();
|
|
});
|
|
li.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
selectItem();
|
|
}
|
|
});
|
|
})(i);
|
|
refs.itemsEl.appendChild(li);
|
|
}
|
|
syncScrollUi();
|
|
}
|
|
|
|
/** โหลดรูปตัวละครจริง (idle ทิศ down) แบบไม่ย้อม — ใช้เป็น fallback ที่สีถูกต้อง แทน heuristic ที่ย้อมเหลืองทั้งรูป */
|
|
function loadRawCharacterImageSrc(characterId) {
|
|
if (!characterId) return Promise.resolve('');
|
|
var enc = encodeURIComponent(characterId);
|
|
var rawCandidates = [
|
|
characterAssetsBasePath() + enc + '_down_idle.png',
|
|
characterAssetsBasePath() + enc + '_down.png',
|
|
characterAssetsBasePath() + enc + '_down_0.png'
|
|
];
|
|
return resolveFirstImage(rawCandidates).then(function (img) {
|
|
return (img && img.src) ? img.src : '';
|
|
}).catch(function () { return ''; });
|
|
}
|
|
|
|
function renderPreview() {
|
|
if (!refs.previewFace) return;
|
|
var token = ++previewRenderToken;
|
|
var domAvatar = resolveDomAvatarSource();
|
|
var fallbackSrc = domAvatar.src || asset('face-' + state.activeItem + '.png');
|
|
refs.previewFace.src = fallbackSrc;
|
|
refs.previewFace.alt = domAvatar.alt;
|
|
var tint = getSelectedTint();
|
|
// resolve characterId: localStorage → default char จาก API (กันเครื่องที่ยังไม่เคยเลือกตัวละคร → preview ว่าง/เหลือง)
|
|
resolveApplyCharacterId(function (characterId) {
|
|
if (token !== previewRenderToken || !refs.previewFace) return;
|
|
composeTintedCharacterPreview(characterId, tint)
|
|
.then(function (outSrc) {
|
|
if (token !== previewRenderToken || !refs.previewFace) return;
|
|
if (outSrc) {
|
|
refs.previewFace.src = outSrc;
|
|
refs.previewFace.alt = 'ตัวอย่างตัวละครที่ปรับแต่งสีแล้ว';
|
|
return;
|
|
}
|
|
// composite ล้ม → โชว์รูปตัวละครจริงแบบไม่ย้อม (สีถูกต้อง) แทน heuristic ที่ย้อมเหลืองทั้งรูป
|
|
return loadRawCharacterImageSrc(characterId).then(function (rawSrc) {
|
|
if (token !== previewRenderToken || !refs.previewFace) return;
|
|
refs.previewFace.src = rawSrc || fallbackSrc;
|
|
refs.previewFace.alt = domAvatar.alt;
|
|
});
|
|
})
|
|
.catch(function () {
|
|
if (token !== previewRenderToken || !refs.previewFace) return;
|
|
refs.previewFace.src = fallbackSrc;
|
|
refs.previewFace.alt = domAvatar.alt;
|
|
});
|
|
});
|
|
}
|
|
|
|
function syncCoins() {
|
|
if (!refs.coinVal) return;
|
|
var c = '150';
|
|
try {
|
|
var raw = localStorage.getItem('jdCoins');
|
|
if (raw != null && raw !== '') c = String(Math.max(0, parseInt(raw, 10) || 0));
|
|
} catch (e) { /* ignore */ }
|
|
refs.coinVal.textContent = c;
|
|
}
|
|
|
|
function syncPlayerName() {
|
|
if (!refs.nameEl) return;
|
|
refs.nameEl.textContent = getPlayerName().toUpperCase();
|
|
}
|
|
|
|
function syncScrollUi() {
|
|
if (!refs.itemsEl || !refs.scrollEl || !refs.scrollThumb) return;
|
|
var total = refs.itemsEl.scrollHeight || 0;
|
|
var visible = refs.itemsEl.clientHeight || 0;
|
|
// Hide scrollbar when overflow is only tiny visual jitter.
|
|
var overflowPx = Math.max(0, total - visible);
|
|
var overflow = overflowPx > 12;
|
|
refs.scrollEl.classList.toggle('is-hidden', !overflow);
|
|
refs.itemsEl.style.overflowY = overflow ? 'auto' : 'hidden';
|
|
if (!overflow) {
|
|
refs.itemsEl.scrollTop = 0;
|
|
return;
|
|
}
|
|
var maxScroll = Math.max(1, total - visible);
|
|
var ratio = Math.max(0, Math.min(1, refs.itemsEl.scrollTop / maxScroll));
|
|
var minBarPct = 18;
|
|
var maxBarPct = 42;
|
|
var rawBarPct = (visible / Math.max(1, total)) * 100;
|
|
var barPct = Math.max(minBarPct, Math.min(maxBarPct, rawBarPct));
|
|
var topPct = 0;
|
|
var travelPct = Math.max(0, 100 - topPct - barPct);
|
|
refs.scrollThumb.style.height = barPct.toFixed(2) + '%';
|
|
refs.scrollThumb.style.top = (topPct + (travelPct * ratio)).toFixed(2) + '%';
|
|
}
|
|
|
|
function setItemsScrollByRatio(ratio) {
|
|
if (!refs.itemsEl) return;
|
|
var total = refs.itemsEl.scrollHeight || 0;
|
|
var visible = refs.itemsEl.clientHeight || 0;
|
|
var maxScroll = Math.max(0, total - visible);
|
|
var safe = Math.max(0, Math.min(1, ratio));
|
|
refs.itemsEl.scrollTop = maxScroll * safe;
|
|
syncScrollUi();
|
|
}
|
|
|
|
function scrollRatioFromClientY(clientY, offsetY) {
|
|
if (!refs.scrollEl || !refs.scrollThumb) return 0;
|
|
var rect = refs.scrollEl.getBoundingClientRect();
|
|
var thumbRect = refs.scrollThumb.getBoundingClientRect();
|
|
var thumbH = Math.max(1, thumbRect.height);
|
|
var trackTop = rect.top;
|
|
var maxTop = rect.height - thumbH;
|
|
if (maxTop <= 0) return 0;
|
|
var thumbTop = clientY - trackTop - Math.max(0, offsetY || 0);
|
|
thumbTop = Math.max(0, Math.min(maxTop, thumbTop));
|
|
return thumbTop / maxTop;
|
|
}
|
|
|
|
function stopScrollbarDrag() {
|
|
if (!scrollbarDrag.active) return;
|
|
scrollbarDrag.active = false;
|
|
if (refs.scrollThumb) refs.scrollThumb.classList.remove('is-dragging');
|
|
try { document.body.style.userSelect = ''; } catch (e) { /* ignore */ }
|
|
}
|
|
|
|
function startScrollbarDrag(e) {
|
|
if (!refs.scrollThumb || !refs.scrollEl || refs.scrollEl.classList.contains('is-hidden')) return;
|
|
e.preventDefault();
|
|
var thumbRect = refs.scrollThumb.getBoundingClientRect();
|
|
scrollbarDrag.active = true;
|
|
scrollbarDrag.pointerOffsetY = e.clientY - thumbRect.top;
|
|
refs.scrollThumb.classList.add('is-dragging');
|
|
try { document.body.style.userSelect = 'none'; } catch (err) { /* ignore */ }
|
|
}
|
|
|
|
function renderAll() {
|
|
renderColorRow();
|
|
renderSkinRow();
|
|
renderTabs();
|
|
renderItems();
|
|
renderPreview();
|
|
syncCoins();
|
|
syncPlayerName();
|
|
syncScale();
|
|
requestAnimationFrame(syncScale);
|
|
requestAnimationFrame(syncScrollUi);
|
|
}
|
|
|
|
function openPopup() {
|
|
createPopupIfNeeded();
|
|
if (!refs.overlay) return;
|
|
resetStateToConfirmed();
|
|
readLobbyTintIntoState();
|
|
renderAll();
|
|
refs.overlay.classList.remove('is-hidden');
|
|
refs.overlay.setAttribute('aria-hidden', 'false');
|
|
syncCoins();
|
|
syncPlayerName();
|
|
syncScale();
|
|
syncScrollUi();
|
|
renderPreview();
|
|
if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow();
|
|
requestAnimationFrame(syncScale);
|
|
requestAnimationFrame(syncScrollUi);
|
|
}
|
|
|
|
function closePopup() {
|
|
if (!refs.overlay) return;
|
|
stopScrollbarDrag();
|
|
/* ย้าย focus ออกจาก overlay ก่อนซ่อน — กันคำเตือน "aria-hidden on focused element" (เช่นปุ่มยืนยันที่เพิ่งกด) */
|
|
try {
|
|
if (refs.overlay.contains(document.activeElement) && document.activeElement.blur) {
|
|
document.activeElement.blur();
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
refs.overlay.classList.add('is-hidden');
|
|
refs.overlay.setAttribute('aria-hidden', 'true');
|
|
}
|
|
|
|
function bindPopupEvents() {
|
|
if (!refs.overlay) return;
|
|
refs.overlay.addEventListener('click', function (e) {
|
|
if (e.target === refs.overlay) closePopup();
|
|
});
|
|
refs.closeBtn && refs.closeBtn.addEventListener('click', closePopup);
|
|
refs.confirmBtn && refs.confirmBtn.addEventListener('click', function () {
|
|
applyCustomizeSelection().finally(function () {
|
|
closePopup();
|
|
});
|
|
});
|
|
refs.itemsEl && refs.itemsEl.addEventListener('scroll', syncScrollUi, { passive: true });
|
|
refs.scrollThumb && refs.scrollThumb.addEventListener('mousedown', startScrollbarDrag);
|
|
refs.scrollTrack && refs.scrollTrack.addEventListener('mousedown', function (e) {
|
|
if (refs.scrollEl && refs.scrollEl.classList.contains('is-hidden')) return;
|
|
if (e.target === refs.scrollThumb) return;
|
|
e.preventDefault();
|
|
var ratio = scrollRatioFromClientY(e.clientY, (refs.scrollThumb.getBoundingClientRect().height || 0) / 2);
|
|
setItemsScrollByRatio(ratio);
|
|
});
|
|
document.addEventListener('mousemove', function (e) {
|
|
if (!scrollbarDrag.active) return;
|
|
e.preventDefault();
|
|
var ratio = scrollRatioFromClientY(e.clientY, scrollbarDrag.pointerOffsetY);
|
|
setItemsScrollByRatio(ratio);
|
|
});
|
|
document.addEventListener('mouseup', function () {
|
|
stopScrollbarDrag();
|
|
});
|
|
window.addEventListener('resize', function () {
|
|
syncScale();
|
|
syncScrollUi();
|
|
});
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape' && refs.overlay && !refs.overlay.classList.contains('is-hidden')) {
|
|
stopScrollbarDrag();
|
|
closePopup();
|
|
}
|
|
});
|
|
}
|
|
|
|
function bindTriggers() {
|
|
triggerSelectors.forEach(function (sel) {
|
|
document.querySelectorAll(sel).forEach(function (el) {
|
|
el.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
openPopup();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/** ซ่อมรูป avatar ที่ถูก "อบ" (bake) ไว้ผิดสีจากโค้ดเวอร์ชันเก่า (heuristic = ชุดขาว+ขอบสี)
|
|
→ คอมโพสิตใหม่จากเลเยอร์จริงด้วยสีที่บันทึกไว้ แล้วเขียนทับ bake เดิม (self-heal ทุกเครื่อง) */
|
|
function rebakeAvatarFromSavedColors() {
|
|
if (isRoomLobbyPage()) return; // room-lobby มีระบบ tint ของตัวเองอยู่แล้ว
|
|
var characterId = getStoredCharacterId();
|
|
if (!characterId) return;
|
|
var hasSavedColor = false;
|
|
try { hasSavedColor = !!localStorage.getItem('lobbyThemeColor'); } catch (e) { /* ignore */ }
|
|
if (!hasSavedColor) return; // ยังไม่เคยเลือกสี → คงรูป default ไว้ ไม่ไปอบทับ
|
|
readLobbyTintIntoState();
|
|
composeTintedCharacterPreview(characterId, getSelectedTint()).then(function (outSrc) {
|
|
if (!outSrc || typeof outSrc !== 'string' || outSrc.indexOf('data:image/') !== 0) return; // composite ไม่สำเร็จ → ไม่เขียนทับด้วย fallback
|
|
try { localStorage.setItem(LOBBY_IDLE_DOWN_PREFIX + characterId, outSrc); } catch (e) { /* ignore */ }
|
|
syncLiveAvatarElements(outSrc, 'ตัวละครที่ปรับแต่งแล้ว');
|
|
}).catch(function () { /* ignore */ });
|
|
}
|
|
|
|
function init() {
|
|
createPopupIfNeeded();
|
|
if (triggerSelectors.length) bindTriggers();
|
|
rebakeAvatarFromSavedColors();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init, { once: true });
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
window.openCustomizePopup = openPopup;
|
|
window.closeCustomizePopup = closePopup;
|
|
window.refreshCustomizeColorRow = renderColorRow;
|
|
/** ใช้โดย lobby.js เพื่อ recompute สีตัวละครจาก index บนทุกเครื่อง */
|
|
window.jdComposeTintedCharacter = composeTintedCharacterByIndex;
|
|
})();
|