fixed แต่งตัวไม่ได้

This commit is contained in:
2026-06-19 10:47:45 +00:00
parent 57642f2d30
commit 4fc71fd08a
11 changed files with 859 additions and 76 deletions
+228
View File
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
/**
* สาธารณะ — ระบบ "รหัสผู้เล่น" สำหรับ sync โปรไฟล์ข้าม browser/เครื่อง (Guest)
* ผูกกับ jdPlayerKey (guest account: providerUserId = key) เหมือน player-coins.php
*
* GET ?action=code&playerKey=...&displayName=...&agentId=...
* → คืนรหัสของบัญชีนี้ (สร้างถ้ายังไม่มี) + อัปเดตชื่อ/agent ฝั่ง server
* { ok, code, codeDisplay }
*
* POST { action:'redeem', code }
* → ดึงโปรไฟล์จากรหัส → คืน playerKey + ข้อมูลโปรไฟล์ของบัญชีนั้น
* { ok, playerKey, coins, colorThemeIndex, skinToneIndex, displayName, agentId }
*
* รหัสที่ไม่ถูกใช้เกิน 30 วัน → หมดอายุ (ลบแบบ lazy ตอนมีการเรียก)
*/
require __DIR__ . '/_common.php';
const LINK_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // ตัด 0 O 1 I L ที่กำกวม
const LINK_CODE_LEN = 8;
const LINK_CODE_TTL_DAYS = 30;
function valid_player_key(string $key): bool
{
return (bool)preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key);
}
function sanitize_display_name(string $raw): string
{
$s = trim($raw);
$s = preg_replace('/[\x00-\x1f\x7f]/u', '', $s) ?? '';
if (function_exists('mb_substr')) {
$s = mb_substr($s, 0, 24);
} else {
$s = substr($s, 0, 24);
}
return $s;
}
function sanitize_agent_id(string $raw): string
{
return preg_match('/^\d{6}$/', trim($raw)) ? trim($raw) : '';
}
function normalize_code(string $raw): string
{
$s = strtoupper(trim($raw));
$s = str_replace([' ', '-', '_'], '', $s);
// กันสับสน: ผู้ใช้พิมพ์ 0/O/1/I/L → map เข้าตัวที่ใช้จริง
$s = strtr($s, ['0' => 'O', '1' => 'I']);
return $s;
}
function generate_code(): string
{
$n = strlen(LINK_CODE_ALPHABET);
$out = '';
for ($i = 0; $i < LINK_CODE_LEN; $i++) {
$out .= LINK_CODE_ALPHABET[random_int(0, $n - 1)];
}
return $out;
}
function format_code_display(string $code): string
{
if (strlen($code) === 8) {
return substr($code, 0, 4) . '-' . substr($code, 4, 4);
}
return $code;
}
function code_is_expired($lastUsedAt): bool
{
if (!$lastUsedAt) {
return false;
}
$ts = strtotime((string)$lastUsedAt);
if ($ts === false) {
return false;
}
return (time() - $ts) > (LINK_CODE_TTL_DAYS * 86400);
}
/** ลบรหัสที่หมดอายุออกจากทุกบัญชี (lazy GC) — แก้ใน $store ตรง ๆ */
function gc_expired_codes(array &$store): bool
{
$changed = false;
foreach ($store['accounts'] as $i => $a) {
if (!empty($a['linkCode']) && code_is_expired($a['linkCodeLastUsedAt'] ?? null)) {
unset($store['accounts'][$i]['linkCode']);
$store['accounts'][$i]['linkCodeLastUsedAt'] = null;
$changed = true;
}
}
return $changed;
}
function code_in_use(array $store, string $code, ?string $exceptKey = null): bool
{
foreach ($store['accounts'] as $a) {
if (($a['linkCode'] ?? '') !== $code) {
continue;
}
if ($exceptKey !== null && ($a['providerUserId'] ?? '') === $exceptKey) {
continue;
}
if (!code_is_expired($a['linkCodeLastUsedAt'] ?? null)) {
return true;
}
}
return false;
}
function profile_payload(array $a): array
{
return [
'playerKey' => $a['providerUserId'] ?? '',
'coins' => max(0, (int)($a['coins'] ?? 0)),
'colorThemeIndex' => ($v = (int)($a['lobbyColorThemeIndex'] ?? 0)) >= 1 && $v <= 8 ? $v : null,
'skinToneIndex' => ($s = (int)($a['lobbySkinToneIndex'] ?? 0)) >= 1 && $s <= 3 ? $s : null,
'displayName' => (string)($a['displayName'] ?? ''),
'agentId' => (string)($a['agentDisplayId'] ?? ''),
];
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
$action = (string)($_GET['action'] ?? 'code');
if ($action !== 'code') {
json_response(['ok' => false, 'error' => 'unknown action'], 400);
}
$key = trim((string)($_GET['playerKey'] ?? ''));
if (!valid_player_key($key)) {
json_response(['ok' => false, 'error' => 'playerKey ต้องยาว 8128 (a-z 0-9 _ -)'], 400);
}
$displayName = sanitize_display_name((string)($_GET['displayName'] ?? ''));
$agentId = sanitize_agent_id((string)($_GET['agentId'] ?? ''));
$store = read_store();
gc_expired_codes($store);
$now = gmdate('c');
$idx = -1;
foreach ($store['accounts'] as $i => $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
$idx = $i;
break;
}
}
if ($idx < 0) {
$store['accounts'][] = [
'id' => new_id(),
'email' => '',
'displayName' => $displayName,
'loginType' => 'guest',
'providerUserId' => $key,
'notes' => 'auto: player-link',
'blocked' => false,
'coins' => 0,
'agentDisplayId' => $agentId,
'createdAt' => $now,
'updatedAt' => $now,
];
$idx = count($store['accounts']) - 1;
} else {
if ($displayName !== '') {
$store['accounts'][$idx]['displayName'] = $displayName;
}
if ($agentId !== '') {
$store['accounts'][$idx]['agentDisplayId'] = $agentId;
}
$store['accounts'][$idx]['updatedAt'] = $now;
}
$code = (string)($store['accounts'][$idx]['linkCode'] ?? '');
if ($code === '' || code_is_expired($store['accounts'][$idx]['linkCodeLastUsedAt'] ?? null)) {
do {
$code = generate_code();
} while (code_in_use($store, $code, $key));
$store['accounts'][$idx]['linkCode'] = $code;
}
$store['accounts'][$idx]['linkCodeLastUsedAt'] = $now;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'สร้างรหัสไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'code' => $code, 'codeDisplay' => format_code_display($code)]);
}
if ($method === 'POST') {
$body = require_json_body();
$action = (string)($body['action'] ?? 'redeem');
if ($action !== 'redeem') {
json_response(['ok' => false, 'error' => 'unknown action'], 400);
}
$code = normalize_code((string)($body['code'] ?? ''));
if (!preg_match('/^[A-Z2-9]{' . LINK_CODE_LEN . '}$/', $code)) {
json_response(['ok' => false, 'error' => 'รูปแบบรหัสไม่ถูกต้อง'], 400);
}
$store = read_store();
$sweep = gc_expired_codes($store);
$now = gmdate('c');
$found = -1;
foreach ($store['accounts'] as $i => $a) {
if (($a['linkCode'] ?? '') === $code && !code_is_expired($a['linkCodeLastUsedAt'] ?? null)) {
$found = $i;
break;
}
}
if ($found < 0) {
if ($sweep) {
write_store($store);
}
json_response(['ok' => false, 'error' => 'ไม่พบรหัสนี้ หรือรหัสหมดอายุแล้ว'], 404);
}
$store['accounts'][$found]['linkCodeLastUsedAt'] = $now;
$store['accounts'][$found]['updatedAt'] = $now;
write_store($store);
$payload = profile_payload($store['accounts'][$found]);
$payload['ok'] = true;
json_response($payload);
}
json_response(['ok' => false, 'error' => 'Use GET or POST'], 405);
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/**
* สาธารณะ — ไม่ต้องล็อกอินแอดมิน
* เก็บ/อ่าน "สีตัวละครในล็อบบี้" (theme color + skin tone) แบบ cross-device
* ผูกกับ jdPlayerKey เหมือน player-coins.php (guest account: providerUserId = key)
*
* GET ?playerKey=... → { ok, colorThemeIndex, skinToneIndex }
* POST { playerKey, colorThemeIndex, skinToneIndex } → บันทึกแล้วคืนค่าที่บันทึก
*
* index ใช้รูปแบบเดียวกับ customize popup: theme 18, skin 13
*/
require __DIR__ . '/_common.php';
const LOBBY_THEME_MIN = 1;
const LOBBY_THEME_MAX = 8;
const LOBBY_SKIN_MIN = 1;
const LOBBY_SKIN_MAX = 3;
function valid_player_key(string $key): bool
{
return (bool)preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key);
}
/** คืน index ที่อยู่ในช่วง หรือ null ถ้าไม่ถูกต้อง */
function clamp_index_or_null($raw, int $min, int $max): ?int
{
if ($raw === null || $raw === '') {
return null;
}
$v = (int)$raw;
if ($v < $min || $v > $max) {
return null;
}
return $v;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
$key = trim((string)($_GET['playerKey'] ?? ''));
if (!valid_player_key($key)) {
json_response(['ok' => false, 'error' => 'playerKey ต้องยาว 8128 (a-z 0-9 _ -)'], 400);
}
$store = read_store();
foreach ($store['accounts'] ?? [] as $a) {
if (($a['loginType'] ?? '') !== 'guest') {
continue;
}
if (($a['providerUserId'] ?? '') !== $key) {
continue;
}
json_response([
'ok' => true,
'colorThemeIndex' => clamp_index_or_null($a['lobbyColorThemeIndex'] ?? null, LOBBY_THEME_MIN, LOBBY_THEME_MAX),
'skinToneIndex' => clamp_index_or_null($a['lobbySkinToneIndex'] ?? null, LOBBY_SKIN_MIN, LOBBY_SKIN_MAX),
]);
}
// ยังไม่มีบัญชี/ยังไม่เคยเลือกสี — ไม่ต้องสร้างบัญชีตอน GET
json_response([
'ok' => true,
'colorThemeIndex' => null,
'skinToneIndex' => null,
]);
}
if ($method === 'POST') {
$body = require_json_body();
$key = trim((string)($body['playerKey'] ?? ''));
if (!valid_player_key($key)) {
json_response(['ok' => false, 'error' => 'playerKey ต้องยาว 8128 (a-z 0-9 _ -)'], 400);
}
$colorThemeIndex = clamp_index_or_null($body['colorThemeIndex'] ?? null, LOBBY_THEME_MIN, LOBBY_THEME_MAX);
$skinToneIndex = clamp_index_or_null($body['skinToneIndex'] ?? null, LOBBY_SKIN_MIN, LOBBY_SKIN_MAX);
if ($colorThemeIndex === null && $skinToneIndex === null) {
json_response(['ok' => false, 'error' => 'ต้องส่ง colorThemeIndex (18) หรือ skinToneIndex (13) อย่างน้อย 1 ค่า'], 400);
}
$store = read_store();
$now = gmdate('c');
$found = false;
foreach ($store['accounts'] as $i => $a) {
if (($a['loginType'] ?? '') !== 'guest') {
continue;
}
if (($a['providerUserId'] ?? '') !== $key) {
continue;
}
if ($colorThemeIndex !== null) {
$store['accounts'][$i]['lobbyColorThemeIndex'] = $colorThemeIndex;
}
if ($skinToneIndex !== null) {
$store['accounts'][$i]['lobbySkinToneIndex'] = $skinToneIndex;
}
$store['accounts'][$i]['updatedAt'] = $now;
$found = true;
break;
}
if (!$found) {
$store['accounts'][] = [
'id' => new_id(),
'email' => '',
'displayName' => 'Guest',
'loginType' => 'guest',
'providerUserId' => $key,
'notes' => 'auto: player-lobby-style',
'blocked' => false,
'coins' => 0,
'lobbyColorThemeIndex' => $colorThemeIndex,
'lobbySkinToneIndex' => $skinToneIndex,
'createdAt' => $now,
'updatedAt' => $now,
];
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกสีไม่สำเร็จ'], 500);
}
json_response([
'ok' => true,
'colorThemeIndex' => $colorThemeIndex,
'skinToneIndex' => $skinToneIndex,
]);
}
json_response(['ok' => false, 'error' => 'Use GET or POST'], 405);
+99 -5
View File
@@ -21,14 +21,19 @@
{
"id": "a30aa9889e7efeb1c371c3e1",
"email": "",
"displayName": "Guest",
"displayName": "QS",
"loginType": "guest",
"providerUserId": "p_1775013892442_jveup54h5u",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-04-01T03:24:52+00:00",
"updatedAt": "2026-04-01T03:24:52+00:00"
"updatedAt": "2026-06-19T10:08:36+00:00",
"lobbyColorThemeIndex": 1,
"lobbySkinToneIndex": 2,
"agentDisplayId": "596772",
"linkCode": "HGBQ964A",
"linkCodeLastUsedAt": "2026-06-19T09:20:42+00:00"
},
{
"id": "483a104becd7a5f92c0e5cad",
@@ -40,7 +45,7 @@
"blocked": false,
"coins": 897,
"createdAt": "2026-04-02T05:52:21+00:00",
"updatedAt": "2026-06-19T07:10:26+00:00",
"updatedAt": "2026-06-19T09:42:28+00:00",
"daily": {
"anchorMs": 1781197200000,
"claimedDays": [
@@ -74,7 +79,9 @@
"d1_minigame_solver": 20,
"a1_first_deduction": 1,
"a5_truth_hunter": 1
}
},
"lobbyColorThemeIndex": 3,
"lobbySkinToneIndex": 1
},
{
"id": "1d64c56fadb64a93eae68a1d",
@@ -437,7 +444,94 @@
"blocked": false,
"coins": 0,
"createdAt": "2026-06-19T08:16:09+00:00",
"updatedAt": "2026-06-19T08:16:09+00:00"
"updatedAt": "2026-06-19T08:58:50+00:00",
"lobbyColorThemeIndex": 3,
"lobbySkinToneIndex": 1
},
{
"id": "3c34ec21b0d1a68412df61c5",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "testkey_1781859026_abcd",
"notes": "auto: player-lobby-style",
"blocked": false,
"coins": 0,
"lobbyColorThemeIndex": 5,
"lobbySkinToneIndex": 3,
"createdAt": "2026-06-19T08:50:26+00:00",
"updatedAt": "2026-06-19T08:50:26+00:00"
},
{
"id": "0ba5fabca3663fe10ac4fce0",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1781859644515_3jr79lcxie9",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-06-19T09:00:44+00:00",
"updatedAt": "2026-06-19T09:01:47+00:00",
"lobbyColorThemeIndex": 7,
"lobbySkinToneIndex": 3
},
{
"id": "4584bc06474178e979c359f7",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1781859715079_rnsmzz48s5c",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-06-19T09:01:55+00:00",
"updatedAt": "2026-06-19T09:01:55+00:00"
},
{
"id": "51b5d5d74babb59f8ee29e99",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1781859729172_0nkqc6xzl6ph",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-06-19T09:02:09+00:00",
"updatedAt": "2026-06-19T09:02:49+00:00",
"lobbyColorThemeIndex": 5,
"lobbySkinToneIndex": 1
},
{
"id": "537d39522aeb42a4a5d7d4a6",
"email": "",
"displayName": "TesterA",
"loginType": "guest",
"providerUserId": "linkuser_1781860644_aaaa",
"notes": "auto: player-lobby-style",
"blocked": false,
"coins": 0,
"lobbyColorThemeIndex": 5,
"lobbySkinToneIndex": 3,
"createdAt": "2026-06-19T09:17:24+00:00",
"updatedAt": "2026-06-19T09:17:24+00:00",
"agentDisplayId": "123456",
"linkCode": "3VRUU83F",
"linkCodeLastUsedAt": "2026-06-19T09:17:24+00:00"
},
{
"id": "80f31f73da0328dbf24e0206",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1781862203558_jhynnz90qrg",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-06-19T09:43:23+00:00",
"updatedAt": "2026-06-19T10:10:56+00:00",
"lobbyColorThemeIndex": 1,
"lobbySkinToneIndex": 1
}
]
}
+127 -43
View File
@@ -144,6 +144,53 @@
}
}
/**
* หา characterId สำหรับ apply — ถ้า localStorage ว่าง ให้ดึงจาก API เหมือน lobby.js
* (กันเคสเครื่องที่ไม่เคยเซฟ gameCharacterId → กดยืนยันแล้วตัวใหญ่ไม่อัปเดต)
*/
function resolveApplyCharacterId(cb) {
var id = getStoredCharacterId();
if (id) { cb(id); 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; }
var last = list[list.length - 1];
cb(last && last.id ? String(last.id).trim() : '');
})
.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;
@@ -359,6 +406,8 @@
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;
@@ -446,9 +495,32 @@
return Promise.resolve(previewCompositeCache[cacheKey] || null);
}
return fetchCharacterLayerManifest(characterId).then(function (manifest) {
var manifestFrame = firstManifestFrame(manifest, dir);
if (manifestFrame) {
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];
@@ -465,33 +537,17 @@
}
}
if (!baseImg) return null;
var c = document.createElement('canvas');
c.width = baseImg.naturalWidth;
c.height = baseImg.naturalHeight;
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);
}
if (!anyTintColorLayer) return null;
var out = c.toDataURL('image/png');
previewCompositeCache[cacheKey] = out;
return out;
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) {
@@ -501,27 +557,50 @@
return resolveFirstImage(urls);
});
return Promise.all(layerPromises).then(function (layers) {
var c = document.createElement('canvas');
c.width = rawImg.naturalWidth;
c.height = rawImg.naturalHeight;
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);
}
if (!anyTintColorLayer) return null;
var out = c.toDataURL('image/png');
previewCompositeCache[cacheKey] = out;
return out;
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() {
@@ -565,8 +644,8 @@
}
function applyCustomizeSelection() {
var characterId = getStoredCharacterId();
return new Promise(function (resolve) {
resolveApplyCharacterId(function (characterId) {
function runApply() {
// ไม่มีตัวละคร (เครื่องที่ยังไม่ได้เลือกตัวละคร) — ยังต้องซิงก์สีไปเซิร์ฟเวอร์
// ให้คนอื่นเห็น แต่ข้ามการ composite รูป preview เพราะไม่มี characterId
@@ -574,6 +653,7 @@
try {
persistConfirmedState();
syncLobbyTintStorageKeys();
postLobbyStyleToServer(state.activeColor, state.activeSkin);
} catch (e0) { /* ignore */ }
try {
window.dispatchEvent(new CustomEvent('customize-popup:applied', {
@@ -610,6 +690,7 @@
}));
persistConfirmedState();
syncLobbyTintStorageKeys();
postLobbyStyleToServer(state.activeColor, state.activeSkin);
} catch (e) { /* ignore */ }
syncLiveAvatarElements(finalSrc, 'ตัวละครที่ปรับแต่งแล้ว');
try {
@@ -643,6 +724,7 @@
return;
}
runApply();
});
});
}
@@ -1181,4 +1263,6 @@
window.openCustomizePopup = openPopup;
window.closeCustomizePopup = closePopup;
window.refreshCustomizeColorRow = renderColorRow;
/** ใช้โดย lobby.js เพื่อ recompute สีตัวละครจาก index บนทุกเครื่อง */
window.jdComposeTintedCharacter = composeTintedCharacterByIndex;
})();
+182
View File
@@ -41,6 +41,20 @@
function readCoins() {
try { return String(Math.max(0, parseInt(localStorage.getItem('jdCoins') || '0', 10) || 0)); } catch (e) { return '0'; }
}
function readAgentIdRaw() {
try { return (localStorage.getItem('agentDisplayId') || '').trim(); } catch (e) { return ''; }
}
/** key ผู้เล่น (เดียวกับ lobby.js) — สร้างถ้ายังไม่มี เพื่อให้ระบบรหัส sync ใช้ได้ */
function ensurePlayerKey() {
var k = '';
try { k = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { k = ''; }
if (!k || k.length < 8) {
k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14);
try { localStorage.setItem('jdPlayerKey', k); } catch (e2) { /* ignore */ }
}
return k;
}
var refs = {
overlay: null,
@@ -286,6 +300,173 @@
});
}
/* ===== ระบบรหัสผู้เล่น — sync โปรไฟล์ (สี/COINS/ชื่อ) ข้าม browser/เครื่อง ===== */
var linkRefs = { overlay: null, codeEl: null, input: null, msgEl: null };
function linkApiUrl() {
return appRel('/Admin/api/player-link.php');
}
function buildLinkUi() {
if (document.getElementById('jd-link-overlay')) return;
var st = document.createElement('style');
st.id = 'jd-link-style';
st.textContent = '' +
'.jd-link-trigger{position:absolute;top:14px;left:16px;z-index:6;display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:999px;border:2px solid rgba(56,232,255,.85);background:rgba(8,24,44,.9);color:#dffaff;font:600 14px/1 Kanit,Segoe UI,sans-serif;cursor:pointer;box-shadow:0 0 14px rgba(56,232,255,.3);}' +
'.jd-link-trigger:hover{background:rgba(20,50,80,.95);}' +
'.jd-link-overlay{position:fixed;inset:0;z-index:10090;display:flex;align-items:center;justify-content:center;padding:16px;}' +
'.jd-link-overlay.is-hidden{display:none!important;}' +
'.jd-link-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.74);}' +
'.jd-link-dialog{position:relative;z-index:1;width:min(440px,calc(100vw - 32px));padding:24px 22px 20px;border-radius:16px;background:linear-gradient(180deg,#0d2848 0%,#061528 100%);border:2px solid rgba(56,232,255,.85);box-shadow:0 0 30px rgba(56,232,255,.35);color:#eaf8ff;font-family:Kanit,Segoe UI,sans-serif;}' +
'.jd-link-close{position:absolute;top:8px;right:12px;background:none;border:none;color:#9fd8ff;font-size:1.7rem;line-height:1;cursor:pointer;}' +
'.jd-link-title{margin:0 0 4px;font-size:1.2rem;font-weight:700;text-align:center;}' +
'.jd-link-sub{margin:0 0 14px;font-size:.82rem;opacity:.75;text-align:center;}' +
'.jd-link-codebox{display:flex;align-items:center;gap:10px;justify-content:center;background:rgba(0,20,40,.85);border:2px solid rgba(56,232,255,.45);border-radius:12px;padding:12px;margin-bottom:6px;}' +
'.jd-link-code{font-size:1.7rem;font-weight:700;letter-spacing:.14em;color:#5ce9ff;}' +
'.jd-link-copy{padding:8px 12px;border-radius:8px;border:2px solid rgba(56,232,255,.55);background:rgba(20,50,80,.7);color:#dffaff;font:600 .85rem Kanit,sans-serif;cursor:pointer;}' +
'.jd-link-hr{height:1px;background:rgba(120,200,255,.25);margin:16px 0;}' +
'.jd-link-label{font-size:.92rem;margin:0 0 8px;font-weight:600;}' +
'.jd-link-row{display:flex;gap:8px;}' +
'.jd-link-input{flex:1;box-sizing:border-box;padding:11px 12px;border-radius:10px;border:2px solid rgba(56,232,255,.55);background:rgba(0,20,40,.85);color:#fff;font:600 1.05rem Kanit,sans-serif;letter-spacing:.1em;text-transform:uppercase;outline:none;}' +
'.jd-link-input:focus{border-color:#5ce9ff;box-shadow:0 0 0 3px rgba(92,233,255,.25);}' +
'.jd-link-go{padding:11px 18px;border-radius:10px;border:2px solid #7df4ff;background:linear-gradient(180deg,#2ee6ff,#0ea5c9);color:#042033;font:700 1rem Kanit,sans-serif;cursor:pointer;}' +
'.jd-link-warn{margin:10px 0 0;font-size:.78rem;color:#ffd27d;text-align:center;}' +
'.jd-link-msg{margin:10px 0 0;font-size:.85rem;text-align:center;min-height:1.1em;}' +
'.jd-link-msg.is-err{color:#ff8a8a;}.jd-link-msg.is-ok{color:#8effa6;}';
document.head.appendChild(st);
var ov = document.createElement('div');
ov.id = 'jd-link-overlay';
ov.className = 'jd-link-overlay is-hidden';
ov.setAttribute('aria-hidden', 'true');
ov.innerHTML = '' +
'<div class="jd-link-backdrop" id="jd-link-backdrop"></div>' +
'<div class="jd-link-dialog" role="dialog" aria-modal="true" aria-label="ซิงก์โปรไฟล์ข้ามเครื่อง">' +
' <button type="button" class="jd-link-close" id="jd-link-close" aria-label="ปิด">×</button>' +
' <h2 class="jd-link-title">ซิงก์โปรไฟล์ข้ามเครื่อง</h2>' +
' <p class="jd-link-sub">ใช้รหัสนี้เพื่อดึงสี / COINS / ชื่อ ไปยังเครื่องหรือ browser อื่น</p>' +
' <p class="jd-link-label">รหัสผู้เล่นของฉัน</p>' +
' <div class="jd-link-codebox"><span class="jd-link-code" id="jd-link-code">····-····</span><button type="button" class="jd-link-copy" id="jd-link-copy">คัดลอก</button></div>' +
' <p class="jd-link-sub" style="margin-top:6px">รหัสจะหมดอายุถ้าไม่ได้ใช้เกิน 30 วัน</p>' +
' <div class="jd-link-hr"></div>' +
' <p class="jd-link-label">มีรหัสจากเครื่องอื่น? กรอกที่นี่</p>' +
' <div class="jd-link-row"><input type="text" class="jd-link-input" id="jd-link-input" maxlength="9" placeholder="XXXX-XXXX" autocomplete="off" inputmode="latin"><button type="button" class="jd-link-go" id="jd-link-go">ดึงข้อมูล</button></div>' +
' <p class="jd-link-warn">การดึงข้อมูลจะแทนที่โปรไฟล์ในเครื่องนี้ (สี/COINS/ชื่อ)</p>' +
' <p class="jd-link-msg" id="jd-link-msg" role="alert"></p>' +
'</div>';
document.body.appendChild(ov);
linkRefs.overlay = ov;
linkRefs.codeEl = document.getElementById('jd-link-code');
linkRefs.input = document.getElementById('jd-link-input');
linkRefs.msgEl = document.getElementById('jd-link-msg');
document.getElementById('jd-link-backdrop').addEventListener('click', closeLinkModal);
document.getElementById('jd-link-close').addEventListener('click', closeLinkModal);
document.getElementById('jd-link-copy').addEventListener('click', copyMyCode);
document.getElementById('jd-link-go').addEventListener('click', redeemEnteredCode);
linkRefs.input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); redeemEnteredCode(); } });
// ปุ่มเปิด — ฝังในกล่องโปรไฟล์ (absolute, ไม่กระทบ layout เดิม)
if (refs.dialog && !document.getElementById('jd-link-trigger')) {
var btn = document.createElement('button');
btn.type = 'button';
btn.id = 'jd-link-trigger';
btn.className = 'jd-link-trigger';
btn.textContent = '🔗 ซิงก์เครื่อง';
btn.addEventListener('click', openLinkModal);
refs.dialog.appendChild(btn);
}
}
function setLinkMsg(text, kind) {
if (!linkRefs.msgEl) return;
linkRefs.msgEl.textContent = text || '';
linkRefs.msgEl.className = 'jd-link-msg' + (kind ? ' is-' + kind : '');
}
function openLinkModal() {
if (!linkRefs.overlay) return;
setLinkMsg('', '');
if (linkRefs.input) linkRefs.input.value = '';
if (linkRefs.codeEl) linkRefs.codeEl.textContent = '····-····';
linkRefs.overlay.classList.remove('is-hidden');
linkRefs.overlay.setAttribute('aria-hidden', 'false');
fetchMyCode();
}
function closeLinkModal() {
if (!linkRefs.overlay) return;
linkRefs.overlay.classList.add('is-hidden');
linkRefs.overlay.setAttribute('aria-hidden', 'true');
}
function fetchMyCode() {
var key = ensurePlayerKey();
var url = linkApiUrl() + '?action=code&playerKey=' + encodeURIComponent(key) +
'&displayName=' + encodeURIComponent(readName()) +
'&agentId=' + encodeURIComponent(readAgentIdRaw());
fetch(url, { credentials: 'omit', cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.ok && d.codeDisplay && linkRefs.codeEl) linkRefs.codeEl.textContent = d.codeDisplay;
else setLinkMsg('โหลดรหัสไม่สำเร็จ ลองใหม่อีกครั้ง', 'err');
})
.catch(function () { setLinkMsg('ออฟไลน์ — โหลดรหัสไม่ได้', 'err'); });
}
function copyMyCode() {
var txt = linkRefs.codeEl ? linkRefs.codeEl.textContent : '';
if (!txt || txt.indexOf('·') >= 0) return;
function done() { setLinkMsg('คัดลอกรหัสแล้ว', 'ok'); }
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(done, function () { done(); });
return;
}
} catch (e) { /* ignore */ }
done();
}
function redeemEnteredCode() {
var raw = (linkRefs.input && linkRefs.input.value || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
if (raw.length !== 8) { setLinkMsg('กรุณากรอกรหัส 8 ตัว', 'err'); return; }
setLinkMsg('กำลังดึงข้อมูล…', '');
fetch(linkApiUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify({ action: 'redeem', code: raw })
})
.then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
.then(function (res) {
var d = res.j || {};
if (!d.ok || !d.playerKey) {
setLinkMsg(d.error || 'ไม่พบรหัสนี้', 'err');
return;
}
applyRedeemedProfile(d);
})
.catch(function () { setLinkMsg('ออฟไลน์ — ดึงข้อมูลไม่ได้', 'err'); });
}
function applyRedeemedProfile(p) {
try {
localStorage.setItem('jdPlayerKey', String(p.playerKey));
if (p.displayName) { localStorage.setItem(DISPLAY_NAME_KEY, p.displayName); localStorage.setItem('playerName', p.displayName); }
if (p.agentId) localStorage.setItem('agentDisplayId', String(p.agentId));
if (p.colorThemeIndex) localStorage.setItem('lobbyThemeColor', String(p.colorThemeIndex));
if (p.skinToneIndex) localStorage.setItem('lobbySkinTone', String(p.skinToneIndex));
if (p.coins != null) localStorage.setItem('jdCoins', String(p.coins));
// ลบ avatar ที่ bake สีไว้ของเครื่องนี้ — บังคับ recompute จากสีที่ดึงมา
Object.keys(localStorage).forEach(function (k) {
if (k.indexOf('jdCharLobbyIdleDown:') === 0) localStorage.removeItem(k);
});
} catch (e) { /* ignore */ }
setLinkMsg('สำเร็จ! กำลังโหลดโปรไฟล์ใหม่…', 'ok');
setTimeout(function () { window.location.reload(); }, 500);
}
function isRoomLobbyPage() {
return /room-lobby\.html$/i.test(String(window.location.pathname || ''));
}
@@ -297,6 +478,7 @@
createMarkupIfNeeded();
bindRefs();
bindEvents();
buildLinkUi();
syncProfileScale();
setGroup(1);
if (window.jdAchievements) window.jdAchievements.load();
+3 -12
View File
@@ -362,18 +362,9 @@
var fromHex = rlHexToRgbCss(hexArr[hi]);
if (fromHex) { rlSwatchCache[key] = fromHex; cb(fromHex); return; }
}
rlLoadImg(RL_CUSTOMIZE_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png', function (img) {
if (!img || !img.naturalWidth) { cb(null); return; }
try {
var c = document.createElement('canvas'); c.width = 1; c.height = 1;
var x = c.getContext('2d');
x.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, 1, 1);
var d = x.getImageData(0, 0, 1, 1).data;
var rgb = 'rgb(' + d[0] + ',' + d[1] + ',' + d[2] + ')';
rlSwatchCache[key] = rgb;
cb(rgb);
} catch (e) { cb(null); }
});
// ไม่ sample สีจากรูปด้วย getImageData อีกต่อไป — การย่อรูป/decode ต่าง browser
// ทำให้ค่าสีเพี้ยนไม่ตรงกัน ใช้ palette hex (ด้านบน) เป็นแหล่งความจริงเดียว
cb(null);
}
var LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint';
+2 -2
View File
@@ -1615,9 +1615,9 @@
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/display-name.js?v=2"></script>
<script src="js/version.js?v=0.0122"></script>
<script src="js/customize-popup.js?v=35" data-customize-triggers="" data-customize-asset-base="img/03-5-Customize"></script>
<script src="js/customize-popup.js?v=38" data-customize-triggers="" data-customize-asset-base="img/03-5-Customize"></script>
<script src="js/achievements.js?v=0.002" data-asset-base="img/03-6-Profile"></script>
<script src="js/room-lobby.js?v=0.0293"></script>
<script src="js/room-lobby.js?v=0.0294"></script>
<div class="version-tag">v —</div>
</body>
</html>
+3 -3
View File
@@ -228,9 +228,9 @@
<script src="../app-base.js?v=2"></script>
<script src="../Game/js/display-name.js?v=2"></script>
<script src="daily-popup.js?v=26" data-daily-trigger="#btn-daily" data-daily-asset-base="IMAGE/Daily" data-daily-test-reset-seconds="0"></script>
<script src="../Game/js/customize-popup.js?v=35" data-customize-triggers="#btn-cloth" data-customize-asset-base="/Game/img/03-5-Customize"></script>
<script src="../Game/js/customize-popup.js?v=38" data-customize-triggers="#btn-cloth" data-customize-asset-base="/Game/img/03-5-Customize"></script>
<script src="../Game/js/achievements.js?v=0.002" data-asset-base="/Game/img/03-6-Profile"></script>
<script src="../Game/js/profile-popup.js?v=7" data-profile-trigger="#btn-myprofile" data-profile-asset-base="/Game/img/03-6-Profile"></script>
<script src="lobby.js?v=0.0192"></script>
<script src="../Game/js/profile-popup.js?v=8" data-profile-trigger="#btn-myprofile" data-profile-asset-base="/Game/img/03-6-Profile"></script>
<script src="lobby.js?v=0.0193"></script>
</body>
</html>
+61 -4
View File
@@ -298,6 +298,38 @@
.catch(function () { /* ออฟไลน์ → คงตัวอย่างเดิม */ });
}
/** สีที่เลือกไว้ใน localStorage (เครื่องนี้) — index theme 18 / skin 13 */
function getLocalLobbyStyle() {
var theme = 0, skin = 0;
try {
theme = parseInt(localStorage.getItem('lobbyThemeColor') || '', 10) || 0;
skin = parseInt(localStorage.getItem('lobbySkinTone') || '', 10) || 0;
} catch (e) { /* ignore */ }
return { theme: theme, skin: skin };
}
function mirrorLobbyStyleToLocal(theme, skin) {
try {
if (theme >= 1 && theme <= 8) localStorage.setItem('lobbyThemeColor', String(theme));
if (skin >= 1 && skin <= 3) localStorage.setItem('lobbySkinTone', String(skin));
} catch (e) { /* ignore */ }
}
/** ดึงสีที่เลือกจากเซิร์ฟเวอร์ (cross-device) ตาม jdPlayerKey */
function fetchServerLobbyStyle(cb) {
var key;
try { key = ensurePlayerKey(); } catch (e) { key = ''; }
if (!key) { cb(null); return; }
var base = (typeof appPath === 'function' ? appPath('/Admin/api/player-lobby-style.php') : '/Admin/api/player-lobby-style.php');
fetch(base + '?playerKey=' + encodeURIComponent(key), { credentials: 'omit', cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !d.ok) { cb(null); return; }
cb({ theme: parseInt(d.colorThemeIndex, 10) || 0, skin: parseInt(d.skinToneIndex, 10) || 0 });
})
.catch(function () { cb(null); });
}
/** รีเฟรชหลังเลือกตัวละคร / สลับแท็บ / ย้อนกลับบน tablet (bfcache) */
function syncMainLobbyCharacterUi() {
applyProfileTexts();
@@ -313,11 +345,36 @@
return;
}
var idleCached = getStoredLobbyIdleDownDataUrl(id);
if (idleCached) {
applyCharAssets(idleCached);
return;
// วาด cache ทันทีกันจอว่าง แล้วค่อย recompute จากสีจริง (cross-device) ทับ
if (idleCached) applyCharAssets(idleCached);
function fallbackToSprite() {
if (idleCached) return; // วาดไปแล้ว
characterLobbySpriteFirstLiveUrl(id, applyCharAssets);
}
characterLobbySpriteFirstLiveUrl(id, applyCharAssets);
// สีจาก server เป็นแหล่งความจริงข้ามเครื่อง, ถ้าไม่มีค่อยใช้ local
fetchServerLobbyStyle(function (server) {
var local = getLocalLobbyStyle();
var theme = (server && server.theme) || local.theme || 0;
var skin = (server && server.skin) || local.skin || 0;
if (theme >= 1 && theme <= 8) mirrorLobbyStyleToLocal(theme, skin);
// ยังไม่เคยเลือกสี → ใช้รูปตามเดิม (ไม่ recolor)
if (!(theme >= 1 && theme <= 8) || typeof window.jdComposeTintedCharacter !== 'function') {
fallbackToSprite();
return;
}
window.jdComposeTintedCharacter(id, theme, skin).then(function (dataUrl) {
if (dataUrl && typeof dataUrl === 'string' && dataUrl.indexOf('data:image/') === 0) {
try { localStorage.setItem(LOBBY_IDLE_DOWN_PREFIX + id, dataUrl); } catch (e) { /* ignore */ }
applyCharAssets(dataUrl);
} else {
fallbackToSprite();
}
}).catch(function () { fallbackToSprite(); });
});
});
}
+1 -1
View File
@@ -45,6 +45,6 @@
</div>
<script src="../app-base.js?v=2"></script>
<script src="quiz-battle.js?v=0.0500"></script>
<script src="quiz-battle.js?v=0.0501"></script>
</body>
</html>
+20 -6
View File
@@ -40,12 +40,7 @@
return [BASE + '/img/characters/' + enc + '_down.png', BASE + '/img/characters/' + enc + '_down_0.png'];
}
function applyProfileAvatar() {
var av = document.getElementById('lobby-profile-avatar');
if (!av) return;
var fallbackAvatar = typeof appPath === 'function' ? appPath('/Main-Menu/char-main.png') : '/Main-Menu/char-main.png';
var cid = getSelectedCharacterId();
if (!cid) { av.onerror = null; av.src = fallbackAvatar; return; }
function renderAvatarForId(av, cid, fallbackAvatar) {
try {
var savedLobbyAvatar = localStorage.getItem(LOBBY_IDLE_DOWN_PREFIX + cid) || '';
if (savedLobbyAvatar && savedLobbyAvatar.indexOf('data:image/') === 0) {
@@ -64,6 +59,25 @@
av.src = urls[0];
}
function applyProfileAvatar() {
var av = document.getElementById('lobby-profile-avatar');
if (!av) return;
var fallbackAvatar = typeof appPath === 'function' ? appPath('/Main-Menu/char-main.png') : '/Main-Menu/char-main.png';
var cid = getSelectedCharacterId();
if (cid) { renderAvatarForId(av, cid, fallbackAvatar); return; }
// ไม่มี id ใน localStorage → ดึงจาก API เหมือน lobby (กัน avatar ไม่ขึ้น/ขึ้นรูป default)
fetch(BASE + '/api/characters', { credentials: 'same-origin', cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (list) {
if (!Array.isArray(list) || !list.length) { av.onerror = null; av.src = fallbackAvatar; return; }
var last = list[list.length - 1];
var id = last && last.id ? String(last.id).trim() : '';
if (!id) { av.onerror = null; av.src = fallbackAvatar; return; }
renderAvatarForId(av, id, fallbackAvatar);
})
.catch(function () { av.onerror = null; av.src = fallbackAvatar; });
}
/* ===================== เข้าฉากเดิน (ZEP walkable) ===================== */
var entering = false;