Initial commit: RPG game project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
409
menu.js
Normal file
409
menu.js
Normal file
@@ -0,0 +1,409 @@
|
||||
// ============================================================
|
||||
// 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());
|
||||
}
|
||||
Reference in New Issue
Block a user