Files
tankwar_proj/js/managers/PlayerProfile.js
T
2026-05-02 13:50:52 +08:00

294 lines
10 KiB
JavaScript

/**
* 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<boolean>} 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<boolean>} 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;