505 lines
17 KiB
JavaScript
505 lines
17 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;
|
|
}
|
|
|
|
/**
|
|
* 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<boolean>} 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<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;
|
|
}
|
|
|
|
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;
|