410 lines
17 KiB
JavaScript
410 lines
17 KiB
JavaScript
// ============================================================
|
||
// MENU.JS — Стартовый экран: анимация, слоты, выбор класса
|
||
// ============================================================
|
||
|
||
let _menuSlot = null;
|
||
|
||
window.onload = async function () {
|
||
try {
|
||
await DataLoader.load();
|
||
} catch (e) {
|
||
// Ошибка уже показана баннером в DataLoader._showError()
|
||
return;
|
||
}
|
||
Audio.init();
|
||
menuBuildClassGrid();
|
||
menuBuildSlots();
|
||
// Показываем сплэш-экран; start-screen скрыт через style="display:none" в HTML
|
||
splashStartAnim();
|
||
};
|
||
|
||
// ── Переход со сплэша в главное меню ──────────────────────
|
||
function splashEnter() {
|
||
const splash = document.getElementById('splash-screen');
|
||
if (!splash) return;
|
||
// Запускаем музыку (первый клик пользователя — браузер разрешает)
|
||
const bgm = document.getElementById('menu-bgm');
|
||
if (bgm) bgm.play().catch(() => {});
|
||
// Сначала показываем главное меню позади сплэша — чтобы не мелькал игровой интерфейс
|
||
const ss = document.getElementById('start-screen');
|
||
if (ss) ss.style.display = '';
|
||
menuStartAnim();
|
||
// Затем плавно убираем сплэш поверх уже готового меню
|
||
splash.style.opacity = '0';
|
||
splash.style.pointerEvents = 'none';
|
||
setTimeout(() => { splash.style.display = 'none'; }, 750);
|
||
}
|
||
|
||
// ── Анимация сплэш-экрана ─────────────────────────────────
|
||
function splashStartAnim() {
|
||
const mc = document.getElementById('splash-canvas');
|
||
if (!mc) return;
|
||
const ctx = mc.getContext('2d');
|
||
mc.width = 900; mc.height = 600;
|
||
|
||
const RUNES = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚷ','ᚹ','ᛗ','ᛟ','ᚾ','ᛁ','ᛃ','ᚲ','ᛏ','ᛚ'];
|
||
|
||
// Три слоя звёзд (параллакс)
|
||
const starLayers = [
|
||
Array.from({length: 110}, () => ({ x: Math.random()*900, y: Math.random()*600, r: Math.random()*0.7+0.1, v: 0.04, a: Math.random()*0.4+0.1 })),
|
||
Array.from({length: 55}, () => ({ x: Math.random()*900, y: Math.random()*600, r: Math.random()*1.1+0.3, v: 0.10, a: Math.random()*0.5+0.2 })),
|
||
Array.from({length: 22}, () => ({ x: Math.random()*900, y: Math.random()*600, r: Math.random()*1.6+0.5, v: 0.18, a: Math.random()*0.6+0.3 })),
|
||
];
|
||
|
||
const runes = [];
|
||
let lastRune = 0;
|
||
let angle = 0;
|
||
|
||
function frame(ts) {
|
||
const el = document.getElementById('splash-screen');
|
||
if (!el || el.style.display === 'none' || el.style.opacity === '0') return;
|
||
requestAnimationFrame(frame);
|
||
|
||
// Фон
|
||
ctx.fillStyle = '#02020a';
|
||
ctx.fillRect(0, 0, 900, 600);
|
||
|
||
// Центральное свечение
|
||
const cx = 450, cy = 288;
|
||
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, 420);
|
||
glow.addColorStop(0, 'rgba(35,8,75,0.55)');
|
||
glow.addColorStop(0.45,'rgba(15,4,38,0.28)');
|
||
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = glow;
|
||
ctx.fillRect(0, 0, 900, 600);
|
||
|
||
// Звёзды
|
||
starLayers.forEach((layer, li) => {
|
||
layer.forEach(s => {
|
||
s.y -= s.v;
|
||
if (s.y < -2) { s.y = 602; s.x = Math.random() * 900; }
|
||
const tw = 0.65 + Math.sin(ts / 900 + s.x * 0.05) * 0.35;
|
||
ctx.fillStyle = li === 2
|
||
? `rgba(255,220,140,${s.a * tw})`
|
||
: `rgba(190,190,255,${s.a * tw})`;
|
||
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
});
|
||
|
||
// Магический круг
|
||
angle += 0.0015;
|
||
ctx.save();
|
||
ctx.translate(cx, cy);
|
||
|
||
// Внешнее кольцо
|
||
ctx.rotate(angle);
|
||
ctx.beginPath(); ctx.arc(0, 0, 215, 0, Math.PI * 2);
|
||
ctx.strokeStyle = 'rgba(80,40,160,0.18)'; ctx.lineWidth = 1; ctx.stroke();
|
||
|
||
// Внутреннее кольцо
|
||
ctx.rotate(-angle * 2.3);
|
||
ctx.beginPath(); ctx.arc(0, 0, 155, 0, Math.PI * 2);
|
||
ctx.strokeStyle = 'rgba(100,55,185,0.14)'; ctx.lineWidth = 1; ctx.stroke();
|
||
|
||
// Шестиугольник
|
||
ctx.rotate(angle * 1.5);
|
||
ctx.strokeStyle = 'rgba(110,65,195,0.10)'; ctx.lineWidth = 0.8;
|
||
for (let i = 0; i < 6; i++) {
|
||
const a1 = (i / 6) * Math.PI * 2;
|
||
const a2 = ((i + 2) / 6) * Math.PI * 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(Math.cos(a1) * 155, Math.sin(a1) * 155);
|
||
ctx.lineTo(Math.cos(a2) * 155, Math.sin(a2) * 155);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Точки на кольце
|
||
ctx.rotate(-angle * 0.5);
|
||
for (let i = 0; i < 8; i++) {
|
||
const a = (i / 8) * Math.PI * 2 + angle * 3;
|
||
const px = Math.cos(a) * 215, py = Math.sin(a) * 215;
|
||
ctx.beginPath(); ctx.arc(px, py, 2.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(150,80,220,${0.4 + Math.sin(ts/600 + i) * 0.2})`;
|
||
ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
|
||
// Лучи света
|
||
ctx.save();
|
||
for (let i = 0; i < 8; i++) {
|
||
const a = (i / 8) * Math.PI * 2 + angle * 1.8;
|
||
const pulse = 0.06 + Math.sin(ts / 1100 + i) * 0.02;
|
||
const grad = ctx.createLinearGradient(cx, cy,
|
||
cx + Math.cos(a) * 380, cy + Math.sin(a) * 380);
|
||
grad.addColorStop(0, `rgba(110,50,210,${pulse})`);
|
||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = grad;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy);
|
||
const w = 0.07;
|
||
ctx.arc(cx, cy, 380, a - w, a + w);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
|
||
// Пульсирующая сфера в центре
|
||
const orbP = Math.sin(ts / 800);
|
||
const orb = ctx.createRadialGradient(cx, cy, 0, cx, cy, 65 + orbP * 6);
|
||
orb.addColorStop(0, `rgba(190,130,255,${0.14 + orbP * 0.05})`);
|
||
orb.addColorStop(0.5, `rgba(90,40,190,${0.07 + orbP * 0.02})`);
|
||
orb.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = orb;
|
||
ctx.beginPath(); ctx.arc(cx, cy, 65 + orbP * 6, 0, Math.PI * 2); ctx.fill();
|
||
|
||
// Силуэты мечей
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.09 + Math.sin(ts / 3200) * 0.02;
|
||
ctx.strokeStyle = '#c8a020'; ctx.lineWidth = 1.6;
|
||
|
||
// Левый меч
|
||
ctx.save();
|
||
ctx.translate(185, 295);
|
||
ctx.rotate(-0.28 + Math.sin(ts / 4000) * 0.015);
|
||
ctx.beginPath(); ctx.moveTo(0, -140); ctx.lineTo(0, 110); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(-22, -55); ctx.lineTo(22, -55); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(0, -140); ctx.lineTo(-6, -118); ctx.moveTo(0, -140); ctx.lineTo(6, -118); ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// Правый меч
|
||
ctx.save();
|
||
ctx.translate(715, 295);
|
||
ctx.rotate(0.28 - Math.sin(ts / 4000) * 0.015);
|
||
ctx.beginPath(); ctx.moveTo(0, -140); ctx.lineTo(0, 110); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(-22, -55); ctx.lineTo(22, -55); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(0, -140); ctx.lineTo(-6, -118); ctx.moveTo(0, -140); ctx.lineTo(6, -118); ctx.stroke();
|
||
ctx.restore();
|
||
ctx.restore();
|
||
|
||
// Руны
|
||
if (ts - lastRune > 420) {
|
||
lastRune = ts;
|
||
const col = Math.random() < 0.35 ? '#c8a020' : Math.random() < 0.5 ? '#9966cc' : '#4a4aaa';
|
||
runes.push({
|
||
x: 40 + Math.random() * 820,
|
||
y: 620,
|
||
ch: RUNES[Math.floor(Math.random() * RUNES.length)],
|
||
a: 0.75,
|
||
vy: -(0.38 + Math.random() * 0.52),
|
||
sz: 10 + Math.random() * 15,
|
||
col,
|
||
dx: (Math.random() - 0.5) * 0.28,
|
||
});
|
||
}
|
||
runes.forEach(r => { r.y += r.vy; r.x += r.dx; r.a -= 0.0022; });
|
||
for (let i = runes.length - 1; i >= 0; i--) {
|
||
const r = runes[i];
|
||
if (r.a <= 0 || r.y < -10) { runes.splice(i, 1); continue; }
|
||
ctx.globalAlpha = r.a;
|
||
ctx.fillStyle = r.col;
|
||
ctx.font = `${r.sz}px serif`;
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(r.ch, r.x, r.y);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
ctx.textAlign = 'left';
|
||
}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
// Вызывается из любой точки взаимодействия с меню (браузер разрешает play() только после клика)
|
||
function _menuStartMusic() {
|
||
const el = document.getElementById('menu-bgm');
|
||
if (el && el.paused) el.play().catch(() => {});
|
||
}
|
||
|
||
// Вызывается из game.js при старте игры
|
||
function _stopMenuBgm() {
|
||
const el = document.getElementById('menu-bgm');
|
||
if (el && !el.paused) { el.pause(); el.currentTime = 0; }
|
||
}
|
||
|
||
// ── Анимация фона главного меню ───────────────────────────
|
||
function menuStartAnim() {
|
||
const mc = document.getElementById('menu-canvas');
|
||
if (!mc) return;
|
||
const ctx = mc.getContext('2d');
|
||
mc.width = 900; mc.height = 600;
|
||
|
||
const stars = Array.from({ length: 180 }, () => ({
|
||
x: Math.random() * 900,
|
||
y: Math.random() * 600,
|
||
r: Math.random() * 1.4 + 0.2,
|
||
vx: (Math.random() - .5) * 0.1,
|
||
vy: (Math.random() - .5) * 0.05,
|
||
a: Math.random() * 0.7 + 0.2,
|
||
}));
|
||
|
||
const runes = [];
|
||
const RUNES = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚷ','ᚹ','ᛗ','ᛟ','ᚾ','ᛁ','ᛃ','ᚲ','ᛏ','ᛚ'];
|
||
let _lr = 0;
|
||
|
||
function frame(ts) {
|
||
const ss = document.getElementById('start-screen');
|
||
if (!ss || ss.style.display === 'none') return;
|
||
requestAnimationFrame(frame);
|
||
|
||
ctx.fillStyle = '#03030b';
|
||
ctx.fillRect(0, 0, 900, 600);
|
||
|
||
const grd = ctx.createRadialGradient(450, 300, 0, 450, 300, 430);
|
||
grd.addColorStop(0, 'rgba(50,20,90,0.28)');
|
||
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = grd;
|
||
ctx.fillRect(0, 0, 900, 600);
|
||
|
||
// Звёзды
|
||
stars.forEach(s => {
|
||
s.x = (s.x + s.vx + 900) % 900;
|
||
s.y = (s.y + s.vy + 600) % 600;
|
||
ctx.fillStyle = `rgba(200,200,255,${s.a})`;
|
||
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
|
||
// Руны
|
||
if (ts - _lr > 650) {
|
||
_lr = ts;
|
||
runes.push({
|
||
x: 80 + Math.random() * 740,
|
||
y: 590 + Math.random() * 20,
|
||
ch: RUNES[Math.floor(Math.random() * RUNES.length)],
|
||
a: 0.65,
|
||
vy: -(0.35 + Math.random() * 0.35),
|
||
sz: 11 + Math.random() * 13,
|
||
col: Math.random() < 0.3 ? '#c8a020' : '#4848a8',
|
||
});
|
||
}
|
||
runes.forEach(r => { r.y += r.vy; r.a -= 0.0025; });
|
||
for (let i = runes.length - 1; i >= 0; i--) {
|
||
const r = runes[i];
|
||
if (r.a <= 0) { runes.splice(i, 1); continue; }
|
||
ctx.globalAlpha = r.a;
|
||
ctx.fillStyle = r.col;
|
||
ctx.font = `${r.sz}px serif`;
|
||
ctx.fillText(r.ch, r.x, r.y);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
// ── Слоты сохранений ──────────────────────────────────────
|
||
function menuBuildSlots() {
|
||
const cont = document.getElementById('s-slots');
|
||
if (!cont) return;
|
||
cont.innerHTML = '';
|
||
for (let sl = 0; sl < 3; sl++) {
|
||
const meta = RPG.getSaveMeta(sl);
|
||
const card = document.createElement('div');
|
||
card.className = 'slot-card ' + (meta ? 'filled' : 'empty');
|
||
card.id = 'slot-card-' + sl;
|
||
|
||
if (meta) {
|
||
card.innerHTML = `
|
||
<div class="sc-num">Слот ${sl + 1}</div>
|
||
<div class="sc-icon">${meta.icon}</div>
|
||
<div class="sc-class">${meta.className}</div>
|
||
<div class="sc-info">
|
||
⭐ Уровень ${meta.level}<br>
|
||
📍 ${meta.mapName} · День ${meta.days}<br>
|
||
⚔️ Убийств: ${meta.kills}<br>
|
||
🕐 ${meta.playTime}
|
||
</div>
|
||
<div class="sc-date">${meta.date} ${meta.saveTime}</div>
|
||
<div class="sc-play">▶ Играть</div>
|
||
<div class="sc-del" onclick="menuDeleteSlot(${sl}, event)">🗑 Удалить</div>`;
|
||
card.onclick = e => {
|
||
if (e.target.classList.contains('sc-del')) return;
|
||
_menuStartMusic();
|
||
Game.loadAndStart(sl);
|
||
};
|
||
} else {
|
||
card.innerHTML = `
|
||
<div class="sc-num">Слот ${sl + 1}</div>
|
||
<div class="sc-icon" style="opacity:.3">📂</div>
|
||
<div class="sc-class" style="color:#444;font-size:12px">Пусто</div>
|
||
<div class="sc-info" style="margin-top:10px;color:#333">Нажмите чтобы<br>начать новую игру</div>`;
|
||
card.onclick = () => menuShowClassSelect(sl);
|
||
}
|
||
cont.appendChild(card);
|
||
}
|
||
}
|
||
|
||
// ── Выбор класса ──────────────────────────────────────────
|
||
function menuBuildClassGrid() {
|
||
const grid = document.getElementById('cls-grid');
|
||
if (!grid) return;
|
||
Object.entries(RPG.CLASSES).forEach(([id, cls]) => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'cls-btn';
|
||
btn.innerHTML = `
|
||
<div class="cb-icon">${cls.icon}</div>
|
||
<div class="cb-name">${cls.name}</div>
|
||
<div class="cb-desc">${cls.desc}</div>
|
||
<div class="cb-stats">HP:${cls.hp} MP:${cls.mp} СИЛ:${cls.str} ЗАЩ:${cls.def}</div>`;
|
||
btn.onclick = () => {
|
||
const slot = _menuSlot !== null ? _menuSlot : _pickFreeSlot();
|
||
Game.start(id, slot);
|
||
};
|
||
grid.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
function _pickFreeSlot() {
|
||
for (let i = 0; i < 3; i++) if (!RPG.hasSave(i)) return i;
|
||
return 0;
|
||
}
|
||
|
||
// ── Навигация меню ────────────────────────────────────────
|
||
function menuShowClassSelect(slot) {
|
||
_menuStartMusic();
|
||
_menuSlot = slot;
|
||
document.getElementById('menu-main').style.display = 'none';
|
||
document.getElementById('menu-class').style.display = 'flex';
|
||
const hint = document.getElementById('cls-slot-hint');
|
||
if (hint) hint.textContent = slot !== null ? `Новая игра — Слот ${slot + 1}` : 'Выберите класс';
|
||
}
|
||
|
||
function menuBack() {
|
||
_menuStartMusic();
|
||
document.getElementById('menu-class').style.display = 'none';
|
||
document.getElementById('menu-main').style.display = 'flex';
|
||
_menuSlot = null;
|
||
}
|
||
|
||
function menuDeleteSlot(slot, event) {
|
||
event.stopPropagation();
|
||
if (!confirm(`Удалить сохранение в слоте ${slot + 1}?`)) return;
|
||
RPG.deleteSave(slot);
|
||
menuBuildSlots();
|
||
}
|
||
|
||
// ── Выбор папки сохранений ────────────────────────────────
|
||
async function menuSelectSaveFolder() {
|
||
if (typeof SaveFS === 'undefined') {
|
||
alert('File System API недоступен в этом браузере.');
|
||
return;
|
||
}
|
||
if (!SaveFS.isSupported()) {
|
||
alert('Ваш браузер не поддерживает сохранение в файлы. Используйте Chrome/Edge.');
|
||
return;
|
||
}
|
||
const ok = await SaveFS.selectDir();
|
||
if (ok) {
|
||
const name = SaveFS.getDirName();
|
||
const btn = document.getElementById('btn-save-folder');
|
||
if (btn) btn.textContent = '📁 ' + name;
|
||
menuBuildSlots(); // обновить слоты из файлов
|
||
}
|
||
}
|
||
|
||
// Экспортировать сохранения в уже выбранную папку
|
||
async function menuExportSaves() {
|
||
if (typeof SaveFS === 'undefined' || !SaveFS.hasDir()) {
|
||
alert('Сначала выберите папку!');
|
||
return;
|
||
}
|
||
await SaveFS.exportAll();
|
||
alert('Сохранения экспортированы в папку: ' + SaveFS.getDirName());
|
||
}
|