Files
tankwar_proj/js/scenes/MenuScene.js
2026-05-02 13:50:52 +08:00

570 lines
18 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
// ============================================================
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;
// 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;
},
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);
// ---- 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)
// ============================================================
/**
* Kick off profile acquisition on menu enter. Since WeChat 2022-10 there
* is NO silent way to get the real nickname — we draw a canvas button and
* call `wx.getUserProfile` directly from its touchend handler.
* @private
*/
_initPlayerProfile() {
const profile = GameGlobal.playerProfile;
if (!profile) return;
// Best-effort placeholder fetch (used only to pre-fill _nickname with
// "微信用户" on older devices; does not mark granted).
if (typeof profile.fetchSilent === 'function') {
profile.fetchSilent().catch(() => { /* ignore */ });
}
},
// ============================================================
// 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;