feat: use wx.createUserInfoButton to get weixin's avarta

This commit is contained in:
jakciehan
2026-05-14 22:41:32 +08:00
parent c4bd390478
commit 9359139186
13 changed files with 1181 additions and 184 deletions
+87
View File
@@ -0,0 +1,87 @@
{
"plugin_protect": {
"name": "外挂防护",
"files": []
},
"watermark": {
"name": "水印保护",
"files": [
"js/data/LevelData.js",
"js/data/SkinData.js",
"js/base/GameGlobal.js",
"js/scenes/BuffSelectScene.js",
"js/scenes/ChatRoomScene.js",
"js/scenes/GameScene.js",
"js/scenes/MenuScene.js",
"js/scenes/ProfileScene.js",
"js/scenes/RankingScene.js",
"js/scenes/ResultScene.js",
"js/scenes/RoomScene.js",
"js/scenes/SettingsScene.js",
"js/scenes/ShopScene.js",
"js/scenes/SkinScene.js",
"js/scenes/BattlePassScene.js",
"js/scenes/TeamGameScene.js",
"js/scenes/TeamResultScene.js",
"js/scenes/TeamRoomScene.js",
"js/managers/PlayerProfile.js",
"js/managers/MapManager.js",
"js/managers/BuffManager.js",
"js/managers/BattlePassManager.js",
"js/managers/ResourceManager.js",
"js/managers/SkinManager.js",
"js/managers/SpawnManager.js",
"js/managers/StaminaManager.js",
"js/managers/StorageManager.js"
]
},
"obfuscation": {
"name": "代码混淆",
"tasks": [
{
"level": 1,
"files": []
},
{
"level": 3,
"files": [
"js/data/LevelData.js",
"js/data/SkinData.js",
"js/base/GameGlobal.js",
"js/scenes/BuffSelectScene.js",
"js/scenes/ChatRoomScene.js",
"js/scenes/GameScene.js",
"js/scenes/MenuScene.js",
"js/scenes/ProfileScene.js",
"js/scenes/RankingScene.js",
"js/scenes/ResultScene.js",
"js/scenes/RoomScene.js",
"js/scenes/SettingsScene.js",
"js/scenes/ShopScene.js",
"js/scenes/SkinScene.js",
"js/scenes/BattlePassScene.js",
"js/scenes/TeamGameScene.js",
"js/scenes/TeamResultScene.js",
"js/scenes/TeamRoomScene.js",
"js/managers/PlayerProfile.js",
"js/managers/MapManager.js",
"js/managers/BuffManager.js",
"js/managers/BattlePassManager.js",
"js/managers/ResourceManager.js",
"js/managers/SkinManager.js",
"js/managers/SpawnManager.js",
"js/managers/StaminaManager.js",
"js/managers/StorageManager.js"
]
}
]
},
"anti_inject": {
"name": "代码防注入",
"files": []
},
"platform_lock": {
"name": "平台锁",
"files": []
}
}
+53
View File
@@ -31,6 +31,7 @@ const ContentSecurityManager = require('./js/managers/ContentSecurityManager');
const BuffManager = require('./js/managers/BuffManager'); const BuffManager = require('./js/managers/BuffManager');
const SkinManager = require('./js/managers/SkinManager'); const SkinManager = require('./js/managers/SkinManager');
const PlayerProfile = require('./js/managers/PlayerProfile'); const PlayerProfile = require('./js/managers/PlayerProfile');
const PrivacyPopup = require('./js/ui/PrivacyPopup');
const EventBus = require('./js/base/EventBus'); const EventBus = require('./js/base/EventBus');
const { const {
SCREEN_WIDTH, SCREEN_WIDTH,
@@ -81,6 +82,7 @@ const contentSecurityManager = new ContentSecurityManager();
const buffManager = new BuffManager(); const buffManager = new BuffManager();
const skinManager = new SkinManager(); const skinManager = new SkinManager();
const playerProfile = new PlayerProfile(); const playerProfile = new PlayerProfile();
const privacyPopup = new PrivacyPopup();
GameGlobal.adManager = adManager; GameGlobal.adManager = adManager;
GameGlobal.shareManager = shareManager; GameGlobal.shareManager = shareManager;
GameGlobal.currencyManager = currencyManager; GameGlobal.currencyManager = currencyManager;
@@ -90,6 +92,7 @@ GameGlobal.contentSecurityManager = contentSecurityManager;
GameGlobal.buffManager = buffManager; GameGlobal.buffManager = buffManager;
GameGlobal.skinManager = skinManager; GameGlobal.skinManager = skinManager;
GameGlobal.playerProfile = playerProfile; GameGlobal.playerProfile = playerProfile;
GameGlobal.privacyPopup = privacyPopup;
// ============================================================ // ============================================================
// Game State // Game State
@@ -101,15 +104,60 @@ let lastTimestamp = 0;
// Touch Event Forwarding // Touch Event Forwarding
// ============================================================ // ============================================================
wx.onTouchStart((e) => { wx.onTouchStart((e) => {
// Privacy popup consumes all touches while active
if (privacyPopup.active) {
privacyPopup.handleTouch('touchstart', e);
return;
}
sceneManager.handleTouch('touchstart', e); sceneManager.handleTouch('touchstart', e);
}); });
wx.onTouchMove((e) => { wx.onTouchMove((e) => {
if (privacyPopup.active) return;
sceneManager.handleTouch('touchmove', e); sceneManager.handleTouch('touchmove', e);
}); });
wx.onTouchEnd((e) => { wx.onTouchEnd((e) => {
if (privacyPopup.active) {
privacyPopup.handleTouch('touchend', e);
return;
}
sceneManager.handleTouch('touchend', e); sceneManager.handleTouch('touchend', e);
}); });
// ============================================================
// WeChat Privacy Authorization (required since base library 2.32.3)
// ============================================================
if (typeof wx !== 'undefined' && typeof wx.onNeedPrivacyAuthorization === 'function') {
// Once the user has explicitly agreed to privacy, we auto-resolve any
// subsequent onNeedPrivacyAuthorization callbacks without showing the
// popup again. This prevents showing duplicate popups.
let _privacyAgreed = false;
wx.onNeedPrivacyAuthorization((resolve, eventInfo) => {
console.log('[game.js] onNeedPrivacyAuthorization triggered, eventInfo:', eventInfo, ', alreadyAgreed:', _privacyAgreed);
// If the user already agreed, auto-resolve without showing the popup.
if (_privacyAgreed) {
console.log('[game.js] Auto-resolving privacy (already agreed)');
resolve({ event: 'agree' });
return;
}
// Show the privacy popup for the first-time authorization.
// When the user taps "Agree" or "Decline", the popup calls resolve().
const wrappedResolve = (result) => {
console.log('[game.js] Privacy resolved with:', JSON.stringify(result));
resolve(result);
// Remember that the user agreed — future triggers auto-resolve.
if (result && result.event === 'agree') {
_privacyAgreed = true;
}
};
privacyPopup.show(wrappedResolve, eventInfo);
});
}
// ============================================================ // ============================================================
// Lifecycle: pause / resume on background switch // Lifecycle: pause / resume on background switch
// ============================================================ // ============================================================
@@ -255,6 +303,11 @@ function gameLoop(timestamp) {
// Update & render current scene // Update & render current scene
sceneManager.update(dt); sceneManager.update(dt);
sceneManager.render(ctx); sceneManager.render(ctx);
// Privacy popup renders on top of everything
if (privacyPopup.active) {
privacyPopup.render(ctx);
}
} }
// ============================================================ // ============================================================
+1
View File
@@ -1,6 +1,7 @@
{ {
"deviceOrientation": "landscape", "deviceOrientation": "landscape",
"showStatusBar": false, "showStatusBar": false,
"__usePrivacyCheck__": true,
"networkTimeout": { "networkTimeout": {
"request": 10000, "request": 10000,
"connectSocket": 10000, "connectSocket": 10000,
-1
View File
@@ -172,7 +172,6 @@ const SCENE = {
TEAM_ROOM: 'team_room', TEAM_ROOM: 'team_room',
TEAM_GAME: 'team_game', TEAM_GAME: 'team_game',
TEAM_RESULT: 'team_result', TEAM_RESULT: 'team_result',
PROFILE: 'profile',
CHAT_ROOM: 'chat_room', CHAT_ROOM: 'chat_room',
}; };
+11 -14
View File
@@ -21,7 +21,6 @@ module.exports = {
// Menu Scene // Menu Scene
// ============================================================ // ============================================================
'menu.title': 'Tank Adventure', 'menu.title': 'Tank Adventure',
'profile.welcome': 'Welcome {name}!',
'menu.subtitle': 'TANK WAR', 'menu.subtitle': 'TANK WAR',
'menu.classic': 'Classic', 'menu.classic': 'Classic',
'menu.endless': 'Endless', 'menu.endless': 'Endless',
@@ -31,8 +30,8 @@ module.exports = {
'menu.skin': 'Skins', 'menu.skin': 'Skins',
'menu.ranking': 'Ranking', 'menu.ranking': 'Ranking',
'menu.settings': 'Settings', 'menu.settings': 'Settings',
'menu.profile': 'Profile',
'menu.chat': 'Chat', 'menu.chat': 'Chat',
'menu.tapToAuth': 'Tap to authorize',
// ============================================================ // ============================================================
// Room Scene (PVP) // Room Scene (PVP)
@@ -209,7 +208,6 @@ module.exports = {
'settings.sound': 'Sound', 'settings.sound': 'Sound',
'settings.music': 'Music', 'settings.music': 'Music',
'settings.vibration': 'Vibration', 'settings.vibration': 'Vibration',
'settings.profile': 'Profile',
// ============================================================ // ============================================================
// Shop Scene (Simplified) // Shop Scene (Simplified)
@@ -229,17 +227,6 @@ module.exports = {
// ============================================================
// Profile Scene
// ============================================================
'profile.title': 'Profile',
'profile.nickname': 'Nickname',
'profile.signature': 'Signature',
'profile.description': 'Space Description',
'profile.changeAvatar': 'Change Avatar',
'profile.tapToEdit': 'Tap to edit',
'profile.save': 'Save',
// ============================================================ // ============================================================
// Chat Room Scene // Chat Room Scene
// ============================================================ // ============================================================
@@ -333,4 +320,14 @@ module.exports = {
'skin.owned': 'Owned', 'skin.owned': 'Owned',
'skin.equipSuccess': '✓ Skin equipped!', 'skin.equipSuccess': '✓ Skin equipped!',
'skin.purchaseSuccess': '✓ Skin unlocked!', 'skin.purchaseSuccess': '✓ Skin unlocked!',
// ============================================================
// Privacy Authorization
// ============================================================
'privacy.title': 'Privacy Notice',
'privacy.body': 'To display your nickname and avatar in the game, we need access to your WeChat basic info.\n\nYou can review the Privacy Policy to understand how your data is used.\n\nIf you decline, you will play anonymously.',
'privacy.policyLink': '👉 View Privacy Policy',
'privacy.agree': 'Agree',
'privacy.decline': 'Decline',
'privacy.footer': 'Your info is only used for in-game display and never shared with third parties',
}; };
+11 -14
View File
@@ -21,7 +21,6 @@ module.exports = {
// Menu Scene // Menu Scene
// ============================================================ // ============================================================
'menu.title': '坦克探险', 'menu.title': '坦克探险',
'profile.welcome': '欢迎 {name}',
'menu.subtitle': '经典坦克对战', 'menu.subtitle': '经典坦克对战',
'menu.classic': '经典模式', 'menu.classic': '经典模式',
'menu.endless': '无尽模式', 'menu.endless': '无尽模式',
@@ -31,8 +30,8 @@ module.exports = {
'menu.skin': '皮肤', 'menu.skin': '皮肤',
'menu.ranking': '排行榜', 'menu.ranking': '排行榜',
'menu.settings': '设置', 'menu.settings': '设置',
'menu.profile': '个人资料',
'menu.chat': '聊天室', 'menu.chat': '聊天室',
'menu.tapToAuth': '点击授权头像',
// ============================================================ // ============================================================
// Room Scene (PVP) // Room Scene (PVP)
@@ -209,7 +208,6 @@ module.exports = {
'settings.sound': '音效', 'settings.sound': '音效',
'settings.music': '音乐', 'settings.music': '音乐',
'settings.vibration': '振动', 'settings.vibration': '振动',
'settings.profile': '个人资料',
// ============================================================ // ============================================================
// Shop Scene (Simplified) // Shop Scene (Simplified)
@@ -229,17 +227,6 @@ module.exports = {
// ============================================================
// Profile Scene
// ============================================================
'profile.title': '个人资料',
'profile.nickname': '昵称',
'profile.signature': '个性签名',
'profile.description': '个人空间描述',
'profile.changeAvatar': '更换头像',
'profile.tapToEdit': '点击编辑',
'profile.save': '保存',
// ============================================================ // ============================================================
// Chat Room Scene // Chat Room Scene
// ============================================================ // ============================================================
@@ -333,4 +320,14 @@ module.exports = {
'skin.owned': '已拥有', 'skin.owned': '已拥有',
'skin.equipSuccess': '✓ 已装备!', 'skin.equipSuccess': '✓ 已装备!',
'skin.purchaseSuccess': '✓ 已解锁!', 'skin.purchaseSuccess': '✓ 已解锁!',
// ============================================================
// Privacy Authorization
// ============================================================
'privacy.title': '用户隐私保护提示',
'privacy.body': '为了在游戏中展示你的昵称和头像,我们需要使用你的微信基本信息。\n\n你可以在《隐私政策》中了解具体的信息使用方式和范围。\n\n如果你不同意,将以匿名身份参与游戏。',
'privacy.policyLink': '👉 查看《隐私政策》',
'privacy.agree': '同意',
'privacy.decline': '不同意',
'privacy.footer': '同意后仅用于游戏内展示,不会向第三方提供',
}; };
+4 -2
View File
@@ -196,17 +196,19 @@ class NetworkManager {
return; return;
} }
// Always include the player's current nickname (if any) so the server // Always include the player's current nickname and avatarUrl so the server
// can propagate it to other clients. Falls back silently when the // can propagate them to other clients. Falls back silently when the
// profile is not yet available. // profile is not yet available.
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null; const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
const nickname = (profile && profile.nickname) ? profile.nickname : ''; const nickname = (profile && profile.nickname) ? profile.nickname : '';
const avatarUrl = (profile && profile.avatarUrl) ? profile.avatarUrl : '';
const message = JSON.stringify({ const message = JSON.stringify({
type, type,
data, data,
playerId: this._playerId, playerId: this._playerId,
nickname, nickname,
avatarUrl,
roomId: this._roomId, roomId: this._roomId,
timestamp: Date.now(), timestamp: Date.now(),
}); });
+264 -53
View File
@@ -97,48 +97,67 @@ class PlayerProfile {
} }
/** /**
* Try to fetch the anonymous nickname silently. * Fetch the user's nickname + avatar following the official WeChat flow:
* In modern WeChat this resolves with a placeholder user ("微信用户") but *
* without popping any authorization UI. Safe to call on MenuScene enter. * 1. `wx.getSetting` check `scope.userInfo`
* @returns {Promise<boolean>} true if something was set. * 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() { fetchSilent(layout) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (this._granted || this._silentFetched) { if (this._granted) {
resolve(!!this._nickname); resolve(true);
return; return;
} }
this._silentFetched = true;
try { try {
if (typeof wx === 'undefined' || typeof wx.getUserInfo !== 'function') { if (typeof wx === 'undefined') {
resolve(false); resolve(false);
return; return;
} }
wx.getUserInfo({
withCredentials: false, // ── Step 1: wx.getSetting — check if scope.userInfo is already granted ──
success: (res) => { if (typeof wx.getSetting === 'function') {
const u = res && res.userInfo; console.log('[PlayerProfile] Checking auth setting via wx.getSetting...');
if (u && u.nickName) { wx.getSetting({
// NOTE: Since WeChat base library 2.27+, wx.getUserInfo returns success: (settingRes) => {
// an anonymous placeholder ("微信用户") on real devices, but the const authorized = settingRes && settingRes.authSetting && settingRes.authSetting['scope.userInfo'];
// devtools environment may still return the real nickname — we console.log('[PlayerProfile] getSetting result: scope.userInfo =', authorized);
// MUST NOT promote it to "granted" here, otherwise the cached
// granted=true would prevent the UserInfoButton from ever being if (authorized) {
// created on real devices. The only source of truth for a real, // ── Step 2: Already authorized → getUserInfo directly ──
// granted nickname is `applyUserInfoResult` (button tap). this._fetchViaGetUserInfo(resolve);
if (!this._nickname) { } else {
this._nickname = u.nickName; // ── Step 3: Not authorized → create UserInfoButton ──
this._avatarUrl = u.avatarUrl || ''; this._createUserInfoButton(layout, resolve);
this._saveToCache();
} }
resolve(true); },
return; fail: (err) => {
} console.warn('[PlayerProfile] wx.getSetting fail:', err && err.errMsg, '— falling back to UserInfoButton');
resolve(false); // Can't determine auth status — create the button as a safe default
}, this._createUserInfoButton(layout, resolve);
fail: () => resolve(false), },
}); });
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) { } catch (e) {
console.warn('[PlayerProfile] fetchSilent error:', e && e.message); console.warn('[PlayerProfile] fetchSilent error:', e && e.message);
resolve(false); resolve(false);
@@ -146,6 +165,162 @@ class PlayerProfile {
}); });
} }
/**
* 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, * Detect the well-known WeChat anonymous placeholder. Since 2022-10,
* `wx.getUserInfo` / `UserInfoButton` return this string for any user who * `wx.getUserInfo` / `UserInfoButton` return this string for any user who
@@ -180,6 +355,7 @@ class PlayerProfile {
this._avatarUrl = u.avatarUrl || ''; this._avatarUrl = u.avatarUrl || '';
this._granted = true; this._granted = true;
this._saveToCache(); this._saveToCache();
this._emitProfileUpdate();
console.log('[PlayerProfile] Nickname granted:', this._nickname); console.log('[PlayerProfile] Nickname granted:', this._nickname);
return true; return true;
} }
@@ -203,28 +379,41 @@ class PlayerProfile {
resolve(false); resolve(false);
return; return;
} }
wx.getUserProfile({
desc: '用于在对战中展示你的昵称', const doProfile = () => {
lang: 'zh_CN', wx.getUserProfile({
success: (res) => { desc: '用于在对战中展示你的昵称',
const u = res && res.userInfo; lang: 'zh_CN',
if (!u || !u.nickName || this.isPlaceholderName(u.nickName)) { success: (res) => {
console.log('[PlayerProfile] getUserProfile returned placeholder.'); 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); resolve(false);
return; },
} });
this._nickname = u.nickName; };
this._avatarUrl = u.avatarUrl || '';
this._granted = true; // Call wx.getUserProfile directly. If the user hasn't authorized
this._saveToCache(); // privacy yet, WeChat will trigger wx.onNeedPrivacyAuthorization
console.log('[PlayerProfile] Nickname granted via getUserProfile:', this._nickname); // (registered in game.js) which shows our PrivacyPopup, then
resolve(true); // automatically retries the pending API call after the user agrees.
}, //
fail: (err) => { // We do NOT call wx.requirePrivacyAuthorize because it is unreliable
console.log('[PlayerProfile] getUserProfile fail:', err && err.errMsg); // in WeChat mini-games — see fetchSilent() comments for details.
resolve(false); doProfile();
},
});
} catch (e) { } catch (e) {
console.warn('[PlayerProfile] requestUserProfile error:', e && e.message); console.warn('[PlayerProfile] requestUserProfile error:', e && e.message);
resolve(false); resolve(false);
@@ -288,6 +477,28 @@ class PlayerProfile {
// Ignore storage errors. // 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; module.exports = PlayerProfile;
+129 -6
View File
@@ -97,6 +97,24 @@ const MenuScene = {
enter() { enter() {
this._pressedIndex = -1; this._pressedIndex = -1;
this._tankAnim = 0; this._tankAnim = 0;
this._avatarImg = null;
// Load avatar image if profile has one
const profile = GameGlobal.playerProfile;
if (profile && profile.avatarUrl) {
this._loadAvatarImage(profile.avatarUrl);
}
// Listen for profile updates (avatar may arrive after initial render)
this._profileHandler = (data) => {
if (data && data.avatarUrl && !this._avatarImg) {
this._loadAvatarImage(data.avatarUrl);
}
};
const bus = GameGlobal.eventBus;
if (bus && typeof bus.on === 'function') {
bus.on('profile:updated', this._profileHandler);
}
// Kick off nickname acquisition as early as possible so that later // Kick off nickname acquisition as early as possible so that later
// network messages (CREATE_TEAM, SOLO_MATCH, ...) can carry it. // network messages (CREATE_TEAM, SOLO_MATCH, ...) can carry it.
@@ -120,6 +138,18 @@ const MenuScene = {
exit() { exit() {
this._pressedIndex = -1; this._pressedIndex = -1;
// Destroy UserInfoButton overlay when leaving the menu
const profile = GameGlobal.playerProfile;
if (profile && typeof profile.destroyUserInfoButton === 'function') {
profile.destroyUserInfoButton();
}
// Remove profile update listener
const bus = GameGlobal.eventBus;
if (bus && typeof bus.off === 'function' && this._profileHandler) {
bus.off('profile:updated', this._profileHandler);
}
this._profileHandler = null;
this._avatarImg = null;
}, },
update(dt) { update(dt) {
@@ -151,6 +181,55 @@ const MenuScene = {
ctx.fillStyle = accentGrad; ctx.fillStyle = accentGrad;
ctx.fillRect(0, 0, SCREEN_WIDTH, 3); ctx.fillRect(0, 0, SCREEN_WIDTH, 3);
// ---- Player Avatar & Nickname (top-left) ----
const profile = GameGlobal.playerProfile;
const avatarSize = 28;
const avatarX = 10;
const avatarY = 10;
const avatarR = avatarSize / 2;
// Avatar circle background
ctx.save();
ctx.beginPath();
ctx.arc(avatarX + avatarR, avatarY + avatarR, avatarR, 0, Math.PI * 2);
ctx.closePath();
ctx.fillStyle = 'rgba(30,48,84,0.7)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,215,0,0.4)';
ctx.lineWidth = 1;
ctx.stroke();
// Avatar image or default icon
if (profile && profile.avatarUrl && this._avatarImg && this._avatarImg.complete) {
ctx.clip();
ctx.drawImage(this._avatarImg, avatarX, avatarY, avatarSize, avatarSize);
} else {
// Default user icon (simple silhouette)
ctx.fillStyle = 'rgba(255,215,0,0.5)';
ctx.beginPath();
ctx.arc(avatarX + avatarR, avatarY + avatarR - 2, avatarR * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(avatarX + avatarR, avatarY + avatarR + avatarR * 0.55, avatarR * 0.55, avatarR * 0.3, 0, Math.PI, 0);
ctx.fill();
}
ctx.restore();
// Nickname
const displayName = profile ? profile.getDisplayName() : 'Tanker';
ctx.font = 'bold 11px Arial';
ctx.fillStyle = MC.GOLD;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(displayName, avatarX + avatarSize + 6, avatarY + avatarR - 5);
// Hint text (only if not yet granted)
if (profile && !profile.granted) {
ctx.font = '9px Arial';
ctx.fillStyle = MC.SUBTITLE;
ctx.fillText(t('menu.tapToAuth') || 'Tap to authorize', avatarX + avatarSize + 6, avatarY + avatarR + 8);
}
// ---- Gold Balance (top-right pill) ---- // ---- Gold Balance (top-right pill) ----
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0; const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
const goldText = `🪙 ${gold}`; const goldText = `🪙 ${gold}`;
@@ -469,19 +548,63 @@ const MenuScene = {
// ============================================================ // ============================================================
/** /**
* Kick off profile acquisition on menu enter. Since WeChat 2022-10 there * Load avatar image from URL for Canvas rendering.
* is NO silent way to get the real nickname we draw a canvas button and * @param {string} url
* call `wx.getUserProfile` directly from its touchend handler. * @private
*/
_loadAvatarImage(url) {
if (!url || this._avatarImg) return;
try {
const img = wx.createImage();
img.onload = () => {
this._avatarImg = img;
};
img.onerror = () => {
console.warn('[MenuScene] Failed to load avatar image');
};
img.src = url;
} catch (e) {
console.warn('[MenuScene] _loadAvatarImage error:', e && e.message);
}
},
/**
* Kick off profile acquisition on menu enter.
*
* Since WeChat 2022-10, `wx.getUserInfo` returns "scope unauthorized"
* the ONLY way to get the real nickname + avatar is `wx.createUserInfoButton`.
* We create a transparent overlay button that covers the avatar area in the
* top-left corner. When the user taps their avatar, WeChat returns the real
* profile data.
* @private * @private
*/ */
_initPlayerProfile() { _initPlayerProfile() {
const profile = GameGlobal.playerProfile; const profile = GameGlobal.playerProfile;
if (!profile) return; if (!profile) return;
// Best-effort placeholder fetch (used only to pre-fill _nickname with // If already granted (from a previous session's cache), no button needed
// "微信用户" on older devices; does not mark granted). if (profile.granted) return;
// Layout must match the avatar area drawn in render():
// avatarX=10, avatarY=10, avatarSize=28 + nickname text area
const avatarLayout = {
x: 8,
y: 8,
width: 120,
height: 32,
};
// Create the UserInfoButton — visible style covering avatar + nickname area
if (typeof profile.fetchSilent === 'function') { if (typeof profile.fetchSilent === 'function') {
profile.fetchSilent().catch(() => { /* ignore */ }); profile.fetchSilent(avatarLayout).then((ok) => {
console.log('[MenuScene] fetchSilent completed, granted=', ok);
if (ok) {
// Button was tapped and profile was granted — destroy it
profile.destroyUserInfoButton();
}
}).catch((e) => {
console.warn('[MenuScene] fetchSilent error:', e);
});
} }
}, },
-39
View File
@@ -85,10 +85,6 @@ const SettingsScene = {
this._renderToggle(ctx, cx, cy, row); this._renderToggle(ctx, cx, cy, row);
} }
// Profile entry button (below the last toggle row)
const profileY = firstCenterY + rows.length * step;
this._renderProfileButton(ctx, cx, profileY);
// Back button // Back button
this._renderBackButton(ctx, cx, backCenterY); this._renderBackButton(ctx, cx, backCenterY);
}, },
@@ -138,34 +134,6 @@ const SettingsScene = {
ctx.fill(); ctx.fill();
}, },
_renderProfileButton(ctx, cx, y) {
const w = SCREEN_WIDTH * 0.7;
const h = 50;
const x = cx - w / 2;
this._buttons['profile'] = { x, y: y - h / 2, w, h };
// Background
ctx.fillStyle = '#1e1e3a';
ctx.fillRect(x, y - h / 2, w, h);
ctx.strokeStyle = '#333366';
ctx.lineWidth = 1;
ctx.strokeRect(x, y - h / 2, w, h);
// Icon and label
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = '16px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`👤 ${t('settings.profile')}`, x + 15, y);
// Arrow indicator
ctx.fillStyle = '#888888';
ctx.font = '14px Arial';
ctx.textAlign = 'right';
ctx.fillText('', x + w - 15, y);
},
_renderBackButton(ctx, cx, y) { _renderBackButton(ctx, cx, y) {
const w = SCREEN_WIDTH * 0.4; const w = SCREEN_WIDTH * 0.4;
const h = 42; const h = 42;
@@ -197,13 +165,6 @@ const SettingsScene = {
if (tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h) { if (tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h) {
if (key === 'back') { if (key === 'back') {
GameGlobal.sceneManager.switchTo(SCENE.MENU); GameGlobal.sceneManager.switchTo(SCENE.MENU);
} else if (key === 'profile') {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.PROFILE)) {
const ProfileScene = require('./ProfileScene');
sm.register(SCENE.PROFILE, ProfileScene);
}
sm.switchTo(SCENE.PROFILE);
} else if (this._settings.hasOwnProperty(key)) { } else if (this._settings.hasOwnProperty(key)) {
this._settings[key] = !this._settings[key]; this._settings[key] = !this._settings[key];
// Notify audio system // Notify audio system
+195 -35
View File
@@ -70,6 +70,7 @@ const TeamRoomScene = {
_cancelMatchBtnRect: null, _cancelMatchBtnRect: null,
_slotRects: [], _slotRects: [],
_kickBtnRects: [], _kickBtnRects: [],
_avatarImages: {},
enter(params) { enter(params) {
this._state = TEAM_STATE.MODE_SELECT; this._state = TEAM_STATE.MODE_SELECT;
@@ -82,10 +83,13 @@ const TeamRoomScene = {
this._networkManager = GameGlobal.networkManager; this._networkManager = GameGlobal.networkManager;
this._myPlayerId = this._networkManager ? this._networkManager.playerId : 'player_' + Date.now(); this._myPlayerId = this._networkManager ? this._networkManager.playerId : 'player_' + Date.now();
this._isLeader = false; this._isLeader = false;
this._avatarImages = {};
this._buildLayout(); this._buildLayout();
// Setup network events BEFORE auto-join so listeners are ready // Setup network events BEFORE auto-join so listeners are ready
this._setupNetworkEvents(); this._setupNetworkEvents();
// Listen for profile updates (avatar/nickname granted mid-session)
this._setupProfileListener();
// If entering with a teamId (from invite card), auto-join // If entering with a teamId (from invite card), auto-join
if (params && params.teamId) { if (params && params.teamId) {
@@ -112,6 +116,7 @@ const TeamRoomScene = {
exit() { exit() {
this._cleanupNetworkEvents(); this._cleanupNetworkEvents();
this._cleanupProfileListener();
// Reset share content when leaving team room // Reset share content when leaving team room
const shareManager = GameGlobal.shareManager; const shareManager = GameGlobal.shareManager;
if (shareManager) { if (shareManager) {
@@ -209,6 +214,24 @@ const TeamRoomScene = {
const unsubs = []; const unsubs = [];
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => { unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
// Invalidate avatar cache for members whose avatarUrl changed
// so that _loadAvatar will reload the new image.
if (data.teamA) {
for (const m of data.teamA) {
if (m.avatarUrl && this._avatarImages[m.playerId] === null) {
// null = previously failed to load; the new URL may work
delete this._avatarImages[m.playerId];
}
}
}
if (data.teamB) {
for (const m of data.teamB) {
if (m.avatarUrl && this._avatarImages[m.playerId] === null) {
delete this._avatarImages[m.playerId];
}
}
}
this._teamData = data; this._teamData = data;
this._isLeader = data.leaderId === this._myPlayerId; this._isLeader = data.leaderId === this._myPlayerId;
@@ -271,6 +294,43 @@ const TeamRoomScene = {
this._unsubscribers = []; this._unsubscribers = [];
}, },
/**
* Listen for profile:updated events. When the avatar URL is granted
* (e.g. after fetchSilent completes or user taps UserInfoButton),
* we need to trigger a network message so the server propagates
* the new avatarUrl to other team members.
* @private
*/
_setupProfileListener() {
this._cleanupProfileListener();
const bus = (typeof GameGlobal !== 'undefined') ? GameGlobal.eventBus : null;
if (!bus || typeof bus.on !== 'function') return;
this._profileUnsub = bus.on('profile:updated', (data) => {
console.log('[TeamRoom] profile:updated received, avatarUrl:', data && data.avatarUrl ? 'present' : 'empty');
// If we are in a team room and have a network connection, send a
// lightweight ping so the server picks up the updated avatarUrl
// from the next message's top-level field.
if (this._teamData && this._networkManager && this._networkManager.connected) {
this._networkManager.send(NET_MSG.PING);
console.log('[TeamRoom] Sent PING to sync updated profile to server');
}
// Also invalidate cached avatar for self so it reloads
if (this._myPlayerId && this._avatarImages[this._myPlayerId] !== undefined) {
delete this._avatarImages[this._myPlayerId];
}
});
},
_cleanupProfileListener() {
if (this._profileUnsub) {
this._profileUnsub();
this._profileUnsub = null;
}
},
update(dt) { update(dt) {
this._animTimer += dt; this._animTimer += dt;
@@ -392,40 +452,18 @@ const TeamRoomScene = {
ctx.stroke(); ctx.stroke();
if (member) { if (member) {
// Avatar placeholder (circle) // Avatar centered in the slot
const avatarR = Math.min(rect.w, rect.h) * 0.22; const avatarR = Math.min(rect.w, rect.h) * 0.32;
const avatarCX = rect.x + rect.w / 2; const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.3; const avatarCY = rect.y + rect.h * 0.42;
this._drawAvatar(ctx, member, avatarCX, avatarCY, avatarR);
ctx.fillStyle = member.isLeader ? '#FFD700' : '#4a90d9'; // Ready state (below avatar)
ctx.beginPath();
ctx.arc(avatarCX, avatarCY, avatarR, 0, Math.PI * 2);
ctx.fill();
// Player icon
ctx.fillStyle = '#FFFFFF';
ctx.font = `${avatarR}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🎖', avatarCX, avatarCY);
// Leader badge
if (member.isLeader) {
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 9px Arial';
ctx.fillText(t('teamRoom.leader'), avatarCX, avatarCY + avatarR + 10);
}
// Player name (truncated)
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
const name = this._getDisplayName(member);
ctx.fillText(name, avatarCX, rect.y + rect.h * 0.7);
// Ready state
if (!member.isLeader) { if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347'; ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial'; ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88); ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88);
} }
@@ -477,19 +515,34 @@ const TeamRoomScene = {
const member = members[i]; const member = members[i];
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a'; ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = '#0f3460'; ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
ctx.lineWidth = 1; ctx.lineWidth = member && member.isLeader ? 3 : 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8); this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
if (member) { if (member) {
ctx.fillStyle = '#FFFFFF'; // Avatar centered in the slot
ctx.font = '10px Arial'; const avatarR = Math.min(rect.w, rect.h) * 0.32;
const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.42;
this._drawAvatar(ctx, member, avatarCX, avatarCY, avatarR);
// Ready state (below avatar)
if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88);
}
} else {
// Empty slot
ctx.fillStyle = '#555555';
ctx.font = '24px Arial';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const name = this._getDisplayName(member); ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
ctx.fillText(name, rect.x + rect.w / 2, rect.y + rect.h / 2);
} }
} }
@@ -575,6 +628,113 @@ const TeamRoomScene = {
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h; return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
}, },
/**
* Draw an avatar for a team member: WeChat image if loaded, else colored placeholder.
* For the local player, also checks PlayerProfile.avatarUrl as a fallback
* source (it may be set before the server broadcasts the update).
* @private
*/
_drawAvatar(ctx, member, cx, cy, r) {
const img = this._avatarImages[member.playerId];
if (img) {
// Circular clip for the avatar image
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2);
ctx.restore();
// Border ring
ctx.strokeStyle = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r + 1, 0, Math.PI * 2);
ctx.stroke();
} else {
// For the local player, check if PlayerProfile has an avatarUrl that
// hasn't been loaded into _avatarImages yet (async timing gap).
if (member.playerId === this._myPlayerId) {
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
if (profile && profile.avatarUrl && !this._avatarImages[member.playerId]) {
// Profile has an avatarUrl but we haven't loaded it yet — trigger load
this._loadAvatar(member);
// Don't draw the placeholder; next frame will render the image.
// However, to avoid a flash, still draw placeholder this frame.
}
}
// Placeholder: colored circle with a simple person silhouette
const bgColor = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.fillStyle = bgColor;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// Draw a simple person silhouette (head + shoulders)
ctx.fillStyle = 'rgba(255,255,255,0.7)';
// Head
ctx.beginPath();
ctx.arc(cx, cy - r * 0.2, r * 0.3, 0, Math.PI * 2);
ctx.fill();
// Shoulders
ctx.beginPath();
ctx.ellipse(cx, cy + r * 0.55, r * 0.55, r * 0.35, 0, Math.PI, 0);
ctx.fill();
// Border ring for placeholder
ctx.strokeStyle = bgColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r + 1, 0, Math.PI * 2);
ctx.stroke();
// Trigger async load
this._loadAvatar(member);
}
},
/**
* Asynchronously load a WeChat avatar image for the given member.
* Caches the Image object so subsequent frames render the real avatar.
* For the local player, also checks PlayerProfile as a fallback source
* for the avatarUrl (it may have been granted after the team state was
* last broadcast from the server).
* @private
*/
_loadAvatar(member) {
// For self, prefer the latest PlayerProfile avatarUrl (it updates
// asynchronously via fetchSilent / UserInfoButton, possibly after
// the server last broadcast the team state).
let avatarUrl = member.avatarUrl;
if (member.playerId === this._myPlayerId) {
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
if (profile && profile.avatarUrl) {
avatarUrl = profile.avatarUrl;
}
}
if (!avatarUrl || this._avatarImages[member.playerId] !== undefined) return;
// Mark as loading (null) to prevent duplicate loads
this._avatarImages[member.playerId] = null;
try {
const img = wx.createImage();
img.onload = () => {
this._avatarImages[member.playerId] = img;
console.log(`[TeamRoom] Avatar loaded for ${member.playerId}, url=${avatarUrl.substring(0, 60)}...`);
};
img.onerror = (err) => {
// Keep null so we don't retry endlessly
console.warn(`[TeamRoom] Failed to load avatar for ${member.playerId}, url=${avatarUrl.substring(0, 60)}..., err:`, err);
};
img.src = avatarUrl;
} catch (e) {
console.warn('[TeamRoom] wx.createImage not available:', e);
}
},
/** /**
* Compute a display name for a team member entry. * Compute a display name for a team member entry.
* Uses real WeChat nickname when available, otherwise a stable fallback. * Uses real WeChat nickname when available, otherwise a stable fallback.
+389
View File
@@ -0,0 +1,389 @@
/**
* PrivacyPopup.js
* Privacy authorization popup for WeChat mini-game.
*
* When a privacy-sensitive API (e.g. wx.getUserInfo) is called, WeChat
* triggers `wx.onNeedPrivacyAuthorization`. This popup shows a compliant
* dialog that explains what data we collect and lets the user agree or
* decline before we call `resolve({ buttonAction: 'agree' })`.
*
* Compliance notes (WeChat 2024+):
* - Must clearly state what data is collected and why.
* - Must provide a way to decline (user can still play with placeholder).
* - Must link to the full privacy policy.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// Colors
const OVERLAY_BG = 'rgba(0, 0, 0, 0.75)';
const DIALOG_BG = '#1e1e32';
const DIALOG_BORDER = '#4a90d9';
const TITLE_COLOR = '#FFD700';
const TEXT_COLOR = '#CCCCCC';
const LINK_COLOR = '#4a90d9';
const AGREE_BG = '#4a90d9';
const AGREE_TEXT = '#FFFFFF';
const DECLINE_BG = 'rgba(255, 255, 255, 0.08)';
const DECLINE_BORDER = '#666666';
const DECLINE_TEXT = '#AAAAAA';
class PrivacyPopup {
constructor() {
/** @type {boolean} Whether the popup is currently visible. */
this._active = false;
/** @type {Function|null} The resolve callback from onNeedPrivacyAuthorization. */
this._resolveCallback = null;
/** @type {string} Which API triggered the privacy request. */
this._referrer = '';
/** @type {object|null} Button hit-test rects (computed during render). */
this._agreeBtn = null;
this._declineBtn = null;
this._policyLink = null;
}
// ============================================================
// Public API
// ============================================================
get active() {
return this._active;
}
/**
* Show the privacy popup.
* @param {Function} resolve - The resolve callback from onNeedPrivacyAuthorization.
* @param {object} eventInfo - The eventInfo from onNeedPrivacyAuthorization.
*/
show(resolve, eventInfo) {
// Guard: if popup is already active, chain the new resolve callback
// so that when the current popup resolves, both callbacks get called.
// This prevents showing two popups at once.
if (this._active && this._resolveCallback) {
console.warn('[PrivacyPopup] Already active, chaining resolve callback. referrer:', this._referrer, ', new referrer:', (eventInfo && eventInfo.referrer) || '');
const prevResolve = this._resolveCallback;
this._resolveCallback = (result) => {
try { prevResolve(result); } catch (e) { /* ignore */ }
try { resolve(result); } catch (e) { /* ignore */ }
};
return;
}
this._active = true;
this._resolveCallback = resolve;
this._referrer = (eventInfo && eventInfo.referrer) || '';
console.log('[PrivacyPopup] Showing popup, referrer:', this._referrer);
}
/**
* Hide the popup (without resolving used internally after resolve is called).
*/
hide() {
this._active = false;
this._resolveCallback = null;
this._agreeBtn = null;
this._declineBtn = null;
this._policyLink = null;
}
/**
* Handle touch event. Returns true if the touch was consumed.
* @param {string} eventType - 'touchstart' | 'touchend'
* @param {object} e - The touch event object.
* @returns {boolean}
*/
handleTouch(eventType, e) {
if (!this._active) return false;
if (eventType !== 'touchend') return true; // Consume touchstart to prevent bleed-through
const touch = e.changedTouches && e.changedTouches[0];
if (!touch) return true;
const tx = touch.clientX;
const ty = touch.clientY;
// Agree button
if (this._agreeBtn && this._hitTest(tx, ty, this._agreeBtn)) {
console.log('[PrivacyPopup] User tapped AGREE');
this._resolve('agree');
return true;
}
// Decline button
if (this._declineBtn && this._hitTest(tx, ty, this._declineBtn)) {
console.log('[PrivacyPopup] User tapped DECLINE');
this._resolve('disagree');
return true;
}
// Privacy policy link
if (this._policyLink && this._hitTest(tx, ty, this._policyLink)) {
console.log('[PrivacyPopup] User tapped privacy policy link');
this._openPrivacyPolicy();
return true;
}
return true; // Consume all touches while popup is active
}
// ============================================================
// Render
// ============================================================
render(ctx) {
if (!this._active) return;
const cw = SCREEN_WIDTH;
const ch = SCREEN_HEIGHT;
const cx = cw / 2;
const cy = ch / 2;
// --- Semi-transparent overlay ---
ctx.fillStyle = OVERLAY_BG;
ctx.fillRect(0, 0, cw, ch);
// --- Dialog box ---
const dialogW = Math.min(cw * 0.88, 420);
const dialogH = Math.min(ch * 0.78, 320);
const dialogX = cx - dialogW / 2;
const dialogY = cy - dialogH / 2;
// Shadow
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
this._roundRect(ctx, dialogX + 4, dialogY + 4, dialogW, dialogH, 12);
ctx.fill();
// Background
ctx.fillStyle = DIALOG_BG;
this._roundRect(ctx, dialogX, dialogY, dialogW, dialogH, 12);
ctx.fill();
// Border
ctx.strokeStyle = DIALOG_BORDER;
ctx.lineWidth = 1.5;
this._roundRect(ctx, dialogX, dialogY, dialogW, dialogH, 12);
ctx.stroke();
// --- Shield icon (Canvas path) ---
const iconY = dialogY + 32;
const sw = 22; // shield width
const sh = 26; // shield height
const sx = cx - sw / 2;
const sy = iconY - sh / 2;
// Shield body
ctx.fillStyle = DIALOG_BORDER;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + sw, sy);
ctx.lineTo(sx + sw, sy + sh * 0.55);
ctx.quadraticCurveTo(sx + sw, sy + sh * 0.85, cx, sy + sh);
ctx.quadraticCurveTo(sx, sy + sh * 0.85, sx, sy + sh * 0.55);
ctx.closePath();
ctx.fill();
// Checkmark inside shield
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(cx - 6, iconY - 1);
ctx.lineTo(cx - 2, iconY + 4);
ctx.lineTo(cx + 7, iconY - 5);
ctx.stroke();
// --- Title ---
const titleY = iconY + 30;
ctx.fillStyle = TITLE_COLOR;
ctx.font = 'bold 17px Arial';
ctx.fillText(t('privacy.title'), cx, titleY);
// --- Body text ---
const bodyY = titleY + 28;
const bodyW = dialogW - 50;
ctx.fillStyle = TEXT_COLOR;
ctx.font = '13px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const bodyLines = this._wrapText(ctx, t('privacy.body'), bodyW);
for (let i = 0; i < bodyLines.length; i++) {
ctx.fillText(bodyLines[i], dialogX + 25, bodyY + i * 20);
}
// --- Privacy policy link ---
const linkY = bodyY + bodyLines.length * 20 + 10;
const linkText = t('privacy.policyLink');
ctx.fillStyle = LINK_COLOR;
ctx.font = 'bold 13px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(linkText, cx, linkY);
// Store link hit area
const linkMetrics = ctx.measureText(linkText);
this._policyLink = {
x: cx - linkMetrics.width / 2 - 6,
y: linkY - 10,
w: linkMetrics.width + 12,
h: 20,
};
// Underline the link
ctx.strokeStyle = LINK_COLOR;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - linkMetrics.width / 2, linkY + 7);
ctx.lineTo(cx + linkMetrics.width / 2, linkY + 7);
ctx.stroke();
// --- Buttons ---
const btnAreaY = linkY + 30;
const btnW = Math.min((dialogW - 60) / 2, 150);
const btnH = 38;
const btnGap = 16;
const agreeX = cx - btnW - btnGap / 2;
const declineX = cx + btnGap / 2;
// Agree button
ctx.fillStyle = AGREE_BG;
this._roundRect(ctx, agreeX, btnAreaY, btnW, btnH, 8);
ctx.fill();
ctx.fillStyle = AGREE_TEXT;
ctx.font = 'bold 15px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('privacy.agree'), agreeX + btnW / 2, btnAreaY + btnH / 2);
this._agreeBtn = { x: agreeX, y: btnAreaY, w: btnW, h: btnH };
// Decline button
ctx.fillStyle = DECLINE_BG;
this._roundRect(ctx, declineX, btnAreaY, btnW, btnH, 8);
ctx.fill();
ctx.strokeStyle = DECLINE_BORDER;
ctx.lineWidth = 1;
this._roundRect(ctx, declineX, btnAreaY, btnW, btnH, 8);
ctx.stroke();
ctx.fillStyle = DECLINE_TEXT;
ctx.font = '14px Arial';
ctx.fillText(t('privacy.decline'), declineX + btnW / 2, btnAreaY + btnH / 2);
this._declineBtn = { x: declineX, y: btnAreaY, w: btnW, h: btnH };
// --- Footer hint ---
const footerY = btnAreaY + btnH + 16;
ctx.fillStyle = '#666666';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('privacy.footer'), cx, footerY);
}
// ============================================================
// Private
// ============================================================
/**
* Resolve the privacy authorization request.
* @param {'agree'|'disagree'} action
*/
_resolve(action) {
if (!this._resolveCallback) {
console.warn('[PrivacyPopup] No resolve callback to call');
this.hide();
return;
}
try {
if (action === 'agree') {
console.log('[PrivacyPopup] Resolving with agree');
this._resolveCallback({ event: 'agree' });
} else {
console.log('[PrivacyPopup] Resolving with disagree');
// Disagree still needs to resolve, but the API will fail gracefully
this._resolveCallback({ event: 'disagree' });
}
} catch (e) {
console.error('[PrivacyPopup] resolve() threw:', e);
}
this.hide();
}
/**
* Open the privacy policy document.
* Uses wx.openPrivacyContract if available.
*/
_openPrivacyPolicy() {
try {
if (typeof wx !== 'undefined' && typeof wx.openPrivacyContract === 'function') {
wx.openPrivacyContract({
success: () => console.log('[PrivacyPopup] Privacy contract opened'),
fail: (err) => console.warn('[PrivacyPopup] Failed to open privacy contract:', err),
});
} else {
console.warn('[PrivacyPopup] wx.openPrivacyContract is not available');
}
} catch (e) {
console.warn('[PrivacyPopup] Error opening privacy contract:', e);
}
}
/**
* Simple hit-test for rectangular area.
*/
_hitTest(tx, ty, rect) {
return tx >= rect.x && tx <= rect.x + rect.w &&
ty >= rect.y && ty <= rect.y + rect.h;
}
/**
* Wrap text into lines that fit within maxWidth.
*/
_wrapText(ctx, text, maxWidth) {
const lines = [];
const paragraphs = text.split('\n');
for (const para of paragraphs) {
let line = '';
for (let i = 0; i < para.length; i++) {
const testLine = line + para[i];
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line.length > 0) {
lines.push(line);
line = para[i];
} else {
line = testLine;
}
}
if (line) lines.push(line);
}
return lines;
}
/**
* Draw a rounded rectangle path.
*/
_roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
}
module.exports = PrivacyPopup;
+37 -20
View File
@@ -229,6 +229,7 @@ class PlayerInfo {
this.ws = ws; this.ws = ws;
this.playerId = playerId; this.playerId = playerId;
this.nickname = ''; this.nickname = '';
this.avatarUrl = '';
this.roomId = null; this.roomId = null;
this.teamId = null; this.teamId = null;
this.isAlive = true; this.isAlive = true;
@@ -248,7 +249,7 @@ class TeamRoom {
* @param {string} [battleMode='3v3'] - Battle mode ('1v1' or '3v3') * @param {string} [battleMode='3v3'] - Battle mode ('1v1' or '3v3')
* @param {string} [leaderNickname=''] - Display nickname of the leader * @param {string} [leaderNickname=''] - Display nickname of the leader
*/ */
constructor(id, leaderWs, leaderId, battleMode = '3v3', leaderNickname = '') { constructor(id, leaderWs, leaderId, battleMode = '3v3', leaderNickname = '', leaderAvatarUrl = '') {
this.id = id; this.id = id;
this.state = 'forming'; // forming | matching | playing | finished this.state = 'forming'; // forming | matching | playing | finished
this.createdAt = Date.now(); this.createdAt = Date.now();
@@ -259,8 +260,8 @@ class TeamRoom {
this.teamSize = config.teamSize; this.teamSize = config.teamSize;
this.fillWithBotsEnabled = config.fillWithBots; this.fillWithBotsEnabled = config.fillWithBots;
// Team A members: { ws, playerId, nickname, ready, isBot, disconnectedAt } // Team A members: { ws, playerId, nickname, avatarUrl, ready, isBot, disconnectedAt }
this.teamA = [{ ws: leaderWs, playerId: leaderId, nickname: leaderNickname || '', ready: true, isBot: false, disconnectedAt: null }]; this.teamA = [{ ws: leaderWs, playerId: leaderId, nickname: leaderNickname || '', avatarUrl: leaderAvatarUrl || '', ready: true, isBot: false, disconnectedAt: null }];
// Team B members // Team B members
this.teamB = []; this.teamB = [];
this.leaderId = leaderId; this.leaderId = leaderId;
@@ -328,19 +329,17 @@ class TeamRoom {
} }
/** Add a player to team A */ /** Add a player to team A */
addToTeamA(ws, playerId, nickname = '') { addToTeamA(ws, playerId, nickname = '', avatarUrl = '') {
if (this.isTeamAFull()) return false; if (this.teamA.length >= this.teamSize) return false;
this.teamA.push({ ws, playerId, nickname, ready: false, isBot: false, disconnectedAt: null }); this.teamA.push({ ws, playerId, nickname, avatarUrl, ready: false, isBot: false, disconnectedAt: null });
return true; return true;
} }
/** Add a player to team B */ addToTeamB(ws, playerId, nickname = '', avatarUrl = '') {
addToTeamB(ws, playerId, nickname = '') {
if (this.teamB.length >= this.teamSize) return false; if (this.teamB.length >= this.teamSize) return false;
this.teamB.push({ ws, playerId, nickname, ready: false, isBot: false, disconnectedAt: null }); this.teamB.push({ ws, playerId, nickname, avatarUrl, ready: false, isBot: false, disconnectedAt: null });
return true; return true;
} }
/** Remove a player from the team room */ /** Remove a player from the team room */
removePlayer(playerId) { removePlayer(playerId) {
this.teamA = this.teamA.filter(m => m.playerId !== playerId); this.teamA = this.teamA.filter(m => m.playerId !== playerId);
@@ -356,6 +355,7 @@ class TeamRoom {
ws: null, ws: null,
playerId: `bot_a_${botCounter}_${this.id}`, playerId: `bot_a_${botCounter}_${this.id}`,
nickname: '', nickname: '',
avatarUrl: '',
ready: true, ready: true,
isBot: true, isBot: true,
disconnectedAt: null, disconnectedAt: null,
@@ -367,6 +367,7 @@ class TeamRoom {
ws: null, ws: null,
playerId: `bot_b_${botCounter}_${this.id}`, playerId: `bot_b_${botCounter}_${this.id}`,
nickname: '', nickname: '',
avatarUrl: '',
ready: true, ready: true,
isBot: true, isBot: true,
disconnectedAt: null, disconnectedAt: null,
@@ -433,6 +434,7 @@ class TeamRoom {
teamA: this.teamA.map(m => ({ teamA: this.teamA.map(m => ({
playerId: m.playerId, playerId: m.playerId,
nickname: m.nickname || '', nickname: m.nickname || '',
avatarUrl: m.avatarUrl || '',
ready: m.ready, ready: m.ready,
isBot: m.isBot, isBot: m.isBot,
isLeader: m.playerId === this.leaderId, isLeader: m.playerId === this.leaderId,
@@ -441,6 +443,7 @@ class TeamRoom {
teamB: this.teamB.map(m => ({ teamB: this.teamB.map(m => ({
playerId: m.playerId, playerId: m.playerId,
nickname: m.nickname || '', nickname: m.nickname || '',
avatarUrl: m.avatarUrl || '',
ready: m.ready, ready: m.ready,
isBot: m.isBot, isBot: m.isBot,
connected: m.isBot || (m.ws && m.ws.readyState === 1), connected: m.isBot || (m.ws && m.ws.readyState === 1),
@@ -548,7 +551,7 @@ function handleCreateRoom(ws, data) {
const roomCode = generateRoomCode(); const roomCode = generateRoomCode();
// Create a TeamRoom in 1v1 mode instead of a legacy Room // Create a TeamRoom in 1v1 mode instead of a legacy Room
const teamRoom = new TeamRoom(roomCode, ws, playerInfo.playerId, '1v1', playerInfo.nickname || ''); const teamRoom = new TeamRoom(roomCode, ws, playerInfo.playerId, '1v1', playerInfo.nickname || '', playerInfo.avatarUrl || '');
teamRooms.set(roomCode, teamRoom); teamRooms.set(roomCode, teamRoom);
playerInfo.teamId = roomCode; playerInfo.teamId = roomCode;
@@ -597,7 +600,7 @@ function handleJoinRoom(ws, data) {
} }
// Join as team B // Join as team B
teamRoom.addToTeamB(ws, playerInfo.playerId, playerInfo.nickname || ''); teamRoom.addToTeamB(ws, playerInfo.playerId, playerInfo.nickname || '', playerInfo.avatarUrl || '');
playerInfo.teamId = roomId; playerInfo.teamId = roomId;
console.log(`[Server] Player ${playerInfo.playerId} joined 1v1 room ${roomId} (TeamRoom)`); console.log(`[Server] Player ${playerInfo.playerId} joined 1v1 room ${roomId} (TeamRoom)`);
@@ -667,7 +670,7 @@ function handleCreateTeam(ws, data) {
} }
const teamId = generateTeamId(); const teamId = generateTeamId();
const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || ''); const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || '', playerInfo.avatarUrl || '');
teamRooms.set(teamId, teamRoom); teamRooms.set(teamId, teamRoom);
playerInfo.teamId = teamId; playerInfo.teamId = teamId;
@@ -691,7 +694,7 @@ function handleJoinTeam(ws, data) {
// Team was cleaned up (e.g. leader disconnected during dev-tool reload). // Team was cleaned up (e.g. leader disconnected during dev-tool reload).
// Auto-create a new room with the same ID so the invite link still works. // Auto-create a new room with the same ID so the invite link still works.
console.log(`[Server] Team ${teamId} not found, auto-creating for ${playerInfo.playerId}`); console.log(`[Server] Team ${teamId} not found, auto-creating for ${playerInfo.playerId}`);
teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || ''); teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || '', playerInfo.avatarUrl || '');
teamRooms.set(teamId, teamRoom); teamRooms.set(teamId, teamRoom);
playerInfo.teamId = teamId; playerInfo.teamId = teamId;
sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState());
@@ -713,7 +716,7 @@ function handleJoinTeam(ws, data) {
handleLeaveTeam(ws, {}); handleLeaveTeam(ws, {});
} }
teamRoom.addToTeamA(ws, playerInfo.playerId, playerInfo.nickname || ''); teamRoom.addToTeamA(ws, playerInfo.playerId, playerInfo.nickname || '', playerInfo.avatarUrl || '');
playerInfo.teamId = teamId; playerInfo.teamId = teamId;
console.log(`[Server] Player ${playerInfo.playerId} joined team ${teamId}`); console.log(`[Server] Player ${playerInfo.playerId} joined team ${teamId}`);
@@ -914,7 +917,7 @@ function handleSoloMatch(ws, data) {
// Create a solo team room for this player // Create a solo team room for this player
const teamId = generateTeamId(); const teamId = generateTeamId();
const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || ''); const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, '3v3', playerInfo.nickname || '', playerInfo.avatarUrl || '');
teamRoom.state = 'matching'; teamRoom.state = 'matching';
teamRoom.matchStartTime = Date.now(); teamRoom.matchStartTime = Date.now();
teamRooms.set(teamId, teamRoom); teamRooms.set(teamId, teamRoom);
@@ -987,7 +990,7 @@ function tryMatchTeams() {
// Merge team B members into team A room as opponents // Merge team B members into team A room as opponents
for (const member of teamB_room.teamA) { for (const member of teamB_room.teamA) {
teamA_room.addToTeamB(member.ws, member.playerId, member.nickname || ''); teamA_room.addToTeamB(member.ws, member.playerId, member.nickname || '', member.avatarUrl || '');
if (member.ws) { if (member.ws) {
const info = players.get(member.ws); const info = players.get(member.ws);
if (info) info.teamId = teamA_room.id; if (info) info.teamId = teamA_room.id;
@@ -1052,9 +1055,9 @@ function tryMatchTeams() {
// Alternate: odd index -> team A, even index -> team B // Alternate: odd index -> team A, even index -> team B
if (i % 2 === 1 && !gameRoom.isTeamAFull()) { if (i % 2 === 1 && !gameRoom.isTeamAFull()) {
gameRoom.addToTeamA(ws, info.playerId, info.nickname || ''); gameRoom.addToTeamA(ws, info.playerId, info.nickname || '', info.avatarUrl || '');
} else { } else {
gameRoom.addToTeamB(ws, info.playerId, info.nickname || ''); gameRoom.addToTeamB(ws, info.playerId, info.nickname || '', info.avatarUrl || '');
} }
} }
@@ -1405,7 +1408,7 @@ function handleMessage(ws, rawData) {
return; return;
} }
const { type, data, playerId, nickname } = msg; const { type, data, playerId, nickname, avatarUrl } = msg;
// Update player info // Update player info
const playerInfo = players.get(ws); const playerInfo = players.get(ws);
@@ -1434,6 +1437,20 @@ function handleMessage(ws, rawData) {
} }
} }
} }
// Refresh avatarUrl on every message (it may be granted mid-session).
if (typeof avatarUrl === 'string' && avatarUrl && playerInfo.avatarUrl !== avatarUrl) {
playerInfo.avatarUrl = avatarUrl;
if (playerInfo.teamId) {
const tr = teamRooms.get(playerInfo.teamId);
if (tr) {
const member = tr.getMemberByWs(ws);
if (member && member.avatarUrl !== avatarUrl) {
member.avatarUrl = avatarUrl;
tr.broadcast(NET_MSG.TEAM_STATE, tr.getTeamState());
}
}
}
}
} }
switch (type) { switch (type) {