/** * ChatRoomScene.js * Chat room scene for in-game communication. * All messages are checked against content security before sending. * Muted users cannot send messages. */ 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_HEIGHT = 36; const SEND_BTN_WIDTH = 60; const MAX_VISIBLE_MESSAGES = 50; const MESSAGE_AREA_TOP = 50; const MESSAGE_AREA_BOTTOM = SCREEN_HEIGHT - 60; // ============================================================ // Chat Room Scene // ============================================================ const ChatRoomScene = { // Chat state _messages: [], _inputText: '', _errorMessage: '', _isSending: false, _localViolation: false, _isMuted: false, _muteRemainingText: '', _scrollOffset: 0, // User info _openid: '', _nickname: '', // Touch rects _backBtnRect: null, _inputRect: null, _sendBtnRect: null, _reportTargetMsgIdx: -1, // Report overlay state _showReportOverlay: false, _reportContentId: '', _reportTargetUserId: '', _reportContentSummary: '', enter() { this._messages = []; this._inputText = ''; this._errorMessage = ''; this._isSending = false; this._localViolation = false; this._isMuted = false; this._muteRemainingText = ''; this._scrollOffset = 0; this._showReportOverlay = false; // Load user info try { this._openid = wx.getStorageSync('player_openid') || ''; const profile = wx.getStorageSync('player_profile'); if (profile) { const parsed = JSON.parse(profile); this._nickname = parsed.nickname || '玩家'; } } catch (e) { this._nickname = '玩家'; } // Calculate layouts this._backBtnRect = { x: 10, y: 10, w: 60, h: 30 }; this._inputRect = { x: 10, y: SCREEN_HEIGHT - INPUT_HEIGHT - 10, w: SCREEN_WIDTH - SEND_BTN_WIDTH - 30, h: INPUT_HEIGHT, }; this._sendBtnRect = { x: SCREEN_WIDTH - SEND_BTN_WIDTH - 10, y: SCREEN_HEIGHT - INPUT_HEIGHT - 10, w: SEND_BTN_WIDTH, h: INPUT_HEIGHT, }; // Check mute status this._checkMuteStatus(); }, exit() { wx.hideKeyboard(); }, update(dt) {}, render(ctx) { // Background ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); // Title bar ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, SCREEN_WIDTH, 45); ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 16px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t('chat.title'), CENTER_X, 22); // Back button ctx.fillStyle = '#AAAAAA'; ctx.font = '12px Arial'; ctx.textAlign = 'left'; ctx.fillText('← ' + t('common.back'), 15, 22); // Messages area this._renderMessages(ctx); // Input area this._renderInputArea(ctx); // Error message if (this._errorMessage) { ctx.fillStyle = '#FF4444'; ctx.font = '11px Arial'; ctx.textAlign = 'center'; ctx.fillText(this._errorMessage, CENTER_X, SCREEN_HEIGHT - INPUT_HEIGHT - 25); } // Report overlay if (this._showReportOverlay) { this._renderReportOverlay(ctx); } }, handleTouch(type, e) { if (type !== 'touchstart') return; const touch = e.touches[0]; const x = touch.clientX; const y = touch.clientY; // Report overlay handling if (this._showReportOverlay) { this._handleReportOverlayTouch(x, y); return; } // Back button if (this._hitTest(x, y, this._backBtnRect)) { const sm = GameGlobal.sceneManager; sm.switchTo(SCENE.MENU); return; } // Input field - show keyboard if (this._hitTest(x, y, this._inputRect)) { if (this._isMuted) { this._errorMessage = `您已被禁言,剩余时间:${this._muteRemainingText}`; return; } this._showKeyboard(); return; } // Send button if (this._hitTest(x, y, this._sendBtnRect)) { this._handleSend(); return; } // Long press on message for reporting // (Simplified: double-tap to report for now) }, // ============================================================ // Message Sending // ============================================================ _showKeyboard() { wx.showKeyboard({ defaultValue: this._inputText, maxLength: 200, multiple: false, confirmHold: false, confirmType: 'send', }); this._onKeyboardInput = (res) => { this._inputText = res.value; // Real-time local sensitive word check if (this._inputText) { const csm = GameGlobal.contentSecurityManager; if (csm && csm.isInitialized()) { const localCheck = csm.checkLocalText(this._inputText); if (localCheck.hasViolation) { this._localViolation = true; this._errorMessage = '内容包含违规信息,请修改'; } else { this._localViolation = false; this._errorMessage = ''; } } } }; this._onKeyboardConfirm = () => { this._handleSend(); }; wx.onKeyboardInput(this._onKeyboardInput); wx.onKeyboardConfirm(this._onKeyboardConfirm); }, _hideKeyboard() { if (this._onKeyboardInput) { wx.offKeyboardInput(this._onKeyboardInput); this._onKeyboardInput = null; } if (this._onKeyboardConfirm) { wx.offKeyboardConfirm(this._onKeyboardConfirm); this._onKeyboardConfirm = null; } wx.hideKeyboard(); }, async _handleSend() { if (this._isSending || this._localViolation) return; const csm = GameGlobal.contentSecurityManager; if (!csm || !csm.isInitialized()) return; const content = this._inputText.trim(); if (!content) return; // Check mute status first if (this._isMuted) { this._errorMessage = `您已被禁言,剩余时间:${this._muteRemainingText}`; return; } // Local check const localCheck = csm.checkLocalText(content); if (localCheck.hasViolation) { this._errorMessage = '内容包含违规信息,请修改'; return; } this._isSending = true; this._errorMessage = ''; // Server-side check const result = await csm.fullTextCheck(this._openid, content, ContentSecurityManager.SCENE.CHAT); this._isSending = false; if (result.pass) { // Add message to list this._messages.push({ id: `msg_${Date.now()}`, userId: this._openid, nickname: this._nickname, content, timestamp: Date.now(), isLocal: true, }); // Keep only last MAX_VISIBLE_MESSAGES if (this._messages.length > MAX_VISIBLE_MESSAGES) { this._messages = this._messages.slice(-MAX_VISIBLE_MESSAGES); } this._inputText = ''; this._errorMessage = ''; this._hideKeyboard(); // Scroll to bottom this._scrollOffset = 0; } else { this._errorMessage = result.errorMessage; } }, // ============================================================ // Mute Status Check // ============================================================ async _checkMuteStatus() { const csm = GameGlobal.contentSecurityManager; if (!csm || !csm.isInitialized() || !this._openid) return; try { const status = await csm.getMuteStatus(this._openid); this._isMuted = status.isMuted; this._muteRemainingText = status.remainingText || ''; } catch (e) { // Assume not muted on error this._isMuted = false; } }, // ============================================================ // Reporting // ============================================================ _showReportForMessage(msgIdx) { if (msgIdx < 0 || msgIdx >= this._messages.length) return; const msg = this._messages[msgIdx]; if (msg.isLocal) return; // Can't report own messages this._reportContentId = msg.id; this._reportTargetUserId = msg.userId; this._reportContentSummary = msg.content.substring(0, 20); this._showReportOverlay = true; }, async _submitReport(reason) { const csm = GameGlobal.contentSecurityManager; if (!csm || !csm.isInitialized()) return; const result = await csm.submitReport({ contentId: this._reportContentId, targetUserId: this._reportTargetUserId, contentType: 'chat', contentSummary: this._reportContentSummary, reporterId: this._openid, reason, }); this._showReportOverlay = false; if (result.success) { wx.showToast({ title: '举报已提交', icon: 'success' }); } else { wx.showToast({ title: result.message || '举报失败', icon: 'none' }); } }, // ============================================================ // Render Helpers // ============================================================ _renderMessages(ctx) { const areaTop = MESSAGE_AREA_TOP; const areaBottom = MESSAGE_AREA_BOTTOM; const padding = 10; const msgHeight = 40; const startY = areaBottom - msgHeight; // Clip to message area ctx.save(); ctx.beginPath(); ctx.rect(0, areaTop, SCREEN_WIDTH, areaBottom - areaTop); ctx.clip(); // Render messages from bottom to top let y = startY - this._scrollOffset; for (let i = this._messages.length - 1; i >= 0; i--) { const msg = this._messages[i]; if (y < areaTop - msgHeight || y > areaBottom) { y -= msgHeight; continue; } const isLocal = msg.isLocal; const msgX = isLocal ? SCREEN_WIDTH - padding - 200 : padding; // Message bubble ctx.fillStyle = isLocal ? 'rgba(74, 144, 226, 0.3)' : 'rgba(255, 255, 255, 0.1)'; ctx.fillRect(msgX, y, 200, msgHeight - 4); // Border ctx.strokeStyle = isLocal ? '#4a90d9' : '#555555'; ctx.lineWidth = 1; ctx.strokeRect(msgX, y, 200, msgHeight - 4); // Nickname ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC'; ctx.font = 'bold 10px Arial'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(msg.nickname, msgX + 6, y + 3); // Content ctx.fillStyle = '#FFFFFF'; ctx.font = '11px Arial'; const contentPreview = msg.content.length > 25 ? msg.content.substring(0, 25) + '...' : msg.content; ctx.fillText(contentPreview, msgX + 6, y + 18); // Report button for non-local messages if (!isLocal) { ctx.fillStyle = '#FF6347'; ctx.font = '9px Arial'; ctx.textAlign = 'right'; ctx.fillText('⚠举报', msgX + 196, y + 3); } y -= msgHeight; } ctx.restore(); // Scroll hint if there are more messages if (this._messages.length > 0 && this._scrollOffset > 0) { ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; ctx.font = '10px Arial'; ctx.textAlign = 'center'; ctx.fillText('↑ 向上滚动查看更多', CENTER_X, areaTop + 10); } }, _renderInputArea(ctx) { // Input box background ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; ctx.fillRect(this._inputRect.x, this._inputRect.y, this._inputRect.w, this._inputRect.h); ctx.strokeStyle = this._localViolation ? '#FF4444' : '#555555'; ctx.lineWidth = 1; ctx.strokeRect(this._inputRect.x, this._inputRect.y, this._inputRect.w, this._inputRect.h); // Input text or placeholder ctx.fillStyle = this._inputText ? '#FFFFFF' : '#666666'; ctx.font = '13px Arial'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const displayText = this._inputText || (this._isMuted ? '您已被禁言' : t('chat.inputPlaceholder')); const truncated = displayText.length > 25 ? displayText.substring(0, 25) + '...' : displayText; ctx.fillText(truncated, this._inputRect.x + 8, this._inputRect.y + this._inputRect.h / 2); // Send button const canSend = this._inputText.trim() && !this._localViolation && !this._isSending && !this._isMuted; ctx.fillStyle = canSend ? '#4a90d9' : '#555555'; ctx.fillRect(this._sendBtnRect.x, this._sendBtnRect.y, this._sendBtnRect.w, this._sendBtnRect.h); ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 13px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(this._isSending ? '...' : t('chat.send'), this._sendBtnRect.x + this._sendBtnRect.w / 2, this._sendBtnRect.y + this._sendBtnRect.h / 2); }, _renderReportOverlay(ctx) { // Semi-transparent overlay ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); // Report dialog const dialogW = Math.min(SCREEN_WIDTH * 0.8, 300); const dialogH = 220; const dialogX = CENTER_X - dialogW / 2; const dialogY = SCREEN_HEIGHT / 2 - dialogH / 2; ctx.fillStyle = '#2a2a3e'; ctx.fillRect(dialogX, dialogY, dialogW, dialogH); ctx.strokeStyle = '#4a90d9'; ctx.lineWidth = 2; ctx.strokeRect(dialogX, dialogY, dialogW, dialogH); // Title ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 16px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t('chat.reportTitle'), CENTER_X, dialogY + 25); // Report reason buttons const reasons = [ { key: 'politics', label: t('chat.reportPolitics') }, { key: 'pornography', label: t('chat.reportPornography') }, { key: 'gambling', label: t('chat.reportGambling') }, { key: 'other', label: t('chat.reportOther') }, ]; const btnW = dialogW - 40; const btnH = 30; let btnY = dialogY + 55; this._reportBtnRects = []; for (const reason of reasons) { const rect = { x: dialogX + 20, y: btnY, w: btnW, h: btnH }; this._reportBtnRects.push({ rect, key: reason.key }); ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); ctx.strokeStyle = '#555555'; ctx.lineWidth = 1; ctx.strokeRect(rect.x, rect.y, rect.w, rect.h); ctx.fillStyle = '#FFFFFF'; ctx.font = '13px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(reason.label, rect.x + rect.w / 2, rect.y + rect.h / 2); btnY += btnH + 8; } // Cancel button const cancelRect = { x: CENTER_X - 50, y: dialogY + dialogH - 35, w: 100, h: 25 }; this._reportCancelRect = cancelRect; ctx.fillStyle = '#666666'; ctx.fillRect(cancelRect.x, cancelRect.y, cancelRect.w, cancelRect.h); ctx.fillStyle = '#FFFFFF'; ctx.font = '12px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t('chat.reportCancel'), cancelRect.x + cancelRect.w / 2, cancelRect.y + cancelRect.h / 2); }, _handleReportOverlayTouch(x, y) { // Check report reason buttons if (this._reportBtnRects) { for (const btn of this._reportBtnRects) { if (this._hitTest(x, y, btn.rect)) { this._submitReport(btn.key); return; } } } // Cancel button if (this._reportCancelRect && this._hitTest(x, y, this._reportCancelRect)) { this._showReportOverlay = false; return; } }, // ============================================================ // Utility // ============================================================ _hitTest(x, y, rect) { return rect && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; }, }; module.exports = ChatRoomScene;