Merge feature/add_skin into master: resolve all conflicts
- GameGlobal.js: keep upstream SERVER_URL with /ws suffix - en.js/zh.js: merge both settings.nickname and settings.profile keys - SettingsScene.js: keep both nickname row and profile button - server/index.js: merge express app + content security proxy with noServer WebSocket mode and path validation - Add .gitignore for node_modules and .codebuddy
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user