1365 lines
45 KiB
JavaScript
1365 lines
45 KiB
JavaScript
/**
|
||
* 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;
|