diff --git a/www/html/Admin/api/player-link.php b/www/html/Admin/api/player-link.php new file mode 100644 index 0000000..1f89ca9 --- /dev/null +++ b/www/html/Admin/api/player-link.php @@ -0,0 +1,228 @@ + '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 ต้องยาว 8–128 (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); diff --git a/www/html/Admin/api/player-lobby-style.php b/www/html/Admin/api/player-lobby-style.php new file mode 100644 index 0000000..fbdc448 --- /dev/null +++ b/www/html/Admin/api/player-lobby-style.php @@ -0,0 +1,133 @@ + $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 ต้องยาว 8–128 (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 ต้องยาว 8–128 (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 (1–8) หรือ skinToneIndex (1–3) อย่างน้อย 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); diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json index 823d167..818922f 100644 --- a/www/html/Admin/private/store.json +++ b/www/html/Admin/private/store.json @@ -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 } ] } \ No newline at end of file diff --git a/www/html/Game/public/js/customize-popup.js b/www/html/Game/public/js/customize-popup.js index c89872d..f206605 100644 --- a/www/html/Game/public/js/customize-popup.js +++ b/www/html/Game/public/js/customize-popup.js @@ -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} 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; })(); diff --git a/www/html/Game/public/js/profile-popup.js b/www/html/Game/public/js/profile-popup.js index 85e79b8..0a25f52 100644 --- a/www/html/Game/public/js/profile-popup.js +++ b/www/html/Game/public/js/profile-popup.js @@ -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 = '' + + '' + + ''; + 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(); diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js index 9d57d7b..55f2cf3 100644 --- a/www/html/Game/public/js/room-lobby.js +++ b/www/html/Game/public/js/room-lobby.js @@ -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'; diff --git a/www/html/Game/public/room-lobby.html b/www/html/Game/public/room-lobby.html index e5442fa..0ade52d 100644 --- a/www/html/Game/public/room-lobby.html +++ b/www/html/Game/public/room-lobby.html @@ -1615,9 +1615,9 @@ - + - +
v —
diff --git a/www/html/Main-Lobby/index.html b/www/html/Main-Lobby/index.html index 2115d5b..094ba76 100644 --- a/www/html/Main-Lobby/index.html +++ b/www/html/Main-Lobby/index.html @@ -228,9 +228,9 @@ - + - - + + diff --git a/www/html/Main-Lobby/lobby.js b/www/html/Main-Lobby/lobby.js index 729921a..16875ac 100644 --- a/www/html/Main-Lobby/lobby.js +++ b/www/html/Main-Lobby/lobby.js @@ -298,6 +298,38 @@ .catch(function () { /* ออฟไลน์ → คงตัวอย่างเดิม */ }); } + /** สีที่เลือกไว้ใน localStorage (เครื่องนี้) — index theme 1–8 / skin 1–3 */ + 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(); }); + }); }); } diff --git a/www/html/Quiz-Battle/index.html b/www/html/Quiz-Battle/index.html index a25722c..1fb2c69 100644 --- a/www/html/Quiz-Battle/index.html +++ b/www/html/Quiz-Battle/index.html @@ -45,6 +45,6 @@ - + diff --git a/www/html/Quiz-Battle/quiz-battle.js b/www/html/Quiz-Battle/quiz-battle.js index 5f72bec..47e22e8 100644 --- a/www/html/Quiz-Battle/quiz-battle.js +++ b/www/html/Quiz-Battle/quiz-battle.js @@ -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;