/** * PlayerProfile.js * Manages the player's public profile (nickname + avatar url) for the game. * * Acquisition strategy (WeChat mini-game, compliant, 2024+): * 1. Try to read nickname from local storage cache (`playerProfile`). * 2. If absent, try `wx.getUserInfo({ withCredentials: false })` — this does NOT * require an authorization popup since 2022 and returns an anonymous * "微信用户" placeholder + default avatar. That is acceptable as the * silent fallback. * 3. The caller (MenuScene) can additionally create a `UserInfoButton` * and register a click handler via `bindUserInfoButton()` below to * upgrade the placeholder into the real nickname when the user taps * the overlay button. * * Display helpers: * - `getDisplayName()` — returns a "safe" display string (nickname if set, * otherwise "坦克手_XXXX" derived from playerId). * - `truncate(name, n)` — truncate at n Chinese-equivalent characters. */ const STORAGE_KEY = 'playerProfile'; class PlayerProfile { constructor() { /** @type {string} Real WeChat nickname, '' if not yet granted. */ this._nickname = ''; /** @type {string} Avatar URL, '' if not yet granted. */ this._avatarUrl = ''; /** @type {boolean} Whether we have attempted a silent fetch at least once. */ this._silentFetched = false; /** @type {boolean} Whether the real (non-anonymous) nickname has been granted. */ this._granted = false; this._loadFromCache(); } // ============================================================ // Public API // ============================================================ /** @returns {string} the cached nickname, or '' if never set. */ get nickname() { return this._nickname; } /** @returns {string} avatar URL or '' */ get avatarUrl() { return this._avatarUrl; } /** @returns {boolean} whether the user has granted the real nickname. */ get granted() { return this._granted; } /** * Build a safe display name for UI rendering. * Order of preference: real nickname > anonymous from last silent fetch * > deterministic "Tanker_XXXX" fallback based on playerId. * @param {string} [playerIdFallback] - optional player id for deterministic fallback. * @returns {string} */ getDisplayName(playerIdFallback) { if (this._nickname) return this._nickname; if (playerIdFallback && typeof playerIdFallback === 'string') { // Use the last 4 chars of playerId for a stable anonymous tag. const tail = playerIdFallback.slice(-4).toUpperCase(); return `Tanker_${tail}`; } return 'Tanker'; } /** * Truncate a display name to at most `maxChineseChars` Chinese-equivalent chars. * A Chinese char counts as 1; a latin char counts as 0.5. * @param {string} name * @param {number} [maxChineseChars=4] * @returns {string} */ truncate(name, maxChineseChars = 4) { if (!name) return ''; let widthBudget = maxChineseChars * 2; // in half-width units let out = ''; for (let i = 0; i < name.length; i++) { const ch = name.charAt(i); const code = name.charCodeAt(i); // Treat CJK + Full-width chars as 2 half-width units, others as 1. const w = code > 0x7f ? 2 : 1; if (widthBudget - w < 0) { return out + '..'; } widthBudget -= w; out += ch; } return out; } /** * Try to fetch the anonymous nickname silently. * In modern WeChat this resolves with a placeholder user ("微信用户") but * without popping any authorization UI. Safe to call on MenuScene enter. * @returns {Promise} true if something was set. */ fetchSilent() { return new Promise((resolve) => { if (this._granted || this._silentFetched) { resolve(!!this._nickname); return; } this._silentFetched = true; try { if (typeof wx === 'undefined' || typeof wx.getUserInfo !== 'function') { resolve(false); return; } wx.getUserInfo({ withCredentials: false, success: (res) => { const u = res && res.userInfo; if (u && u.nickName) { // NOTE: Since WeChat base library 2.27+, wx.getUserInfo returns // an anonymous placeholder ("微信用户") on real devices, but the // devtools environment may still return the real nickname — we // MUST NOT promote it to "granted" here, otherwise the cached // granted=true would prevent the UserInfoButton from ever being // created on real devices. The only source of truth for a real, // granted nickname is `applyUserInfoResult` (button tap). if (!this._nickname) { this._nickname = u.nickName; this._avatarUrl = u.avatarUrl || ''; this._saveToCache(); } resolve(true); return; } resolve(false); }, fail: () => resolve(false), }); } catch (e) { console.warn('[PlayerProfile] fetchSilent error:', e && e.message); resolve(false); } }); } /** * Detect the well-known WeChat anonymous placeholder. Since 2022-10, * `wx.getUserInfo` / `UserInfoButton` return this string for any user who * has not explicitly granted profile access — it must NOT be promoted to * the "granted" state. * @param {string} name * @returns {boolean} */ isPlaceholderName(name) { if (!name) return true; return name === '微信用户' || name === 'WeChat User' || name === 'Weixin User'; } /** * Handle the result of a `UserInfoButton` tap. Should be wired in by * whichever scene created the button (usually MenuScene). * @param {object} res - the `res` payload passed into the button's onTap callback. * @returns {boolean} true if a REAL (non-placeholder) nickname was stored. */ applyUserInfoResult(res) { const u = res && res.userInfo; if (!u || !u.nickName) return false; // Reject WeChat's anonymous placeholder — it's what `getUserInfo` now // returns for non-granted users and we must not mark that as "granted". if (this.isPlaceholderName(u.nickName)) { console.log('[PlayerProfile] Ignored placeholder nickname from UserInfoButton.'); return false; } this._nickname = u.nickName; this._avatarUrl = u.avatarUrl || ''; this._granted = true; this._saveToCache(); console.log('[PlayerProfile] Nickname granted:', this._nickname); return true; } /** * Active authorization flow for WeChat mini-GAMES. * * Since 2022-10 `wx.createUserInfoButton` silently returns the "微信用户" * placeholder, so the ONLY API that still pops a real authorization UI and * returns the user's actual WeChat nickname on small-game runtimes is * `wx.getUserProfile`. This call MUST be triggered directly by a user tap * (a touchend / click handler) — not by any async continuation — or WeChat * will reject it with `fail api scope is not declared in the privacy agreement`. * * @returns {Promise} true iff a real nickname was granted. */ requestUserProfile() { return new Promise((resolve) => { try { if (typeof wx === 'undefined' || typeof wx.getUserProfile !== 'function') { resolve(false); return; } wx.getUserProfile({ desc: '用于在对战中展示你的昵称', lang: 'zh_CN', success: (res) => { const u = res && res.userInfo; if (!u || !u.nickName || this.isPlaceholderName(u.nickName)) { console.log('[PlayerProfile] getUserProfile returned placeholder.'); resolve(false); return; } this._nickname = u.nickName; this._avatarUrl = u.avatarUrl || ''; this._granted = true; this._saveToCache(); console.log('[PlayerProfile] Nickname granted via getUserProfile:', this._nickname); resolve(true); }, fail: (err) => { console.log('[PlayerProfile] getUserProfile fail:', err && err.errMsg); resolve(false); }, }); } catch (e) { console.warn('[PlayerProfile] requestUserProfile error:', e && e.message); resolve(false); } }); } /** * Manual nickname fallback — used when `getUserProfile` is unavailable or * denied (e.g. user refused, API deprecated). Stores the user-typed name * and marks the profile as "granted" so the button stops prompting. * @param {string} name * @returns {boolean} */ setManualNickname(name) { if (!name || typeof name !== 'string') return false; const trimmed = name.trim(); if (!trimmed || this.isPlaceholderName(trimmed)) return false; this._nickname = trimmed.slice(0, 16); // hard cap to 16 chars this._granted = true; this._saveToCache(); console.log('[PlayerProfile] Nickname set manually:', this._nickname); return true; } /** * Clear the cached profile (e.g. user wants to re-auth). */ reset() { this._nickname = ''; this._avatarUrl = ''; this._granted = false; try { wx.removeStorageSync(STORAGE_KEY); } catch (e) { /* ignore */ } } // ============================================================ // Private // ============================================================ _loadFromCache() { try { const raw = wx.getStorageSync(STORAGE_KEY); if (raw && typeof raw === 'object') { this._nickname = raw.nickname || ''; this._avatarUrl = raw.avatarUrl || ''; this._granted = !!raw.granted; } } catch (e) { // Ignore storage errors. } } _saveToCache() { try { wx.setStorageSync(STORAGE_KEY, { nickname: this._nickname, avatarUrl: this._avatarUrl, granted: this._granted, }); } catch (e) { // Ignore storage errors. } } } module.exports = PlayerProfile;