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:
jakciehan
2026-05-12 07:05:20 +08:00
parent 38294c040c
commit d263c7bf48
48 changed files with 10480 additions and 25 deletions
+548
View File
@@ -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;
+597
View File
@@ -0,0 +1,597 @@
/**
* 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;
+39
View File
@@ -90,6 +90,10 @@ const SettingsScene = {
}
}
// 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);
},
@@ -180,6 +184,34 @@ 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;
@@ -215,6 +247,13 @@ const SettingsScene = {
// IMPORTANT: wx.getUserProfile must be called synchronously from a
// user tap handler; invoking it here is fine (touchstart is a tap).
this._requestNicknameAuth();
} 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