Initial commit: RPG game project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-02-25 01:01:02 +03:00
commit ac1f348311
24 changed files with 13329 additions and 0 deletions

409
menu.js Normal file
View 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());
}