/** * 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 _originalText: '', // Original text before editing (for change detection) _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, _cancelBtnRect: 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 (only show when NOT editing to avoid overlap) if (this._errorMessage && !this._editingField) { ctx.fillStyle = '#FF4444'; ctx.font = '12px Arial'; ctx.textAlign = 'center'; ctx.fillText(this._errorMessage, cx, SCREEN_HEIGHT - 140); } // Editing overlay (includes save/cancel buttons) 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; } }, _startEditing(field, currentValue) { const csm = GameGlobal.contentSecurityManager; if (!csm || !csm.isInitialized()) return; this._editingField = field; this._inputText = currentValue || ''; this._originalText = 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 with empty defaultValue, so the native "Done" button highlights // as soon as user types any character. The original value is preserved in // _originalText and shown in the overlay preview for reference. wx.showKeyboard({ defaultValue: '', maxLength, multiple: field === FIELD.DESCRIPTION, confirmHold: false, confirmType: 'done', }); // Reset _inputText so we know if user actually typed something this._inputText = ''; // Listen for keyboard input this._onKeyboardInput = (res) => { this._inputText = res.value || ''; this._validateInput(field); }; this._onKeyboardConfirm = (res) => { // Ensure we have the final value on confirm if (res && res.value !== undefined) { this._inputText = res.value; } this._validateInput(field); // Only auto-submit if no violations if (!this._localViolation) { this._handleSubmit(); } }; this._onKeyboardComplete = (res) => { // Keyboard closed - get final value if (res && res.value !== undefined) { this._inputText = res.value; this._validateInput(field); } }; wx.onKeyboardInput(this._onKeyboardInput); wx.onKeyboardConfirm(this._onKeyboardConfirm); wx.onKeyboardComplete(this._onKeyboardComplete); // Initial validation to set correct button state this._validateInput(field); }, _stopEditing() { if (this._onKeyboardInput) { wx.offKeyboardInput(this._onKeyboardInput); this._onKeyboardInput = null; } if (this._onKeyboardConfirm) { wx.offKeyboardConfirm(this._onKeyboardConfirm); this._onKeyboardConfirm = null; } if (this._onKeyboardComplete) { wx.offKeyboardComplete(this._onKeyboardComplete); this._onKeyboardComplete = null; } wx.hideKeyboard(); this._editingField = null; }, _validateInput(field) { const csm = GameGlobal.contentSecurityManager; const text = this._inputText || ''; // Reset violation state first this._localViolation = false; this._errorMessage = ''; // Empty check if (!text || text.trim().length === 0) { this._localViolation = true; this._errorMessage = '内容不能为空'; return; } // Sensitive word check if (csm && csm.isInitialized()) { const localCheck = csm.checkLocalText(text); if (localCheck.hasViolation) { this._localViolation = true; this._errorMessage = '内容包含违规信息,请修改'; return; } } // Length validation if (field === FIELD.NICKNAME) { if (text.length < NICKNAME_MIN) { this._localViolation = true; this._errorMessage = `昵称至少需要${NICKNAME_MIN}个字符`; } else if (text.length > NICKNAME_MAX) { this._localViolation = true; this._errorMessage = `昵称不能超过${NICKNAME_MAX}个字符`; } } else if (field === FIELD.SIGNATURE) { if (text.length > SIGNATURE_MAX) { this._localViolation = true; this._errorMessage = `签名不能超过${SIGNATURE_MAX}个字符`; } } else if (field === FIELD.DESCRIPTION) { if (text.length > DESCRIPTION_MAX) { this._localViolation = true; this._errorMessage = `描述不能超过${DESCRIPTION_MAX}个字符`; } } }, 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); } }, _renderEditOverlay(ctx) { // Place overlay at TOP of screen, so save/cancel buttons are NOT hidden by keyboard. // The keyboard occupies the bottom portion of the screen when shown. const overlayY = 0; const overlayH = Math.min(SCREEN_HEIGHT * 0.4, 220); ctx.fillStyle = 'rgba(0, 0, 0, 0.92)'; ctx.fillRect(0, overlayY, SCREEN_WIDTH, overlayH); // Field label const fieldLabels = { [FIELD.NICKNAME]: t('profile.nickname'), [FIELD.SIGNATURE]: t('profile.signature'), [FIELD.DESCRIPTION]: t('profile.description'), }; ctx.fillStyle = '#AAAAAA'; ctx.font = '12px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('编辑 ' + (fieldLabels[this._editingField] || ''), CENTER_X, overlayY + 14); // Input text preview area const previewBoxY = overlayY + 30; const previewBoxH = 50; ctx.fillStyle = 'rgba(255, 255, 255, 0.08)'; ctx.fillRect(20, previewBoxY, SCREEN_WIDTH - 40, previewBoxH); ctx.strokeStyle = '#4a90d9'; ctx.lineWidth = 1; ctx.strokeRect(20, previewBoxY, SCREEN_WIDTH - 40, previewBoxH); // Current input text ctx.fillStyle = '#FFFFFF'; ctx.font = '15px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const previewText = this._inputText || ''; if (previewText) { const lines = this._wrapText(ctx, previewText, SCREEN_WIDTH - 60, 2); for (let i = 0; i < lines.length; i++) { ctx.fillText(lines[i], CENTER_X, previewBoxY + 16 + i * 18); } } else { // Placeholder ctx.fillStyle = '#888888'; ctx.font = '13px Arial'; ctx.fillText( this._originalText ? `当前: ${this._originalText}(在下方键盘输入新内容)` : '请在下方键盘输入', CENTER_X, previewBoxY + previewBoxH / 2 ); } // 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.textAlign = 'right'; ctx.fillText(`${this._inputText.length}/${maxLen}`, SCREEN_WIDTH - 24, previewBoxY + previewBoxH + 12); // Violation warning if (this._localViolation && this._errorMessage) { ctx.fillStyle = '#FF4444'; ctx.font = 'bold 12px Arial'; ctx.textAlign = 'center'; ctx.fillText('⚠ ' + this._errorMessage, CENTER_X, previewBoxY + previewBoxH + 12); } // Buttons row at bottom of overlay const btnRowY = overlayY + overlayH - BTN_HEIGHT - 12; const btnGap = 12; const btnW = (SCREEN_WIDTH - 40 - btnGap) / 2; // Cancel button (left) this._cancelBtnRect = { x: 20, y: btnRowY, w: btnW, h: BTN_HEIGHT, }; // Save button (right) this._saveBtnRect = { x: 20 + btnW + btnGap, y: btnRowY, w: btnW, h: BTN_HEIGHT, }; const btnR = 6; // Cancel button render ctx.fillStyle = '#3a3a3a'; this._drawRoundRect(ctx, this._cancelBtnRect.x, this._cancelBtnRect.y, this._cancelBtnRect.w, this._cancelBtnRect.h, btnR); ctx.fill(); ctx.strokeStyle = '#666666'; ctx.lineWidth = 1; this._drawRoundRect(ctx, this._cancelBtnRect.x, this._cancelBtnRect.y, this._cancelBtnRect.w, this._cancelBtnRect.h, btnR); ctx.stroke(); ctx.fillStyle = '#CCCCCC'; ctx.font = '14px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t('common.cancel') || '取消', this._cancelBtnRect.x + this._cancelBtnRect.w / 2, this._cancelBtnRect.y + this._cancelBtnRect.h / 2); // Save button render: always highlighted unless submitting const isActive = !this._isSubmitting; if (isActive) { ctx.shadowColor = '#00CC66'; ctx.shadowBlur = 16; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; ctx.fillStyle = '#00CC66'; } else { ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.fillStyle = '#3a3a3a'; } this._drawRoundRect(ctx, this._saveBtnRect.x, this._saveBtnRect.y, this._saveBtnRect.w, this._saveBtnRect.h, btnR); ctx.fill(); ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; if (isActive) { ctx.strokeStyle = '#33FF99'; ctx.lineWidth = 2; } else { ctx.strokeStyle = '#555555'; ctx.lineWidth = 1; } this._drawRoundRect(ctx, this._saveBtnRect.x, this._saveBtnRect.y, this._saveBtnRect.w, this._saveBtnRect.h, btnR); ctx.stroke(); ctx.fillStyle = isActive ? '#FFFFFF' : '#666666'; ctx.font = 'bold 15px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(this._isSubmitting ? '审核中...' : t('profile.save'), this._saveBtnRect.x + this._saveBtnRect.w / 2, this._saveBtnRect.y + this._saveBtnRect.h / 2); }, _handleEditOverlayTouch(x, y) { // Save button - always allow click, _handleSubmit will validate if (this._hitTest(x, y, this._saveBtnRect)) { if (!this._isSubmitting) { // Re-validate before submit (in case keyboard callbacks did not fire) this._validateInput(this._editingField); if (!this._localViolation) { this._handleSubmit(); } // If validation fails, error message is now shown via _localViolation/_errorMessage } return; } // Cancel button if (this._hitTest(x, y, this._cancelBtnRect)) { this._stopEditing(); return; } // Ignore taps elsewhere inside the overlay (do NOT auto-cancel) }, // ============================================================ // Utility // ============================================================ _hitTest(x, y, rect) { return rect && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; }, _drawRoundRect(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(); }, _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;