693 lines
22 KiB
JavaScript
693 lines
22 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
|
||
// ============================================================
|
||
const MC = {
|
||
BG_TOP: '#0b0e17',
|
||
BG_BOT: '#141b2d',
|
||
ACCENT: '#e94560',
|
||
GOLD: '#FFD700',
|
||
GOLD_DIM: '#B8860B',
|
||
BTN_BG: '#16213e',
|
||
BTN_BORDER: '#1e3054',
|
||
BTN_HOVER: '#0f3460',
|
||
BTN_TEXT: '#E8E8E8',
|
||
TITLE: '#FFD700',
|
||
SUBTITLE: '#8899AA',
|
||
FOOTER: '#445566',
|
||
TANK_BODY: '#FFD700',
|
||
TANK_TRACK: '#B8860B',
|
||
};
|
||
|
||
// ============================================================
|
||
// Button Layout
|
||
// ============================================================
|
||
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.55, 280);
|
||
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
|
||
const BTN_GAP = Math.min(8, SCREEN_HEIGHT * 0.015);
|
||
const BTN_START_Y = SCREEN_HEIGHT * 0.38;
|
||
const BTN_X = (SCREEN_WIDTH - BTN_WIDTH) / 2;
|
||
|
||
const HALF_BTN_WIDTH = (BTN_WIDTH - BTN_GAP) / 2;
|
||
const THIRD_BTN_WIDTH = (BTN_WIDTH - BTN_GAP * 2) / 3;
|
||
|
||
// Main game mode buttons (full width, vertical)
|
||
const MAIN_BUTTONS = [
|
||
{ labelKey: 'menu.classic', mode: GAME_MODE.CLASSIC, scene: SCENE.GAME },
|
||
{ labelKey: 'menu.endless', mode: GAME_MODE.ENDLESS, scene: SCENE.GAME },
|
||
{ labelKey: 'menu.pvp', mode: GAME_MODE.PVP, scene: SCENE.PVP_ROOM },
|
||
{ labelKey: 'menu.team3v3', mode: GAME_MODE.TEAM_3V3, scene: SCENE.TEAM_ROOM },
|
||
];
|
||
|
||
// Utility buttons: daily gold, skin, ranking, settings (grid)
|
||
// NOTE: Shop button is temporarily disabled
|
||
const UTIL_BUTTONS = [
|
||
{ labelKey: 'dailyGold.btn', mode: null, scene: 'DAILY_GOLD' },
|
||
{ labelKey: 'menu.skin', mode: null, scene: SCENE.SKIN },
|
||
{ labelKey: 'menu.ranking', mode: null, scene: SCENE.RANKING },
|
||
{ labelKey: 'menu.settings', mode: null, scene: SCENE.SETTINGS },
|
||
];
|
||
|
||
// Pre-calculate button rects for main buttons
|
||
const mainBtnRects = MAIN_BUTTONS.map((btn, i) => ({
|
||
x: BTN_X,
|
||
y: BTN_START_Y + i * (BTN_HEIGHT + BTN_GAP),
|
||
w: BTN_WIDTH,
|
||
h: BTN_HEIGHT,
|
||
...btn,
|
||
}));
|
||
|
||
// Pre-calculate button rects for utility buttons (2 rows x 2 cols)
|
||
const utilStartY = BTN_START_Y + MAIN_BUTTONS.length * (BTN_HEIGHT + BTN_GAP) + BTN_GAP;
|
||
const utilBtnRects = UTIL_BUTTONS.map((btn, i) => {
|
||
const row = Math.floor(i / 2);
|
||
const col = i % 2;
|
||
return {
|
||
x: BTN_X + col * (HALF_BTN_WIDTH + BTN_GAP),
|
||
y: utilStartY + row * (BTN_HEIGHT + BTN_GAP),
|
||
w: HALF_BTN_WIDTH,
|
||
h: BTN_HEIGHT,
|
||
...btn,
|
||
};
|
||
});
|
||
|
||
const buttonRects = [...mainBtnRects, ...utilBtnRects];
|
||
|
||
// ============================================================
|
||
// Menu Scene
|
||
// ============================================================
|
||
const MenuScene = {
|
||
_pressedIndex: -1,
|
||
_tankAnim: 0,
|
||
|
||
enter() {
|
||
this._pressedIndex = -1;
|
||
this._tankAnim = 0;
|
||
this._avatarImg = null;
|
||
|
||
// 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;
|
||
GameGlobal._pendingTeamId = null;
|
||
console.log(`[MenuScene] Found pendingTeamId: ${teamId}, will auto-navigate to TeamRoomScene`);
|
||
setTimeout(() => {
|
||
console.log(`[MenuScene] Auto-navigating to TeamRoomScene with teamId: ${teamId}`);
|
||
const sm = GameGlobal.sceneManager;
|
||
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
|
||
const TeamRoomScene = require('./TeamRoomScene');
|
||
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
|
||
}
|
||
sm.switchTo(SCENE.TEAM_ROOM, { teamId });
|
||
}, 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 ----
|
||
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.025;
|
||
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);
|
||
|
||
// ---- Player Avatar & Nickname (top-left) ----
|
||
const profile = GameGlobal.playerProfile;
|
||
const avatarSize = 28;
|
||
const avatarX = 10;
|
||
const avatarY = 10;
|
||
const avatarR = avatarSize / 2;
|
||
|
||
// Avatar circle background
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(avatarX + avatarR, avatarY + avatarR, avatarR, 0, Math.PI * 2);
|
||
ctx.closePath();
|
||
ctx.fillStyle = 'rgba(30,48,84,0.7)';
|
||
ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255,215,0,0.4)';
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
|
||
// Avatar image or default icon
|
||
if (profile && profile.avatarUrl && this._avatarImg && this._avatarImg.complete) {
|
||
ctx.clip();
|
||
ctx.drawImage(this._avatarImg, avatarX, avatarY, avatarSize, avatarSize);
|
||
} else {
|
||
// Default user icon (simple silhouette)
|
||
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
|
||
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);
|
||
|
||
// Hint text (only if not yet granted)
|
||
if (profile && !profile.granted) {
|
||
ctx.font = '9px Arial';
|
||
ctx.fillStyle = MC.SUBTITLE;
|
||
ctx.fillText(t('menu.tapToAuth') || 'Tap to authorize', avatarX + avatarSize + 6, avatarY + avatarR + 8);
|
||
}
|
||
|
||
// ---- Gold Balance (top-right pill) ----
|
||
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 + 16;
|
||
const pillH = 22;
|
||
const pillX = SCREEN_WIDTH - pillW - 12;
|
||
const pillY = 10;
|
||
|
||
ctx.fillStyle = 'rgba(255, 215, 0, 0.08)';
|
||
ctx.strokeStyle = 'rgba(255, 215, 0, 0.3)';
|
||
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);
|
||
|
||
// ---- Title with glow ----
|
||
ctx.save();
|
||
ctx.shadowColor = MC.GOLD;
|
||
ctx.shadowBlur = 16;
|
||
ctx.fillStyle = MC.TITLE;
|
||
ctx.font = 'bold 34px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(t('menu.title'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.15);
|
||
ctx.restore();
|
||
|
||
// ---- Subtitle ----
|
||
ctx.fillStyle = MC.SUBTITLE;
|
||
ctx.font = '13px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(t('menu.subtitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.22);
|
||
|
||
// ---- Animated Tank Icon ----
|
||
this._drawTankIcon(ctx, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.30);
|
||
|
||
// ---- Main Buttons ----
|
||
for (let i = 0; i < mainBtnRects.length; i++) {
|
||
const btn = mainBtnRects[i];
|
||
const isPressed = this._pressedIndex === i;
|
||
this._drawMenuButton(ctx, btn, t(btn.labelKey), isPressed, 'bold 16px Arial', 8);
|
||
}
|
||
|
||
// ---- Utility Buttons ----
|
||
for (let i = 0; i < utilBtnRects.length; i++) {
|
||
const btn = utilBtnRects[i];
|
||
const globalIdx = mainBtnRects.length + i;
|
||
const isPressed = this._pressedIndex === globalIdx;
|
||
|
||
const isDailyGold = btn.scene === 'DAILY_GOLD';
|
||
let label = t(btn.labelKey);
|
||
let customBg = null;
|
||
|
||
if (isDailyGold) {
|
||
const remaining = GameGlobal.adManager ? GameGlobal.adManager.getDailyGoldRemaining() : 0;
|
||
if (remaining > 0) {
|
||
label = `${t('dailyGold.btn')} ${remaining}/3`;
|
||
customBg = '#1a3a2a';
|
||
} else {
|
||
label = t('dailyGold.exhausted') || 'Come back tomorrow';
|
||
customBg = '#2a2a2a';
|
||
}
|
||
}
|
||
|
||
this._drawMenuButton(ctx, btn, label, isPressed, 'bold 13px Arial', 6, customBg);
|
||
}
|
||
|
||
// ---- Footer ----
|
||
ctx.fillStyle = MC.FOOTER;
|
||
ctx.font = '10px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 18);
|
||
},
|
||
|
||
// ---- Menu Button ----
|
||
_drawMenuButton(ctx, btn, label, isPressed, font, radius, customBg) {
|
||
const r = radius || 8;
|
||
|
||
// Shadow
|
||
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||
this._roundRect(ctx, btn.x + 1, btn.y + 2, btn.w, btn.h, r);
|
||
ctx.fill();
|
||
|
||
// Body
|
||
ctx.fillStyle = isPressed ? MC.BTN_HOVER : (customBg || MC.BTN_BG);
|
||
ctx.strokeStyle = MC.BTN_BORDER;
|
||
ctx.lineWidth = 1.5;
|
||
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, r);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
|
||
// Label
|
||
ctx.fillStyle = isPressed ? MC.TITLE : MC.BTN_TEXT;
|
||
ctx.font = font || 'bold 16px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2);
|
||
},
|
||
|
||
// ---- Tank Icon ----
|
||
_drawTankIcon(ctx, cx, cy) {
|
||
const bounce = Math.sin(this._tankAnim * 3) * 2;
|
||
const pulse = 0.5 + 0.5 * Math.sin(this._tankAnim * 2);
|
||
const s = 15; // body half-size (square tank: 2s × 2s)
|
||
|
||
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
|
||
*/
|
||
_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_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;
|