Files
tankwar_proj/js/scenes/MenuScene.js
2026-06-07 22:08:00 +08:00

1365 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MenuScene.js
* Main menu scene — military-tech themed UI with game title and mode selection.
* Rendered entirely with Canvas API (no DOM).
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
GAME_MODE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Style — Military-tech theme matching reference design
// ============================================================
const MC = {
BG_TOP: '#0a0e1a',
BG_BOT: '#111827',
ACCENT: '#e94560',
GOLD: '#FFD700',
GOLD_LIGHT: '#FFF3A8',
GOLD_DIM: '#B8860B',
BTN_BG: '#162844',
BTN_BG_HIGHLIGHT: '#1a3555',
BG_GRAD_START: '#142236',
BG_GRAD_END: '#0c1320',
BTN_BORDER_GLOW: 'rgba(70,140,220,0.4)',
BTN_BORDER: 'rgba(50,100,170,0.5)',
BTN_HOVER: '#244a75',
BTN_TEXT_PRIMARY: '#FFFFFF',
BTN_TEXT_SECONDARY: '#88AACC',
TITLE: '#FFD700',
SUBTITLE_BG: 'rgba(25,55,95,0.85)',
SUBTITLE_BORDER: '#3a68a0',
SUBTITLE_TEXT: '#7CB9E8',
FOOTER: '#445566',
ICON_GOLD: '#FFD700',
ICON_BLUE: '#4A90D9',
HOT_BADGE: '#e94560',
UTIL_GREEN: '#1a4a2e',
};
// ============================================================
// Layout — Left-Right Split (Reference Design)
//
// ┌─ Left Panel (~54%) ─┬─ Right Panel (~43%) ─┐
// │ Tank visual area │ 5 mode buttons │
// │ + Title + Subtitle │ (vertical stack) │
// │ + Promo text │ │
// ├──────────────────────┴───────────────────────┤
// │ Bottom Utility Bar (4 columns) │
// └───────────────────────────────────────────────┘
// ============================================================
const PAD = Math.max(12, SCREEN_WIDTH * 0.03);
const TOP_BAR_H = 48;
const SPLIT_X = SCREEN_WIDTH * 0.55;
const RIGHT_PAD = Math.max(14, PAD * 2);
// --- Right Panel: Mode Buttons (vertical) ---
const MODE_INNER_PAD = PAD; // inner left padding inside right panel
const MODE_BTN_W = SCREEN_WIDTH - SPLIT_X - RIGHT_PAD - MODE_INNER_PAD;
const MODE_BTN_H = Math.min(66, SCREEN_HEIGHT * 0.095);
const MODE_GAP = Math.max(10, SCREEN_HEIGHT * 0.015);
const MODE_START_Y = TOP_BAR_H + SCREEN_HEIGHT * 0.035;
// --- Bottom Utility Bar ---
const UTIL_BAR_Y = SCREEN_HEIGHT * 0.805;
const UTIL_BAR_H = Math.min(58, SCREEN_HEIGHT * 0.088);
const UTIL_EDGE_PAD = Math.max(24, SCREEN_WIDTH * 0.058);
const UTIL_GAP = Math.max(14, SCREEN_WIDTH * 0.025);
const UTIL_COL_W = (SCREEN_WIDTH - UTIL_EDGE_PAD * 2 - UTIL_GAP * 3) / 4;
// ============================================================
// Button Definitions
// ============================================================
const MAIN_BUTTONS = [
{ labelKey: 'menu.classic', subKey: 'menu.classic.sub', icon: 'trophy', mode: GAME_MODE.CLASSIC, scene: SCENE.GAME },
{ labelKey: 'menu.endless', subKey: 'menu.endless.sub', icon: 'target', mode: GAME_MODE.ENDLESS, scene: SCENE.GAME },
{ labelKey: 'menu.pvp', subKey: 'menu.pvp.sub', icon: 'swords', mode: GAME_MODE.PVP, scene: SCENE.PVP_ROOM },
{ labelKey: 'menu.team2v2', subKey: 'menu.team2v2.sub', icon: 'swords', mode: GAME_MODE.TEAM_2V2, scene: SCENE.TEAM_2V2_ROOM },
{ labelKey: 'menu.team3v3', subKey: 'menu.team3v3.sub', icon: 'shield', mode: GAME_MODE.TEAM_3V3, scene: SCENE.TEAM_ROOM, hot: true },
];
const UTIL_BUTTONS = [
{ labelKey: 'dailyGold.btn', subKey: 'dailyGold.desc', icon: 'coins', mode: null, scene: 'DAILY_GOLD' },
{ labelKey: 'menu.skin', subKey: 'menu.skin.sub', icon: 'skin', mode: null, scene: SCENE.SKIN },
{ labelKey: 'menu.ranking', subKey: 'menu.ranking.sub', icon: 'ranking', mode: null, scene: SCENE.RANKING },
{ labelKey: 'menu.settings', subKey: 'menu.settings.sub', icon: 'settings', mode: null, scene: SCENE.SETTINGS },
];
// Pre-calculate mode button rects (right panel, vertical stack)
const mainBtnRects = MAIN_BUTTONS.map((btn, i) => ({
x: SPLIT_X + MODE_INNER_PAD,
y: MODE_START_Y + i * (MODE_BTN_H + MODE_GAP),
w: MODE_BTN_W,
h: MODE_BTN_H,
...btn,
}));
// Pre-calculate utility button rects (bottom row)
const utilBtnRects = UTIL_BUTTONS.map((btn, i) => ({
x: UTIL_EDGE_PAD + i * (UTIL_COL_W + UTIL_GAP),
y: UTIL_BAR_Y,
w: UTIL_COL_W,
h: UTIL_BAR_H,
...btn,
}));
const buttonRects = [...mainBtnRects, ...utilBtnRects];
// ============================================================
// Menu Scene
// ============================================================
const MenuScene = {
_pressedIndex: -1,
_tankAnim: 0,
// Background image
_bgImage: null,
enter() {
this._pressedIndex = -1;
this._tankAnim = 0;
this._avatarImg = null;
this._bgImage = null;
// Load background image
this._loadBgImage();
// Load avatar image if profile has one
const profile = GameGlobal.playerProfile;
if (profile && profile.avatarUrl) {
this._loadAvatarImage(profile.avatarUrl);
}
// Listen for profile updates (avatar may arrive after initial render)
this._profileHandler = (data) => {
if (data && data.avatarUrl && !this._avatarImg) {
this._loadAvatarImage(data.avatarUrl);
}
};
const bus = GameGlobal.eventBus;
if (bus && typeof bus.on === 'function') {
bus.on('profile:updated', this._profileHandler);
}
// Kick off nickname acquisition as early as possible so that later
// network messages (CREATE_TEAM, SOLO_MATCH, ...) can carry it.
this._initPlayerProfile();
if (GameGlobal._pendingTeamId) {
const teamId = GameGlobal._pendingTeamId;
const teamMode = GameGlobal._pendingTeamMode || null;
GameGlobal._pendingTeamId = null;
GameGlobal._pendingTeamMode = null;
const is2v2 = teamMode === '2v2';
const targetScene = is2v2 ? SCENE.TEAM_2V2_ROOM : SCENE.TEAM_ROOM;
const sceneName = is2v2 ? 'Team2v2RoomScene' : 'TeamRoomScene';
console.log(`[MenuScene] Found pendingTeamId: ${teamId}, mode: ${teamMode || '3v3'}, will auto-navigate to ${sceneName}`);
setTimeout(() => {
console.log(`[MenuScene] Auto-navigating to ${sceneName} with teamId: ${teamId}`);
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(targetScene)) {
const SceneModule = is2v2
? require('./Team2v2RoomScene')
: require('./TeamRoomScene');
sm.register(targetScene, SceneModule);
}
sm.switchTo(targetScene, { teamId });
}, 100);
}
if (GameGlobal._pendingRoomId) {
const roomId = GameGlobal._pendingRoomId;
GameGlobal._pendingRoomId = null;
console.log(`[MenuScene] Found pendingRoomId: ${roomId}, will auto-navigate to RoomScene`);
setTimeout(() => {
console.log(`[MenuScene] Auto-navigating to RoomScene with roomId: ${roomId}`);
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.PVP_ROOM)) {
const RoomScene = require('./RoomScene');
sm.register(SCENE.PVP_ROOM, RoomScene);
}
sm.switchTo(SCENE.PVP_ROOM, { roomId });
}, 100);
}
},
exit() {
this._pressedIndex = -1;
// Destroy UserInfoButton overlay when leaving the menu
const profile = GameGlobal.playerProfile;
if (profile && typeof profile.destroyUserInfoButton === 'function') {
profile.destroyUserInfoButton();
}
// Remove profile update listener
const bus = GameGlobal.eventBus;
if (bus && typeof bus.off === 'function' && this._profileHandler) {
bus.off('profile:updated', this._profileHandler);
}
this._profileHandler = null;
this._avatarImg = null;
},
update(dt) {
this._tankAnim += dt;
},
render(ctx) {
// ---- Background (image or fallback gradient) ----
if (this._bgImage) {
// Cover-fit: scale image to fill entire screen, center-crop if aspect differs
const imgW = this._bgImage.width;
const imgH = this._bgImage.height;
const imgRatio = imgW / imgH;
const screenRatio = SCREEN_WIDTH / SCREEN_HEIGHT;
var drawW, drawH, dx, dy;
if (imgRatio > screenRatio) {
drawH = SCREEN_HEIGHT;
drawW = SCREEN_HEIGHT * imgRatio;
dx = (SCREEN_WIDTH - drawW) / 2;
dy = 0;
} else {
drawW = SCREEN_WIDTH;
drawH = SCREEN_WIDTH / imgRatio;
dx = 0;
dy = (SCREEN_HEIGHT - drawH) / 2;
}
ctx.drawImage(this._bgImage, dx, dy, drawW, drawH);
} else {
const bgGrad = ctx.createLinearGradient(0, 0, 0, SCREEN_HEIGHT);
bgGrad.addColorStop(0, MC.BG_TOP);
bgGrad.addColorStop(1, MC.BG_BOT);
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Scan-lines
ctx.globalAlpha = 0.02;
ctx.fillStyle = '#FFFFFF';
for (let sy = 0; sy < SCREEN_HEIGHT; sy += 4) {
ctx.fillRect(0, sy, SCREEN_WIDTH, 1);
}
ctx.globalAlpha = 1;
}
// Top accent bar
const accentGrad = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
accentGrad.addColorStop(0, 'transparent');
accentGrad.addColorStop(0.3, MC.ACCENT);
accentGrad.addColorStop(0.7, MC.ACCENT);
accentGrad.addColorStop(1, 'transparent');
ctx.fillStyle = accentGrad;
ctx.fillRect(0, 0, SCREEN_WIDTH, 3);
// ============================================================
// LEFT PANEL — Branding area (~54% width)
// - Tank icon (animated)
// - Title + subtitle tag
// - Decorative treasure icon
// - Promotional text
// ============================================================
const leftCenterX = SPLIT_X * 0.5;
const leftCenterY = SCREEN_HEIGHT * 0.48;
// ---- Animated Tank Icon (larger, as visual anchor) ----
ctx.save();
ctx.translate(leftCenterX * 0.65, leftCenterY - 10);
const tankScale = 1.6;
ctx.scale(tankScale, tankScale);
ctx.translate(-leftCenterX * 0.65 / tankScale, (-leftCenterY + 10) / tankScale);
this._drawTankIcon(ctx, leftCenterX * 0.65, (leftCenterY - 10) / tankScale);
ctx.restore();
// ---- Title (positioned in upper-right of left panel) ----
const titleX = SPLIT_X * 0.52;
const titleY = TOP_BAR_H + SCREEN_HEIGHT * 0.04;
ctx.save();
ctx.shadowColor = MC.GOLD;
ctx.shadowBlur = 22;
ctx.fillStyle = MC.TITLE;
const titleSize = Math.max(Math.min(SPLIT_X * 0.13, 38), 26);
ctx.font = `bold ${titleSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('menu.title'), titleX, titleY);
// Subtitle tag below title
ctx.shadowBlur = 0;
const subText = t('menu.subtitle');
const sFont = '11px Arial';
ctx.font = sFont;
const stw = ctx.measureText(subText).width;
const stagW = Math.max(stw + 20, 120);
const stagH = 24;
const stagX = titleX - stagW / 2;
const stagY = titleY + titleSize * 0.58;
ctx.fillStyle = MC.SUBTITLE_BG;
ctx.strokeStyle = MC.SUBTITLE_BORDER;
ctx.lineWidth = 1.2;
this._roundRect(ctx, stagX, stagY, stagW, stagH, stagH / 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = MC.SUBTITLE_TEXT;
ctx.font = sFont;
ctx.fillText(subText, titleX, stagY + stagH / 2);
ctx.restore();
// ---- Promotional Text (bottom-left) ----
ctx.save();
const promoY = UTIL_BAR_Y - 38;
const promoX = PAD * 3.2;
// "3v3 团队竞技" style text
ctx.fillStyle = '#4A9EFF';
ctx.font = `bold ${Math.max(16, SCREEN_WIDTH * 0.042)}px Arial`;
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
// Add subtle glow
ctx.shadowColor = '#4A9EFF';
ctx.shadowBlur = 8;
const promoLine1 = '3v3 团队竞技';
ctx.fillText(promoLine1, promoX, promoY);
// "随时随地开一局!" style text
ctx.shadowBlur = 0;
ctx.fillStyle = '#FFD54F';
ctx.font = `bold ${Math.max(14, SCREEN_WIDTH * 0.037)}px Arial`;
const promoLine2 = '随时随地开一局!';
ctx.fillText(promoLine2, promoX, promoY + 32);
ctx.restore();
// ============================================================
// RIGHT PANEL — Mode buttons (vertical stack)
// ============================================================
for (let i = 0; i < mainBtnRects.length; i++) {
const btn = mainBtnRects[i];
const isPressed = this._pressedIndex === i;
this._drawModeButton(
ctx, btn,
t(btn.labelKey),
isPressed,
null,
t(btn.subKey || ''),
btn.icon,
!!btn.hot
);
}
// ============================================================
// TOP BAR — Avatar + Gold pill
// ============================================================
// Player Avatar & Nickname (top-left)
const profile = GameGlobal.playerProfile;
const avatarSize = 28;
const avatarX = PAD;
const avatarY = 8;
const avatarR = avatarSize / 2;
ctx.save();
ctx.beginPath();
ctx.arc(avatarX + avatarR, avatarY + avatarR, avatarR, 0, Math.PI * 2);
ctx.closePath();
ctx.fillStyle = 'rgba(30,48,84,0.75)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,215,0,0.45)';
ctx.lineWidth = 1.2;
ctx.stroke();
if (profile && profile.avatarUrl && this._avatarImg && this._avatarImg.complete) {
ctx.clip();
ctx.drawImage(this._avatarImg, avatarX, avatarY, avatarSize, avatarSize);
} else {
ctx.fillStyle = 'rgba(255,215,0,0.5)';
ctx.beginPath();
ctx.arc(avatarX + avatarR, avatarY + avatarR - 2, avatarR * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(avatarX + avatarR, avatarY + avatarR + avatarR * 0.55, avatarR * 0.55, avatarR * 0.3, 0, Math.PI, 0);
ctx.fill();
}
ctx.restore();
// Nickname next to avatar
const displayName = profile ? profile.getDisplayName() : 'Tanker';
ctx.font = 'bold 11px Arial';
ctx.fillStyle = MC.GOLD;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(displayName, avatarX + avatarSize + 6, avatarY + avatarR - 5);
if (profile && !profile.granted) {
ctx.font = '9px Arial';
ctx.fillStyle = MC.FOOTER;
ctx.fillText(t('menu.tapToAuth') || 'Tap to authorize', avatarX + avatarSize + 6, avatarY + avatarR + 8);
}
// Gold Balance pill (to the right of nickname)
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
const goldText = `🪙 ${gold}`;
ctx.font = 'bold 12px Arial';
const gtw = ctx.measureText(goldText).width;
const pillW = gtw + 14;
const pillH = 22;
const pillX = avatarX + avatarSize + 6; // right under nickname
const pillY = avatarY + avatarR + 12;
ctx.fillStyle = 'rgba(255,215,0,0.08)';
ctx.strokeStyle = 'rgba(255,215,0,0.35)';
ctx.lineWidth = 1;
this._roundRect(ctx, pillX, pillY, pillW, pillH, pillH / 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = MC.GOLD;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(goldText, pillX + pillW / 2, pillY + pillH / 2);
// ============================================================
// BOTTOM UTILITY BAR — 4 columns
// ============================================================
for (let i = 0; i < utilBtnRects.length; i++) {
const btn = utilBtnRects[i];
const globalIdx = mainBtnRects.length + i;
const isPressed = this._pressedIndex === globalIdx;
let label = t(btn.labelKey);
let subtitle = t(btn.subKey || '');
let customBg = null;
let iconOverride = btn.icon;
if (btn.scene === 'DAILY_GOLD') {
const remaining = GameGlobal.adManager ? GameGlobal.adManager.getDailyGoldRemaining() : 0;
if (remaining > 0) {
label = `${t('dailyGold.btn')} ${remaining}/3`;
subtitle = t('dailyGold.desc') || '';
customBg = MC.UTIL_GREEN;
iconOverride = 'coins';
} else {
label = t('dailyGold.exhausted') || '...';
subtitle = '';
customBg = '#222';
iconOverride = 'coins';
}
}
this._drawUtilButton(ctx, btn, label, subtitle, isPressed, customBg, iconOverride);
}
// ---- Footer version ----
ctx.fillStyle = MC.FOOTER;
ctx.font = `${Math.max(9, SCREEN_WIDTH * 0.022)}px Arial`;
ctx.textAlign = 'center';
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 10);
},
// ---- Mode Button (Right panel: icon + two-line text + optional hot badge) ----
_drawModeButton(ctx, btn, label, isPressed, font, subtitle, iconType, isHot) {
const r = 10;
ctx.save();
ctx.globalAlpha = 0.85;
const iconSz = Math.max(26, Math.min(btn.h * 0.48, 34));
const pad = Math.max(10, iconSz * 0.38);
const txtOff = iconSz + pad + 6;
const iconX = btn.x + pad;
const iconY = btn.y + (btn.h - iconSz) / 2;
const textX = btn.x + txtOff;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.35)';
this._roundRect(ctx, btn.x + 2, btn.y + 3, btn.w, btn.h, r);
ctx.fill();
// Body gradient — slightly lighter for mode buttons
const bodyGrad = ctx.createLinearGradient(btn.x, btn.y, btn.x, btn.y + btn.h);
if (isPressed) {
bodyGrad.addColorStop(0, '#284a70');
bodyGrad.addColorStop(1, '#1c3055');
} else if (isHot) {
bodyGrad.addColorStop(0, '#1a4080');
bodyGrad.addColorStop(1, '#142d55');
} else {
bodyGrad.addColorStop(0, MC.BTN_BG_HIGHLIGHT);
bodyGrad.addColorStop(1, MC.BTN_BG);
}
ctx.fillStyle = bodyGrad;
// Border glow
if (isHot) {
ctx.strokeStyle = 'rgba(100,180,255,0.6)';
ctx.lineWidth = 1.5;
} else {
ctx.strokeStyle = isPressed ? MC.GOLD : MC.BTN_BORDER_GLOW;
ctx.lineWidth = isPressed ? 2 : 1.2;
}
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, r);
ctx.fill();
ctx.stroke();
// Inner top highlight
if (!isPressed) {
ctx.save();
ctx.clip();
const hl = ctx.createLinearGradient(btn.x, btn.y, btn.x, btn.y + btn.h * 0.32);
hl.addColorStop(0, isHot ? 'rgba(100,180,255,0.12)' : 'rgba(255,255,255,0.08)');
hl.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hl;
ctx.fillRect(btn.x, btn.y, btn.w, btn.h * 0.32);
ctx.restore();
}
// Icon
if (iconType) {
this._drawButtonIcon(ctx, iconX, iconY, iconSz, iconType, isPressed);
}
// Two-line text — larger fonts with clear vertical separation
const pFont = font || `bold ${Math.max(15, btn.h * 0.32)}px Arial`;
const sFont = `${Math.max(11, btn.h * 0.24)}px Arial`;
if (subtitle) {
ctx.fillStyle = isPressed ? MC.GOLD_LIGHT : MC.BTN_TEXT_PRIMARY;
ctx.font = pFont;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(label, textX, btn.y + btn.h * 0.33);
ctx.fillStyle = isPressed ? 'rgba(200,220,255,0.85)' : MC.BTN_TEXT_SECONDARY;
ctx.font = sFont;
ctx.fillText(subtitle, textX, btn.y + btn.h * 0.73);
} else {
ctx.fillStyle = isPressed ? MC.TITLE : MC.BTN_TEXT_PRIMARY;
ctx.font = pFont;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2);
}
// Hot badge ("火爆" style corner badge)
if (isHot && !isPressed) {
const badgeW = 34;
const badgeH = 18;
const bx = btn.x + btn.w - badgeW - 2;
const by = btn.y + 2;
ctx.save();
ctx.fillStyle = MC.HOT_BADGE;
this._roundRect(ctx, bx, by, badgeW, badgeH, 4);
ctx.fill();
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('HOT', bx + badgeW / 2, by + badgeH / 2);
ctx.restore();
}
ctx.restore();
},
/**
* Draw decorative treasure/chest icon in left panel.
*/
_drawTreasureIcon(ctx, cx, cy) {
const pulse = 0.5 + 0.5 * Math.sin(this._tankAnim * 1.8);
const size = 36;
ctx.save();
// Glow halo
ctx.globalAlpha = 0.12 + pulse * 0.1;
ctx.fillStyle = MC.GOLD;
ctx.beginPath();
ctx.arc(cx, cy, size * 0.7, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
// Chest body (rounded rectangle with metallic look)
const bw = size * 0.85;
const bh = size * 0.65;
const br = 5;
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.25)';
this._roundRect(ctx, cx - bw / 2 + 2, cy - bh / 2 + 3, bw, bh, br);
ctx.fill();
// Body gradient
const chestGrad = ctx.createLinearGradient(cx - bw / 2, cy - bh / 2, cx - bw / 2, cy + bh / 2);
chestGrad.addColorStop(0, '#FFD54F');
chestGrad.addColorStop(0.3, MC.GOLD);
chestGrad.addColorStop(0.7, '#DAA520');
chestGrad.addColorStop(1, '#B8860B');
ctx.save();
ctx.shadowColor = MC.GOLD;
ctx.shadowBlur = 12 + pulse * 5;
ctx.fillStyle = chestGrad;
this._roundRect(ctx, cx - bw / 2, cy - bh / 2, bw, bh, br);
ctx.fill();
ctx.restore();
// Edge outline
ctx.strokeStyle = 'rgba(255,235,150,0.6)';
ctx.lineWidth = 1;
this._roundRect(ctx, cx - bw / 2, cy - bh / 2, bw, bh, br);
ctx.stroke();
// Center lock/keyhole area
const lockR = size * 0.14;
ctx.fillStyle = '#7A5A0A';
ctx.beginPath();
ctx.arc(cx, cy, lockR, 0, Math.PI * 2);
ctx.fill();
// Keyhole inner
ctx.fillStyle = '#FFF3A8';
ctx.beginPath();
ctx.arc(cx, cy - 1, lockR * 0.45, 0, Math.PI * 2);
ctx.fill();
ctx.fillRect(cx - 1.5, cy, 3, lockR * 0.6);
// Horizontal lid line
ctx.strokeStyle = 'rgba(120,80,0,0.5)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx - bw * 0.38, cy - 2);
ctx.lineTo(cx - lockR * 1.3, cy - 2);
ctx.moveTo(cx + lockR * 1.3, cy - 2);
ctx.lineTo(cx + bw * 0.38, cy - 2);
ctx.stroke();
// Decorative rivets
ctx.fillStyle = '#B8860B';
for (const [rx, ry] of [[-bw * 0.32, -bh * 0.25], [bw * 0.32, -bh * 0.25],
[-bw * 0.32, bh * 0.25], [bw * 0.32, bh * 0.25]]) {
ctx.beginPath();
ctx.arc(cx + rx, cy + ry, 2.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
},
/**
* Draw button icons — simplified vector-style icons for each button type.
* @param {CanvasRenderingContext2D} ctx
* @param {number} x - left position
* @param {number} y - top position
* @param {number} size - icon size (square)
* @param {string} type - icon identifier
* @param {boolean} isActive - pressed/highlighted state
*/
_drawButtonIcon(ctx, x, y, size, type, isActive) {
const cx = x + size / 2;
const cy = y + size / 2;
const s = size / 2.2; // drawing scale
ctx.save();
// Icon glow when active
if (isActive) {
ctx.shadowColor = MC.GOLD;
ctx.shadowBlur = 8;
}
const baseColor = isActive ? MC.GOLD_LIGHT : MC.ICON_GOLD;
const dimColor = isActive ? '#E0B020' : MC.GOLD_DIM;
switch (type) {
case 'trophy': {
// Trophy cup
ctx.fillStyle = baseColor;
ctx.beginPath();
ctx.moveTo(cx - s * 0.5, cy - s * 0.7);
ctx.lineTo(cx + s * 0.5, cy - s * 0.7);
ctx.lineTo(cx + s * 0.35, cy - s * 0.1);
ctx.lineTo(cx - s * 0.35, cy - s * 0.1);
ctx.closePath();
ctx.fill();
// Cup base
ctx.fillRect(cx - s * 0.25, cy - s * 0.1, s * 0.5, s * 0.35);
ctx.fillRect(cx - s * 0.45, cy + s * 0.2, s * 0.9, s * 0.15);
// Handles
ctx.strokeStyle = baseColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx - s * 0.5, cy - s * 0.3, s * 0.25, Math.PI * 0.6, Math.PI * 1.4);
ctx.stroke();
ctx.beginPath();
ctx.arc(cx + s * 0.5, cy - s * 0.3, s * 0.25, -Math.PI * 0.4, Math.PI * 0.4);
ctx.stroke();
break;
}
case 'target': {
// Crosshair target
ctx.strokeStyle = baseColor;
ctx.lineWidth = 2.5;
// Outer circle
ctx.beginPath();
ctx.arc(cx, cy, s * 0.7, 0, Math.PI * 2);
ctx.stroke();
// Inner circle
ctx.beginPath();
ctx.arc(cx, cy, s * 0.28, 0, Math.PI * 2);
ctx.stroke();
// Crosshairs
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx - s * 0.95, cy); ctx.lineTo(cx - s * 0.45, cy);
ctx.moveTo(cx + s * 0.45, cy); ctx.lineTo(cx + s * 0.95, cy);
ctx.moveTo(cx, cy - s * 0.95); ctx.lineTo(cx, cy - s * 0.45);
ctx.moveTo(cx, cy + s * 0.45); ctx.lineTo(cx, cy + s * 0.95);
ctx.stroke();
// Center dot
ctx.fillStyle = dimColor;
ctx.beginPath();
ctx.arc(cx, cy, s * 0.12, 0, Math.PI * 2);
ctx.fill();
break;
}
case 'users': {
// Two user figures
ctx.fillStyle = baseColor;
// Left person (head + body)
ctx.beginPath(); ctx.arc(cx - s * 0.32, cy - s * 0.2, s * 0.28, 0, Math.PI * 2); ctx.fill();
ctx.beginPath();
ctx.ellipse(cx - s * 0.32, cy + s * 0.42, s * 0.34, s * 0.22, 0, Math.PI, 0); ctx.fill();
// Right person (slightly behind)
ctx.fillStyle = dimColor;
ctx.globalAlpha = 0.85;
ctx.beginPath(); ctx.arc(cx + s * 0.32, cy - s * 0.12, s * 0.26, 0, Math.PI * 2); ctx.fill();
ctx.beginPath();
ctx.ellipse(cx + s * 0.32, cy + s * 0.48, s * 0.30, s * 0.20, 0, Math.PI, 0); ctx.fill();
break;
}
case 'swords': {
// Two crossed swords
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(Math.PI / 4);
// Sword 1
ctx.fillStyle = baseColor;
ctx.fillRect(-s * 0.08, -s * 0.85, s * 0.16, s * 1.4);
// Guard
ctx.fillRect(-s * 0.38, -s * 0.05, s * 0.76, s * 0.14);
// Sword 2 (rotated)
ctx.rotate(Math.PI / 2);
ctx.fillStyle = dimColor;
ctx.fillRect(-s * 0.08, -s * 0.85, s * 0.16, s * 1.4);
ctx.fillRect(-s * 0.38, -s * 0.05, s * 0.76, s * 0.14);
ctx.restore();
break;
}
case 'coins': {
// Stack of coins
ctx.fillStyle = baseColor;
// Bottom coin (offset)
ctx.beginPath();
ctx.ellipse(cx + s * 0.15, cy + s * 0.18, s * 0.55, s * 0.22, 0, 0, Math.PI * 2);
ctx.fill();
// Top coin
ctx.fillStyle = MC.GOLD_LIGHT;
ctx.beginPath();
ctx.ellipse(cx, cy, s * 0.55, s * 0.22, 0, 0, Math.PI * 2);
ctx.fill();
// Coin detail ($ symbol hint)
ctx.fillStyle = dimColor;
ctx.font = `bold ${Math.floor(s)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('$', cx, cy + 1);
break;
}
case 'skin': {
// Tank/paintbrush icon
ctx.fillStyle = baseColor;
// Tank body
this._roundRect(ctx, cx - s * 0.55, cy - s * 0.15, s * 1.1, s * 0.55, 3);
ctx.fill();
// Barrel
ctx.fillRect(cx + s * 0.2, cy - s * 0.65, s * 0.18, s * 0.52);
// Wheels hint
ctx.fillStyle = dimColor;
ctx.beginPath();
ctx.arc(cx - s * 0.3, cy + s * 0.4, s * 0.15, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(cx + s * 0.3, cy + s * 0.4, s * 0.15, 0, Math.PI * 2);
ctx.fill();
// Star sparkle (skin customization hint)
ctx.fillStyle = MC.GOLD_LIGHT;
for (let i = 0; i < 3; i++) {
const angle = (i / 3) * Math.PI * 2 - Math.PI / 2;
const sx = cx + Math.cos(angle) * s * 0.7;
const sy = cy + Math.sin(angle) * s * 0.7 - s * 0.2;
ctx.beginPath();
ctx.arc(sx, sy, s * 0.08, 0, Math.PI * 2);
ctx.fill();
}
break;
}
case 'ranking': {
// Podium/ranking icon
ctx.fillStyle = baseColor;
// 2nd place (left, shorter)
ctx.fillRect(cx - s * 0.55, cy, s * 0.4, s * 0.6);
ctx.fillStyle = '#C0C0C0';
ctx.fillRect(cx - s * 0.15, cy - s * 0.3, s * 0.4, s * 0.9);
// 1st place (center, tallest)
ctx.fillStyle = baseColor;
ctx.fillRect(cx + s * 0.25, cy + s * 0.15, s * 0.4, s * 0.45);
// Numbers
ctx.fillStyle = '#333';
ctx.font = `bold ${Math.floor(s * 0.3)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('2', cx - s * 0.35, cy + s * 0.3);
ctx.fillText('1', cx + s * 0.05, cy);
ctx.fillText('3', cx + s * 0.45, cy + s * 0.4);
break;
}
case 'settings': {
// Gear/cog icon
ctx.strokeStyle = baseColor;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(cx, cy, s * 0.35, 0, Math.PI * 2);
ctx.stroke();
// Gear teeth
ctx.fillStyle = baseColor;
const teeth = 8;
for (let i = 0; i < teeth; i++) {
const angle = (i / teeth) * Math.PI * 2;
const tx = cx + Math.cos(angle) * s * 0.5;
const ty = cy + Math.sin(angle) * s * 0.5;
ctx.beginPath();
ctx.arc(tx, ty, s * 0.1, 0, Math.PI * 2);
ctx.fill();
}
// Center hole
ctx.fillStyle = dimColor;
ctx.beginPath();
ctx.arc(cx, cy, s * 0.15, 0, Math.PI * 2);
ctx.fill();
break;
}
case 'shield': {
// Shield icon (for 3v3 team mode)
ctx.fillStyle = baseColor;
ctx.beginPath();
ctx.moveTo(cx, cy - s * 0.65);
ctx.lineTo(cx + s * 0.55, cy - s * 0.35);
ctx.lineTo(cx + s * 0.5, cy + s * 0.45);
ctx.lineTo(cx, cy + s * 0.6);
ctx.lineTo(cx - s * 0.5, cy + s * 0.45);
ctx.lineTo(cx - s * 0.55, cy - s * 0.35);
ctx.closePath();
ctx.fill();
// Inner detail
ctx.strokeStyle = dimColor;
ctx.lineWidth = 1;
const shieldInner = 0.7;
ctx.beginPath();
ctx.moveTo(cx, cy - s * 0.65 * shieldInner);
ctx.lineTo(cx + s * 0.55 * shieldInner, cy - s * 0.35 * shieldInner);
ctx.lineTo(cx + s * 0.5 * shieldInner, cy + s * 0.45 * shieldInner);
ctx.lineTo(cx, cy + s * 0.6 * shieldInner);
ctx.lineTo(cx - s * 0.5 * shieldInner, cy + s * 0.45 * shieldInner);
ctx.lineTo(cx - s * 0.55 * shieldInner, cy - s * 0.35 * shieldInner);
ctx.closePath();
ctx.stroke();
// "3V3" text inside
ctx.fillStyle = dimColor;
ctx.font = `bold ${Math.max(6, s * 0.32)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('3v3', cx, cy + s * 0.08);
break;
}
default:
// Fallback circle icon
ctx.fillStyle = baseColor;
ctx.beginPath();
ctx.arc(cx, cy, s * 0.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
},
/**
* Utility bar button — horizontal layout (icon on left, text on right).
* Used for the 4-column bottom utility bar.
*/
_drawUtilButton(ctx, btn, label, subtitle, isPressed, customBg, iconType) {
const r = 8;
ctx.save();
ctx.globalAlpha = 0.85;
const iconSz = Math.min(24, btn.h * 0.48);
const innerPad = Math.max(8, btn.w * 0.06);
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.25)';
this._roundRect(ctx, btn.x + 1, btn.y + 2, btn.w, btn.h, r);
ctx.fill();
// Body gradient
const bgGrad = ctx.createLinearGradient(btn.x, btn.y, btn.x, btn.y + btn.h);
if (isPressed) {
bgGrad.addColorStop(0, '#243a5c');
bgGrad.addColorStop(1, '#1a2d4a');
} else if (customBg) {
bgGrad.addColorStop(0, customBg);
bgGrad.addColorStop(1, this._darkenColor(customBg, 12));
} else {
bgGrad.addColorStop(0, '#182844');
bgGrad.addColorStop(1, '#121d33');
}
ctx.fillStyle = bgGrad;
ctx.strokeStyle = isPressed ? MC.GOLD : 'rgba(50,90,140,0.5)';
ctx.lineWidth = 1;
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, r);
ctx.fill();
ctx.stroke();
// Icon on the left side
if (iconType) {
const ix = btn.x + innerPad;
const iy = btn.y + (btn.h - iconSz) / 2;
this._drawButtonIcon(ctx, ix, iy, iconSz, iconType, isPressed);
}
// Text to the right of icon — match right-panel mode button font size
const txtX = btn.x + innerPad + iconSz + 6;
const maxTxtW = btn.x + btn.w - innerPad - txtX;
// Match mode button primary text size: bold max(15, h*0.32)
const txtFont = `bold ${Math.max(14, btn.h * 0.30)}px Arial`;
ctx.fillStyle = isPressed ? MC.GOLD_LIGHT : MC.BTN_TEXT_PRIMARY;
ctx.font = txtFont;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
// Truncate long text to fit remaining width
let displayLabel = label;
while (ctx.measureText(displayLabel).width > maxTxtW && displayLabel.length > 3) {
displayLabel = displayLabel.slice(0, -2) + '…';
}
ctx.fillText(displayLabel, txtX, btn.y + btn.h / 2);
ctx.restore();
},
/** Darken a hex color by a percentage */
_darkenColor(hex, percent) {
const num = parseInt(hex.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = Math.max((num >> 16) - amt, 0);
const G = Math.max(((num >> 8) & 0x00FF) - amt, 0);
const B = Math.max((num & 0x0000FF) - amt, 0);
return '#' + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1);
},
// ---- Tank Icon ----
_drawTankIcon(ctx, cx, cy) {
const bounce = Math.sin(this._tankAnim * 3) * 1.5;
const pulse = 0.5 + 0.5 * Math.sin(this._tankAnim * 2);
const s = 13; // body half-size (compact: 26×26)
ctx.save();
ctx.translate(cx, cy + bounce);
// ── 1. OUTER GLOW HALO (breathing golden aura) ──
ctx.save();
ctx.globalAlpha = 0.15 + pulse * 0.12;
ctx.fillStyle = MC.GOLD;
ctx.beginPath();
ctx.arc(0, 0, s * 1.55, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// ── 2. GROUND SHADOW (soft ellipse underneath) ──
ctx.save();
ctx.globalAlpha = 0.35;
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.ellipse(0, s + 6, s * 1.1, 3, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// ── 3. TRACKS (left & right, with segment pattern) ──
const trackW = 5;
const trackX = s;
// Left track
ctx.fillStyle = '#4A3508';
this._roundRect(ctx, -trackX - trackW, -s, trackW, s * 2, 1.5);
ctx.fill();
// Right track
this._roundRect(ctx, trackX, -s, trackW, s * 2, 1.5);
ctx.fill();
// Track top highlight
ctx.fillStyle = 'rgba(255, 220, 120, 0.35)';
ctx.fillRect(-trackX - trackW + 0.8, -s + 0.8, trackW - 1.6, 1);
ctx.fillRect(trackX + 0.8, -s + 0.8, trackW - 1.6, 1);
// Track segment lines (metallic plate pattern)
ctx.strokeStyle = 'rgba(0, 0, 0, 0.55)';
ctx.lineWidth = 0.8;
for (let ty = -s + 4; ty < s - 1; ty += 4) {
ctx.beginPath();
ctx.moveTo(-trackX - trackW, ty);
ctx.lineTo(-trackX, ty);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(trackX, ty);
ctx.lineTo(trackX + trackW, ty);
ctx.stroke();
}
// ── 4. BODY (square, with vertical metallic gradient) ──
ctx.save();
ctx.shadowColor = MC.GOLD;
ctx.shadowBlur = 12;
const bodyGrad = ctx.createLinearGradient(0, -s, 0, s);
bodyGrad.addColorStop(0, '#FFF3A8'); // top highlight
bodyGrad.addColorStop(0.3, MC.GOLD); // main gold
bodyGrad.addColorStop(0.75, '#C89A1C');
bodyGrad.addColorStop(1, '#7A5A0A'); // bottom shadow
ctx.fillStyle = bodyGrad;
this._roundRect(ctx, -s, -s, s * 2, s * 2, 3);
ctx.fill();
ctx.restore();
// ── 5. BODY EDGE OUTLINE ──
ctx.strokeStyle = 'rgba(255, 235, 150, 0.7)';
ctx.lineWidth = 1;
this._roundRect(ctx, -s, -s, s * 2, s * 2, 3);
ctx.stroke();
// ── 6. TOP HIGHLIGHT STRIP (bright metallic sheen) ──
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
this._roundRect(ctx, -s + 2, -s + 2, s * 2 - 4, 3, 1.5);
ctx.fill();
// ── 7. ARMOR PLATE DETAIL (X-cross rivets in 4 corners + center crosshair) ──
const rivetOffset = s * 0.6;
ctx.fillStyle = '#6B4A08';
for (const [rx, ry] of [[-rivetOffset, -rivetOffset], [rivetOffset, -rivetOffset],
[-rivetOffset, rivetOffset], [rivetOffset, rivetOffset]]) {
ctx.beginPath();
ctx.arc(rx, ry, 1.4, 0, Math.PI * 2);
ctx.fill();
// Tiny rivet highlight
ctx.fillStyle = 'rgba(255, 240, 180, 0.8)';
ctx.beginPath();
ctx.arc(rx - 0.3, ry - 0.3, 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#6B4A08';
}
// ── 8. TURRET (diamond-shaped center cap with gradient) ──
const turretR = s * 0.42;
ctx.save();
ctx.shadowColor = MC.GOLD;
ctx.shadowBlur = 6;
const turretGrad = ctx.createRadialGradient(-turretR * 0.3, -turretR * 0.3, 0, 0, 0, turretR);
turretGrad.addColorStop(0, '#FFF3A8');
turretGrad.addColorStop(0.5, '#E0B020');
turretGrad.addColorStop(1, '#8B6914');
ctx.fillStyle = turretGrad;
// Diamond (rotated square) for "military hatch" feel
ctx.beginPath();
ctx.moveTo(0, -turretR);
ctx.lineTo(turretR, 0);
ctx.lineTo(0, turretR);
ctx.lineTo(-turretR, 0);
ctx.closePath();
ctx.fill();
ctx.restore();
// Turret edge
ctx.strokeStyle = 'rgba(255, 235, 150, 0.8)';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(0, -turretR);
ctx.lineTo(turretR, 0);
ctx.lineTo(0, turretR);
ctx.lineTo(-turretR, 0);
ctx.closePath();
ctx.stroke();
// Turret internal cross
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
ctx.lineWidth = 0.6;
ctx.beginPath();
ctx.moveTo(0, -turretR); ctx.lineTo(0, turretR);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-turretR, 0); ctx.lineTo(turretR, 0);
ctx.stroke();
// Turret center hatch
ctx.fillStyle = '#FFF3A8';
ctx.beginPath();
ctx.arc(0, 0, 2.5, 0, Math.PI * 2);
ctx.fill();
// ── 9. BARREL (thick, with metallic gradient) ──
const barrelW = 6;
const barrelH = 16;
const barrelY = -s - barrelH;
ctx.save();
const barrelGrad = ctx.createLinearGradient(-barrelW / 2, 0, barrelW / 2, 0);
barrelGrad.addColorStop(0, '#6B4A08');
barrelGrad.addColorStop(0.3, '#B8860B');
barrelGrad.addColorStop(0.5, '#FFF3A8');
barrelGrad.addColorStop(0.7, '#B8860B');
barrelGrad.addColorStop(1, '#6B4A08');
ctx.fillStyle = barrelGrad;
this._roundRect(ctx, -barrelW / 2, barrelY, barrelW, barrelH, 1.5);
ctx.fill();
ctx.restore();
// Barrel outline
ctx.strokeStyle = 'rgba(255, 235, 150, 0.55)';
ctx.lineWidth = 0.6;
this._roundRect(ctx, -barrelW / 2, barrelY, barrelW, barrelH, 1.5);
ctx.stroke();
// ── 10. MUZZLE TIP (flared end with glow) ──
ctx.save();
ctx.shadowColor = '#FFF3A8';
ctx.shadowBlur = 5 + pulse * 3;
ctx.fillStyle = '#2A1D05';
this._roundRect(ctx, -barrelW / 2 - 1, barrelY - 2, barrelW + 2, 3, 1);
ctx.fill();
ctx.restore();
// Muzzle inner bright dot
ctx.fillStyle = `rgba(255, 240, 150, ${0.6 + pulse * 0.4})`;
ctx.beginPath();
ctx.arc(0, barrelY - 0.5, 1, 0, Math.PI * 2);
ctx.fill();
// ── 11. HEADLIGHTS (front of tank — 2 small glowing dots) ──
ctx.fillStyle = `rgba(255, 255, 200, ${0.7 + pulse * 0.3})`;
ctx.shadowColor = '#FFF3A8';
ctx.shadowBlur = 4;
ctx.beginPath();
ctx.arc(-s * 0.55, -s + 2.5, 1.3, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(s * 0.55, -s + 2.5, 1.3, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
},
// ---- Utility ----
_roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
},
// ============================================================
// Player Profile (nickname acquisition)
// ============================================================
/**
* Load avatar image from URL for Canvas rendering.
* @param {string} url
* @private
*/
/**
* Load background image from local path.
* @private
*/
_loadBgImage() {
if (this._bgImage) return;
try {
const img = wx.createImage();
img.onload = () => {
this._bgImage = img;
console.log('[MenuScene] Background image loaded:', img.width, 'x', img.height);
};
img.onerror = (e) => {
console.warn('[MenuScene] Failed to load background image, using gradient fallback');
};
img.src = 'js/ui/images/bg.png';
} catch (e) {
console.warn('[MenuScene] _loadBgImage error:', e && e.message);
}
},
_loadAvatarImage(url) {
if (!url || this._avatarImg) return;
try {
const img = wx.createImage();
img.onload = () => {
this._avatarImg = img;
};
img.onerror = () => {
console.warn('[MenuScene] Failed to load avatar image');
};
img.src = url;
} catch (e) {
console.warn('[MenuScene] _loadAvatarImage error:', e && e.message);
}
},
/**
* Kick off profile acquisition on menu enter.
*
* Since WeChat 2022-10, `wx.getUserInfo` returns "scope unauthorized" —
* the ONLY way to get the real nickname + avatar is `wx.createUserInfoButton`.
* We create a transparent overlay button that covers the avatar area in the
* top-left corner. When the user taps their avatar, WeChat returns the real
* profile data.
* @private
*/
_initPlayerProfile() {
const profile = GameGlobal.playerProfile;
if (!profile) return;
// If already granted (from a previous session's cache), no button needed
if (profile.granted) return;
// Layout must match the avatar area drawn in render():
// avatarX=10, avatarY=10, avatarSize=28 + nickname text area
const avatarLayout = {
x: 8,
y: 8,
width: 120,
height: 32,
};
// Create the UserInfoButton — visible style covering avatar + nickname area
if (typeof profile.fetchSilent === 'function') {
profile.fetchSilent(avatarLayout).then((ok) => {
console.log('[MenuScene] fetchSilent completed, granted=', ok);
if (ok) {
// Button was tapped and profile was granted — destroy it
profile.destroyUserInfoButton();
}
}).catch((e) => {
console.warn('[MenuScene] fetchSilent error:', e);
});
}
},
// ============================================================
// Touch
// ============================================================
handleTouch(eventType, e) {
if (eventType === 'touchstart') {
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
for (let i = 0; i < buttonRects.length; i++) {
const btn = buttonRects[i];
if (tx >= btn.x && tx <= btn.x + btn.w && ty >= btn.y && ty <= btn.y + btn.h) {
this._pressedIndex = i;
break;
}
}
} else if (eventType === 'touchend') {
if (this._pressedIndex >= 0) {
const btn = buttonRects[this._pressedIndex];
this._pressedIndex = -1;
const sm = GameGlobal.sceneManager;
if (btn.scene === SCENE.GAME) {
if (!sm._scenes.has(SCENE.BUFF_SELECT)) {
const BuffSelectScene = require('./BuffSelectScene');
sm.register(SCENE.BUFF_SELECT, BuffSelectScene);
}
sm.switchTo(SCENE.BUFF_SELECT, { mode: btn.mode });
} else if (btn.scene === 'DAILY_GOLD') {
const adm = GameGlobal.adManager;
if (adm && adm.getDailyGoldRemaining() > 0) {
adm.showDailyGoldAd((completed) => {
if (completed) {
try {
wx.showToast({ title: t('dailyGold.reward') || '+100 Gold!', icon: 'none', duration: 1500 });
} catch (e) {}
}
});
}
} else if (btn.scene === SCENE.SHOP) {
if (!sm._scenes.has(SCENE.SHOP)) {
const ShopScene = require('./ShopScene');
sm.register(SCENE.SHOP, ShopScene);
}
sm.switchTo(SCENE.SHOP);
} else if (btn.scene === SCENE.SKIN) {
if (!sm._scenes.has(SCENE.SKIN)) {
const SkinScene = require('./SkinScene');
sm.register(SCENE.SKIN, SkinScene);
}
sm.switchTo(SCENE.SKIN);
} else if (btn.scene === SCENE.SETTINGS) {
if (!sm._scenes.has(SCENE.SETTINGS)) {
const SettingsScene = require('./SettingsScene');
sm.register(SCENE.SETTINGS, SettingsScene);
}
sm.switchTo(SCENE.SETTINGS);
} else if (btn.scene === SCENE.RANKING) {
if (!sm._scenes.has(SCENE.RANKING)) {
const RankingScene = require('./RankingScene');
sm.register(SCENE.RANKING, RankingScene);
}
sm.switchTo(SCENE.RANKING);
} else if (btn.scene === SCENE.PVP_ROOM) {
if (!sm._scenes.has(SCENE.PVP_ROOM)) {
const RoomScene = require('./RoomScene');
sm.register(SCENE.PVP_ROOM, RoomScene);
}
sm.switchTo(SCENE.PVP_ROOM);
} else if (btn.scene === SCENE.TEAM_2V2_ROOM) {
if (!sm._scenes.has(SCENE.TEAM_2V2_ROOM)) {
const Team2v2RoomScene = require('./Team2v2RoomScene');
sm.register(SCENE.TEAM_2V2_ROOM, Team2v2RoomScene);
}
sm.switchTo(SCENE.TEAM_2V2_ROOM);
} else if (btn.scene === SCENE.TEAM_ROOM) {
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
const TeamRoomScene = require('./TeamRoomScene');
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
}
sm.switchTo(SCENE.TEAM_ROOM);
}
}
}
},
};
module.exports = MenuScene;