From 935913918646658196acb384835d715157fca1d0 Mon Sep 17 00:00:00 2001 From: jakciehan Date: Thu, 14 May 2026 22:41:32 +0800 Subject: [PATCH] feat: use wx.createUserInfoButton to get weixin's avarta --- code.fortify.config.json | 87 ++++++++ game.js | 53 +++++ game.json | 1 + js/base/GameGlobal.js | 1 - js/i18n/en.js | 25 +-- js/i18n/zh.js | 25 +-- js/managers/NetworkManager.js | 6 +- js/managers/PlayerProfile.js | 317 ++++++++++++++++++++++----- js/scenes/MenuScene.js | 135 +++++++++++- js/scenes/SettingsScene.js | 39 ---- js/scenes/TeamRoomScene.js | 230 +++++++++++++++++--- js/ui/PrivacyPopup.js | 389 ++++++++++++++++++++++++++++++++++ server/index.js | 57 +++-- 13 files changed, 1181 insertions(+), 184 deletions(-) create mode 100644 code.fortify.config.json create mode 100644 js/ui/PrivacyPopup.js diff --git a/code.fortify.config.json b/code.fortify.config.json new file mode 100644 index 0000000..d691178 --- /dev/null +++ b/code.fortify.config.json @@ -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": [] + } +} \ No newline at end of file diff --git a/game.js b/game.js index f1d3769..800beac 100644 --- a/game.js +++ b/game.js @@ -31,6 +31,7 @@ const ContentSecurityManager = require('./js/managers/ContentSecurityManager'); const BuffManager = require('./js/managers/BuffManager'); const SkinManager = require('./js/managers/SkinManager'); const PlayerProfile = require('./js/managers/PlayerProfile'); +const PrivacyPopup = require('./js/ui/PrivacyPopup'); const EventBus = require('./js/base/EventBus'); const { SCREEN_WIDTH, @@ -81,6 +82,7 @@ const contentSecurityManager = new ContentSecurityManager(); const buffManager = new BuffManager(); const skinManager = new SkinManager(); const playerProfile = new PlayerProfile(); +const privacyPopup = new PrivacyPopup(); GameGlobal.adManager = adManager; GameGlobal.shareManager = shareManager; GameGlobal.currencyManager = currencyManager; @@ -90,6 +92,7 @@ GameGlobal.contentSecurityManager = contentSecurityManager; GameGlobal.buffManager = buffManager; GameGlobal.skinManager = skinManager; GameGlobal.playerProfile = playerProfile; +GameGlobal.privacyPopup = privacyPopup; // ============================================================ // Game State @@ -101,15 +104,60 @@ let lastTimestamp = 0; // Touch Event Forwarding // ============================================================ wx.onTouchStart((e) => { + // Privacy popup consumes all touches while active + if (privacyPopup.active) { + privacyPopup.handleTouch('touchstart', e); + return; + } sceneManager.handleTouch('touchstart', e); }); wx.onTouchMove((e) => { + if (privacyPopup.active) return; sceneManager.handleTouch('touchmove', e); }); wx.onTouchEnd((e) => { + if (privacyPopup.active) { + privacyPopup.handleTouch('touchend', e); + return; + } 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 // ============================================================ @@ -255,6 +303,11 @@ function gameLoop(timestamp) { // Update & render current scene sceneManager.update(dt); sceneManager.render(ctx); + + // Privacy popup renders on top of everything + if (privacyPopup.active) { + privacyPopup.render(ctx); + } } // ============================================================ diff --git a/game.json b/game.json index c94fc29..6b8d0fa 100644 --- a/game.json +++ b/game.json @@ -1,6 +1,7 @@ { "deviceOrientation": "landscape", "showStatusBar": false, + "__usePrivacyCheck__": true, "networkTimeout": { "request": 10000, "connectSocket": 10000, diff --git a/js/base/GameGlobal.js b/js/base/GameGlobal.js index bd8e06c..c4aab8e 100644 --- a/js/base/GameGlobal.js +++ b/js/base/GameGlobal.js @@ -172,7 +172,6 @@ const SCENE = { TEAM_ROOM: 'team_room', TEAM_GAME: 'team_game', TEAM_RESULT: 'team_result', - PROFILE: 'profile', CHAT_ROOM: 'chat_room', }; diff --git a/js/i18n/en.js b/js/i18n/en.js index aa0edb1..8a069c8 100644 --- a/js/i18n/en.js +++ b/js/i18n/en.js @@ -21,7 +21,6 @@ module.exports = { // Menu Scene // ============================================================ 'menu.title': 'Tank Adventure', - 'profile.welcome': 'Welcome {name}!', 'menu.subtitle': 'TANK WAR', 'menu.classic': 'Classic', 'menu.endless': 'Endless', @@ -31,8 +30,8 @@ module.exports = { 'menu.skin': 'Skins', 'menu.ranking': 'Ranking', 'menu.settings': 'Settings', - 'menu.profile': 'Profile', 'menu.chat': 'Chat', + 'menu.tapToAuth': 'Tap to authorize', // ============================================================ // Room Scene (PVP) @@ -209,7 +208,6 @@ module.exports = { 'settings.sound': 'Sound', 'settings.music': 'Music', 'settings.vibration': 'Vibration', - 'settings.profile': 'Profile', // ============================================================ // 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 // ============================================================ @@ -333,4 +320,14 @@ module.exports = { 'skin.owned': 'Owned', 'skin.equipSuccess': '✓ Skin equipped!', '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', }; diff --git a/js/i18n/zh.js b/js/i18n/zh.js index b1a2952..061c9ac 100644 --- a/js/i18n/zh.js +++ b/js/i18n/zh.js @@ -21,7 +21,6 @@ module.exports = { // Menu Scene // ============================================================ 'menu.title': '坦克探险', - 'profile.welcome': '欢迎 {name}!', 'menu.subtitle': '经典坦克对战', 'menu.classic': '经典模式', 'menu.endless': '无尽模式', @@ -31,8 +30,8 @@ module.exports = { 'menu.skin': '皮肤', 'menu.ranking': '排行榜', 'menu.settings': '设置', - 'menu.profile': '个人资料', 'menu.chat': '聊天室', + 'menu.tapToAuth': '点击授权头像', // ============================================================ // Room Scene (PVP) @@ -209,7 +208,6 @@ module.exports = { 'settings.sound': '音效', 'settings.music': '音乐', 'settings.vibration': '振动', - 'settings.profile': '个人资料', // ============================================================ // 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 // ============================================================ @@ -333,4 +320,14 @@ module.exports = { 'skin.owned': '已拥有', 'skin.equipSuccess': '✓ 已装备!', 'skin.purchaseSuccess': '✓ 已解锁!', + + // ============================================================ + // Privacy Authorization + // ============================================================ + 'privacy.title': '用户隐私保护提示', + 'privacy.body': '为了在游戏中展示你的昵称和头像,我们需要使用你的微信基本信息。\n\n你可以在《隐私政策》中了解具体的信息使用方式和范围。\n\n如果你不同意,将以匿名身份参与游戏。', + 'privacy.policyLink': '👉 查看《隐私政策》', + 'privacy.agree': '同意', + 'privacy.decline': '不同意', + 'privacy.footer': '同意后仅用于游戏内展示,不会向第三方提供', }; diff --git a/js/managers/NetworkManager.js b/js/managers/NetworkManager.js index d9d00e7..7d06a70 100644 --- a/js/managers/NetworkManager.js +++ b/js/managers/NetworkManager.js @@ -196,17 +196,19 @@ class NetworkManager { return; } - // Always include the player's current nickname (if any) so the server - // can propagate it to other clients. Falls back silently when the + // Always include the player's current nickname and avatarUrl so the server + // can propagate them to other clients. Falls back silently when the // profile is not yet available. const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null; const nickname = (profile && profile.nickname) ? profile.nickname : ''; + const avatarUrl = (profile && profile.avatarUrl) ? profile.avatarUrl : ''; const message = JSON.stringify({ type, data, playerId: this._playerId, nickname, + avatarUrl, roomId: this._roomId, timestamp: Date.now(), }); diff --git a/js/managers/PlayerProfile.js b/js/managers/PlayerProfile.js index 3d52e04..934eafe 100644 --- a/js/managers/PlayerProfile.js +++ b/js/managers/PlayerProfile.js @@ -97,48 +97,67 @@ class PlayerProfile { } /** - * Try to fetch the anonymous nickname silently. - * In modern WeChat this resolves with a placeholder user ("微信用户") but - * without popping any authorization UI. Safe to call on MenuScene enter. - * @returns {Promise} true if something was set. + * 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() { + fetchSilent(layout) { return new Promise((resolve) => { - if (this._granted || this._silentFetched) { - resolve(!!this._nickname); + if (this._granted) { + resolve(true); return; } - this._silentFetched = true; try { - if (typeof wx === 'undefined' || typeof wx.getUserInfo !== 'function') { + if (typeof wx === 'undefined') { 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(); + + // ── 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); } - resolve(true); - return; - } - resolve(false); - }, - fail: () => resolve(false), - }); + }, + 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); @@ -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, * `wx.getUserInfo` / `UserInfoButton` return this string for any user who @@ -180,6 +355,7 @@ class PlayerProfile { this._avatarUrl = u.avatarUrl || ''; this._granted = true; this._saveToCache(); + this._emitProfileUpdate(); console.log('[PlayerProfile] Nickname granted:', this._nickname); return true; } @@ -203,28 +379,41 @@ class PlayerProfile { 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.'); + + 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); - 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); - }, - }); + }, + }); + }; + + // 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); @@ -288,6 +477,28 @@ class PlayerProfile { // 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; diff --git a/js/scenes/MenuScene.js b/js/scenes/MenuScene.js index 2e4676e..a8eb676 100644 --- a/js/scenes/MenuScene.js +++ b/js/scenes/MenuScene.js @@ -97,6 +97,24 @@ const MenuScene = { enter() { this._pressedIndex = -1; 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 // network messages (CREATE_TEAM, SOLO_MATCH, ...) can carry it. @@ -120,6 +138,18 @@ const MenuScene = { exit() { 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) { @@ -151,6 +181,55 @@ const MenuScene = { ctx.fillStyle = accentGrad; 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) ---- const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0; const goldText = `🪙 ${gold}`; @@ -469,19 +548,63 @@ const MenuScene = { // ============================================================ /** - * Kick off profile acquisition on menu enter. Since WeChat 2022-10 there - * is NO silent way to get the real nickname — we draw a canvas button and - * call `wx.getUserProfile` directly from its touchend handler. + * Load avatar image from URL for Canvas rendering. + * @param {string} url + * @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 */ _initPlayerProfile() { const profile = GameGlobal.playerProfile; if (!profile) return; - // Best-effort placeholder fetch (used only to pre-fill _nickname with - // "微信用户" on older devices; does not mark granted). + // If already granted (from a previous session's cache), no button needed + 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') { - 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); + }); } }, diff --git a/js/scenes/SettingsScene.js b/js/scenes/SettingsScene.js index 4ad0688..2694cda 100644 --- a/js/scenes/SettingsScene.js +++ b/js/scenes/SettingsScene.js @@ -85,10 +85,6 @@ const SettingsScene = { 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 this._renderBackButton(ctx, cx, backCenterY); }, @@ -138,34 +134,6 @@ const SettingsScene = { 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) { const w = SCREEN_WIDTH * 0.4; 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 (key === 'back') { 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)) { this._settings[key] = !this._settings[key]; // Notify audio system diff --git a/js/scenes/TeamRoomScene.js b/js/scenes/TeamRoomScene.js index 4aa3598..9c23fc2 100644 --- a/js/scenes/TeamRoomScene.js +++ b/js/scenes/TeamRoomScene.js @@ -70,6 +70,7 @@ const TeamRoomScene = { _cancelMatchBtnRect: null, _slotRects: [], _kickBtnRects: [], + _avatarImages: {}, enter(params) { this._state = TEAM_STATE.MODE_SELECT; @@ -82,10 +83,13 @@ const TeamRoomScene = { this._networkManager = GameGlobal.networkManager; this._myPlayerId = this._networkManager ? this._networkManager.playerId : 'player_' + Date.now(); this._isLeader = false; + this._avatarImages = {}; this._buildLayout(); // Setup network events BEFORE auto-join so listeners are ready this._setupNetworkEvents(); + // Listen for profile updates (avatar/nickname granted mid-session) + this._setupProfileListener(); // If entering with a teamId (from invite card), auto-join if (params && params.teamId) { @@ -112,6 +116,7 @@ const TeamRoomScene = { exit() { this._cleanupNetworkEvents(); + this._cleanupProfileListener(); // Reset share content when leaving team room const shareManager = GameGlobal.shareManager; if (shareManager) { @@ -209,6 +214,24 @@ const TeamRoomScene = { const unsubs = []; 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._isLeader = data.leaderId === this._myPlayerId; @@ -271,6 +294,43 @@ const TeamRoomScene = { 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) { this._animTimer += dt; @@ -392,40 +452,18 @@ const TeamRoomScene = { ctx.stroke(); if (member) { - // Avatar placeholder (circle) - const avatarR = Math.min(rect.w, rect.h) * 0.22; + // Avatar centered in the slot + const avatarR = Math.min(rect.w, rect.h) * 0.32; 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'; - 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 + // 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); } @@ -477,19 +515,34 @@ const TeamRoomScene = { const member = members[i]; ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a'; - ctx.strokeStyle = '#0f3460'; - ctx.lineWidth = 1; + ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333'; + ctx.lineWidth = member && member.isLeader ? 3 : 1; this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8); ctx.fill(); ctx.stroke(); if (member) { - ctx.fillStyle = '#FFFFFF'; - ctx.font = '10px Arial'; + // Avatar centered in the slot + 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.textBaseline = 'middle'; - const name = this._getDisplayName(member); - ctx.fillText(name, rect.x + rect.w / 2, rect.y + rect.h / 2); + ctx.fillText('+', 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; }, + /** + * 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. * Uses real WeChat nickname when available, otherwise a stable fallback. diff --git a/js/ui/PrivacyPopup.js b/js/ui/PrivacyPopup.js new file mode 100644 index 0000000..642362b --- /dev/null +++ b/js/ui/PrivacyPopup.js @@ -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; diff --git a/server/index.js b/server/index.js index c90fe56..8219f51 100644 --- a/server/index.js +++ b/server/index.js @@ -229,6 +229,7 @@ class PlayerInfo { this.ws = ws; this.playerId = playerId; this.nickname = ''; + this.avatarUrl = ''; this.roomId = null; this.teamId = null; this.isAlive = true; @@ -248,7 +249,7 @@ class TeamRoom { * @param {string} [battleMode='3v3'] - Battle mode ('1v1' or '3v3') * @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.state = 'forming'; // forming | matching | playing | finished this.createdAt = Date.now(); @@ -259,8 +260,8 @@ class TeamRoom { this.teamSize = config.teamSize; this.fillWithBotsEnabled = config.fillWithBots; - // Team A members: { ws, playerId, nickname, ready, isBot, disconnectedAt } - this.teamA = [{ ws: leaderWs, playerId: leaderId, nickname: leaderNickname || '', ready: true, isBot: false, disconnectedAt: null }]; + // Team A members: { ws, playerId, nickname, avatarUrl, ready, isBot, disconnectedAt } + this.teamA = [{ ws: leaderWs, playerId: leaderId, nickname: leaderNickname || '', avatarUrl: leaderAvatarUrl || '', ready: true, isBot: false, disconnectedAt: null }]; // Team B members this.teamB = []; this.leaderId = leaderId; @@ -328,19 +329,17 @@ class TeamRoom { } /** Add a player to team A */ - addToTeamA(ws, playerId, nickname = '') { - if (this.isTeamAFull()) return false; - this.teamA.push({ ws, playerId, nickname, ready: false, isBot: false, disconnectedAt: null }); + addToTeamA(ws, playerId, nickname = '', avatarUrl = '') { + if (this.teamA.length >= this.teamSize) return false; + this.teamA.push({ ws, playerId, nickname, avatarUrl, ready: false, isBot: false, disconnectedAt: null }); return true; } - /** Add a player to team B */ - addToTeamB(ws, playerId, nickname = '') { + addToTeamB(ws, playerId, nickname = '', avatarUrl = '') { 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; } - /** Remove a player from the team room */ removePlayer(playerId) { this.teamA = this.teamA.filter(m => m.playerId !== playerId); @@ -356,6 +355,7 @@ class TeamRoom { ws: null, playerId: `bot_a_${botCounter}_${this.id}`, nickname: '', + avatarUrl: '', ready: true, isBot: true, disconnectedAt: null, @@ -367,6 +367,7 @@ class TeamRoom { ws: null, playerId: `bot_b_${botCounter}_${this.id}`, nickname: '', + avatarUrl: '', ready: true, isBot: true, disconnectedAt: null, @@ -433,6 +434,7 @@ class TeamRoom { teamA: this.teamA.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', + avatarUrl: m.avatarUrl || '', ready: m.ready, isBot: m.isBot, isLeader: m.playerId === this.leaderId, @@ -441,6 +443,7 @@ class TeamRoom { teamB: this.teamB.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', + avatarUrl: m.avatarUrl || '', ready: m.ready, isBot: m.isBot, connected: m.isBot || (m.ws && m.ws.readyState === 1), @@ -548,7 +551,7 @@ function handleCreateRoom(ws, data) { const roomCode = generateRoomCode(); // 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); playerInfo.teamId = roomCode; @@ -597,7 +600,7 @@ function handleJoinRoom(ws, data) { } // Join as team B - teamRoom.addToTeamB(ws, playerInfo.playerId, playerInfo.nickname || ''); + teamRoom.addToTeamB(ws, playerInfo.playerId, playerInfo.nickname || '', playerInfo.avatarUrl || ''); playerInfo.teamId = roomId; console.log(`[Server] Player ${playerInfo.playerId} joined 1v1 room ${roomId} (TeamRoom)`); @@ -667,7 +670,7 @@ function handleCreateTeam(ws, data) { } 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); playerInfo.teamId = teamId; @@ -691,7 +694,7 @@ function handleJoinTeam(ws, data) { // 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. 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); playerInfo.teamId = teamId; sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); @@ -713,7 +716,7 @@ function handleJoinTeam(ws, data) { handleLeaveTeam(ws, {}); } - teamRoom.addToTeamA(ws, playerInfo.playerId, playerInfo.nickname || ''); + teamRoom.addToTeamA(ws, playerInfo.playerId, playerInfo.nickname || '', playerInfo.avatarUrl || ''); playerInfo.teamId = 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 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.matchStartTime = Date.now(); teamRooms.set(teamId, teamRoom); @@ -987,7 +990,7 @@ function tryMatchTeams() { // Merge team B members into team A room as opponents 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) { const info = players.get(member.ws); if (info) info.teamId = teamA_room.id; @@ -1052,9 +1055,9 @@ function tryMatchTeams() { // Alternate: odd index -> team A, even index -> team B if (i % 2 === 1 && !gameRoom.isTeamAFull()) { - gameRoom.addToTeamA(ws, info.playerId, info.nickname || ''); + gameRoom.addToTeamA(ws, info.playerId, info.nickname || '', info.avatarUrl || ''); } 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; } - const { type, data, playerId, nickname } = msg; + const { type, data, playerId, nickname, avatarUrl } = msg; // Update player info 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) {