/** * 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; } /** * Fetch the user's nickname + avatar following the official WeChat flow: * * 1. `wx.getSetting` → check `scope.userInfo` * 2. Already authorized → `wx.getUserInfo` to get profile directly * 3. Not authorized → `wx.createUserInfoButton` overlay, wait for tap * * @param {object} [layout] - Position/size for the UserInfoButton overlay. * { x, y, width, height } in CSS pixels (logical pixels). * If omitted, a default area in the top-left corner is used. * @returns {Promise} true if a real (non-placeholder) nickname was set. */ fetchSilent(layout) { return new Promise((resolve) => { if (this._granted) { resolve(true); return; } try { if (typeof wx === 'undefined') { resolve(false); return; } // ── Step 1: wx.getSetting — check if scope.userInfo is already granted ── if (typeof wx.getSetting === 'function') { console.log('[PlayerProfile] Checking auth setting via wx.getSetting...'); wx.getSetting({ success: (settingRes) => { const authorized = settingRes && settingRes.authSetting && settingRes.authSetting['scope.userInfo']; console.log('[PlayerProfile] getSetting result: scope.userInfo =', authorized); if (authorized) { // ── Step 2: Already authorized → getUserInfo directly ── this._fetchViaGetUserInfo(resolve); } else { // ── Step 3: Not authorized → create UserInfoButton ── this._createUserInfoButton(layout, resolve); } }, fail: (err) => { console.warn('[PlayerProfile] wx.getSetting fail:', err && err.errMsg, '— falling back to UserInfoButton'); // Can't determine auth status — create the button as a safe default this._createUserInfoButton(layout, resolve); }, }); return; } // ── Fallback: no wx.getSetting → try UserInfoButton, then getUserInfo ── if (typeof wx.createUserInfoButton === 'function') { this._createUserInfoButton(layout, resolve); return; } if (typeof wx.getUserInfo === 'function') { this._fetchViaGetUserInfo(resolve); return; } resolve(false); } catch (e) { console.warn('[PlayerProfile] fetchSilent error:', e && e.message); resolve(false); } }); } /** * Create a transparent UserInfoButton overlay and wait for user tap. * This is the ONLY way to acquire real profile data from a user who * has not yet granted scope.userInfo. * @param {object} [layout] - { x, y, width, height } * @param {Function} resolve - Promise resolver * @private */ _createUserInfoButton(layout, resolve) { if (typeof wx.createUserInfoButton !== 'function') { resolve(false); return; } console.log('[PlayerProfile] Creating UserInfoButton (scope not authorized)...'); const btnLayout = layout || { x: 10, y: 10, width: 120, height: 32 }; const button = wx.createUserInfoButton({ type: 'text', text: '', style: { left: btnLayout.x, top: btnLayout.y, width: btnLayout.width, height: btnLayout.height, backgroundColor: 'transparent', borderColor: 'transparent', color: 'transparent', fontSize: 1, borderRadius: 0, textAlign: 'center', lineHeight: btnLayout.height, }, }); button.onTap((res) => { console.log('[PlayerProfile] UserInfoButton onTap:', res && res.userInfo ? { nickName: res.userInfo.nickName, hasAvatar: !!res.userInfo.avatarUrl } : (res && res.errMsg ? { errMsg: res.errMsg } : 'null')); const u = res && res.userInfo; if (!u || !u.nickName || this.isPlaceholderName(u.nickName)) { console.log('[PlayerProfile] UserInfoButton returned placeholder or no data — keeping button for retry'); if (u && u.avatarUrl && !this._avatarUrl) { this._avatarUrl = u.avatarUrl; this._saveToCache(); this._emitProfileUpdate(); } // Don't destroy the button — user may tap again later. if (!this._granted) resolve(false); return; } // Success — destroy the button, we don't need it anymore try { button.destroy(); } catch (e) { /* ignore */ } this._userInfoButton = null; this._nickname = u.nickName; this._avatarUrl = u.avatarUrl || ''; this._granted = true; this._silentFetched = true; this._saveToCache(); this._emitProfileUpdate(); console.log('[PlayerProfile] Profile granted via UserInfoButton:', this._nickname); resolve(true); }); button.show(); // Store button ref so MenuScene can destroy it on exit this._userInfoButton = button; // Timeout: if user never taps, unblock the Promise but keep the button visible setTimeout(() => { if (!this._granted && this._userInfoButton === button) { console.log('[PlayerProfile] UserInfoButton not tapped after 30s — resolving false, button stays visible'); resolve(false); } }, 30000); } /** * Deprecated fallback: call wx.getUserInfo. * On modern WeChat (2022-10+) this returns "scope unauthorized" and * should NOT be used as the primary acquisition path. * @param {Function} resolve * @private */ _fetchViaGetUserInfo(resolve) { if (this._silentFetched) { resolve(!!this._nickname); return; } this._silentFetched = true; let resolved = false; console.log('[PlayerProfile] Calling wx.getUserInfo (deprecated fallback)...'); wx.getUserInfo({ withCredentials: false, success: (res) => { if (resolved) return; resolved = true; const u = res && res.userInfo; console.log('[PlayerProfile] wx.getUserInfo success, userInfo:', u ? { nickName: u.nickName, hasAvatar: !!u.avatarUrl } : 'null'); if (u) { let changed = false; if (u.nickName && u.nickName !== this._nickname && !this.isPlaceholderName(u.nickName)) { this._nickname = u.nickName; this._granted = true; changed = true; } if (u.avatarUrl && u.avatarUrl !== this._avatarUrl) { this._avatarUrl = u.avatarUrl; changed = true; } if (changed) { console.log('[PlayerProfile] Profile updated from getUserInfo'); this._saveToCache(); this._emitProfileUpdate(); } resolve(!!this._nickname); return; } resolve(false); }, fail: (err) => { if (resolved) return; resolved = true; console.warn('[PlayerProfile] wx.getUserInfo fail:', err && err.errMsg); resolve(false); }, }); // Timeout guard setTimeout(() => { if (!resolved) { resolved = true; console.warn('[PlayerProfile] wx.getUserInfo timed out after 5s'); resolve(false); } }, 5000); } /** * Destroy the UserInfoButton overlay if one exists. * Call this when leaving the MenuScene or when the profile is granted. */ destroyUserInfoButton() { if (this._userInfoButton) { try { this._userInfoButton.destroy(); } catch (e) { /* ignore */ } this._userInfoButton = null; } } /** * 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(); this._emitProfileUpdate(); 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; } const doProfile = () => { 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(); this._emitProfileUpdate(); console.log('[PlayerProfile] Nickname granted via getUserProfile:', this._nickname); resolve(true); }, fail: (err) => { console.log('[PlayerProfile] getUserProfile fail:', err && err.errMsg); resolve(false); }, }); }; // Call wx.getUserProfile directly. If the user hasn't authorized // privacy yet, WeChat will trigger wx.onNeedPrivacyAuthorization // (registered in game.js) which shows our PrivacyPopup, then // automatically retries the pending API call after the user agrees. // // We do NOT call wx.requirePrivacyAuthorize because it is unreliable // in WeChat mini-games — see fetchSilent() comments for details. doProfile(); } 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. } } /** * Emit a `profile:updated` event via the global EventBus so that * other systems (e.g. NetworkManager, TeamRoomScene) can react to * nickname / avatarUrl changes — typically by re-sending the latest * profile data to the server so peers see the updated avatar. * @private */ _emitProfileUpdate() { try { const bus = (typeof GameGlobal !== 'undefined') ? GameGlobal.eventBus : null; if (bus && typeof bus.emit === 'function') { bus.emit('profile:updated', { nickname: this._nickname, avatarUrl: this._avatarUrl, granted: this._granted, }); } } catch (e) { // Non-critical — event emission should never break the game. } } } module.exports = PlayerProfile;