228 lines
8.5 KiB
JavaScript
228 lines
8.5 KiB
JavaScript
const canvas = document.getElementById('gameCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const cysCanvas = document.getElementById('cystinstein-canvas');
|
|
const cysCtx = cysCanvas.getContext('2d');
|
|
|
|
canvas.width = window.innerWidth;
|
|
canvas.height = window.innerHeight;
|
|
|
|
const CX = canvas.width / 2;
|
|
const CY = canvas.height / 2;
|
|
|
|
// ─── Leaderboard ─────────────────────────────────────────────────────────────
|
|
const LEADERBOARD_KEY = 'sysdigger_leaderboard';
|
|
const LEADERBOARD_MAX = 10;
|
|
|
|
function loadLeaderboard() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(LEADERBOARD_KEY)) || [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveLeaderboard(board) {
|
|
localStorage.setItem(LEADERBOARD_KEY, JSON.stringify(board));
|
|
}
|
|
|
|
function addEntry(name, score) {
|
|
const board = loadLeaderboard();
|
|
board.push({ name: name.trim() || 'Anonymous', score });
|
|
board.sort((a, b) => b.score - a.score);
|
|
const trimmed = board.slice(0, LEADERBOARD_MAX);
|
|
saveLeaderboard(trimmed);
|
|
return trimmed;
|
|
}
|
|
|
|
function renderLeaderboard(highlightEntry) {
|
|
const board = loadLeaderboard();
|
|
const tbody = document.getElementById('leaderboard-rows');
|
|
tbody.innerHTML = '';
|
|
|
|
if (board.length === 0) {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `<td colspan="3" style="color:#555;text-align:center;padding:12px">No scores yet</td>`;
|
|
tbody.appendChild(tr);
|
|
return;
|
|
}
|
|
|
|
board.forEach((entry, i) => {
|
|
const tr = document.createElement('tr');
|
|
const isHighlight = highlightEntry &&
|
|
entry.name === highlightEntry.name &&
|
|
entry.score === highlightEntry.score;
|
|
|
|
if (isHighlight) tr.classList.add('highlight');
|
|
else if (i === 0) tr.classList.add('rank-1');
|
|
else if (i === 1) tr.classList.add('rank-2');
|
|
else if (i === 2) tr.classList.add('rank-3');
|
|
|
|
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : i + 1;
|
|
tr.innerHTML = `
|
|
<td class="rank-num">${medal}</td>
|
|
<td style="text-align:left">${escapeHtml(entry.name)}</td>
|
|
<td style="text-align:right">${entry.score}</td>`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function topScore() {
|
|
const board = loadLeaderboard();
|
|
return board.length > 0 ? board[0].score : null;
|
|
}
|
|
|
|
// ─── State ───────────────────────────────────────────────────────────────────
|
|
let score = 0;
|
|
let docks = 0;
|
|
let gameOver = false;
|
|
let incomingAtoms = [];
|
|
let anchorPoints = [];
|
|
let particles = [];
|
|
let clickEffects = [];
|
|
let lastSpawn = 0;
|
|
let spawnInterval = SPAWN_INTERVAL_START;
|
|
|
|
const stars = Array.from({ length: STAR_COUNT }, () => ({
|
|
x: Math.random() * canvas.width,
|
|
y: Math.random() * canvas.height,
|
|
r: Math.random() * STAR_RADIUS_MAX,
|
|
a: STAR_ALPHA_MIN + Math.random() * STAR_ALPHA_RANGE
|
|
}));
|
|
|
|
// ─── Anchors ─────────────────────────────────────────────────────────────────
|
|
function initAnchors() {
|
|
anchorPoints = [];
|
|
for (let i = 0; i < NUM_ANCHORS; i++) {
|
|
const angle = (i / NUM_ANCHORS) * Math.PI * 2 - Math.PI / 2;
|
|
anchorPoints.push({
|
|
x: CX + Math.cos(angle) * ANCHOR_RADIUS,
|
|
y: CY + Math.sin(angle) * ANCHOR_RADIUS,
|
|
angle,
|
|
active: false,
|
|
blockTimer: 0,
|
|
pulsePhase: Math.random() * Math.PI * 2
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Spawn ───────────────────────────────────────────────────────────────────
|
|
function spawnAtom() {
|
|
const available = anchorPoints.map((_, i) => i).filter(i => !anchorPoints[i].active);
|
|
if (available.length === 0) return;
|
|
const idx = available[Math.floor(Math.random() * available.length)];
|
|
incomingAtoms.push(new IncomingAtom(idx));
|
|
}
|
|
|
|
// ─── Input ───────────────────────────────────────────────────────────────────
|
|
canvas.addEventListener('click', e => {
|
|
if (gameOver) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left;
|
|
const my = e.clientY - rect.top;
|
|
let hit = false;
|
|
|
|
anchorPoints.forEach((ap, i) => {
|
|
if (!ap.active) return;
|
|
if (Math.hypot(mx - ap.x, my - ap.y) > CLICK_RADIUS) return;
|
|
|
|
const atom = incomingAtoms.find(a => a.anchorIndex === i && !a.dead);
|
|
if (atom) {
|
|
atom.dead = true;
|
|
ap.active = false;
|
|
ap.blockTimer = BLOCK_TIMER_FRAMES;
|
|
score++;
|
|
document.getElementById('score').textContent = `Score: ${score}`;
|
|
burst(ap.x, ap.y, '#00ff88');
|
|
clickEffects.push({ x: ap.x, y: ap.y, radius: 5, life: 1 });
|
|
hit = true;
|
|
}
|
|
});
|
|
|
|
if (!hit) clickEffects.push({ x: mx, y: my, radius: 5, life: 0.5 });
|
|
});
|
|
|
|
// ─── Game flow ───────────────────────────────────────────────────────────────
|
|
function triggerGameOver() {
|
|
gameOver = true;
|
|
const best = topScore();
|
|
const isNewRecord = best === null || score > best;
|
|
|
|
document.getElementById('final-score').textContent = `Score: ${score}`;
|
|
document.getElementById('new-record').classList.toggle('visible', isNewRecord);
|
|
|
|
const input = document.getElementById('player-name');
|
|
input.value = '';
|
|
setTimeout(() => input.focus(), 50);
|
|
|
|
renderLeaderboard(null);
|
|
document.getElementById('overlay').classList.add('active');
|
|
}
|
|
|
|
function submitScore() {
|
|
const name = document.getElementById('player-name').value.trim() || 'Anonymous';
|
|
const entry = { name, score };
|
|
addEntry(name, score);
|
|
renderLeaderboard(entry);
|
|
|
|
const best = topScore();
|
|
updateHudBest(best);
|
|
|
|
document.getElementById('name-form').style.display = 'none';
|
|
}
|
|
|
|
function restartGame() {
|
|
score = docks = 0;
|
|
gameOver = false;
|
|
incomingAtoms = [];
|
|
particles = [];
|
|
clickEffects = [];
|
|
lastSpawn = 0;
|
|
spawnInterval = SPAWN_INTERVAL_START;
|
|
document.getElementById('score').textContent = 'Score: 0';
|
|
document.getElementById('docks-count').textContent = '0 / 8 docks';
|
|
document.getElementById('name-form').style.display = 'flex';
|
|
document.getElementById('overlay').classList.remove('active');
|
|
initAnchors();
|
|
updateCystinstein();
|
|
}
|
|
|
|
function updateHudBest(best) {
|
|
document.getElementById('highscore').textContent = best !== null ? `Best: ${best}` : 'Best: —';
|
|
}
|
|
|
|
// ─── Enter key on name input ──────────────────────────────────────────────────
|
|
document.getElementById('player-name').addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') submitScore();
|
|
});
|
|
|
|
// ─── Main loop ───────────────────────────────────────────────────────────────
|
|
function gameLoop(ts) {
|
|
drawBackground();
|
|
|
|
if (!gameOver && ts - lastSpawn > spawnInterval) {
|
|
spawnAtom();
|
|
lastSpawn = ts;
|
|
spawnInterval = Math.max(SPAWN_INTERVAL_MIN, spawnInterval - SPAWN_DIFFICULTY_STEP);
|
|
}
|
|
|
|
incomingAtoms = incomingAtoms.filter(a => !a.dead);
|
|
incomingAtoms.forEach(a => { a.update(); a.draw(); });
|
|
|
|
drawCentralMolecule();
|
|
drawAnchorPoints();
|
|
drawParticles();
|
|
drawClickEffects();
|
|
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
// ─── Boot ────────────────────────────────────────────────────────────────────
|
|
updateHudBest(topScore());
|
|
initAnchors();
|
|
updateCystinstein();
|
|
requestAnimationFrame(gameLoop);
|