1618 lines
54 KiB
JavaScript
1618 lines
54 KiB
JavaScript
/**
|
|
* SkinScene.js
|
|
* Tank skin gallery — military-tech themed UI.
|
|
* Players can preview, purchase, and equip cosmetic tank skins.
|
|
*/
|
|
|
|
const {
|
|
SCREEN_WIDTH,
|
|
SCREEN_HEIGHT,
|
|
COLORS,
|
|
SCENE,
|
|
} = require('../base/GameGlobal');
|
|
const { t } = require('../i18n/I18n');
|
|
const { drawTankSkin } = require('../entities/TankSkinRenderer');
|
|
|
|
// ============================================================
|
|
// Layout
|
|
// ============================================================
|
|
const COLS = 2;
|
|
const CARD_GAP = 12;
|
|
const SIDE_PAD = Math.max(16, (SCREEN_WIDTH - 320) / 2);
|
|
const GRID_W = SCREEN_WIDTH - SIDE_PAD * 2;
|
|
const CARD_W = (GRID_W - CARD_GAP) / COLS;
|
|
const CARD_H = 108;
|
|
const HEADER_H = SCREEN_HEIGHT * 0.15;
|
|
const CARDS_START_Y = HEADER_H + 8;
|
|
const BACK_BTN_W = 110;
|
|
const BACK_BTN_H = 38;
|
|
|
|
// ============================================================
|
|
// Colors & Style
|
|
// ============================================================
|
|
const C = {
|
|
BG_TOP: '#0b0e17',
|
|
BG_BOT: '#141b2d',
|
|
HEADER_LINE: '#e94560',
|
|
CARD_BG: 'rgba(22, 33, 62, 0.85)',
|
|
CARD_BORDER: '#1e3054',
|
|
CARD_EQUIPPED_BG: 'rgba(46, 125, 50, 0.18)',
|
|
CARD_EQUIPPED_BORDER: '#4CAF50',
|
|
CARD_LOCKED_BG: 'rgba(15, 20, 35, 0.9)',
|
|
CARD_LOCKED_BORDER: '#2a2a3a',
|
|
GOLD: '#FFD700',
|
|
GOLD_DIM: '#B8860B',
|
|
TEXT_PRIMARY: '#E8E8E8',
|
|
TEXT_SECONDARY: '#8899AA',
|
|
TEXT_MUTED: '#556677',
|
|
GREEN: '#4CAF50',
|
|
RED: '#FF4444',
|
|
ORANGE: '#FF9800',
|
|
BACK_BTN: '#1a2744',
|
|
BACK_BTN_BORDER: '#2a4060',
|
|
TOAST_BG: 'rgba(0,0,0,0.82)',
|
|
};
|
|
|
|
// ============================================================
|
|
// Scene
|
|
// ============================================================
|
|
const SkinScene = {
|
|
_buttons: {},
|
|
_skinCards: [],
|
|
_message: '',
|
|
_messageTimer: 0,
|
|
_animTimer: 0,
|
|
// Scroll state
|
|
_scrollY: 0,
|
|
_maxScrollY: 0,
|
|
_touchStartY: 0,
|
|
_touchLastY: 0,
|
|
_isDragging: false,
|
|
_scrollVelocity: 0,
|
|
|
|
enter() {
|
|
this._message = '';
|
|
this._messageTimer = 0;
|
|
this._animTimer = 0;
|
|
this._scrollY = 0;
|
|
this._scrollVelocity = 0;
|
|
this._isDragging = false;
|
|
this._calculateLayout();
|
|
},
|
|
|
|
exit() {},
|
|
|
|
_calculateLayout() {
|
|
const sm = GameGlobal.skinManager;
|
|
if (!sm) return;
|
|
|
|
const skins = sm.getAllSkins();
|
|
this._skinCards = [];
|
|
|
|
for (let i = 0; i < skins.length; i++) {
|
|
const col = i % COLS;
|
|
const row = Math.floor(i / COLS);
|
|
const x = SIDE_PAD + col * (CARD_W + CARD_GAP);
|
|
const y = CARDS_START_Y + row * (CARD_H + CARD_GAP);
|
|
|
|
this._skinCards.push({
|
|
...skins[i],
|
|
rect: { x, y, w: CARD_W, h: CARD_H },
|
|
});
|
|
}
|
|
|
|
// Back button — fixed at bottom of screen (not scrollable)
|
|
const BACK_BTN_BOTTOM_PAD = 12;
|
|
this._buttons = {
|
|
back: {
|
|
x: (SCREEN_WIDTH - BACK_BTN_W) / 2,
|
|
y: SCREEN_HEIGHT - BACK_BTN_H - BACK_BTN_BOTTOM_PAD,
|
|
w: BACK_BTN_W,
|
|
h: BACK_BTN_H,
|
|
},
|
|
};
|
|
|
|
// Calculate max scroll: total content height vs visible area
|
|
// Visible area ends where the fixed back button starts
|
|
const visibleBottom = SCREEN_HEIGHT - BACK_BTN_H - BACK_BTN_BOTTOM_PAD - 8;
|
|
const lastRow = Math.ceil(skins.length / COLS);
|
|
const contentBottom = CARDS_START_Y + lastRow * (CARD_H + CARD_GAP);
|
|
this._maxScrollY = Math.max(0, contentBottom - visibleBottom);
|
|
// Clamp current scroll
|
|
this._scrollY = Math.min(this._scrollY, this._maxScrollY);
|
|
},
|
|
|
|
update(dt) {
|
|
this._animTimer += dt;
|
|
if (this._messageTimer > 0) {
|
|
this._messageTimer -= dt;
|
|
if (this._messageTimer <= 0) {
|
|
this._message = '';
|
|
}
|
|
}
|
|
// Inertia scrolling
|
|
if (!this._isDragging && Math.abs(this._scrollVelocity) > 0.5) {
|
|
this._scrollY += this._scrollVelocity * dt;
|
|
this._scrollVelocity *= 0.92; // friction
|
|
// Clamp
|
|
if (this._scrollY < 0) {
|
|
this._scrollY = 0;
|
|
this._scrollVelocity = 0;
|
|
} else if (this._scrollY > this._maxScrollY) {
|
|
this._scrollY = this._maxScrollY;
|
|
this._scrollVelocity = 0;
|
|
}
|
|
} else if (!this._isDragging) {
|
|
this._scrollVelocity = 0;
|
|
}
|
|
},
|
|
|
|
// ============================================================
|
|
// Render
|
|
// ============================================================
|
|
render(ctx) {
|
|
this._drawBackground(ctx);
|
|
|
|
const sm = GameGlobal.skinManager;
|
|
if (!sm) return;
|
|
const equippedId = sm.getEquippedSkinId();
|
|
|
|
// Header (fixed, not affected by scroll)
|
|
this._drawHeader(ctx);
|
|
|
|
// Clip scrollable area (between header and back button)
|
|
const clipTop = HEADER_H;
|
|
const clipBottom = this._buttons.back.y - 4;
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(0, clipTop, SCREEN_WIDTH, clipBottom - clipTop);
|
|
ctx.clip();
|
|
|
|
// Apply scroll offset for cards
|
|
ctx.save();
|
|
ctx.translate(0, -this._scrollY);
|
|
|
|
// Cards
|
|
for (const card of this._skinCards) {
|
|
// Skip cards that are completely off-screen (optimization)
|
|
const cardScreenY = card.rect.y - this._scrollY;
|
|
if (cardScreenY + card.rect.h < clipTop - 10 || cardScreenY > clipBottom + 10) continue;
|
|
this._renderSkinCard(ctx, card, sm.isUnlocked(card.id), equippedId === card.id);
|
|
}
|
|
|
|
ctx.restore(); // scroll translate
|
|
ctx.restore(); // clip
|
|
|
|
// Back button (fixed at bottom, not affected by scroll)
|
|
this._drawBackButton(ctx);
|
|
|
|
// Toast (fixed position, not affected by scroll)
|
|
this._drawToast(ctx);
|
|
},
|
|
|
|
// ---- Background ----
|
|
_drawBackground(ctx) {
|
|
// Gradient background
|
|
const grad = ctx.createLinearGradient(0, 0, 0, SCREEN_HEIGHT);
|
|
grad.addColorStop(0, C.BG_TOP);
|
|
grad.addColorStop(1, C.BG_BOT);
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
|
|
|
// Subtle horizontal scan-lines
|
|
ctx.globalAlpha = 0.03;
|
|
ctx.fillStyle = '#FFFFFF';
|
|
for (let sy = 0; sy < SCREEN_HEIGHT; sy += 4) {
|
|
ctx.fillRect(0, sy, SCREEN_WIDTH, 1);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
},
|
|
|
|
// ---- Header ----
|
|
_drawHeader(ctx) {
|
|
// Accent line
|
|
const lineGrad = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
|
|
lineGrad.addColorStop(0, 'transparent');
|
|
lineGrad.addColorStop(0.3, C.HEADER_LINE);
|
|
lineGrad.addColorStop(0.7, C.HEADER_LINE);
|
|
lineGrad.addColorStop(1, 'transparent');
|
|
ctx.fillStyle = lineGrad;
|
|
ctx.fillRect(0, 0, SCREEN_WIDTH, 3);
|
|
|
|
// Title
|
|
ctx.fillStyle = C.GOLD;
|
|
ctx.font = 'bold 20px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const titleY = HEADER_H * 0.38;
|
|
ctx.fillText(t('skin.title') || 'Tank Skins', SCREEN_WIDTH / 2, titleY);
|
|
|
|
// Decorative dashes beside title
|
|
const tw = ctx.measureText(t('skin.title') || 'Tank Skins').width;
|
|
ctx.strokeStyle = C.GOLD_DIM;
|
|
ctx.lineWidth = 1;
|
|
ctx.globalAlpha = 0.5;
|
|
const dashW = 30;
|
|
const dashY = titleY;
|
|
// left
|
|
ctx.beginPath();
|
|
ctx.moveTo(SCREEN_WIDTH / 2 - tw / 2 - 12, dashY);
|
|
ctx.lineTo(SCREEN_WIDTH / 2 - tw / 2 - 12 - dashW, dashY);
|
|
ctx.stroke();
|
|
// right
|
|
ctx.beginPath();
|
|
ctx.moveTo(SCREEN_WIDTH / 2 + tw / 2 + 12, dashY);
|
|
ctx.lineTo(SCREEN_WIDTH / 2 + tw / 2 + 12 + dashW, dashY);
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Gold balance — pill badge
|
|
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
|
|
const goldText = `🪙 ${gold}`;
|
|
ctx.font = 'bold 13px Arial';
|
|
const gtw = ctx.measureText(goldText).width;
|
|
const pillW = gtw + 20;
|
|
const pillH = 22;
|
|
const pillX = (SCREEN_WIDTH - pillW) / 2;
|
|
const pillY = HEADER_H * 0.7 - pillH / 2;
|
|
|
|
// Pill background
|
|
ctx.fillStyle = 'rgba(255, 215, 0, 0.1)';
|
|
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 = C.GOLD;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(goldText, SCREEN_WIDTH / 2, HEADER_H * 0.7);
|
|
},
|
|
|
|
// ---- Skin Card ----
|
|
_renderSkinCard(ctx, card, isUnlocked, isEquipped) {
|
|
const { rect } = card;
|
|
const r = 10;
|
|
|
|
// Card shadow
|
|
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
|
this._roundRect(ctx, rect.x + 2, rect.y + 2, rect.w, rect.h, r);
|
|
ctx.fill();
|
|
|
|
// Card background
|
|
if (isEquipped) {
|
|
ctx.fillStyle = C.CARD_EQUIPPED_BG;
|
|
ctx.strokeStyle = C.CARD_EQUIPPED_BORDER;
|
|
ctx.lineWidth = 2;
|
|
} else if (isUnlocked) {
|
|
ctx.fillStyle = C.CARD_BG;
|
|
ctx.strokeStyle = C.CARD_BORDER;
|
|
ctx.lineWidth = 1.5;
|
|
} else {
|
|
ctx.fillStyle = C.CARD_LOCKED_BG;
|
|
ctx.strokeStyle = C.CARD_LOCKED_BORDER;
|
|
ctx.lineWidth = 1;
|
|
}
|
|
|
|
this._roundRect(ctx, rect.x, rect.y, rect.w, rect.h, r);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
// Equipped glow pulse
|
|
if (isEquipped) {
|
|
const pulse = 0.12 + Math.sin(this._animTimer * 3) * 0.08;
|
|
ctx.save();
|
|
ctx.shadowColor = C.GREEN;
|
|
ctx.shadowBlur = 12;
|
|
ctx.globalAlpha = pulse;
|
|
ctx.strokeStyle = C.GREEN;
|
|
ctx.lineWidth = 2;
|
|
this._roundRect(ctx, rect.x, rect.y, rect.w, rect.h, r);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
const cx = rect.x + rect.w / 2;
|
|
|
|
// Tank preview area — subtle dark circle backdrop
|
|
const previewY = rect.y + 34;
|
|
ctx.fillStyle = 'rgba(0,0,0,0.25)';
|
|
ctx.beginPath();
|
|
ctx.arc(cx, previewY, 20, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Mini tank
|
|
this._drawMiniTank(ctx, cx, previewY, card, isUnlocked);
|
|
|
|
// Locked overlay icon
|
|
if (!isUnlocked) {
|
|
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
|
ctx.beginPath();
|
|
ctx.arc(cx, previewY, 20, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = C.TEXT_MUTED;
|
|
ctx.font = '16px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('🔒', cx, previewY);
|
|
}
|
|
|
|
// Skin name
|
|
ctx.fillStyle = isUnlocked ? C.TEXT_PRIMARY : C.TEXT_SECONDARY;
|
|
ctx.font = 'bold 12px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(t(card.nameKey) || card.id, cx, rect.y + 62);
|
|
|
|
// Status / price tag
|
|
const tagY = rect.y + rect.h - 18;
|
|
if (isEquipped) {
|
|
// Green badge
|
|
const badgeText = t('skin.equipped') || '✓ Equipped';
|
|
ctx.font = 'bold 10px Arial';
|
|
const bw = ctx.measureText(badgeText).width + 14;
|
|
const bh = 18;
|
|
const bx = cx - bw / 2;
|
|
ctx.fillStyle = 'rgba(76, 175, 80, 0.2)';
|
|
ctx.strokeStyle = C.GREEN;
|
|
ctx.lineWidth = 1;
|
|
this._roundRect(ctx, bx, tagY - bh / 2, bw, bh, bh / 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = C.GREEN;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(badgeText, cx, tagY);
|
|
} else if (isUnlocked) {
|
|
ctx.fillStyle = C.TEXT_MUTED;
|
|
ctx.font = '10px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(t('skin.owned') || 'Owned', cx, tagY);
|
|
} else {
|
|
// Price tag
|
|
const canAfford = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(card.cost);
|
|
const priceText = `🪙 ${card.cost}`;
|
|
ctx.font = 'bold 11px Arial';
|
|
const pw = ctx.measureText(priceText).width + 14;
|
|
const ph = 18;
|
|
const px = cx - pw / 2;
|
|
ctx.fillStyle = canAfford ? 'rgba(255, 215, 0, 0.12)' : 'rgba(255, 68, 68, 0.12)';
|
|
ctx.strokeStyle = canAfford ? C.GOLD_DIM : C.RED;
|
|
ctx.lineWidth = 1;
|
|
this._roundRect(ctx, px, tagY - ph / 2, pw, ph, ph / 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = canAfford ? C.GOLD : C.RED;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(priceText, cx, tagY);
|
|
}
|
|
},
|
|
|
|
// ---- Mini Tank (dispatcher) ----
|
|
// ★ Delegates to the shared TankSkinRenderer — SAME drawing code as the
|
|
// in-game Tank.render() path, so preview and battle look identical.
|
|
_drawMiniTank(ctx, cx, cy, card, isUnlocked) {
|
|
ctx.save();
|
|
ctx.translate(cx, cy);
|
|
if (!isUnlocked) ctx.globalAlpha = 0.35;
|
|
|
|
drawTankSkin(ctx, card.id, card.colors, this._animTimer || 0);
|
|
|
|
ctx.restore();
|
|
},
|
|
|
|
// ======== DEFAULT — Classic rounded tank ========
|
|
_tankDefault(ctx, bc, tc, kc) {
|
|
const s = 12;
|
|
// Tracks
|
|
ctx.fillStyle = kc;
|
|
this._roundRect(ctx, -s - 4, -s + 1, 4, s * 2 - 2, 2); ctx.fill();
|
|
this._roundRect(ctx, s, -s + 1, 4, s * 2 - 2, 2); ctx.fill();
|
|
// Body
|
|
ctx.fillStyle = bc;
|
|
this._roundRect(ctx, -s, -s, s * 2, s * 2, 3); ctx.fill();
|
|
// Highlight
|
|
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
|
ctx.fillRect(-s + 2, -s + 2, s - 2, 3);
|
|
// Turret base
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath(); ctx.arc(0, 0, s * 0.4, 0, Math.PI * 2); ctx.fill();
|
|
// Barrel
|
|
ctx.fillStyle = tc;
|
|
this._roundRect(ctx, -2.5, -s - 8, 5, s - 2, 2); ctx.fill();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
|
ctx.fillRect(-1.5, -s - 8, 3, 3);
|
|
},
|
|
|
|
// ======== ARCTIC — Ice-crystal armor with snowflake detail ========
|
|
_tankArctic(ctx, bc, tc, kc) {
|
|
const s = 12;
|
|
// Wide icy tracks with tread marks
|
|
ctx.fillStyle = kc;
|
|
ctx.fillRect(-s - 5, -s, 5, s * 2);
|
|
ctx.fillRect(s, -s, 5, s * 2);
|
|
// Tread lines
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
|
|
ctx.lineWidth = 0.8;
|
|
for (let ty = -s + 3; ty < s; ty += 4) {
|
|
ctx.beginPath(); ctx.moveTo(-s - 5, ty); ctx.lineTo(-s, ty); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s, ty); ctx.lineTo(s + 5, ty); ctx.stroke();
|
|
}
|
|
// Body — hexagonal ice shape
|
|
ctx.fillStyle = bc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -s - 2);
|
|
ctx.lineTo(s - 1, -s + 4);
|
|
ctx.lineTo(s - 1, s - 4);
|
|
ctx.lineTo(0, s + 2);
|
|
ctx.lineTo(-s + 1, s - 4);
|
|
ctx.lineTo(-s + 1, -s + 4);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Ice shimmer
|
|
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s + 3, -s + 4);
|
|
ctx.lineTo(0, -s - 1);
|
|
ctx.lineTo(2, -s + 5);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Snowflake center detail (cross + diagonals)
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(0, -4); ctx.lineTo(0, 4); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-4, 0); ctx.lineTo(4, 0); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-3, -3); ctx.lineTo(3, 3); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(3, -3); ctx.lineTo(-3, 3); ctx.stroke();
|
|
// Turret — diamond shape
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -5); ctx.lineTo(5, 0); ctx.lineTo(0, 5); ctx.lineTo(-5, 0);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Barrel — tapered
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-3, -s + 3); ctx.lineTo(3, -s + 3);
|
|
ctx.lineTo(2, -s - 9); ctx.lineTo(-2, -s - 9);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Ice tip glow
|
|
ctx.fillStyle = 'rgba(176,224,230,0.6)';
|
|
ctx.beginPath(); ctx.arc(0, -s - 9, 2, 0, Math.PI * 2); ctx.fill();
|
|
},
|
|
|
|
// ======== INFERNO — Aggressive angular flame tank ========
|
|
_tankInferno(ctx, bc, tc, kc) {
|
|
const s = 12;
|
|
// Spiked tracks
|
|
ctx.fillStyle = kc;
|
|
ctx.fillRect(-s - 4, -s, 4, s * 2);
|
|
ctx.fillRect(s, -s, 4, s * 2);
|
|
// Track spikes
|
|
for (let ty = -s + 2; ty < s - 2; ty += 5) {
|
|
ctx.fillStyle = kc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s - 4, ty); ctx.lineTo(-s - 7, ty + 2.5); ctx.lineTo(-s - 4, ty + 5);
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(s + 4, ty); ctx.lineTo(s + 7, ty + 2.5); ctx.lineTo(s + 4, ty + 5);
|
|
ctx.fill();
|
|
}
|
|
// Body — angular/aggressive shape
|
|
ctx.fillStyle = bc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s + 2, -s - 1);
|
|
ctx.lineTo(s - 2, -s - 1);
|
|
ctx.lineTo(s + 1, -s + 4);
|
|
ctx.lineTo(s + 1, s - 2);
|
|
ctx.lineTo(s - 3, s + 1);
|
|
ctx.lineTo(-s + 3, s + 1);
|
|
ctx.lineTo(-s - 1, s - 2);
|
|
ctx.lineTo(-s - 1, -s + 4);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Flame stripes on body
|
|
ctx.fillStyle = 'rgba(255,165,0,0.5)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s + 3, s); ctx.lineTo(-s + 6, 0); ctx.lineTo(-s + 3, -2);
|
|
ctx.lineTo(-s + 1, s - 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(s - 3, s); ctx.lineTo(s - 6, 0); ctx.lineTo(s - 3, -2);
|
|
ctx.lineTo(s + 1, s - 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Hot glow center
|
|
ctx.fillStyle = 'rgba(255,69,0,0.3)';
|
|
ctx.beginPath(); ctx.arc(0, 0, 5, 0, Math.PI * 2); ctx.fill();
|
|
// Turret — angular pentagon
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -5); ctx.lineTo(5, -2); ctx.lineTo(4, 4);
|
|
ctx.lineTo(-4, 4); ctx.lineTo(-5, -2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Barrel — dual cannon
|
|
ctx.fillStyle = tc;
|
|
this._roundRect(ctx, -4, -s - 10, 3, s - 1, 1); ctx.fill();
|
|
this._roundRect(ctx, 1, -s - 10, 3, s - 1, 1); ctx.fill();
|
|
// Muzzle flash dots
|
|
ctx.fillStyle = 'rgba(255,100,0,0.7)';
|
|
ctx.beginPath(); ctx.arc(-2.5, -s - 10, 2, 0, Math.PI * 2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(2.5, -s - 10, 2, 0, Math.PI * 2); ctx.fill();
|
|
},
|
|
|
|
// ======== PHANTOM — Ghostly translucent stealth tank ========
|
|
_tankPhantom(ctx, bc, tc, kc) {
|
|
const s = 12;
|
|
const t = this._animTimer || 0;
|
|
const flicker = 0.6 + Math.sin(t * 5) * 0.15;
|
|
ctx.globalAlpha = ctx.globalAlpha * flicker;
|
|
// Slim tracks
|
|
ctx.fillStyle = kc;
|
|
this._roundRect(ctx, -s - 3, -s + 2, 3, s * 2 - 4, 1.5); ctx.fill();
|
|
this._roundRect(ctx, s, -s + 2, 3, s * 2 - 4, 1.5); ctx.fill();
|
|
// Body — sleek elliptical
|
|
ctx.fillStyle = bc;
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, s - 1, s + 1, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
// Ghost aura rings
|
|
ctx.strokeStyle = 'rgba(147,112,219,0.3)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.ellipse(0, 0, s + 2, s + 4, 0, 0, Math.PI * 2); ctx.stroke();
|
|
ctx.strokeStyle = 'rgba(147,112,219,0.15)';
|
|
ctx.beginPath(); ctx.ellipse(0, 0, s + 5, s + 7, 0, 0, Math.PI * 2); ctx.stroke();
|
|
// Inner glow
|
|
ctx.fillStyle = 'rgba(200,180,255,0.2)';
|
|
ctx.beginPath(); ctx.ellipse(0, -2, s * 0.5, s * 0.6, 0, 0, Math.PI * 2); ctx.fill();
|
|
// Turret — crescent shape
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, 5, Math.PI * 0.2, Math.PI * 1.8);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Barrel — thin and long
|
|
ctx.fillStyle = tc;
|
|
ctx.fillRect(-1.5, -s - 10, 3, s);
|
|
// Phantom tip — glowing orb
|
|
ctx.fillStyle = 'rgba(147,112,219,0.8)';
|
|
ctx.beginPath(); ctx.arc(0, -s - 11, 2.5, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
|
ctx.beginPath(); ctx.arc(0, -s - 11, 1, 0, Math.PI * 2); ctx.fill();
|
|
},
|
|
|
|
// ======== JUNGLE — Camo-patterned heavy tank with wide tracks ========
|
|
_tankJungle(ctx, bc, tc, kc) {
|
|
const s = 13;
|
|
// Extra-wide tracks with tread pattern
|
|
ctx.fillStyle = kc;
|
|
this._roundRect(ctx, -s - 6, -s, 6, s * 2, 2); ctx.fill();
|
|
this._roundRect(ctx, s, -s, 6, s * 2, 2); ctx.fill();
|
|
// Tread chevrons
|
|
ctx.strokeStyle = 'rgba(0,100,0,0.4)';
|
|
ctx.lineWidth = 1;
|
|
for (let ty = -s + 2; ty < s - 2; ty += 4) {
|
|
ctx.beginPath(); ctx.moveTo(-s - 6, ty); ctx.lineTo(-s - 3, ty + 2); ctx.lineTo(-s - 6, ty + 4); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s + 6, ty); ctx.lineTo(s + 3, ty + 2); ctx.lineTo(s + 6, ty + 4); ctx.stroke();
|
|
}
|
|
// Body — boxy heavy tank
|
|
ctx.fillStyle = bc;
|
|
this._roundRect(ctx, -s, -s, s * 2, s * 2, 2); ctx.fill();
|
|
// Camo patches
|
|
ctx.fillStyle = 'rgba(0,80,0,0.35)';
|
|
ctx.beginPath(); ctx.arc(-4, -4, 5, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = 'rgba(34,60,20,0.3)';
|
|
ctx.beginPath(); ctx.arc(5, 3, 4, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = 'rgba(0,50,0,0.25)';
|
|
ctx.beginPath(); ctx.arc(-2, 6, 3.5, 0, Math.PI * 2); ctx.fill();
|
|
// Leaf detail
|
|
ctx.strokeStyle = 'rgba(0,100,0,0.4)';
|
|
ctx.lineWidth = 0.8;
|
|
ctx.beginPath(); ctx.moveTo(6, -8); ctx.quadraticCurveTo(10, -6, 7, -3); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(8, -7); ctx.quadraticCurveTo(11, -8, 9, -4); ctx.stroke();
|
|
// Turret — square with rounded corners
|
|
ctx.fillStyle = tc;
|
|
this._roundRect(ctx, -5, -5, 10, 10, 3); ctx.fill();
|
|
// Turret camo dot
|
|
ctx.fillStyle = 'rgba(0,50,0,0.3)';
|
|
ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.fill();
|
|
// Barrel — thick and short
|
|
ctx.fillStyle = tc;
|
|
this._roundRect(ctx, -3, -s - 7, 6, s - 3, 2); ctx.fill();
|
|
// Muzzle brake
|
|
ctx.fillStyle = kc;
|
|
ctx.fillRect(-4, -s - 7, 8, 2);
|
|
},
|
|
|
|
// ======== NEON — Glowing wireframe sci-fi tank ========
|
|
_tankNeon(ctx, bc, tc, kc) {
|
|
const s = 12;
|
|
const t = this._animTimer || 0;
|
|
const glow = 0.6 + Math.sin(t * 4) * 0.4;
|
|
|
|
// Neon glow aura
|
|
ctx.save();
|
|
ctx.shadowColor = bc;
|
|
ctx.shadowBlur = 8 * glow;
|
|
|
|
// Tracks — glowing lines
|
|
ctx.strokeStyle = kc;
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.moveTo(-s - 3, -s); ctx.lineTo(-s - 3, s); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s + 3, -s); ctx.lineTo(s + 3, s); ctx.stroke();
|
|
// Track end caps
|
|
ctx.fillStyle = kc;
|
|
ctx.beginPath(); ctx.arc(-s - 3, -s, 2, 0, Math.PI * 2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(-s - 3, s, 2, 0, Math.PI * 2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(s + 3, -s, 2, 0, Math.PI * 2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(s + 3, s, 2, 0, Math.PI * 2); ctx.fill();
|
|
|
|
// Body — wireframe rectangle with glow
|
|
ctx.strokeStyle = bc;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.strokeRect(-s, -s, s * 2, s * 2);
|
|
// Diagonal cross wires
|
|
ctx.lineWidth = 0.8;
|
|
ctx.beginPath(); ctx.moveTo(-s, -s); ctx.lineTo(s, s); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s, -s); ctx.lineTo(-s, s); ctx.stroke();
|
|
// Inner fill (very subtle)
|
|
ctx.fillStyle = 'rgba(0,255,127,0.08)';
|
|
ctx.fillRect(-s, -s, s * 2, s * 2);
|
|
|
|
// Circuit lines
|
|
ctx.strokeStyle = bc;
|
|
ctx.lineWidth = 0.6;
|
|
ctx.beginPath(); ctx.moveTo(-s, 0); ctx.lineTo(-3, 0); ctx.lineTo(-3, -5); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s, 0); ctx.lineTo(3, 0); ctx.lineTo(3, 5); ctx.stroke();
|
|
|
|
// Turret — glowing ring
|
|
ctx.strokeStyle = tc;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.arc(0, 0, 5, 0, Math.PI * 2); ctx.stroke();
|
|
// Center dot
|
|
ctx.fillStyle = bc;
|
|
ctx.beginPath(); ctx.arc(0, 0, 2, 0, Math.PI * 2); ctx.fill();
|
|
|
|
// Barrel — energy beam line
|
|
ctx.strokeStyle = tc;
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.moveTo(0, -5); ctx.lineTo(0, -s - 10); ctx.stroke();
|
|
// Barrel energy nodes
|
|
ctx.fillStyle = bc;
|
|
ctx.beginPath(); ctx.arc(0, -s - 2, 1.5, 0, Math.PI * 2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(0, -s - 6, 1.5, 0, Math.PI * 2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(0, -s - 10, 2.5, 0, Math.PI * 2); ctx.fill();
|
|
|
|
ctx.restore();
|
|
},
|
|
|
|
// ======== NEBULA — Cosmic purple tank with starfield and magenta energy ========
|
|
_tankNebula(ctx, bc, tc, kc) {
|
|
const s = 12;
|
|
const t = this._animTimer || 0;
|
|
const pulse = 0.7 + Math.sin(t * 3) * 0.3;
|
|
|
|
// Cosmic tracks with star dust
|
|
ctx.fillStyle = kc;
|
|
this._roundRect(ctx, -s - 4, -s + 1, 4, s * 2 - 2, 2); ctx.fill();
|
|
this._roundRect(ctx, s, -s + 1, 4, s * 2 - 2, 2); ctx.fill();
|
|
// Magenta trim on tracks
|
|
ctx.strokeStyle = 'rgba(255,0,255,0.45)';
|
|
ctx.lineWidth = 0.8;
|
|
ctx.beginPath(); ctx.moveTo(-s - 4, -s + 1); ctx.lineTo(-s - 4, s - 1); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s + 4, -s + 1); ctx.lineTo(s + 4, s - 1); ctx.stroke();
|
|
|
|
// Body — rounded cosmic hull
|
|
ctx.fillStyle = bc;
|
|
this._roundRect(ctx, -s, -s, s * 2, s * 2, 5); ctx.fill();
|
|
|
|
// Nebula gradient overlay (top-left bright, bottom-right dark)
|
|
ctx.fillStyle = 'rgba(180,0,255,0.15)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s, -s); ctx.lineTo(s, -s); ctx.lineTo(-s, s);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Starfield particles (twinkling)
|
|
ctx.save();
|
|
const stars = [
|
|
{ x: -7, y: -8, size: 1.2, phase: 0 },
|
|
{ x: 5, y: -6, size: 0.9, phase: 1.2 },
|
|
{ x: -4, y: 3, size: 1.0, phase: 2.4 },
|
|
{ x: 8, y: 5, size: 0.8, phase: 3.6 },
|
|
{ x: -2, y: -3, size: 1.1, phase: 4.8 },
|
|
{ x: 6, y: -1, size: 0.7, phase: 0.8 },
|
|
{ x: -8, y: 6, size: 0.9, phase: 2.0 },
|
|
{ x: 3, y: 7, size: 1.0, phase: 3.2 },
|
|
];
|
|
for (const star of stars) {
|
|
const twinkle = 0.4 + Math.sin(t * 5 + star.phase) * 0.6;
|
|
ctx.globalAlpha = Math.max(0, twinkle);
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.beginPath();
|
|
ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
// Star cross sparkle for larger stars
|
|
if (star.size > 0.9 && twinkle > 0.7) {
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
|
ctx.lineWidth = 0.4;
|
|
ctx.beginPath(); ctx.moveTo(star.x - 2, star.y); ctx.lineTo(star.x + 2, star.y); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(star.x, star.y - 2); ctx.lineTo(star.x, star.y + 2); ctx.stroke();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
|
|
// Cosmic swirl (subtle rotating nebula cloud)
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.2;
|
|
ctx.strokeStyle = '#FF00FF';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
for (let a = 0; a < Math.PI * 4; a += 0.15) {
|
|
const r = 2 + a * 1.2;
|
|
const sx = Math.cos(a + t * 0.5) * r;
|
|
const sy = Math.sin(a + t * 0.5) * r;
|
|
if (a === 0) ctx.moveTo(sx, sy);
|
|
else ctx.lineTo(sx, sy);
|
|
if (r > s - 2) break;
|
|
}
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
// Turret — glowing magenta orb
|
|
ctx.save();
|
|
ctx.shadowColor = '#FF00FF';
|
|
ctx.shadowBlur = 6 * pulse;
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath(); ctx.arc(0, 0, 5.5, 0, Math.PI * 2); ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Turret inner ring
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
|
ctx.lineWidth = 0.8;
|
|
ctx.beginPath(); ctx.arc(0, 0, 3.5, 0, Math.PI * 2); ctx.stroke();
|
|
|
|
// Turret core — bright white-magenta
|
|
ctx.fillStyle = `rgba(255,200,255,${0.5 + pulse * 0.3})`;
|
|
ctx.beginPath(); ctx.arc(0, 0, 2, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
|
ctx.beginPath(); ctx.arc(0, 0, 0.8, 0, Math.PI * 2); ctx.fill();
|
|
|
|
// Barrel — cosmic energy beam
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-3, -5); ctx.lineTo(3, -5);
|
|
ctx.lineTo(2, -s - 9); ctx.lineTo(-2, -s - 9);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Barrel energy rings
|
|
ctx.strokeStyle = `rgba(255,0,255,${pulse * 0.6})`;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(-3.5, -s); ctx.lineTo(3.5, -s); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-3, -s - 4); ctx.lineTo(3, -s - 4); ctx.stroke();
|
|
|
|
// Barrel tip — bright cosmic flare
|
|
ctx.save();
|
|
ctx.shadowColor = '#FF00FF';
|
|
ctx.shadowBlur = 8 * pulse;
|
|
ctx.fillStyle = '#FF00FF';
|
|
ctx.beginPath(); ctx.arc(0, -s - 9, 2.8, 0, Math.PI * 2); ctx.fill();
|
|
ctx.restore();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
|
ctx.beginPath(); ctx.arc(0, -s - 9, 1.2, 0, Math.PI * 2); ctx.fill();
|
|
},
|
|
|
|
// ======== ROYAL — Ornate golden tank with crown ========
|
|
_tankRoyal(ctx, bc, tc, kc) {
|
|
const s = 12;
|
|
// Ornate tracks with gold trim
|
|
ctx.fillStyle = kc;
|
|
this._roundRect(ctx, -s - 5, -s + 1, 5, s * 2 - 2, 2); ctx.fill();
|
|
this._roundRect(ctx, s, -s + 1, 5, s * 2 - 2, 2); ctx.fill();
|
|
// Gold trim lines on tracks
|
|
ctx.strokeStyle = 'rgba(255,215,0,0.5)';
|
|
ctx.lineWidth = 0.8;
|
|
ctx.beginPath(); ctx.moveTo(-s - 5, -s + 1); ctx.lineTo(-s - 5, s - 1); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s + 5, -s + 1); ctx.lineTo(s + 5, s - 1); ctx.stroke();
|
|
|
|
// Body — rounded luxury shape
|
|
ctx.fillStyle = bc;
|
|
this._roundRect(ctx, -s, -s, s * 2, s * 2, 5); ctx.fill();
|
|
|
|
// Royal emblem — shield shape in center
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-4, -5);
|
|
ctx.lineTo(4, -5);
|
|
ctx.lineTo(4, 1);
|
|
ctx.quadraticCurveTo(4, 5, 0, 7);
|
|
ctx.quadraticCurveTo(-4, 5, -4, 1);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Emblem inner
|
|
ctx.fillStyle = 'rgba(255,215,0,0.6)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-2, -3);
|
|
ctx.lineTo(2, -3);
|
|
ctx.lineTo(2, 0);
|
|
ctx.quadraticCurveTo(2, 3, 0, 4);
|
|
ctx.quadraticCurveTo(-2, 3, -2, 0);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Crown on top of turret
|
|
ctx.fillStyle = '#FFD700';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-6, -s + 2);
|
|
ctx.lineTo(-5, -s - 2);
|
|
ctx.lineTo(-3, -s + 1);
|
|
ctx.lineTo(0, -s - 4);
|
|
ctx.lineTo(3, -s + 1);
|
|
ctx.lineTo(5, -s - 2);
|
|
ctx.lineTo(6, -s + 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Crown jewels
|
|
ctx.fillStyle = '#FF0000';
|
|
ctx.beginPath(); ctx.arc(0, -s - 2, 1.2, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = '#0066FF';
|
|
ctx.beginPath(); ctx.arc(-4, -s, 0.8, 0, Math.PI * 2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(4, -s, 0.8, 0, Math.PI * 2); ctx.fill();
|
|
|
|
// Barrel — ornate with rings
|
|
ctx.fillStyle = tc;
|
|
this._roundRect(ctx, -2.5, -s - 10, 5, s - 4, 2); ctx.fill();
|
|
// Barrel rings
|
|
ctx.strokeStyle = '#FFD700';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(-3, -s - 3); ctx.lineTo(3, -s - 3); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-3, -s - 7); ctx.lineTo(3, -s - 7); ctx.stroke();
|
|
// Royal tip
|
|
ctx.fillStyle = '#FFD700';
|
|
ctx.beginPath(); ctx.arc(0, -s - 10, 2.5, 0, Math.PI * 2); ctx.fill();
|
|
},
|
|
|
|
// ======== SAKURA — Elegant cherry-blossom tank with petal details ========
|
|
_tankSakura(ctx, bc, tc, kc) {
|
|
const s = 12;
|
|
const t = this._animTimer || 0;
|
|
|
|
// Soft rounded tracks with petal-pink trim
|
|
ctx.fillStyle = kc;
|
|
this._roundRect(ctx, -s - 4, -s + 1, 4, s * 2 - 2, 2); ctx.fill();
|
|
this._roundRect(ctx, s, -s + 1, 4, s * 2 - 2, 2); ctx.fill();
|
|
// Pink trim highlights
|
|
ctx.strokeStyle = 'rgba(255,105,180,0.4)';
|
|
ctx.lineWidth = 0.7;
|
|
ctx.beginPath(); ctx.moveTo(-s - 4, -s + 1); ctx.lineTo(-s - 4, s - 1); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s + 4, -s + 1); ctx.lineTo(s + 4, s - 1); ctx.stroke();
|
|
|
|
// Body — soft rounded rectangle
|
|
ctx.fillStyle = bc;
|
|
this._roundRect(ctx, -s, -s, s * 2, s * 2, 6); ctx.fill();
|
|
|
|
// Petal shimmer overlay
|
|
ctx.fillStyle = 'rgba(255,255,255,0.18)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s + 2, -s);
|
|
ctx.lineTo(s - 2, -s);
|
|
ctx.lineTo(-s + 2, -s + 8);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Floating cherry blossom petals (animated)
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.6;
|
|
const petals = [
|
|
{ ox: -6, oy: -7, phase: 0 },
|
|
{ ox: 7, oy: -4, phase: 1.5 },
|
|
{ ox: -3, oy: 6, phase: 3 },
|
|
{ ox: 5, oy: 5, phase: 4.5 },
|
|
];
|
|
for (const p of petals) {
|
|
const drift = Math.sin(t * 2 + p.phase) * 1.5;
|
|
const px = p.ox + drift;
|
|
const py = p.oy;
|
|
ctx.fillStyle = '#FF69B4';
|
|
ctx.beginPath();
|
|
// 5-petal flower shape
|
|
for (let i = 0; i < 5; i++) {
|
|
const angle = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
|
const pr = 2.2;
|
|
const fx = px + Math.cos(angle) * pr;
|
|
const fy = py + Math.sin(angle) * pr;
|
|
ctx.moveTo(fx, fy);
|
|
ctx.arc(fx, fy, 1.2, 0, Math.PI * 2);
|
|
}
|
|
ctx.fill();
|
|
// Flower center
|
|
ctx.fillStyle = '#FFE4E1';
|
|
ctx.beginPath(); ctx.arc(px, py, 0.8, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
|
|
// Turret — circular with sakura emblem
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath(); ctx.arc(0, 0, 5.5, 0, Math.PI * 2); ctx.fill();
|
|
// Inner petal ring
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
|
|
ctx.lineWidth = 0.8;
|
|
ctx.beginPath(); ctx.arc(0, 0, 3.5, 0, Math.PI * 2); ctx.stroke();
|
|
// Center dot
|
|
ctx.fillStyle = '#FFE4E1';
|
|
ctx.beginPath(); ctx.arc(0, 0, 1.5, 0, Math.PI * 2); ctx.fill();
|
|
|
|
// Barrel — elegant tapered with pink glow
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-3, -5); ctx.lineTo(3, -5);
|
|
ctx.lineTo(2, -s - 9); ctx.lineTo(-2, -s - 9);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Barrel tip glow
|
|
ctx.fillStyle = 'rgba(255,183,197,0.7)';
|
|
ctx.beginPath(); ctx.arc(0, -s - 9, 2.5, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
|
ctx.beginPath(); ctx.arc(0, -s - 9, 1.2, 0, Math.PI * 2); ctx.fill();
|
|
},
|
|
|
|
// ======== THUNDER — Electric-blue lightning tank with arc effects ========
|
|
_tankThunder(ctx, bc, tc, kc) {
|
|
const s = 12;
|
|
const t = this._animTimer || 0;
|
|
const flash = 0.7 + Math.sin(t * 8) * 0.3;
|
|
|
|
// Heavy electrified tracks
|
|
ctx.fillStyle = kc;
|
|
this._roundRect(ctx, -s - 5, -s, 5, s * 2, 2); ctx.fill();
|
|
this._roundRect(ctx, s, -s, 5, s * 2, 2); ctx.fill();
|
|
// Electric sparks on tracks
|
|
ctx.save();
|
|
ctx.globalAlpha = flash * 0.6;
|
|
ctx.strokeStyle = '#00BFFF';
|
|
ctx.lineWidth = 0.8;
|
|
for (let ty = -s + 2; ty < s - 2; ty += 5) {
|
|
// Left track spark
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s - 5, ty);
|
|
ctx.lineTo(-s - 3, ty + 1.5);
|
|
ctx.lineTo(-s - 5, ty + 3);
|
|
ctx.stroke();
|
|
// Right track spark
|
|
ctx.beginPath();
|
|
ctx.moveTo(s + 5, ty);
|
|
ctx.lineTo(s + 3, ty + 1.5);
|
|
ctx.lineTo(s + 5, ty + 3);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
|
|
// Body — angular armored shape
|
|
ctx.fillStyle = bc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s + 1, -s - 2);
|
|
ctx.lineTo(s - 1, -s - 2);
|
|
ctx.lineTo(s + 2, -s + 3);
|
|
ctx.lineTo(s + 2, s - 3);
|
|
ctx.lineTo(s - 1, s + 2);
|
|
ctx.lineTo(-s + 1, s + 2);
|
|
ctx.lineTo(-s - 2, s - 3);
|
|
ctx.lineTo(-s - 2, -s + 3);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Lightning bolt emblem on body
|
|
ctx.fillStyle = 'rgba(0,191,255,0.5)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-1, -7);
|
|
ctx.lineTo(3, -7);
|
|
ctx.lineTo(0, -1);
|
|
ctx.lineTo(4, -1);
|
|
ctx.lineTo(-2, 8);
|
|
ctx.lineTo(0, 2);
|
|
ctx.lineTo(-3, 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Electric aura glow (pulsing)
|
|
ctx.save();
|
|
ctx.shadowColor = '#00BFFF';
|
|
ctx.shadowBlur = 6 * flash;
|
|
|
|
// Body edge glow
|
|
ctx.strokeStyle = 'rgba(0,191,255,0.35)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s + 1, -s - 2);
|
|
ctx.lineTo(s - 1, -s - 2);
|
|
ctx.lineTo(s + 2, -s + 3);
|
|
ctx.lineTo(s + 2, s - 3);
|
|
ctx.lineTo(s - 1, s + 2);
|
|
ctx.lineTo(-s + 1, s + 2);
|
|
ctx.lineTo(-s - 2, s - 3);
|
|
ctx.lineTo(-s - 2, -s + 3);
|
|
ctx.closePath();
|
|
ctx.stroke();
|
|
|
|
// Turret — hexagonal with energy core
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < 6; i++) {
|
|
const angle = (i / 6) * Math.PI * 2 - Math.PI / 6;
|
|
const hx = Math.cos(angle) * 5.5;
|
|
const hy = Math.sin(angle) * 5.5;
|
|
if (i === 0) ctx.moveTo(hx, hy);
|
|
else ctx.lineTo(hx, hy);
|
|
}
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Energy core (pulsing bright center)
|
|
ctx.fillStyle = `rgba(0,191,255,${0.4 + flash * 0.3})`;
|
|
ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = `rgba(255,255,255,${0.5 + flash * 0.3})`;
|
|
ctx.beginPath(); ctx.arc(0, 0, 1.5, 0, Math.PI * 2); ctx.fill();
|
|
|
|
// Barrel — energy cannon with lightning nodes
|
|
ctx.fillStyle = tc;
|
|
this._roundRect(ctx, -2.5, -s - 10, 5, s - 3, 2); ctx.fill();
|
|
|
|
// Lightning arcs along barrel
|
|
ctx.strokeStyle = `rgba(0,191,255,${flash * 0.8})`;
|
|
ctx.lineWidth = 1;
|
|
// Left arc
|
|
ctx.beginPath();
|
|
ctx.moveTo(-2.5, -s); ctx.lineTo(-5, -s - 3);
|
|
ctx.lineTo(-2.5, -s - 5); ctx.lineTo(-5, -s - 8);
|
|
ctx.stroke();
|
|
// Right arc
|
|
ctx.beginPath();
|
|
ctx.moveTo(2.5, -s - 1); ctx.lineTo(5, -s - 4);
|
|
ctx.lineTo(2.5, -s - 6); ctx.lineTo(5, -s - 9);
|
|
ctx.stroke();
|
|
|
|
// Barrel tip — bright energy ball
|
|
ctx.fillStyle = '#00BFFF';
|
|
ctx.beginPath(); ctx.arc(0, -s - 10, 3, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
|
ctx.beginPath(); ctx.arc(0, -s - 10, 1.5, 0, Math.PI * 2); ctx.fill();
|
|
|
|
ctx.restore();
|
|
},
|
|
|
|
// ======== DIAMOND — Ultra-luxurious brilliant-cut crystalline tank ========
|
|
// Design: Multi-layered prismatic facets with rainbow fire dispersion,
|
|
// rotating spectral rays, breathing aurora glow, and diamond dust particles.
|
|
// ======== DIAMOND 💎 — Elegant brilliant-cut gem tank ========
|
|
// Inspired by the 💎 emoji: clean geometric facets, ice-blue tones,
|
|
// gentle luxurious glow — NOT frantic flashing. Slow, elegant, premium.
|
|
_tankDiamond(ctx, bc, tc, kc) {
|
|
const s = 12;
|
|
const t = this._animTimer || 0;
|
|
// ★ All animation speeds deliberately slow for elegance
|
|
const breathe = 0.5 + Math.sin(t * 0.8) * 0.5; // very slow breathing (~8s cycle)
|
|
const shimmer = 0.7 + Math.sin(t * 1.2) * 0.3; // gentle shimmer (~5s cycle)
|
|
const rotPhase = t * 0.15; // ultra-slow rotation
|
|
|
|
// ── SOFT OUTER GLOW (subtle ice-blue aura) ──
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.06 + breathe * 0.04;
|
|
ctx.fillStyle = 'rgba(125, 249, 255, 0.2)';
|
|
ctx.beginPath(); ctx.arc(0, -2, 20, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = 'rgba(180, 220, 255, 0.15)';
|
|
ctx.beginPath(); ctx.arc(0, -2, 16, 0, Math.PI * 2); ctx.fill();
|
|
ctx.restore();
|
|
|
|
// ── SLOW ROTATING PRISMATIC RAYS (very subtle, 6 rays) ──
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.04 + breathe * 0.03;
|
|
const rayColors = ['#FF9AA2', '#FFD6A5', '#CAFFBF', '#9BF6FF', '#BDB2FF', '#FFC6FF'];
|
|
for (let i = 0; i < 6; i++) {
|
|
const angle = rotPhase + (i / 6) * Math.PI * 2;
|
|
const rayLen = 15 + Math.sin(t * 0.5 + i * 1.0) * 3;
|
|
ctx.strokeStyle = rayColors[i];
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(Math.cos(angle) * 4, Math.sin(angle) * 4 - 2);
|
|
ctx.lineTo(Math.cos(angle) * rayLen, Math.sin(angle) * rayLen - 2);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
|
|
// ── CRYSTAL TRACKS ──
|
|
ctx.fillStyle = kc;
|
|
this._roundRect(ctx, -s - 5, -s + 1, 5, s * 2 - 2, 2); ctx.fill();
|
|
this._roundRect(ctx, s, -s + 1, 5, s * 2 - 2, 2); ctx.fill();
|
|
// Gentle edge highlight (steady ice-blue, barely pulsing)
|
|
ctx.strokeStyle = `rgba(125, 249, 255, ${0.25 + breathe * 0.1})`;
|
|
ctx.lineWidth = 0.8;
|
|
ctx.beginPath(); ctx.moveTo(-s - 5, -s + 1); ctx.lineTo(-s - 5, s - 1); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-s, -s + 1); ctx.lineTo(-s, s - 1); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s + 5, -s + 1); ctx.lineTo(s + 5, s - 1); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s, -s + 1); ctx.lineTo(s, s - 1); ctx.stroke();
|
|
// Track crystal segments
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
|
|
ctx.lineWidth = 0.5;
|
|
for (let ty = -s + 4; ty < s - 2; ty += 4) {
|
|
ctx.beginPath(); ctx.moveTo(-s - 5, ty); ctx.lineTo(-s, ty); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s, ty); ctx.lineTo(s + 5, ty); ctx.stroke();
|
|
}
|
|
|
|
// ── BODY — 💎 Brilliant-cut gem shape (12-sided) ──
|
|
ctx.fillStyle = bc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s + 4, -s - 2);
|
|
ctx.lineTo(s - 4, -s - 2);
|
|
ctx.lineTo(s, -s + 2);
|
|
ctx.lineTo(s + 2, -s + 6);
|
|
ctx.lineTo(s + 2, s - 6);
|
|
ctx.lineTo(s, s - 2);
|
|
ctx.lineTo(s - 4, s + 2);
|
|
ctx.lineTo(-s + 4, s + 2);
|
|
ctx.lineTo(-s, s - 2);
|
|
ctx.lineTo(-s - 2, s - 6);
|
|
ctx.lineTo(-s - 2, -s + 6);
|
|
ctx.lineTo(-s, -s + 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// ── FACET STRUCTURE (static crystal lines — no animation) ──
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
|
ctx.lineWidth = 0.5;
|
|
// Girdle line
|
|
ctx.beginPath(); ctx.moveTo(-s - 2, 0); ctx.lineTo(s + 2, 0); ctx.stroke();
|
|
// Crown facets
|
|
ctx.beginPath(); ctx.moveTo(-s + 4, -s - 2); ctx.lineTo(0, 0); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s - 4, -s - 2); ctx.lineTo(0, 0); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-s - 2, -s + 6); ctx.lineTo(0, 0); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s + 2, -s + 6); ctx.lineTo(0, 0); ctx.stroke();
|
|
// Pavilion facets
|
|
ctx.beginPath(); ctx.moveTo(-s + 4, s + 2); ctx.lineTo(0, 0); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s - 4, s + 2); ctx.lineTo(0, 0); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-s - 2, s - 6); ctx.lineTo(0, 0); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s + 2, s - 6); ctx.lineTo(0, 0); ctx.stroke();
|
|
// Secondary facets
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
|
ctx.beginPath(); ctx.moveTo(-s, -s + 2); ctx.lineTo(0, -4); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s, -s + 2); ctx.lineTo(0, -4); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-s, s - 2); ctx.lineTo(0, 4); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(s, s - 2); ctx.lineTo(0, 4); ctx.stroke();
|
|
|
|
// ── FACET COLOR FILLS (very slow spectral drift — like real diamond fire) ──
|
|
ctx.save();
|
|
const facetData = [
|
|
{ pts: [[-s + 4, -s - 2], [s - 4, -s - 2], [0, 0]], hueBase: 200 },
|
|
{ pts: [[-s + 4, -s - 2], [-s, -s + 2], [0, 0]], hueBase: 220 },
|
|
{ pts: [[s - 4, -s - 2], [s, -s + 2], [0, 0]], hueBase: 180 },
|
|
{ pts: [[-s, -s + 2], [-s - 2, -s + 6], [0, 0]], hueBase: 240 },
|
|
{ pts: [[s, -s + 2], [s + 2, -s + 6], [0, 0]], hueBase: 160 },
|
|
{ pts: [[-s + 4, s + 2], [s - 4, s + 2], [0, 0]], hueBase: 260 },
|
|
{ pts: [[-s + 4, s + 2], [-s, s - 2], [0, 0]], hueBase: 280 },
|
|
{ pts: [[s - 4, s + 2], [s, s - 2], [0, 0]], hueBase: 300 },
|
|
{ pts: [[-s, s - 2], [-s - 2, s - 6], [0, 0]], hueBase: 320 },
|
|
{ pts: [[s, s - 2], [s + 2, s - 6], [0, 0]], hueBase: 340 },
|
|
];
|
|
for (const facet of facetData) {
|
|
// ★ Very slow hue drift: t * 8 (was t * 40)
|
|
const hue = (facet.hueBase + t * 8) % 360;
|
|
// ★ Very slow alpha pulse: t * 0.6 (was t * 3)
|
|
const facetAlpha = 0.05 + Math.sin(t * 0.6 + facet.hueBase * 0.01) * 0.03;
|
|
ctx.globalAlpha = facetAlpha;
|
|
ctx.fillStyle = `hsla(${hue}, 70%, 75%, 1)`;
|
|
ctx.beginPath();
|
|
ctx.moveTo(facet.pts[0][0], facet.pts[0][1]);
|
|
ctx.lineTo(facet.pts[1][0], facet.pts[1][1]);
|
|
ctx.lineTo(facet.pts[2][0], facet.pts[2][1]);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
|
|
// ── TOP CROWN HIGHLIGHT (the "table" — gentle breathing) ──
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.15 + breathe * 0.1;
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s + 4, -s - 2);
|
|
ctx.lineTo(s - 4, -s - 2);
|
|
ctx.lineTo(s * 0.3, -s * 0.3);
|
|
ctx.lineTo(-s * 0.3, -s * 0.3);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// ── DIAMOND DUST PARTICLES (slow, sparse twinkle) ──
|
|
ctx.save();
|
|
const dustParticles = [
|
|
{ x: -8, y: -9, size: 0.7, speed: 1.5, phase: 0 },
|
|
{ x: 9, y: -6, size: 0.5, speed: 1.2, phase: 1.5 },
|
|
{ x: -6, y: 7, size: 0.6, speed: 1.0, phase: 3.0 },
|
|
{ x: 7, y: 8, size: 0.5, speed: 1.3, phase: 4.5 },
|
|
{ x: -10, y: 0, size: 0.5, speed: 0.8, phase: 6.0 },
|
|
{ x: 10, y: -2, size: 0.6, speed: 1.1, phase: 2.0 },
|
|
];
|
|
for (const d of dustParticles) {
|
|
// ★ Slow twinkle with long visible duration
|
|
const twinkle = Math.max(0, Math.sin(t * d.speed + d.phase));
|
|
if (twinkle > 0.4) {
|
|
ctx.globalAlpha = (twinkle - 0.4) * 1.2;
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.beginPath(); ctx.arc(d.x, d.y, d.size, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
|
|
// ── SPARKLE STARS (slow, elegant cross flares — only 4, staggered) ──
|
|
ctx.save();
|
|
const flares = [
|
|
{ x: -5, y: -7, phase: 0, len: 2.5 },
|
|
{ x: 7, y: -4, phase: 2.5, len: 2 },
|
|
{ x: -3, y: 6, phase: 5.0, len: 2.2 },
|
|
{ x: 6, y: 5, phase: 7.5, len: 1.8 },
|
|
];
|
|
for (const fl of flares) {
|
|
// ★ Very slow twinkle: t * 0.8 (was t * 4)
|
|
const twinkle = Math.max(0, Math.sin(t * 0.8 + fl.phase));
|
|
if (twinkle > 0.6) {
|
|
const alpha = (twinkle - 0.6) * 2.5;
|
|
const color = `rgba(200, 240, 255, ${alpha * 0.8})`;
|
|
ctx.strokeStyle = color;
|
|
ctx.fillStyle = color;
|
|
ctx.lineWidth = 0.5;
|
|
// 4-point star cross
|
|
ctx.beginPath(); ctx.moveTo(fl.x - fl.len, fl.y); ctx.lineTo(fl.x + fl.len, fl.y); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(fl.x, fl.y - fl.len); ctx.lineTo(fl.x, fl.y + fl.len); ctx.stroke();
|
|
// Center dot
|
|
ctx.beginPath(); ctx.arc(fl.x, fl.y, 0.6, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
|
|
// ── BODY EDGE GLOW (gentle ice-blue border) ──
|
|
ctx.save();
|
|
ctx.shadowColor = '#7DF9FF';
|
|
ctx.shadowBlur = 6 + breathe * 4;
|
|
ctx.strokeStyle = `rgba(125, 249, 255, ${0.2 + breathe * 0.15})`;
|
|
ctx.lineWidth = 1.2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s + 4, -s - 2); ctx.lineTo(s - 4, -s - 2);
|
|
ctx.lineTo(s, -s + 2); ctx.lineTo(s + 2, -s + 6);
|
|
ctx.lineTo(s + 2, s - 6); ctx.lineTo(s, s - 2);
|
|
ctx.lineTo(s - 4, s + 2); ctx.lineTo(-s + 4, s + 2);
|
|
ctx.lineTo(-s, s - 2); ctx.lineTo(-s - 2, s - 6);
|
|
ctx.lineTo(-s - 2, -s + 6); ctx.lineTo(-s, -s + 2);
|
|
ctx.closePath();
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
// Inner crisp edge
|
|
ctx.strokeStyle = `rgba(255, 255, 255, ${0.1 + shimmer * 0.05})`;
|
|
ctx.lineWidth = 0.4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-s + 4, -s - 2); ctx.lineTo(s - 4, -s - 2);
|
|
ctx.lineTo(s, -s + 2); ctx.lineTo(s + 2, -s + 6);
|
|
ctx.lineTo(s + 2, s - 6); ctx.lineTo(s, s - 2);
|
|
ctx.lineTo(s - 4, s + 2); ctx.lineTo(-s + 4, s + 2);
|
|
ctx.lineTo(-s, s - 2); ctx.lineTo(-s - 2, s - 6);
|
|
ctx.lineTo(-s - 2, -s + 6); ctx.lineTo(-s, -s + 2);
|
|
ctx.closePath();
|
|
ctx.stroke();
|
|
|
|
// ── TURRET — 💎 Gem top view (10-pointed star) ──
|
|
ctx.save();
|
|
ctx.shadowColor = '#7DF9FF';
|
|
ctx.shadowBlur = 5 + breathe * 3;
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < 10; i++) {
|
|
const angle = (i / 10) * Math.PI * 2 - Math.PI / 2;
|
|
const r = i % 2 === 0 ? 7 : 4;
|
|
const hx = Math.cos(angle) * r;
|
|
const hy = Math.sin(angle) * r;
|
|
if (i === 0) ctx.moveTo(hx, hy);
|
|
else ctx.lineTo(hx, hy);
|
|
}
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Turret facet lines
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
|
|
ctx.lineWidth = 0.4;
|
|
for (let i = 0; i < 10; i += 2) {
|
|
const angle = (i / 10) * Math.PI * 2 - Math.PI / 2;
|
|
const hx = Math.cos(angle) * 7;
|
|
const hy = Math.sin(angle) * 7;
|
|
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(hx, hy); ctx.stroke();
|
|
}
|
|
|
|
// Turret edge — gentle ice-blue tint (steady, not rainbow)
|
|
ctx.save();
|
|
ctx.strokeStyle = `rgba(125, 249, 255, ${0.2 + breathe * 0.1})`;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < 10; i++) {
|
|
const angle = (i / 10) * Math.PI * 2 - Math.PI / 2;
|
|
const r = i % 2 === 0 ? 7 : 4;
|
|
const hx = Math.cos(angle) * r;
|
|
const hy = Math.sin(angle) * r;
|
|
if (i === 0) ctx.moveTo(hx, hy);
|
|
else ctx.lineTo(hx, hy);
|
|
}
|
|
ctx.closePath();
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
// Turret center — white core
|
|
ctx.save();
|
|
ctx.shadowColor = '#FFFFFF';
|
|
ctx.shadowBlur = 4 + breathe * 2;
|
|
ctx.fillStyle = `rgba(255,255,255,${0.5 + breathe * 0.2})`;
|
|
ctx.beginPath(); ctx.arc(0, 0, 2.5, 0, Math.PI * 2); ctx.fill();
|
|
ctx.restore();
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.beginPath(); ctx.arc(0, 0, 1.2, 0, Math.PI * 2); ctx.fill();
|
|
|
|
// ── BARREL — Clean crystalline cannon ──
|
|
ctx.fillStyle = tc;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-3.5, -7); ctx.lineTo(3.5, -7);
|
|
ctx.lineTo(2.5, -s - 11); ctx.lineTo(-2.5, -s - 11);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Barrel center line
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
|
ctx.lineWidth = 0.5;
|
|
ctx.beginPath(); ctx.moveTo(0, -7); ctx.lineTo(0, -s - 11); ctx.stroke();
|
|
|
|
// Barrel rings (steady ice-blue, gentle pulse)
|
|
const ringPositions = [-s + 1, -s - 3, -s - 7];
|
|
for (let ri = 0; ri < ringPositions.length; ri++) {
|
|
const ry = ringPositions[ri];
|
|
// ★ Slow pulse: t * 1.0 (was t * 5)
|
|
const ringAlpha = 0.3 + Math.sin(t * 1.0 + ri * 2.0) * 0.15;
|
|
ctx.strokeStyle = `rgba(125, 249, 255, ${ringAlpha})`;
|
|
ctx.lineWidth = 1;
|
|
const halfW = 3.5 - ri * 0.3;
|
|
ctx.beginPath(); ctx.moveTo(-halfW, ry); ctx.lineTo(halfW, ry); ctx.stroke();
|
|
}
|
|
|
|
// ── BARREL TIP — 💎 Diamond shape muzzle ──
|
|
ctx.save();
|
|
ctx.shadowColor = '#7DF9FF';
|
|
ctx.shadowBlur = 8 + breathe * 4;
|
|
ctx.fillStyle = `rgba(125, 249, 255, ${0.6 + breathe * 0.2})`;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -s - 15); ctx.lineTo(4, -s - 11);
|
|
ctx.lineTo(0, -s - 7); ctx.lineTo(-4, -s - 11);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Tip facet lines
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
|
ctx.lineWidth = 0.4;
|
|
ctx.beginPath(); ctx.moveTo(0, -s - 15); ctx.lineTo(0, -s - 7); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(-4, -s - 11); ctx.lineTo(4, -s - 11); ctx.stroke();
|
|
|
|
// Tip center — white core
|
|
ctx.save();
|
|
ctx.shadowColor = '#FFFFFF';
|
|
ctx.shadowBlur = 4 + shimmer * 2;
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.beginPath(); ctx.arc(0, -s - 11, 1.2, 0, Math.PI * 2); ctx.fill();
|
|
ctx.restore();
|
|
|
|
// ── SUBTLE SPECTRAL BEAMS from tip (very faint) ──
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.06 + breathe * 0.04;
|
|
const beamColors = ['#FF9AA2', '#CAFFBF', '#9BF6FF', '#BDB2FF', '#FFC6FF'];
|
|
for (let bi = 0; bi < beamColors.length; bi++) {
|
|
const spread = (bi - 2) * 2;
|
|
ctx.strokeStyle = beamColors[bi];
|
|
ctx.lineWidth = 0.6;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -s - 15);
|
|
ctx.lineTo(spread, -s - 19);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
},
|
|
|
|
// ---- Back Button ----
|
|
_drawBackButton(ctx) {
|
|
const btn = this._buttons.back;
|
|
if (!btn) return;
|
|
|
|
const r = btn.h / 2;
|
|
|
|
// Shadow
|
|
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
|
this._roundRect(ctx, btn.x + 1, btn.y + 2, btn.w, btn.h, r);
|
|
ctx.fill();
|
|
|
|
// Button body
|
|
ctx.fillStyle = C.BACK_BTN;
|
|
ctx.strokeStyle = C.BACK_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 = C.TEXT_SECONDARY;
|
|
ctx.font = 'bold 13px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(
|
|
t('common.back') || '← Back',
|
|
btn.x + btn.w / 2,
|
|
btn.y + btn.h / 2
|
|
);
|
|
},
|
|
|
|
// ---- Toast ----
|
|
_drawToast(ctx) {
|
|
if (!this._message) return;
|
|
|
|
// Fade calculation
|
|
const alpha = Math.min(1, this._messageTimer / 0.3);
|
|
ctx.globalAlpha = alpha;
|
|
|
|
const msgW = 200;
|
|
const msgH = 32;
|
|
const msgX = (SCREEN_WIDTH - msgW) / 2;
|
|
const msgY = SCREEN_HEIGHT * 0.9;
|
|
|
|
// Toast background
|
|
ctx.fillStyle = C.TOAST_BG;
|
|
this._roundRect(ctx, msgX, msgY, msgW, msgH, msgH / 2);
|
|
ctx.fill();
|
|
|
|
// Toast border
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
|
ctx.lineWidth = 1;
|
|
this._roundRect(ctx, msgX, msgY, msgW, msgH, msgH / 2);
|
|
ctx.stroke();
|
|
|
|
// Toast text
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = '12px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(this._message, SCREEN_WIDTH / 2, msgY + msgH / 2);
|
|
|
|
ctx.globalAlpha = 1;
|
|
},
|
|
|
|
// ---- Utility: Rounded Rect ----
|
|
_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();
|
|
},
|
|
|
|
// ============================================================
|
|
// Touch
|
|
// ============================================================
|
|
_hitTest(tx, ty, rect) {
|
|
if (!rect) return false;
|
|
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
|
|
},
|
|
|
|
_showMessage(msg) {
|
|
this._message = msg;
|
|
this._messageTimer = 2;
|
|
},
|
|
|
|
handleTouch(eventType, e) {
|
|
const sm = GameGlobal.skinManager;
|
|
if (!sm) return;
|
|
|
|
if (eventType === 'touchstart') {
|
|
const touch = e.touches[0];
|
|
this._touchStartY = touch.clientY;
|
|
this._touchLastY = touch.clientY;
|
|
this._isDragging = false;
|
|
this._scrollVelocity = 0;
|
|
this._touchStartTime = Date.now();
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'touchmove') {
|
|
const touch = e.touches[0];
|
|
const dy = this._touchLastY - touch.clientY;
|
|
const totalDy = Math.abs(touch.clientY - this._touchStartY);
|
|
|
|
// Start dragging if moved more than 5px
|
|
if (totalDy > 5) {
|
|
this._isDragging = true;
|
|
}
|
|
|
|
if (this._isDragging) {
|
|
this._scrollY += dy;
|
|
// Clamp with elastic overscroll feel
|
|
if (this._scrollY < 0) this._scrollY = 0;
|
|
if (this._scrollY > this._maxScrollY) this._scrollY = this._maxScrollY;
|
|
|
|
// Track velocity for inertia
|
|
const now = Date.now();
|
|
const elapsed = now - (this._touchMoveTime || now);
|
|
if (elapsed > 0) {
|
|
this._scrollVelocity = (dy / elapsed) * 1000; // px per second
|
|
}
|
|
this._touchMoveTime = now;
|
|
}
|
|
|
|
this._touchLastY = touch.clientY;
|
|
return;
|
|
}
|
|
|
|
if (eventType === 'touchend') {
|
|
// If was dragging, just release with inertia
|
|
if (this._isDragging) {
|
|
this._isDragging = false;
|
|
// Velocity is already set from touchmove
|
|
return;
|
|
}
|
|
|
|
// Not dragging — treat as tap
|
|
this._isDragging = false;
|
|
const touch = e.changedTouches[0];
|
|
const tx = touch.clientX;
|
|
const rawTy = touch.clientY;
|
|
|
|
// Back button uses screen coordinates (fixed position)
|
|
if (this._hitTest(tx, rawTy, this._buttons.back)) {
|
|
GameGlobal.sceneManager.switchTo(SCENE.MENU);
|
|
return;
|
|
}
|
|
|
|
// Adjust tap Y by scroll offset for scrollable content
|
|
const ty = rawTy + this._scrollY;
|
|
|
|
// Check skin cards
|
|
for (const card of this._skinCards) {
|
|
if (this._hitTest(tx, ty, card.rect)) {
|
|
if (sm.isUnlocked(card.id)) {
|
|
// Equip
|
|
if (sm.getEquippedSkinId() !== card.id) {
|
|
const result = sm.equipSkin(card.id);
|
|
if (result.success) {
|
|
this._showMessage(t('skin.equipSuccess') || '✓ Skin equipped!');
|
|
}
|
|
}
|
|
} else {
|
|
// Purchase
|
|
const result = sm.purchaseSkin(card.id);
|
|
if (result.success) {
|
|
this._showMessage(t('skin.purchaseSuccess') || '✓ Skin unlocked!');
|
|
sm.equipSkin(card.id);
|
|
} else if (result.error === 'Insufficient gold') {
|
|
this._showMessage(t('currency.insufficient') || 'Insufficient Gold');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
module.exports = SkinScene;
|