/** * ProfileScene.js * Player profile editing scene. * Supports nickname, signature, description editing and avatar upload. * All user-generated content is checked against content security before saving. */ const { SCREEN_WIDTH, SCREEN_HEIGHT, COLORS, SCENE, } = require('../base/GameGlobal'); const { t } = require('../i18n/I18n'); const ContentSecurityManager = require('../managers/ContentSecurityManager'); // ============================================================ // Layout Constants // ============================================================ const CENTER_X = SCREEN_WIDTH / 2; const INPUT_WIDTH = Math.min(SCREEN_WIDTH * 0.7, 280); const INPUT_HEIGHT = 36; const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.5, 200); const BTN_HEIGHT = 36; // Field constraints const NICKNAME_MIN = 2; const NICKNAME_MAX = 20; const SIGNATURE_MAX = 50; const DESCRIPTION_MAX = 200; // Profile field IDs const FIELD = { NICKNAME: 'nickname', SIGNATURE: 'signature', DESCRIPTION: 'description', AVATAR: 'avatar', }; // ============================================================ // Profile Scene // ============================================================ const ProfileScene = { // Current editing state _editingField: null, // Which field is being edited _inputText: '', // Current input text _errorMessage: '', // Error message to display _isSubmitting: false, // Whether a submission is in progress _localViolation: false, // Whether local check found violation // User profile data _profile: { nickname: '', signature: '', description: '', avatarUrl: '', }, // Open ID for API calls _openid: '', // Button rects for touch handling _backBtnRect: null, _nicknameRect: null, _signatureRect: null, _descriptionRect: null, _avatarRect: null, _saveBtnRect: null, enter() { this._editingField = null; this._inputText = ''; this._errorMessage = ''; this._isSubmitting = false; this._localViolation = false; // Load profile from storage this._loadProfile(); // Get openid try { this._openid = wx.getStorageSync('player_openid') || ''; } catch (e) { this._openid = ''; } // Calculate button positions this._backBtnRect = { x: 10, y: 10, w: 60, h: 30 }; this._avatarRect = { x: CENTER_X - 40, y: 70, w: 80, h: 80, }; this._nicknameRect = { x: CENTER_X - INPUT_WIDTH / 2, y: 175, w: INPUT_WIDTH, h: INPUT_HEIGHT, }; this._signatureRect = { x: CENTER_X - INPUT_WIDTH / 2, y: 240, w: INPUT_WIDTH, h: INPUT_HEIGHT, }; this._descriptionRect = { x: CENTER_X - INPUT_WIDTH / 2, y: 305, w: INPUT_WIDTH, h: 80, }; this._saveBtnRect = { x: CENTER_X - BTN_WIDTH / 2, y: SCREEN_HEIGHT - 100, w: BTN_WIDTH, h: BTN_HEIGHT, }; }, exit() { // Save profile to storage this._saveProfile(); }, update(dt) {}, render(ctx) { // Background ctx.fillStyle = COLORS.MENU_BG; ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); const cx = CENTER_X; // Back button this._renderBackButton(ctx); // Title ctx.fillStyle = COLORS.MENU_TITLE; ctx.font = 'bold 24px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t('profile.title'), cx, 50); // Avatar this._renderAvatar(ctx); // Nickname field this._renderField(ctx, this._nicknameRect, t('profile.nickname'), this._profile.nickname, FIELD.NICKNAME); // Signature field this._renderField(ctx, this._signatureRect, t('profile.signature'), this._profile.signature, FIELD.SIGNATURE); // Description field this._renderDescriptionField(ctx); // Error message if (this._errorMessage) { ctx.fillStyle = '#FF4444'; ctx.font = '12px Arial'; ctx.textAlign = 'center'; ctx.fillText(this._errorMessage, cx, SCREEN_HEIGHT - 140); } // Save button this._renderSaveButton(ctx); // Editing overlay if (this._editingField) { this._renderEditOverlay(ctx); } }, handleTouch(type, e) { if (type !== 'touchstart') return; const touch = e.touches[0]; const x = touch.clientX; const y = touch.clientY; // If editing, handle save/cancel in overlay if (this._editingField) { this._handleEditOverlayTouch(x, y); return; } // Back button if (this._hitTest(x, y, this._backBtnRect)) { const sm = GameGlobal.sceneManager; sm.switchTo(SCENE.MENU); return; } // Avatar click if (this._hitTest(x, y, this._avatarRect)) { this._handleChangeAvatar(); return; } // Nickname click if (this._hitTest(x, y, this._nicknameRect)) { this._startEditing(FIELD.NICKNAME, this._profile.nickname); return; } // Signature click if (this._hitTest(x, y, this._signatureRect)) { this._startEditing(FIELD.SIGNATURE, this._profile.signature); return; } // Description click if (this._hitTest(x, y, this._descriptionRect)) { this._startEditing(FIELD.DESCRIPTION, this._profile.description); return; } }, // ============================================================ // Editing // ============================================================ _startEditing(field, currentValue) { const csm = GameGlobal.contentSecurityManager; if (!csm || !csm.isInitialized()) return; this._editingField = field; this._inputText = currentValue || ''; this._errorMessage = ''; this._localViolation = false; // Determine input constraints let maxLength = 200; if (field === FIELD.NICKNAME) maxLength = NICKNAME_MAX; if (field === FIELD.SIGNATURE) maxLength = SIGNATURE_MAX; if (field === FIELD.DESCRIPTION) maxLength = DESCRIPTION_MAX; // Show keyboard wx.showKeyboard({ defaultValue: this._inputText, maxLength, multiple: field === FIELD.DESCRIPTION, confirmHold: false, confirmType: 'done', }); // Listen for keyboard input this._onKeyboardInput = (res) => { this._inputText = res.value; // Real-time local sensitive word check if (this._inputText) { const localCheck = csm.checkLocalText(this._inputText); if (localCheck.hasViolation) { this._localViolation = true; this._errorMessage = '内容包含违规信息,请修改'; } else { this._localViolation = false; this._errorMessage = ''; } // Length validation if (field === FIELD.NICKNAME) { if (this._inputText.length < NICKNAME_MIN) { this._errorMessage = `昵称至少需要${NICKNAME_MIN}个字符`; this._localViolation = true; } else if (this._inputText.length > NICKNAME_MAX) { this._errorMessage = `昵称不能超过${NICKNAME_MAX}个字符`; this._localViolation = true; } } else if (field === FIELD.SIGNATURE && this._inputText.length > SIGNATURE_MAX) { this._errorMessage = `签名不能超过${SIGNATURE_MAX}个字符`; this._localViolation = true; } else if (field === FIELD.DESCRIPTION && this._inputText.length > DESCRIPTION_MAX) { this._errorMessage = `描述不能超过${DESCRIPTION_MAX}个字符`; this._localViolation = true; } } }; this._onKeyboardConfirm = () => { this._handleSubmit(); }; wx.onKeyboardInput(this._onKeyboardInput); wx.onKeyboardConfirm(this._onKeyboardConfirm); }, _stopEditing() { if (this._onKeyboardInput) { wx.offKeyboardInput(this._onKeyboardInput); this._onKeyboardInput = null; } if (this._onKeyboardConfirm) { wx.offKeyboardConfirm(this._onKeyboardConfirm); this._onKeyboardConfirm = null; } wx.hideKeyboard(); this._editingField = null; }, async _handleSubmit() { if (this._isSubmitting || this._localViolation) return; const csm = GameGlobal.contentSecurityManager; if (!csm || !this._editingField) return; const content = this._inputText.trim(); if (!content) { this._errorMessage = '内容不能为空'; return; } this._isSubmitting = true; this._errorMessage = '审核中...'; // Determine scene const sceneMap = { [FIELD.NICKNAME]: ContentSecurityManager.SCENE.NICKNAME, [FIELD.SIGNATURE]: ContentSecurityManager.SCENE.SIGNATURE, [FIELD.DESCRIPTION]: ContentSecurityManager.SCENE.DESCRIPTION, }; const scene = sceneMap[this._editingField]; // Full text check (local + server) const result = await csm.fullTextCheck(this._openid, content, scene); this._isSubmitting = false; if (result.pass) { // Save the content this._profile[this._editingField] = content; this._saveProfile(); this._errorMessage = ''; this._stopEditing(); } else { this._errorMessage = result.errorMessage; } }, // ============================================================ // Avatar Upload // ============================================================ _handleChangeAvatar() { const csm = GameGlobal.contentSecurityManager; if (!csm || !csm.isInitialized()) return; wx.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: async (res) => { const filePath = res.tempFilePaths[0]; // Check file size const fileInfo = wx.getFileInfo({ filePath }); if (fileInfo && fileInfo.size > 1024 * 1024) { this._errorMessage = '图片大小不能超过1MB'; return; } this._isSubmitting = true; this._errorMessage = '审核中...'; // Check image content const result = await csm.checkImageContent(filePath, this._openid); this._isSubmitting = false; if (result.pass) { this._profile.avatarUrl = filePath; this._saveProfile(); this._errorMessage = ''; } else { this._errorMessage = result.errmsg || '图片内容违规,请更换'; } }, fail: () => { // User cancelled or error }, }); }, // ============================================================ // Render Helpers // ============================================================ _renderBackButton(ctx) { ctx.fillStyle = '#AAAAAA'; ctx.font = '14px Arial'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText('← ' + t('common.back'), 15, 25); }, _renderAvatar(ctx) { const rect = this._avatarRect; const cx = rect.x + rect.w / 2; const cy = rect.y + rect.h / 2; const r = rect.w / 2; // Circle background ctx.fillStyle = '#4a90d9'; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); // Camera icon ctx.fillStyle = '#FFFFFF'; ctx.font = '24px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('📷', cx, cy); // Hint text ctx.fillStyle = '#888888'; ctx.font = '10px Arial'; ctx.fillText(t('profile.changeAvatar'), cx, rect.y + rect.h + 14); }, _renderField(ctx, rect, label, value, field) { const isEditing = this._editingField === field; // Label ctx.fillStyle = '#CCCCCC'; ctx.font = '12px Arial'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; ctx.fillText(label, rect.x, rect.y - 4); // Input box ctx.fillStyle = isEditing ? 'rgba(74, 144, 226, 0.2)' : 'rgba(255, 255, 255, 0.1)'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); // Border ctx.strokeStyle = isEditing ? '#4a90d9' : '#555555'; ctx.lineWidth = 1; ctx.strokeRect(rect.x, rect.y, rect.w, rect.h); // Value ctx.fillStyle = value ? '#FFFFFF' : '#666666'; ctx.font = '13px Arial'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const displayText = value || t('profile.tapToEdit'); const truncated = displayText.length > 20 ? displayText.substring(0, 20) + '...' : displayText; ctx.fillText(truncated, rect.x + 8, rect.y + rect.h / 2); }, _renderDescriptionField(ctx) { const rect = this._descriptionRect; const isEditing = this._editingField === FIELD.DESCRIPTION; // Label ctx.fillStyle = '#CCCCCC'; ctx.font = '12px Arial'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; ctx.fillText(t('profile.description'), rect.x, rect.y - 4); // Input box (taller for description) ctx.fillStyle = isEditing ? 'rgba(74, 144, 226, 0.2)' : 'rgba(255, 255, 255, 0.1)'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); ctx.strokeStyle = isEditing ? '#4a90d9' : '#555555'; ctx.lineWidth = 1; ctx.strokeRect(rect.x, rect.y, rect.w, rect.h); // Value (multi-line) ctx.fillStyle = this._profile.description ? '#FFFFFF' : '#666666'; ctx.font = '12px Arial'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; const displayText = this._profile.description || t('profile.tapToEdit'); const lines = this._wrapText(ctx, displayText, rect.w - 16, 4); for (let i = 0; i < lines.length; i++) { ctx.fillText(lines[i], rect.x + 8, rect.y + 6 + i * 16); } }, _renderSaveButton(ctx) { const rect = this._saveBtnRect; const isActive = this._editingField && !this._localViolation && !this._isSubmitting; ctx.fillStyle = isActive ? '#4a90d9' : '#555555'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 14px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(this._isSubmitting ? '审核中...' : t('profile.save'), rect.x + rect.w / 2, rect.y + rect.h / 2); }, _renderEditOverlay(ctx) { // Semi-transparent overlay at bottom const overlayY = SCREEN_HEIGHT * 0.6; ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(0, overlayY, SCREEN_WIDTH, SCREEN_HEIGHT - overlayY); // Current input text preview ctx.fillStyle = '#FFFFFF'; ctx.font = '14px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const previewText = this._inputText || ''; const lines = this._wrapText(ctx, previewText, INPUT_WIDTH, 3); for (let i = 0; i < lines.length; i++) { ctx.fillText(lines[i], CENTER_X, overlayY + 30 + i * 20); } // Character count let maxLen = 200; if (this._editingField === FIELD.NICKNAME) maxLen = NICKNAME_MAX; if (this._editingField === FIELD.SIGNATURE) maxLen = SIGNATURE_MAX; ctx.fillStyle = '#888888'; ctx.font = '11px Arial'; ctx.fillText(`${this._inputText.length}/${maxLen}`, CENTER_X, overlayY + 100); // Violation warning if (this._localViolation) { ctx.fillStyle = '#FF4444'; ctx.font = 'bold 12px Arial'; ctx.fillText('⚠ ' + this._errorMessage, CENTER_X, overlayY + 120); } }, _handleEditOverlayTouch(x, y) { // Save button if (this._hitTest(x, y, this._saveBtnRect)) { if (!this._localViolation && !this._isSubmitting) { this._handleSubmit(); } return; } // Tap outside to cancel this._stopEditing(); }, // ============================================================ // Utility // ============================================================ _hitTest(x, y, rect) { return rect && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; }, _wrapText(ctx, text, maxWidth, maxLines) { const words = text.split(''); const lines = []; let currentLine = ''; for (const char of words) { const testLine = currentLine + char; const metrics = ctx.measureText(testLine); if (metrics.width > maxWidth && currentLine.length > 0) { lines.push(currentLine); currentLine = char; if (lines.length >= maxLines) { lines[lines.length - 1] += '...'; break; } } else { currentLine = testLine; } } if (currentLine && lines.length < maxLines) { lines.push(currentLine); } return lines; }, _loadProfile() { try { const saved = wx.getStorageSync('player_profile'); if (saved) { this._profile = { ...this._profile, ...JSON.parse(saved) }; } } catch (e) { console.warn('[ProfileScene] Failed to load profile:', e); } }, _saveProfile() { try { wx.setStorageSync('player_profile', JSON.stringify(this._profile)); } catch (e) { console.warn('[ProfileScene] Failed to save profile:', e); } }, }; module.exports = ProfileScene;