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:
@@ -172,6 +172,8 @@ const SCENE = {
|
||||
TEAM_ROOM: 'team_room',
|
||||
TEAM_GAME: 'team_game',
|
||||
TEAM_RESULT: 'team_result',
|
||||
PROFILE: 'profile',
|
||||
CHAT_ROOM: 'chat_room',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -30,6 +30,8 @@ module.exports = {
|
||||
'menu.skin': 'Skins',
|
||||
'menu.ranking': 'Ranking',
|
||||
'menu.settings': 'Settings',
|
||||
'menu.profile': 'Profile',
|
||||
'menu.chat': 'Chat',
|
||||
|
||||
// ============================================================
|
||||
// Room Scene (PVP)
|
||||
@@ -207,6 +209,7 @@ module.exports = {
|
||||
'settings.music': 'Music',
|
||||
'settings.vibration': 'Vibration',
|
||||
'settings.nickname': 'Display Name',
|
||||
'settings.profile': 'Profile',
|
||||
|
||||
// ============================================================
|
||||
// Shop Scene (Simplified)
|
||||
@@ -226,6 +229,44 @@ module.exports = {
|
||||
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Profile Scene
|
||||
// ============================================================
|
||||
'profile.title': 'Profile',
|
||||
'profile.nickname': 'Nickname',
|
||||
'profile.signature': 'Signature',
|
||||
'profile.description': 'Space Description',
|
||||
'profile.changeAvatar': 'Change Avatar',
|
||||
'profile.tapToEdit': 'Tap to edit',
|
||||
'profile.save': 'Save',
|
||||
|
||||
// ============================================================
|
||||
// Chat Room Scene
|
||||
// ============================================================
|
||||
'chat.title': 'Chat Room',
|
||||
'chat.inputPlaceholder': 'Type a message...',
|
||||
'chat.send': 'Send',
|
||||
'chat.reportTitle': 'Report',
|
||||
'chat.reportPolitics': 'Harmful politics',
|
||||
'chat.reportPornography': 'Pornography',
|
||||
'chat.reportGambling': 'Gambling & fraud',
|
||||
'chat.reportOther': 'Other',
|
||||
'chat.reportCancel': 'Cancel',
|
||||
|
||||
// ============================================================
|
||||
// Content Security
|
||||
// ============================================================
|
||||
'contentSecurity.violation': 'Content contains prohibited information',
|
||||
'contentSecurity.nicknameViolation': 'Nickname contains prohibited content',
|
||||
'contentSecurity.chatViolation': 'Message failed: content violation',
|
||||
'contentSecurity.muted': 'You cannot modify profile due to violation',
|
||||
'contentSecurity.mutedChat': 'You are muted, remaining: {time}',
|
||||
'contentSecurity.imageViolation': 'Image contains prohibited content',
|
||||
'contentSecurity.checking': 'Checking...',
|
||||
'contentSecurity.timeout': 'Check timeout, please retry',
|
||||
'contentSecurity.reportSuccess': 'Report submitted',
|
||||
'contentSecurity.reportFail': 'Report failed',
|
||||
|
||||
// ============================================================
|
||||
// Ad System
|
||||
// ============================================================
|
||||
|
||||
@@ -30,6 +30,8 @@ module.exports = {
|
||||
'menu.skin': '皮肤',
|
||||
'menu.ranking': '排行榜',
|
||||
'menu.settings': '设置',
|
||||
'menu.profile': '个人资料',
|
||||
'menu.chat': '聊天室',
|
||||
|
||||
// ============================================================
|
||||
// Room Scene (PVP)
|
||||
@@ -207,6 +209,7 @@ module.exports = {
|
||||
'settings.music': '音乐',
|
||||
'settings.vibration': '振动',
|
||||
'settings.nickname': '显示名字',
|
||||
'settings.profile': '个人资料',
|
||||
|
||||
// ============================================================
|
||||
// Shop Scene (Simplified)
|
||||
@@ -226,6 +229,44 @@ module.exports = {
|
||||
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Profile Scene
|
||||
// ============================================================
|
||||
'profile.title': '个人资料',
|
||||
'profile.nickname': '昵称',
|
||||
'profile.signature': '个性签名',
|
||||
'profile.description': '个人空间描述',
|
||||
'profile.changeAvatar': '更换头像',
|
||||
'profile.tapToEdit': '点击编辑',
|
||||
'profile.save': '保存',
|
||||
|
||||
// ============================================================
|
||||
// Chat Room Scene
|
||||
// ============================================================
|
||||
'chat.title': '聊天室',
|
||||
'chat.inputPlaceholder': '输入消息...',
|
||||
'chat.send': '发送',
|
||||
'chat.reportTitle': '举报',
|
||||
'chat.reportPolitics': '政治有害',
|
||||
'chat.reportPornography': '色情低俗',
|
||||
'chat.reportGambling': '赌博诈骗',
|
||||
'chat.reportOther': '其他',
|
||||
'chat.reportCancel': '取消',
|
||||
|
||||
// ============================================================
|
||||
// Content Security
|
||||
// ============================================================
|
||||
'contentSecurity.violation': '内容包含违规信息,请修改',
|
||||
'contentSecurity.nicknameViolation': '昵称包含违规内容,请重新输入',
|
||||
'contentSecurity.chatViolation': '消息发送失败:内容违规',
|
||||
'contentSecurity.muted': '由于违规行为,您暂无法修改个人信息',
|
||||
'contentSecurity.mutedChat': '您已被禁言,剩余时间:{time}',
|
||||
'contentSecurity.imageViolation': '图片内容违规,请更换',
|
||||
'contentSecurity.checking': '审核中...',
|
||||
'contentSecurity.timeout': '审核超时,请重试',
|
||||
'contentSecurity.reportSuccess': '举报已提交',
|
||||
'contentSecurity.reportFail': '举报失败',
|
||||
|
||||
// ============================================================
|
||||
// Ad System
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* ContentSecurityManager
|
||||
* Client-side content security filtering for WeChat mini game.
|
||||
*
|
||||
* Features:
|
||||
* - Local sensitive word filtering (50ms target)
|
||||
* - Remote word list fetching with 24-hour local cache
|
||||
* - Built-in fallback word list when remote fetch fails
|
||||
* - Incremental word list updates without blocking ongoing checks
|
||||
* - Integration with server-side msgSecCheck/imgSecCheck APIs
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Configuration
|
||||
// ============================================================
|
||||
const CACHE_KEY = 'content_security_word_cache';
|
||||
const CACHE_VERSION_KEY = 'content_security_word_version';
|
||||
const CACHE_TIMESTAMP_KEY = 'content_security_word_timestamp';
|
||||
const CACHE_VALIDITY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const SERVER_BASE_URL = ''; // Will use relative URL or configured server URL
|
||||
const DEFAULT_GAME_ID = 'tankwar'; // Default game identifier for multi-tenant isolation
|
||||
|
||||
// Scene mapping (must match server-side)
|
||||
const SCENE = {
|
||||
NICKNAME: 1,
|
||||
CHAT: 2,
|
||||
SIGNATURE: 3,
|
||||
DESCRIPTION: 4,
|
||||
};
|
||||
|
||||
// Report reasons
|
||||
const REPORT_REASONS = {
|
||||
POLITICS: 'politics',
|
||||
PORNOGRAPHY: 'pornography',
|
||||
GAMBLING: 'gambling',
|
||||
OTHER: 'other',
|
||||
};
|
||||
|
||||
// Report reason display labels
|
||||
const REPORT_REASON_LABELS = {
|
||||
politics: '政治有害',
|
||||
pornography: '色情低俗',
|
||||
gambling: '赌博诈骗',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Built-in Fallback Sensitive Word List
|
||||
// This is used when the remote word list is unavailable.
|
||||
// ============================================================
|
||||
const FALLBACK_WORDS = [
|
||||
// Politics
|
||||
'颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义',
|
||||
'法轮', '法轮功', '台独', '藏独', '疆独',
|
||||
// Pornography
|
||||
'色情', '淫秽', '裸体', '卖淫', '嫖娼',
|
||||
'约炮', '援交', '一夜情', '黄色视频',
|
||||
// Gambling
|
||||
'赌博', '赌场', '下注', '博彩', '六合彩',
|
||||
'时时彩', '赌球', '百家乐', '老虎机',
|
||||
// Violence
|
||||
'杀人', '砍人', '捅死', '自制炸弹', '血腥屠杀',
|
||||
// Abuse
|
||||
'傻逼', '操你', '妈的', '草泥马', '脑残',
|
||||
'贱人', '狗日的', '废物', '滚蛋', '王八蛋',
|
||||
// Fraud
|
||||
'代开发票', '传销', '诈骗', '洗钱',
|
||||
// Other
|
||||
'代孕', '毒品', '吸毒', '走私', '枪支',
|
||||
];
|
||||
|
||||
class ContentSecurityManager {
|
||||
constructor() {
|
||||
/** @type {string[]} Active sensitive word list */
|
||||
this._words = [];
|
||||
|
||||
/** @type {string} Current word list version */
|
||||
this._version = '';
|
||||
|
||||
/** @type {boolean} Whether the manager is initialized */
|
||||
this._initialized = false;
|
||||
|
||||
/** @type {boolean} Whether an update is in progress */
|
||||
this._updating = false;
|
||||
|
||||
/** @type {string} Server base URL for API calls */
|
||||
this._serverUrl = '';
|
||||
|
||||
/** @type {string} Game identifier for multi-tenant isolation */
|
||||
this._gameId = DEFAULT_GAME_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the manager: load cached words, then fetch latest.
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.serverUrl] - Server base URL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async init(options = {}) {
|
||||
this._serverUrl = options.serverUrl || '';
|
||||
this._gameId = options.gameId || DEFAULT_GAME_ID;
|
||||
|
||||
// Try to load from cache first
|
||||
this._loadFromCache();
|
||||
|
||||
// If no cached words, use fallback
|
||||
if (this._words.length === 0) {
|
||||
this._words = [...FALLBACK_WORDS];
|
||||
console.log('[ContentSecurity] Using fallback word list (' + this._words.length + ' words)');
|
||||
}
|
||||
|
||||
this._initialized = true;
|
||||
|
||||
// Fetch latest words from server (non-blocking)
|
||||
this.fetchWordList();
|
||||
|
||||
console.log('[ContentSecurity] Initialized with ' + this._words.length + ' words');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check text content against local sensitive words.
|
||||
* Must complete within 50ms.
|
||||
* @param {string} content - Text to check
|
||||
* @returns {{ hasViolation: boolean, matchedWords: string[] }}
|
||||
*/
|
||||
checkLocalText(content) {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { hasViolation: false, matchedWords: [] };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const lowerContent = content.toLowerCase();
|
||||
const matchedWords = [];
|
||||
|
||||
for (let i = 0; i < this._words.length; i++) {
|
||||
const word = this._words[i];
|
||||
if (word && lowerContent.includes(word.toLowerCase())) {
|
||||
matchedWords.push(word);
|
||||
// Early exit if we find violations (performance optimization)
|
||||
if (matchedWords.length >= 3) break;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
if (duration > 50) {
|
||||
console.warn('[ContentSecurity] Local check took ' + duration + 'ms (target: 50ms)');
|
||||
}
|
||||
|
||||
return {
|
||||
hasViolation: matchedWords.length > 0,
|
||||
matchedWords,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check text content via server-side API (msgSecCheck).
|
||||
* @param {string} openid - User's openid
|
||||
* @param {string} content - Text to check
|
||||
* @param {number} scene - Scene value (1=nickname, 2=chat, 3=signature, 4=description)
|
||||
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string, suggest: string, label: number}>}
|
||||
*/
|
||||
async checkTextContent(openid, content, scene) {
|
||||
if (!this._serverUrl) {
|
||||
return {
|
||||
pass: true,
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
suggest: 'pass',
|
||||
label: 100,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const res = await this._request({
|
||||
url: `${this._serverUrl}/api/content/check-text`,
|
||||
method: 'POST',
|
||||
data: { openid, content, scene },
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
console.error('[ContentSecurity] checkTextContent error:', err && (err.message || err.errMsg) || 'unknown');
|
||||
return {
|
||||
pass: false,
|
||||
errcode: -1,
|
||||
errmsg: '审核服务暂时不可用,请稍后再试',
|
||||
suggest: 'risky',
|
||||
label: 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check image content via server-side API (imgSecCheck).
|
||||
* @param {string} filePath - Local temporary file path of the image
|
||||
* @param {string} openid - User's openid
|
||||
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>}
|
||||
*/
|
||||
async checkImageContent(filePath, openid) {
|
||||
if (!this._serverUrl) {
|
||||
return { pass: true, errcode: 0, errmsg: 'ok' };
|
||||
}
|
||||
try {
|
||||
const res = await this._uploadImage(filePath, openid);
|
||||
return res;
|
||||
} catch (err) {
|
||||
console.error('[ContentSecurity] checkImageContent error:', err && (err.message || err.errMsg) || 'unknown');
|
||||
return {
|
||||
pass: false,
|
||||
errcode: -1,
|
||||
errmsg: '审核服务暂时不可用,请稍后再试',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch sensitive word list from server with cache validation.
|
||||
* Non-blocking: does not interfere with ongoing checks.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async fetchWordList() {
|
||||
if (this._updating) return false;
|
||||
if (!this._serverUrl) return false;
|
||||
this._updating = true;
|
||||
|
||||
try {
|
||||
const res = await this._request({
|
||||
url: `${this._serverUrl}/api/content/sensitive-words`,
|
||||
method: 'GET',
|
||||
data: { version: this._version },
|
||||
});
|
||||
|
||||
if (res.updated && res.words) {
|
||||
// Incremental update: replace word list atomically
|
||||
this._words = res.words;
|
||||
this._version = res.version;
|
||||
|
||||
// Cache locally
|
||||
this._saveToCache();
|
||||
|
||||
console.log('[ContentSecurity] Word list updated: ' + this._words.length + ' words, version: ' + this._version);
|
||||
}
|
||||
|
||||
this._updating = false;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[ContentSecurity] fetchWordList error:', err && (err.message || err.errMsg) || 'unknown');
|
||||
this._updating = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is muted.
|
||||
* @param {string} openid
|
||||
* @returns {Promise<{isMuted: boolean, remainingMs: number, remainingText: string}>}
|
||||
*/
|
||||
async getMuteStatus(openid) {
|
||||
if (!this._serverUrl) {
|
||||
return { isMuted: false, remainingMs: 0, remainingText: '' };
|
||||
}
|
||||
try {
|
||||
const res = await this._request({
|
||||
url: `${this._serverUrl}/api/content/user/mute-status`,
|
||||
method: 'GET',
|
||||
data: { openid },
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
console.error('[ContentSecurity] getMuteStatus error:', err && (err.message || err.errMsg) || 'unknown');
|
||||
return { isMuted: false, remainingMs: 0, remainingText: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a content report.
|
||||
* @param {object} entry
|
||||
* @param {string} entry.contentId - Unique content identifier
|
||||
* @param {string} entry.targetUserId - Content author's user ID
|
||||
* @param {string} entry.contentType - Content type ('chat', 'nickname', 'signature', 'description', 'avatar')
|
||||
* @param {string} entry.contentSummary - Content summary
|
||||
* @param {string} entry.reporterId - Reporter's user ID
|
||||
* @param {string} entry.reason - Report reason
|
||||
* @returns {Promise<{success: boolean, message: string}>}
|
||||
*/
|
||||
async submitReport(entry) {
|
||||
if (!this._serverUrl) {
|
||||
return { success: false, message: '举报功能暂不可用' };
|
||||
}
|
||||
try {
|
||||
const res = await this._request({
|
||||
url: `${this._serverUrl}/api/content/report`,
|
||||
method: 'POST',
|
||||
data: entry,
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
console.error('[ContentSecurity] submitReport error:', err && (err.message || err.errMsg) || 'unknown');
|
||||
return { success: false, message: '举报提交失败,请稍后再试' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full text check: local check first, then server-side if local passes.
|
||||
* @param {string} openid
|
||||
* @param {string} content
|
||||
* @param {number} scene
|
||||
* @returns {Promise<{pass: boolean, localViolation: boolean, serverResult: object|null, errorMessage: string}>}
|
||||
*/
|
||||
async fullTextCheck(openid, content, scene) {
|
||||
// Step 1: Local sensitive word check (fast)
|
||||
const localResult = this.checkLocalText(content);
|
||||
if (localResult.hasViolation) {
|
||||
return {
|
||||
pass: false,
|
||||
localViolation: true,
|
||||
serverResult: null,
|
||||
errorMessage: this._getErrorMessage({ suggest: 'risky' }, scene),
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Check mute status (for nickname/signature/description scenes)
|
||||
if (scene === SCENE.NICKNAME || scene === SCENE.SIGNATURE || scene === SCENE.DESCRIPTION) {
|
||||
const muteStatus = await this.getMuteStatus(openid);
|
||||
if (muteStatus.isMuted) {
|
||||
return {
|
||||
pass: false,
|
||||
localViolation: false,
|
||||
serverResult: null,
|
||||
errorMessage: '由于违规行为,您暂无法修改个人信息',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Server-side msgSecCheck
|
||||
const serverResult = await this.checkTextContent(openid, content, scene);
|
||||
if (!serverResult.pass) {
|
||||
return {
|
||||
pass: false,
|
||||
localViolation: false,
|
||||
serverResult,
|
||||
errorMessage: this._getErrorMessage(serverResult, scene),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pass: true,
|
||||
localViolation: false,
|
||||
serverResult,
|
||||
errorMessage: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate error message based on scene and result.
|
||||
* @param {object} result
|
||||
* @param {number} scene
|
||||
* @returns {string}
|
||||
*/
|
||||
_getErrorMessage(result, scene) {
|
||||
if (scene === SCENE.NICKNAME) {
|
||||
return '昵称包含违规内容,请重新输入';
|
||||
}
|
||||
if (scene === SCENE.CHAT) {
|
||||
return '消息发送失败:内容违规';
|
||||
}
|
||||
if (scene === SCENE.SIGNATURE || scene === SCENE.DESCRIPTION) {
|
||||
return '内容包含违规信息,请修改';
|
||||
}
|
||||
return '内容违规,请修改';
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request using wx.request.
|
||||
* @param {object} options
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
_request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const header = Object.assign({}, options.header || { 'content-type': 'application/json' });
|
||||
// Add X-Game-Id header for multi-tenant isolation
|
||||
if (this._gameId) {
|
||||
header['X-Game-Id'] = this._gameId;
|
||||
}
|
||||
wx.request({
|
||||
url: options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
header,
|
||||
success: (res) => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(res.data);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload image for security check using wx.uploadFile.
|
||||
* @param {string} filePath - Local file path
|
||||
* @param {string} openid - User openid
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
_uploadImage(filePath, openid) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const header = {};
|
||||
if (this._gameId) {
|
||||
header['X-Game-Id'] = this._gameId;
|
||||
}
|
||||
wx.uploadFile({
|
||||
url: `${this._serverUrl}/api/content/check-image`,
|
||||
filePath,
|
||||
name: 'image',
|
||||
formData: { openid },
|
||||
header,
|
||||
success: (res) => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
const data = JSON.parse(res.data);
|
||||
resolve(data);
|
||||
} catch (e) {
|
||||
reject(new Error('Invalid response'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load word list from local cache.
|
||||
*/
|
||||
_loadFromCache() {
|
||||
try {
|
||||
const timestamp = wx.getStorageSync(CACHE_TIMESTAMP_KEY);
|
||||
const version = wx.getStorageSync(CACHE_VERSION_KEY);
|
||||
const words = wx.getStorageSync(CACHE_KEY);
|
||||
|
||||
if (timestamp && version && words && Array.isArray(words)) {
|
||||
// Check if cache is still valid
|
||||
if (Date.now() - timestamp < CACHE_VALIDITY_MS) {
|
||||
this._words = words;
|
||||
this._version = version;
|
||||
console.log('[ContentSecurity] Loaded ' + words.length + ' words from cache (version: ' + version + ')');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ContentSecurity] Failed to load cache:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save word list to local cache.
|
||||
*/
|
||||
_saveToCache() {
|
||||
try {
|
||||
wx.setStorageSync(CACHE_KEY, this._words);
|
||||
wx.setStorageSync(CACHE_VERSION_KEY, this._version);
|
||||
wx.setStorageSync(CACHE_TIMESTAMP_KEY, Date.now());
|
||||
} catch (err) {
|
||||
console.error('[ContentSecurity] Failed to save cache:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current word count.
|
||||
* @returns {number}
|
||||
*/
|
||||
getWordCount() {
|
||||
return this._words.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version.
|
||||
* @returns {string}
|
||||
*/
|
||||
getVersion() {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if initialized.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isInitialized() {
|
||||
return this._initialized;
|
||||
}
|
||||
}
|
||||
|
||||
// Export scene and report reason constants
|
||||
ContentSecurityManager.SCENE = SCENE;
|
||||
ContentSecurityManager.REPORT_REASONS = REPORT_REASONS;
|
||||
ContentSecurityManager.REPORT_REASON_LABELS = REPORT_REASON_LABELS;
|
||||
|
||||
module.exports = ContentSecurityManager;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user