diff --git a/public/app.js b/public/app.js deleted file mode 100644 index 239964d..0000000 --- a/public/app.js +++ /dev/null @@ -1,156 +0,0 @@ -const countEl = document.getElementById('count'); -const directEl = document.getElementById('direct'); -const canvas = document.getElementById('network'); -const ctx = canvas.getContext('2d'); -let particles = []; - -function resize() { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; -} - -window.addEventListener('resize', resize); -resize(); - -class Particle { - constructor() { - this.x = Math.random() * canvas.width; - this.y = Math.random() * canvas.height; - this.vx = (Math.random() - 0.5) * 1; - this.vy = (Math.random() - 0.5) * 1; - this.size = 3; - } - - update() { - this.x += this.vx; - this.y += this.vy; - - if (this.x < 0 || this.x > canvas.width) this.vx *= -1; - if (this.y < 0 || this.y > canvas.height) this.vy *= -1; - } - - draw() { - ctx.beginPath(); - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); - ctx.fillStyle = '#4ade80'; - ctx.fill(); - } -} - -const updateParticles = (count) => { - const VISUAL_LIMIT = 500; - const visualCount = Math.min(count, VISUAL_LIMIT); - - const currentCount = particles.length; - if (visualCount > currentCount) { - for (let i = 0; i < visualCount - currentCount; i++) { - particles.push(new Particle()); - } - } else if (visualCount < currentCount) { - particles.splice(visualCount, currentCount - visualCount); - } -} - -const animate = () => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - - ctx.strokeStyle = 'rgba(74, 222, 128, 0.15)'; - ctx.lineWidth = 1; - for (let i = 0; i < particles.length; i++) { - for (let j = i + 1; j < particles.length; j++) { - const dx = particles[i].x - particles[j].x; - const dy = particles[i].y - particles[j].y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < 150) { - ctx.beginPath(); - ctx.moveTo(particles[i].x, particles[i].y); - ctx.lineTo(particles[j].x, particles[j].y); - ctx.stroke(); - } - } - } - - particles.forEach(p => { - p.update(); - p.draw(); - }); - - requestAnimationFrame(animate); -} - -const openDiagnostics = () => { - document.getElementById('diagnosticsModal').classList.add('active'); -} - -const closeDiagnostics = () => { - document.getElementById('diagnosticsModal').classList.remove('active'); -} - -document.getElementById('diagnosticsModal').addEventListener('click', (e) => { - if (e.target.id === 'diagnosticsModal') { - closeDiagnostics(); - } -}); - -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - closeDiagnostics(); - } -}); - -const evtSource = new EventSource("/events"); - -evtSource.onmessage = (event) => { - const data = JSON.parse(event.data); - - updateParticles(data.count); - - if (countEl.innerText != data.count) { - countEl.innerText = data.count; - countEl.classList.remove('pulse'); - void countEl.offsetWidth; - countEl.classList.add('pulse'); - } - - directEl.innerText = data.direct; - - if (data.diagnostics) { - const d = data.diagnostics; - - const formatBandwidth = (bytes) => { - const kb = bytes / 1024; - const mb = kb / 1024; - const gb = mb / 1024; - - if (gb >= 1) { - return gb.toFixed(2) + ' GB'; - } else if (mb >= 1) { - return mb.toFixed(2) + ' MB'; - } else { - return kb.toFixed(1) + ' KB'; - } - }; - - document.getElementById('diag-heartbeats-rx').innerText = d.heartbeatsReceived.toLocaleString(); - document.getElementById('diag-heartbeats-tx').innerText = d.heartbeatsRelayed.toLocaleString(); - document.getElementById('diag-new-peers').innerText = d.newPeersAdded.toLocaleString(); - document.getElementById('diag-dup-seq').innerText = d.duplicateSeq.toLocaleString(); - document.getElementById('diag-invalid-pow').innerText = d.invalidPoW.toLocaleString(); - document.getElementById('diag-invalid-sig').innerText = d.invalidSig.toLocaleString(); - document.getElementById('diag-bandwidth-in').innerText = formatBandwidth(d.bytesReceived); - document.getElementById('diag-bandwidth-out').innerText = formatBandwidth(d.bytesRelayed); - document.getElementById('diag-leave').innerText = d.leaveMessages.toLocaleString(); - } -}; - -evtSource.onerror = (err) => { - // Removing console error here as it's extremely spammy in the browser console and it will reconnct automatically anyway, so pretty redundant. -}; - -const initialCount = parseInt(countEl.dataset.initialCount) || 0; -countEl.innerText = initialCount; -countEl.classList.add('loaded'); -updateParticles(initialCount); -animate(); - diff --git a/public/index.html b/public/index.html index 77d90c0..d92d6c7 100644 --- a/public/index.html +++ b/public/index.html @@ -18,9 +18,17 @@ ID: {{ID}}
Direct Connections: {{DIRECT}}
diagnostics + | + ? +
+
0
+
+
HI: 0
+
+ + - + diff --git a/public/js/constants.js b/public/js/constants.js new file mode 100644 index 0000000..b995d7c --- /dev/null +++ b/public/js/constants.js @@ -0,0 +1,7 @@ +// Shared constants +export const TWO_PI = Math.PI * 2; +export const CONNECTION_DISTANCE_SQ = 150 * 150; +export const TRAIL_MAX_LENGTH = 8; +export const MAX_VELOCITY_HISTORY = 10; +export const SHORTCUT_DELAY = 200; +export const VISUAL_PARTICLE_LIMIT = 500; diff --git a/public/js/effects.js b/public/js/effects.js new file mode 100644 index 0000000..523d4d6 --- /dev/null +++ b/public/js/effects.js @@ -0,0 +1,138 @@ +import { TWO_PI } from './constants.js'; +import * as state from './state.js'; + +export const effectsManager = { + sparks: [], + explosions: [], + floatingScores: [], + screenShake: { x: 0, y: 0, intensity: 0 }, + + update() { + // Update sparks (in-place to avoid GC) + let writeIdx = 0; + for (let i = 0; i < this.sparks.length; i++) { + const spark = this.sparks[i]; + spark.x += spark.vx; + spark.y += spark.vy; + spark.vy += 0.15; + spark.life -= 0.025; + spark.size *= 0.97; + if (spark.life > 0) { + this.sparks[writeIdx++] = spark; + } + } + this.sparks.length = writeIdx; + + // Update explosions (in-place) + writeIdx = 0; + for (let i = 0; i < this.explosions.length; i++) { + const exp = this.explosions[i]; + exp.radius += exp.speed; + exp.alpha -= 0.04; + if (exp.alpha > 0) { + this.explosions[writeIdx++] = exp; + } + } + this.explosions.length = writeIdx; + + // Update floating scores (in-place) + writeIdx = 0; + for (let i = 0; i < this.floatingScores.length; i++) { + const fs = this.floatingScores[i]; + fs.y += fs.vy; + fs.life -= 0.02; + if (fs.life > 0) { + this.floatingScores[writeIdx++] = fs; + } + } + this.floatingScores.length = writeIdx; + + // Decay screen shake + this.screenShake.intensity *= 0.9; + if (this.screenShake.intensity > 0.5) { + this.screenShake.x = (Math.random() - 0.5) * this.screenShake.intensity; + this.screenShake.y = (Math.random() - 0.5) * this.screenShake.intensity; + } else { + this.screenShake.x = 0; + this.screenShake.y = 0; + } + }, + + draw() { + const ctx = state.ctx; + + // Draw sparks + for (const spark of this.sparks) { + ctx.beginPath(); + ctx.arc(spark.x, spark.y, spark.size, 0, TWO_PI); + ctx.fillStyle = `hsla(${spark.hue}, 100%, 70%, ${spark.life})`; + ctx.fill(); + } + + // Draw explosions + for (const exp of this.explosions) { + ctx.beginPath(); + ctx.arc(exp.x, exp.y, exp.radius, 0, TWO_PI); + ctx.strokeStyle = `hsla(${exp.hue}, 100%, 70%, ${exp.alpha})`; + ctx.lineWidth = 2; + ctx.stroke(); + } + + // Draw floating scores + for (const fs of this.floatingScores) { + ctx.font = `bold ${14 + fs.multiplier * 2}px -apple-system, sans-serif`; + ctx.fillStyle = `rgba(255, 255, 255, ${fs.life})`; + ctx.textAlign = 'center'; + ctx.fillText(fs.text, fs.x, fs.y); + + if (fs.multiplier > 1) { + ctx.font = '11px -apple-system, sans-serif'; + ctx.fillStyle = `rgba(255, 215, 0, ${fs.life})`; + ctx.fillText(`x${fs.multiplier}`, fs.x + 25, fs.y); + } + } + }, + + spawnSparks(x, y, intensity) { + if (!state.chaosMode) return; + const sparkCount = Math.min(Math.floor(intensity * 3), 20); + for (let i = 0; i < sparkCount; i++) { + const angle = Math.random() * TWO_PI; + const speed = intensity * (0.5 + Math.random()); + this.sparks.push({ + x, y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + size: 2 + Math.random() * 2, + life: 1, + hue: 50 + Math.random() * 30 + }); + } + + if (intensity > 5) { + this.explosions.push({ + x, y, + radius: 5, + speed: intensity * 0.8, + alpha: 0.7, + hue: 60 + }); + } + }, + + addFloatingScore(x, y, points, multiplier) { + if (!state.chaosMode) return; + this.floatingScores.push({ + x, y, + text: `+${points}`, + multiplier, + life: 1, + vy: -2 + }); + } +}; + +export function triggerScreenShake(intensity) { + if (!state.chaosMode) return; + effectsManager.screenShake.intensity = Math.min(intensity, 15); +} diff --git a/public/js/game.js b/public/js/game.js new file mode 100644 index 0000000..077c8e5 --- /dev/null +++ b/public/js/game.js @@ -0,0 +1,119 @@ +import * as state from './state.js'; +import { effectsManager, triggerScreenShake } from './effects.js'; +import { showAchievement } from './ui.js'; +import { spawnConfetti } from './triggers.js'; + +// DOM elements (set by main.js) +let scoreEl = null; +let comboEl = null; +let highScoreEl = null; + +export function initGameUI(elements) { + scoreEl = elements.scoreEl; + comboEl = elements.comboEl; + highScoreEl = elements.highScoreEl; +} + +export const gameState = { + score: 0, + displayScore: 0, + combo: 0, + lastInteractionTime: 0, + comboTimeout: 2000, + highScore: parseInt(localStorage.getItem('hypermindHighScore')) || 0, + achievements: JSON.parse(localStorage.getItem('hypermindAchievements')) || {}, + totalCollisions: 0, + totalThrows: 0, + maxCombo: 0, + + addScore(points, type) { + if (!state.chaosMode) return; + + const now = performance.now(); + + if (now - this.lastInteractionTime < this.comboTimeout) { + this.combo++; + this.maxCombo = Math.max(this.maxCombo, this.combo); + + // Confetti on milestone combos + if (this.combo === 10 || this.combo === 25 || this.combo === 50 || this.combo === 100) { + spawnConfetti(state.canvas.width / 2, state.canvas.height / 2); + if (this.combo >= 25) { + triggerScreenShake(this.combo / 5); + } + } + } else { + this.combo = 1; + } + + const multiplier = Math.min(this.combo, 10); + const totalPoints = points * multiplier; + + this.score += totalPoints; + this.lastInteractionTime = now; + + if (type === 'collision') this.totalCollisions++; + if (type === 'throw' || type === 'powerThrow') this.totalThrows++; + + effectsManager.addFloatingScore( + state.interactionState.mouseX || state.canvas.width / 2, + state.interactionState.mouseY || state.canvas.height / 2, + totalPoints, + multiplier + ); + + this.checkAchievements(); + }, + + update() { + if (!state.chaosMode) return; + + const diff = this.score - this.displayScore; + this.displayScore += diff * 0.1; + + if (this.score > this.highScore) { + this.highScore = this.score; + localStorage.setItem('hypermindHighScore', this.highScore); + } + + if (performance.now() - this.lastInteractionTime > this.comboTimeout) { + this.combo = 0; + } + + this.updateUI(); + }, + + updateUI() { + if (scoreEl) scoreEl.textContent = Math.floor(this.displayScore).toLocaleString(); + if (highScoreEl) highScoreEl.textContent = this.highScore.toLocaleString(); + + if (comboEl) { + if (this.combo > 1) { + comboEl.textContent = `${this.combo}x COMBO`; + comboEl.style.opacity = '1'; + } else { + comboEl.style.opacity = '0'; + } + } + }, + + checkAchievements() { + const checks = [ + { id: 'firstBlood', check: () => this.totalCollisions >= 1, title: 'First Blood', desc: 'Cause your first collision' }, + { id: 'chainReaction', check: () => this.combo >= 10, title: 'Chain Reaction', desc: 'Get a 10x combo' }, + { id: 'centurion', check: () => this.totalCollisions >= 100, title: 'Centurion', desc: '100 total collisions' }, + { id: 'pitcher', check: () => this.totalThrows >= 50, title: 'The Pitcher', desc: 'Throw 50 particles' }, + { id: 'highRoller', check: () => this.score >= 10000, title: 'High Roller', desc: 'Score 10,000 points' }, + { id: 'comboKing', check: () => this.maxCombo >= 25, title: 'Combo King', desc: '25x combo' }, + { id: 'legend', check: () => this.score >= 100000, title: 'Legend', desc: 'Score 100,000 points' } + ]; + + for (const ach of checks) { + if (!this.achievements[ach.id] && ach.check()) { + this.achievements[ach.id] = true; + localStorage.setItem('hypermindAchievements', JSON.stringify(this.achievements)); + showAchievement(ach.title, ach.desc); + } + } + } +}; diff --git a/public/js/input.js b/public/js/input.js new file mode 100644 index 0000000..b737ecc --- /dev/null +++ b/public/js/input.js @@ -0,0 +1,371 @@ +import { MAX_VELOCITY_HISTORY, SHORTCUT_DELAY } from './constants.js'; +import * as state from './state.js'; +import { getParticleAtPoint } from './particle.js'; +import { gameState } from './game.js'; +import { showAchievement, openHelp, closeDiagnostics, closeHelp } from './ui.js'; +import { secretPatterns, triggerNuke, activateKonamiMode, triggerYeet, triggerReverse } from './triggers.js'; +import { activateChaosMode, toggleMatrixMode, toggleZenMode, toggleWarpMode, toggleDrunkMode, triggerPartyMode, triggerRaveMode } from './modes.js'; + +// Typing buffer for secret codes +let typedChars = ''; +let typingTimeout = null; + +// Pending shortcut system +let pendingShortcut = null; +let pendingShortcutTimer = null; +let pendingShortcutBufferLength = 0; + +// Konami and chaos activation codes +let konamiIndex = 0; +const konamiCode = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']; +let chaosCodeIndex = 0; +const chaosCode = ['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'ArrowLeft']; + +function executePendingShortcut() { + if (pendingShortcut) { + if (typedChars.length <= pendingShortcutBufferLength + 1) { + pendingShortcut(); + typedChars = ''; + } + pendingShortcut = null; + } +} + +function scheduleShortcut(action) { + pendingShortcut = action; + pendingShortcutBufferLength = typedChars.length; + clearTimeout(pendingShortcutTimer); + pendingShortcutTimer = setTimeout(executePendingShortcut, SHORTCUT_DELAY); +} + +function cancelPendingShortcut() { + clearTimeout(pendingShortcutTimer); + pendingShortcut = null; +} + +function resetTypingBuffer() { + clearTimeout(typingTimeout); + typingTimeout = setTimeout(() => { + typedChars = ''; + }, 1500); +} + +// Pointer handlers +export function handlePointerDown(e) { + const x = e.clientX; + const y = e.clientY; + + state.interactionState.isMouseDown = true; + secretPatterns.addClick(x, y); + + const particle = getParticleAtPoint(x, y); + if (particle) { + state.interactionState.isDragging = true; + state.interactionState.draggedParticle = particle; + particle.isDragged = true; + state.interactionState.velocityIndex = 0; + state.interactionState.velocityCount = 0; + + if (particle.isGolden && state.chaosMode) { + gameState.addScore(100, 'goldenGrab'); + particle.isGolden = false; + showAchievement('Midas Touch', 'Caught a golden particle!'); + } else { + gameState.addScore(10, 'grab'); + } + + state.canvas.classList.add('grabbing'); + } + + state.interactionState.mouseX = x; + state.interactionState.mouseY = y; + state.interactionState.prevMouseX = x; + state.interactionState.prevMouseY = y; + state.interactionState.lastMouseTime = performance.now(); +} + +export function handlePointerMove(e) { + const x = e.clientX; + const y = e.clientY; + const now = performance.now(); + const dt = (now - state.interactionState.lastMouseTime) / 1000; + + state.interactionState.prevMouseX = state.interactionState.mouseX; + state.interactionState.prevMouseY = state.interactionState.mouseY; + state.interactionState.mouseX = x; + state.interactionState.mouseY = y; + + if (dt > 0) { + const vx = (x - state.interactionState.prevMouseX) / dt; + const vy = (y - state.interactionState.prevMouseY) / dt; + + state.interactionState.velocityHistory[state.interactionState.velocityIndex] = { vx, vy, time: now }; + state.interactionState.velocityIndex = (state.interactionState.velocityIndex + 1) % MAX_VELOCITY_HISTORY; + if (state.interactionState.velocityCount < MAX_VELOCITY_HISTORY) { + state.interactionState.velocityCount++; + } + } + + if (state.interactionState.isDragging && state.interactionState.draggedParticle) { + state.interactionState.draggedParticle.x = x; + state.interactionState.draggedParticle.y = y; + } + + state.interactionState.lastMouseTime = now; +} + +export function handlePointerUp() { + if (state.interactionState.isDragging && state.interactionState.draggedParticle) { + const throwVelocity = calculateThrowVelocity(); + + state.interactionState.draggedParticle.vx = throwVelocity.vx * 0.015; + state.interactionState.draggedParticle.vy = throwVelocity.vy * 0.015; + state.interactionState.draggedParticle.isDragged = false; + + const throwSpeed = Math.sqrt(throwVelocity.vx ** 2 + throwVelocity.vy ** 2); + if (throwSpeed > 500) { + gameState.addScore(50, 'powerThrow'); + } else if (throwSpeed > 150) { + gameState.addScore(25, 'throw'); + } + + state.canvas.classList.remove('grabbing'); + } + + state.interactionState.isDragging = false; + state.interactionState.draggedParticle = null; + state.interactionState.isMouseDown = false; +} + +function calculateThrowVelocity() { + if (state.interactionState.velocityCount === 0) { + return { vx: 0, vy: 0 }; + } + + const now = performance.now(); + let totalVx = 0, totalVy = 0; + let validCount = 0; + + for (let i = 0; i < state.interactionState.velocityCount; i++) { + const idx = (state.interactionState.velocityIndex - 1 - i + MAX_VELOCITY_HISTORY) % MAX_VELOCITY_HISTORY; + const v = state.interactionState.velocityHistory[idx]; + if (v && now - v.time <= 100) { + totalVx += v.vx; + totalVy += v.vy; + validCount++; + } + } + + if (validCount === 0) { + return { vx: 0, vy: 0 }; + } + + return { + vx: totalVx / validCount, + vy: totalVy / validCount + }; +} + +// Set up all event listeners +export function setupInputListeners(canvas) { + // Mouse events + canvas.addEventListener('mousedown', handlePointerDown); + canvas.addEventListener('mousemove', handlePointerMove); + canvas.addEventListener('mouseup', handlePointerUp); + canvas.addEventListener('mouseleave', handlePointerUp); + + // Touch events + canvas.addEventListener('touchstart', (e) => { + e.preventDefault(); + const touch = e.touches[0]; + handlePointerDown({ clientX: touch.clientX, clientY: touch.clientY }); + }, { passive: false }); + + canvas.addEventListener('touchmove', (e) => { + e.preventDefault(); + const touch = e.touches[0]; + handlePointerMove({ clientX: touch.clientX, clientY: touch.clientY }); + }, { passive: false }); + + canvas.addEventListener('touchend', handlePointerUp); + + // Right-click for attraction + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + + canvas.addEventListener('mousedown', (e) => { + if (e.button === 2) { + state.setRightMouseDown(true); + state.interactionState.mouseX = e.clientX; + state.interactionState.mouseY = e.clientY; + } + }); + + canvas.addEventListener('mouseup', (e) => { + if (e.button === 2) { + state.setRightMouseDown(false); + } + }); + + // Keyboard events + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); + document.addEventListener('keypress', handleKeyPress); +} + +function handleKeyDown(e) { + // Chaos mode activation code + if (e.key === chaosCode[chaosCodeIndex]) { + chaosCodeIndex++; + if (chaosCodeIndex === chaosCode.length) { + activateChaosMode(); + chaosCodeIndex = 0; + } + } else if (e.key.startsWith('Arrow')) { + chaosCodeIndex = 0; + } + + // Konami code + const konamiKey = konamiCode[konamiIndex]; + const keyMatches = e.key === konamiKey || e.key.toLowerCase() === konamiKey; + if (keyMatches) { + konamiIndex++; + if (konamiIndex === konamiCode.length) { + activateKonamiMode(); + konamiIndex = 0; + } + } else if (!e.key.startsWith('Arrow') || konamiCode[konamiIndex].startsWith('Arrow')) { + konamiIndex = 0; + } + + // Escape to close modals + if (e.key === 'Escape') { + closeDiagnostics(); + closeHelp(); + } + + const isTypingSecrets = typedChars.length > 0; + const inKonamiSequence = konamiIndex > 0; + + // H or ? to open help + if (!isTypingSecrets && !inKonamiSequence && (e.key === 'h' || e.key === 'H' || e.key === '?')) { + openHelp(); + } + + // Chaos mode controls with delayed execution + if (state.chaosMode) { + if (e.key === 'g' || e.key === 'G') { + scheduleShortcut(() => { + state.setGravityMode((state.gravityMode + 1) % 3); + const modes = ['off', 'DOWN', 'UP']; + showAchievement('Gravity: ' + modes[state.gravityMode], + state.gravityMode === 0 ? 'Weightless' : state.gravityMode === 1 ? 'What goes up...' : 'Defying physics!'); + }); + } + + if (e.key === 'n' || e.key === 'N') { + scheduleShortcut(() => triggerNuke()); + } + + if (e.key === 'r' || e.key === 'R') { + scheduleShortcut(() => { + state.setRainbowMode(!state.rainbowMode); + showAchievement(state.rainbowMode ? 'Rainbow Mode!' : 'Rainbow Off', + state.rainbowMode ? 'Taste the rainbow' : 'Back to normal'); + }); + } + + if (e.key === 'd' || e.key === 'D') { + scheduleShortcut(() => { + state.setDiscoMode(!state.discoMode); + showAchievement(state.discoMode ? 'DISCO MODE!' : 'Disco Off', + state.discoMode ? 'Stayin\' alive!' : 'Party\'s over'); + }); + } + + if (e.key === 'm' || e.key === 'M') { + scheduleShortcut(() => toggleMatrixMode()); + } + + if (e.key === 'f' || e.key === 'F') { + scheduleShortcut(() => { + state.setFreezeMode(!state.freezeMode); + showAchievement(state.freezeMode ? 'FREEZE!' : 'Thawed', + state.freezeMode ? 'Time stands still' : 'Motion restored'); + }); + } + + if (e.key === 'v' || e.key === 'V') { + scheduleShortcut(() => { + state.setVortexMode(!state.vortexMode); + showAchievement(state.vortexMode ? 'VORTEX!' : 'Vortex Off', + state.vortexMode ? 'Into the spiral!' : 'Escaping the whirlpool'); + }); + } + + if (e.key === 'b' || e.key === 'B') { + scheduleShortcut(() => { + state.setBounceMode(!state.bounceMode); + showAchievement(state.bounceMode ? 'SUPER BOUNCE!' : 'Bounce Off', + state.bounceMode ? 'Walls amplify energy!' : 'Normal physics'); + }); + } + + if (e.code === 'Space' && !e.repeat) { + e.preventDefault(); + state.setSlowMotion(true); + showAchievement('Bullet Time', 'Everything slows down...'); + } + } +} + +function handleKeyUp(e) { + if (e.code === 'Space') { + state.setSlowMotion(false); + } +} + +function handleKeyPress(e) { + if (!e.key.match(/[a-zA-Z]/)) return; + + typedChars += e.key.toUpperCase(); + resetTypingBuffer(); + + // Check for secret words - cancel pending shortcuts when triggered + if (typedChars.includes('MATRIX')) { + cancelPendingShortcut(); + toggleMatrixMode(); + typedChars = ''; + } else if (typedChars.includes('PARTY')) { + cancelPendingShortcut(); + triggerPartyMode(); + typedChars = ''; + } else if (typedChars.includes('RAVE')) { + cancelPendingShortcut(); + triggerRaveMode(); + typedChars = ''; + } else if (typedChars.includes('YEET')) { + cancelPendingShortcut(); + triggerYeet(); + typedChars = ''; + } else if (typedChars.includes('ZEN')) { + cancelPendingShortcut(); + toggleZenMode(); + typedChars = ''; + } else if (typedChars.includes('WARP')) { + cancelPendingShortcut(); + toggleWarpMode(); + typedChars = ''; + } else if (typedChars.includes('DRUNK')) { + cancelPendingShortcut(); + toggleDrunkMode(); + typedChars = ''; + } else if (typedChars.includes('REVERSE')) { + cancelPendingShortcut(); + triggerReverse(); + typedChars = ''; + } + + if (typedChars.length > 10) { + typedChars = typedChars.slice(-10); + } +} diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..e8d6222 --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,365 @@ +import { TWO_PI, CONNECTION_DISTANCE_SQ, VISUAL_PARTICLE_LIMIT } from './constants.js'; +import * as state from './state.js'; +import { Particle } from './particle.js'; +import { effectsManager } from './effects.js'; +import { gameState, initGameUI } from './game.js'; +import { initModals } from './ui.js'; +import { setupInputListeners } from './input.js'; +import { maybeSpawnGoldenParticle } from './triggers.js'; + +// DOM elements +const countEl = document.getElementById('count'); +const directEl = document.getElementById('direct'); +const canvas = document.getElementById('network'); + +// Diagnostics elements +const diagElements = { + heartbeatsRx: document.getElementById('diag-heartbeats-rx'), + heartbeatsTx: document.getElementById('diag-heartbeats-tx'), + newPeers: document.getElementById('diag-new-peers'), + dupSeq: document.getElementById('diag-dup-seq'), + invalidPow: document.getElementById('diag-invalid-pow'), + invalidSig: document.getElementById('diag-invalid-sig'), + bandwidthIn: document.getElementById('diag-bandwidth-in'), + bandwidthOut: document.getElementById('diag-bandwidth-out'), + leave: document.getElementById('diag-leave') +}; + +// Initialize canvas +state.initCanvas(canvas); + +// Initialize UI modules +initModals({ + diagnosticsModal: document.getElementById('diagnosticsModal'), + helpModal: document.getElementById('helpModal') +}); + +initGameUI({ + scoreEl: document.getElementById('score'), + comboEl: document.getElementById('combo'), + highScoreEl: document.getElementById('highScore') +}); + +// Resize handler +function resize() { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; +} + +window.addEventListener('resize', resize); +resize(); + +// Set up input listeners +setupInputListeners(canvas); + +// Matrix rain effect +let matrixFrame = 0; + +function drawMatrixRain() { + if (!state.matrixMode) return; + if (++matrixFrame % 3 !== 0) return; + + state.ctx.font = '14px monospace'; + state.ctx.fillStyle = 'rgba(0, 255, 0, 0.08)'; + + for (let i = 0; i < canvas.width; i += 25) { + const char = String.fromCharCode(0x30A0 + Math.random() * 96); + state.ctx.fillText(char, i, Math.random() * canvas.height); + } +} + +// Black hole effect +function drawBlackHole() { + if (!state.blackHoleActive || !state.blackHolePos) return; + + const gradient = state.ctx.createRadialGradient( + state.blackHolePos.x, state.blackHolePos.y, 0, + state.blackHolePos.x, state.blackHolePos.y, 60 + ); + gradient.addColorStop(0, 'rgba(0, 0, 0, 0.9)'); + gradient.addColorStop(0.5, 'rgba(75, 0, 130, 0.5)'); + gradient.addColorStop(1, 'rgba(75, 0, 130, 0)'); + + state.ctx.beginPath(); + state.ctx.arc(state.blackHolePos.x, state.blackHolePos.y, 60, 0, TWO_PI); + state.ctx.fillStyle = gradient; + state.ctx.fill(); +} + +// Collision handling +function handleCollisions() { + const particles = state.particles; + for (let i = 0; i < particles.length; i++) { + for (let j = i + 1; j < particles.length; j++) { + const p1 = particles[i]; + const p2 = particles[j]; + + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const distanceSq = dx * dx + dy * dy; + const minDistance = p1.size + p2.size; + const minDistanceSq = minDistance * minDistance; + + if (distanceSq < minDistanceSq && distanceSq > 0) { + const distance = Math.sqrt(distanceSq); + resolveCollision(p1, p2, dx, dy, distance, minDistance); + } + } + } +} + +function resolveCollision(p1, p2, dx, dy, distance, minDistance) { + const nx = dx / distance; + const ny = dy / distance; + + const dvx = p1.vx - p2.vx; + const dvy = p1.vy - p2.vy; + const dvn = dvx * nx + dvy * ny; + + if (dvn > 0) return; + + const restitution = 0.85; + const massSum = p1.mass + p2.mass; + const impulse = -(1 + restitution) * dvn / massSum; + + if (!p1.isDragged) { + p1.vx += impulse * p2.mass * nx; + p1.vy += impulse * p2.mass * ny; + } + if (!p2.isDragged) { + p2.vx -= impulse * p1.mass * nx; + p2.vy -= impulse * p1.mass * ny; + } + + const overlap = minDistance - distance; + const separationX = overlap * nx * 0.5; + const separationY = overlap * ny * 0.5; + + if (!p1.isDragged) { + p1.x -= separationX; + p1.y -= separationY; + } + if (!p2.isDragged) { + p2.x += separationX; + p2.y += separationY; + } + + const collisionSpeed = Math.abs(dvn); + + if (collisionSpeed > 1.5) { + const collisionX = (p1.x + p2.x) / 2; + const collisionY = (p1.y + p2.y) / 2; + + effectsManager.spawnSparks(collisionX, collisionY, collisionSpeed); + + if (state.chaosMode) { + p1.glowIntensity = Math.min(collisionSpeed / 4, 1); + p2.glowIntensity = Math.min(collisionSpeed / 4, 1); + gameState.addScore(Math.floor(collisionSpeed * 5), 'collision'); + + if (collisionSpeed > 6) { + effectsManager.screenShake.intensity = Math.min(collisionSpeed * 0.6, 15); + } + } + } +} + +// Mouse repulsion/attraction +function applyMouseRepulsion() { + if (!state.interactionState.isMouseDown || state.interactionState.isDragging) return; + + const repulsionRadius = 120; + const repulsionRadiusSq = repulsionRadius * repulsionRadius; + const repulsionStrength = 0.8; + + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + if (p.isDragged) continue; + + const dx = p.x - state.interactionState.mouseX; + const dy = p.y - state.interactionState.mouseY; + const distanceSq = dx * dx + dy * dy; + + if (distanceSq < repulsionRadiusSq && distanceSq > 0) { + const distance = Math.sqrt(distanceSq); + const force = (repulsionRadius - distance) / repulsionRadius * repulsionStrength; + p.vx += (dx / distance) * force; + p.vy += (dy / distance) * force; + } + } +} + +function applyMouseAttraction() { + if (!state.isRightMouseDown || !state.chaosMode) return; + + const attractRadius = 200; + const attractRadiusSq = attractRadius * attractRadius; + const minDistSq = 400; + const attractStrength = 0.4; + + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + if (p.isDragged) continue; + + const dx = state.interactionState.mouseX - p.x; + const dy = state.interactionState.mouseY - p.y; + const distanceSq = dx * dx + dy * dy; + + if (distanceSq < attractRadiusSq && distanceSq > minDistSq) { + const distance = Math.sqrt(distanceSq); + const force = (attractRadius - distance) / attractRadius * attractStrength; + p.vx += (dx / distance) * force; + p.vy += (dy / distance) * force; + } + } +} + +// Update particle count +function updateParticles(count) { + const visualCount = Math.min(count, VISUAL_PARTICLE_LIMIT); + const currentCount = state.particles.length; + + if (visualCount > currentCount) { + const newParticles = [...state.particles]; + for (let i = 0; i < visualCount - currentCount; i++) { + newParticles.push(new Particle()); + } + state.setParticles(newParticles); + } else if (visualCount < currentCount) { + state.setParticles(state.particles.slice(0, visualCount)); + } +} + +// Main animation loop +function animate() { + const ctx = state.ctx; + + ctx.save(); + ctx.translate(effectsManager.screenShake.x, effectsManager.screenShake.y); + ctx.clearRect(-20, -20, canvas.width + 40, canvas.height + 40); + + drawMatrixRain(); + drawBlackHole(); + + // Connection lines + ctx.lineWidth = 1; + for (let i = 0; i < state.particles.length; i++) { + for (let j = i + 1; j < state.particles.length; j++) { + const dx = state.particles[i].x - state.particles[j].x; + const dy = state.particles[i].y - state.particles[j].y; + const distanceSq = dx * dx + dy * dy; + + if (distanceSq < CONNECTION_DISTANCE_SQ) { + let alpha = 0.15; + if (state.chaosMode) { + const maxSpeed = Math.max(state.particles[i].speed, state.particles[j].speed); + alpha = 0.15 + Math.min(maxSpeed * 0.03, 0.25); + } + ctx.strokeStyle = `rgba(74, 222, 128, ${alpha})`; + ctx.beginPath(); + ctx.moveTo(state.particles[i].x, state.particles[i].y); + ctx.lineTo(state.particles[j].x, state.particles[j].y); + ctx.stroke(); + } + } + } + + handleCollisions(); + applyMouseRepulsion(); + applyMouseAttraction(); + + // Update disco hue + if (state.discoMode) { + state.setDiscoHue((state.discoHue + 5) % 360); + } + + // Update and draw particles + if (state.freezeMode) { + for (let i = 0; i < state.particles.length; i++) { + state.particles[i].draw(); + } + } else if (state.slowMotion) { + const doUpdate = Math.random() > 0.7; + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + if (doUpdate) p.update(); + p.draw(); + } + } else { + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + p.update(); + p.draw(); + } + } + + // Disco flash effect + if (state.discoMode && state.chaosMode) { + ctx.fillStyle = `hsla(${state.discoHue}, 100%, 50%, 0.03)`; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + // Chaos mode extras + if (state.chaosMode) { + effectsManager.update(); + effectsManager.draw(); + gameState.update(); + maybeSpawnGoldenParticle(); + } + + ctx.restore(); + requestAnimationFrame(animate); +} + +// Bandwidth formatting +function formatBandwidth(bytes) { + const kb = bytes / 1024; + const mb = kb / 1024; + const gb = mb / 1024; + + if (gb >= 1) return gb.toFixed(2) + ' GB'; + if (mb >= 1) return mb.toFixed(2) + ' MB'; + return kb.toFixed(1) + ' KB'; +} + +// SSE connection +const evtSource = new EventSource("/events"); + +evtSource.onmessage = (event) => { + const data = JSON.parse(event.data); + + updateParticles(data.count); + + if (countEl.innerText != data.count) { + countEl.innerText = data.count; + countEl.classList.remove('pulse'); + void countEl.offsetWidth; + countEl.classList.add('pulse'); + } + + directEl.innerText = data.direct; + + if (data.diagnostics) { + const d = data.diagnostics; + diagElements.heartbeatsRx.innerText = d.heartbeatsReceived.toLocaleString(); + diagElements.heartbeatsTx.innerText = d.heartbeatsRelayed.toLocaleString(); + diagElements.newPeers.innerText = d.newPeersAdded.toLocaleString(); + diagElements.dupSeq.innerText = d.duplicateSeq.toLocaleString(); + diagElements.invalidPow.innerText = d.invalidPoW.toLocaleString(); + diagElements.invalidSig.innerText = d.invalidSig.toLocaleString(); + diagElements.bandwidthIn.innerText = formatBandwidth(d.bytesReceived); + diagElements.bandwidthOut.innerText = formatBandwidth(d.bytesRelayed); + diagElements.leave.innerText = d.leaveMessages.toLocaleString(); + } +}; + +evtSource.onerror = () => { + // SSE reconnects automatically +}; + +// Initialize +const initialCount = parseInt(countEl.dataset.initialCount) || 0; +countEl.innerText = initialCount; +countEl.classList.add('loaded'); +updateParticles(initialCount); +animate(); diff --git a/public/js/modes.js b/public/js/modes.js new file mode 100644 index 0000000..03f03eb --- /dev/null +++ b/public/js/modes.js @@ -0,0 +1,168 @@ +import { TWO_PI } from './constants.js'; +import * as state from './state.js'; +import { effectsManager, triggerScreenShake } from './effects.js'; +import { showAchievement, showAchievementForce } from './ui.js'; + +let matrixTimer = null; +let drunkTimer = null; + +export function activateChaosMode() { + if (state.chaosMode) return; + state.setChaosMode(true); + document.body.classList.add('chaos-mode'); + + showAchievementForce('Chaos Unleashed', 'Let the madness begin!'); + + // Celebratory explosion + for (let i = 0; i < 60; i++) { + const angle = (i / 60) * TWO_PI; + effectsManager.sparks.push({ + x: state.canvas.width / 2, + y: state.canvas.height / 2, + vx: Math.cos(angle) * (6 + Math.random() * 6), + vy: Math.sin(angle) * (6 + Math.random() * 6), + size: 3 + Math.random() * 3, + life: 1.2, + hue: Math.random() * 360 + }); + } + triggerScreenShake(15); +} + +export function toggleMatrixMode() { + if (!state.chaosMode) return; + + state.setMatrixMode(!state.matrixMode); + + if (state.matrixMode) { + showAchievement('Wake Up, Neo', 'The Matrix has you...'); + clearTimeout(matrixTimer); + matrixTimer = setTimeout(() => { + state.setMatrixMode(false); + }, 30000); + } else { + clearTimeout(matrixTimer); + showAchievement('Unplugged', 'Back to reality'); + } +} + +export function toggleZenMode() { + if (!state.chaosMode) return; + + state.setZenMode(!state.zenMode); + + if (state.zenMode) { + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + p.vx *= 0.1; + p.vy *= 0.1; + } + state.setGravityMode(0); + showAchievement('Zen Mode', 'Breathe... relax...'); + } else { + showAchievement('Zen Off', 'Back to chaos!'); + } +} + +export function toggleWarpMode() { + if (!state.chaosMode) return; + + state.setWarpMode(!state.warpMode); + + if (state.warpMode) { + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + const angle = Math.random() * TWO_PI; + p.vx += Math.cos(angle) * 8; + p.vy += Math.sin(angle) * 8; + } + triggerScreenShake(10); + showAchievement('WARP DRIVE!', 'Ludicrous speed engaged!'); + } else { + showAchievement('Warp Off', 'Dropping out of hyperspace'); + } +} + +export function toggleDrunkMode() { + if (!state.chaosMode) return; + + state.setDrunkMode(!state.drunkMode); + + if (state.drunkMode) { + showAchievement('Drunk Mode', '*hic* Everything\'s spinning...'); + clearTimeout(drunkTimer); + drunkTimer = setTimeout(() => { + state.setDrunkMode(false); + }, 15000); + } else { + clearTimeout(drunkTimer); + showAchievement('Sobering Up', 'Clarity returns'); + } +} + +export function triggerPartyMode() { + if (!state.chaosMode) return; + + const points = [ + { x: state.canvas.width * 0.2, y: state.canvas.height * 0.3 }, + { x: state.canvas.width * 0.8, y: state.canvas.height * 0.3 }, + { x: state.canvas.width * 0.5, y: state.canvas.height * 0.2 }, + { x: state.canvas.width * 0.3, y: state.canvas.height * 0.7 }, + { x: state.canvas.width * 0.7, y: state.canvas.height * 0.7 } + ]; + + for (const point of points) { + for (let i = 0; i < 25; i++) { + const angle = Math.random() * TWO_PI; + const speed = 4 + Math.random() * 8; + effectsManager.sparks.push({ + x: point.x, + y: point.y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 2, + size: 3 + Math.random() * 4, + life: 2, + hue: Math.random() * 360 + }); + } + } + + state.setRainbowMode(true); + setTimeout(() => { state.setRainbowMode(false); }, 5000); + + triggerScreenShake(10); + showAchievement('PARTY TIME!', 'Let\'s celebrate!'); +} + +export function triggerRaveMode() { + if (!state.chaosMode) return; + + state.setRainbowMode(true); + state.setDiscoMode(true); + + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + p.vx *= 2; + p.vy *= 2; + } + + for (let i = 0; i < 100; i++) { + effectsManager.sparks.push({ + x: Math.random() * state.canvas.width, + y: Math.random() * state.canvas.height, + vx: (Math.random() - 0.5) * 10, + vy: (Math.random() - 0.5) * 10, + size: 2 + Math.random() * 4, + life: 1.5, + hue: Math.random() * 360 + }); + } + + triggerScreenShake(15); + showAchievement('RAVE MODE!', 'UNTZ UNTZ UNTZ!'); + + setTimeout(() => { + state.setRainbowMode(false); + state.setDiscoMode(false); + }, 8000); +} diff --git a/public/js/particle.js b/public/js/particle.js new file mode 100644 index 0000000..f3d06c4 --- /dev/null +++ b/public/js/particle.js @@ -0,0 +1,238 @@ +import { TWO_PI, TRAIL_MAX_LENGTH } from './constants.js'; +import * as state from './state.js'; + +export class Particle { + constructor() { + this.x = Math.random() * state.canvas.width; + this.y = Math.random() * state.canvas.height; + this.vx = (Math.random() - 0.5) * 1; + this.vy = (Math.random() - 0.5) * 1; + this.size = 3 + Math.random() * 3; + this.mass = this.size * this.size; + this.speed = 0; + this.isDragged = false; + this.glowIntensity = 0; + this.hue = 140; + this.trail = new Array(TRAIL_MAX_LENGTH); + this.trailIndex = 0; + this.trailLength = 0; + this.isGolden = false; + this.goldenTimer = 0; + } + + update() { + if (this.isDragged) { + this.vx = 0; + this.vy = 0; + return; + } + + // Black hole gravity + if (state.blackHoleActive && state.blackHolePos) { + const dx = state.blackHolePos.x - this.x; + const dy = state.blackHolePos.y - this.y; + const distanceSq = dx * dx + dy * dy; + if (distanceSq > 400) { + const invDist = 1 / Math.sqrt(distanceSq); + const force = 400 * invDist * invDist; + this.vx += dx * invDist * force; + this.vy += dy * invDist * force; + } + } + + // Matrix mode - fall down + if (state.matrixMode) { + this.vy += 0.05; + this.hue = 120; + } + + // Gravity modes + if (state.chaosMode && state.gravityMode === 1) { + this.vy += 0.15; + } else if (state.chaosMode && state.gravityMode === 2) { + this.vy -= 0.1; + } + + // Drunk mode - random wobble + if (state.chaosMode && state.drunkMode) { + this.vx += (Math.random() - 0.5) * 0.5; + this.vy += (Math.random() - 0.5) * 0.5; + } + + // Zen mode - gentle drift + if (state.chaosMode && state.zenMode && this.speed > 0.3) { + this.vx *= 0.98; + this.vy *= 0.98; + } + + // Warp mode - continuous random acceleration + if (state.chaosMode && state.warpMode) { + const angle = Math.random() * TWO_PI; + this.vx += Math.cos(angle) * 0.3; + this.vy += Math.sin(angle) * 0.3; + } + + // Vortex mode - spiral toward center + if (state.chaosMode && state.vortexMode) { + const centerX = state.canvas.width / 2; + const centerY = state.canvas.height / 2; + const dx = centerX - this.x; + const dy = centerY - this.y; + const distanceSq = dx * dx + dy * dy; + if (distanceSq > 2500) { + const invDist = 1 / Math.sqrt(distanceSq); + const tangentX = -dy * invDist; + const tangentY = dx * invDist; + this.vx += dx * invDist * 0.05 + tangentX * 0.15; + this.vy += dy * invDist * 0.05 + tangentY * 0.15; + } + } + + // Rainbow mode - cycle hue + if (state.chaosMode && state.rainbowMode) { + this.hue = (performance.now() * 0.1 + this.x * 0.5) % 360; + } + + // Disco mode - pulsing + if (state.chaosMode && state.discoMode) { + this.hue = (state.discoHue + this.x * 0.3 + this.y * 0.3) % 360; + } + + // Calculate speed + this.speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy); + const naturalSpeed = 0.5; + + // Apply friction when moving fast + if (this.speed > naturalSpeed) { + const friction = 0.995; + this.vx *= friction; + this.vy *= friction; + } else if (this.speed < naturalSpeed * 0.5) { + this.vx += (Math.random() - 0.5) * 0.02; + this.vy += (Math.random() - 0.5) * 0.02; + } + + this.x += this.vx; + this.y += this.vy; + + // Trail - circular buffer + if (this.speed > 1.5) { + this.trail[this.trailIndex] = { x: this.x, y: this.y }; + this.trailIndex = (this.trailIndex + 1) % TRAIL_MAX_LENGTH; + if (this.trailLength < TRAIL_MAX_LENGTH) this.trailLength++; + } else if (this.trailLength > 0) { + this.trailLength--; + } + + // Edge bouncing + const bounceDamping = (state.chaosMode && state.bounceMode) ? 1.1 : 0.8; + if (this.x < this.size) { + this.x = this.size; + this.vx = Math.abs(this.vx) * bounceDamping; + } else if (this.x > state.canvas.width - this.size) { + this.x = state.canvas.width - this.size; + this.vx = -Math.abs(this.vx) * bounceDamping; + } + + if (this.y < this.size) { + this.y = this.size; + this.vy = Math.abs(this.vy) * bounceDamping; + } else if (this.y > state.canvas.height - this.size) { + this.y = state.canvas.height - this.size; + this.vy = -Math.abs(this.vy) * bounceDamping; + } + + // Color shift based on velocity + if (!state.matrixMode && !state.rainbowMode && !state.discoMode) { + this.hue = 140 - Math.min(this.speed * 20, 140); + } + + // Decay glow + this.glowIntensity *= 0.92; + + // Golden timer + if (this.isGolden) { + this.goldenTimer--; + if (this.goldenTimer <= 0) this.isGolden = false; + } + } + + draw() { + const ctx = state.ctx; + + // Draw trail from circular buffer + if (this.trailLength > 1) { + ctx.beginPath(); + const startIdx = (this.trailIndex - this.trailLength + TRAIL_MAX_LENGTH) % TRAIL_MAX_LENGTH; + const firstPoint = this.trail[startIdx]; + ctx.moveTo(firstPoint.x, firstPoint.y); + for (let i = 1; i < this.trailLength; i++) { + const idx = (startIdx + i) % TRAIL_MAX_LENGTH; + ctx.lineTo(this.trail[idx].x, this.trail[idx].y); + } + ctx.strokeStyle = `hsla(${this.hue}, 80%, 65%, 0.3)`; + ctx.lineWidth = this.size * 0.6; + ctx.lineCap = 'round'; + ctx.stroke(); + } + + // Draw glow + if ((state.chaosMode && this.glowIntensity > 0.1) || this.isDragged) { + const glowAmount = this.isDragged ? 1 : this.glowIntensity; + const gradient = ctx.createRadialGradient( + this.x, this.y, 0, + this.x, this.y, this.size * 3 + ); + gradient.addColorStop(0, `hsla(${this.hue}, 100%, 70%, ${0.4 * glowAmount})`); + gradient.addColorStop(1, `hsla(${this.hue}, 100%, 70%, 0)`); + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size * 3, 0, TWO_PI); + ctx.fillStyle = gradient; + ctx.fill(); + } + + // Golden shimmer + if (this.isGolden && state.chaosMode) { + const gradient = ctx.createRadialGradient( + this.x, this.y, 0, + this.x, this.y, this.size + 5 + ); + gradient.addColorStop(0, 'rgba(255, 215, 0, 0.8)'); + gradient.addColorStop(1, 'rgba(255, 215, 0, 0)'); + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size + 5, 0, TWO_PI); + ctx.fillStyle = gradient; + ctx.fill(); + } + + // Main particle + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size, 0, TWO_PI); + ctx.fillStyle = this.isGolden ? '#ffd700' : `hsl(${this.hue}, 80%, 65%)`; + ctx.fill(); + + // Highlight when dragged + if (this.isDragged) { + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size * 0.4, 0, TWO_PI); + ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; + ctx.fill(); + } + } +} + +// Find particle at point +export function getParticleAtPoint(x, y) { + const hitRadius = 20; + for (let i = state.particles.length - 1; i >= 0; i--) { + const p = state.particles[i]; + const dx = p.x - x; + const dy = p.y - y; + const distanceSq = dx * dx + dy * dy; + const maxDist = hitRadius + p.size; + if (distanceSq < maxDist * maxDist) { + return p; + } + } + return null; +} diff --git a/public/js/state.js b/public/js/state.js new file mode 100644 index 0000000..c2c6698 --- /dev/null +++ b/public/js/state.js @@ -0,0 +1,75 @@ +import { MAX_VELOCITY_HISTORY } from './constants.js'; + +// Canvas and context (set by main.js) +export let canvas = null; +export let ctx = null; + +export function initCanvas(canvasEl) { + canvas = canvasEl; + ctx = canvas.getContext('2d'); +} + +// Particles array +export let particles = []; + +export function setParticles(newParticles) { + particles = newParticles; +} + +// Mode flags +export let chaosMode = false; +export let matrixMode = false; +export let rainbowMode = false; +export let discoMode = false; +export let freezeMode = false; +export let vortexMode = false; +export let bounceMode = false; +export let slowMotion = false; +export let gravityMode = 0; // 0 = off, 1 = down, 2 = up +export let zenMode = false; +export let warpMode = false; +export let drunkMode = false; +export let discoHue = 0; + +// Setters for mode flags +export function setChaosMode(val) { chaosMode = val; } +export function setMatrixMode(val) { matrixMode = val; } +export function setRainbowMode(val) { rainbowMode = val; } +export function setDiscoMode(val) { discoMode = val; } +export function setFreezeMode(val) { freezeMode = val; } +export function setVortexMode(val) { vortexMode = val; } +export function setBounceMode(val) { bounceMode = val; } +export function setSlowMotion(val) { slowMotion = val; } +export function setGravityMode(val) { gravityMode = val; } +export function setZenMode(val) { zenMode = val; } +export function setWarpMode(val) { warpMode = val; } +export function setDrunkMode(val) { drunkMode = val; } +export function setDiscoHue(val) { discoHue = val; } + +// Black hole state +export let blackHoleActive = false; +export let blackHolePos = null; +export let blackHoleTimer = null; + +export function setBlackHoleActive(val) { blackHoleActive = val; } +export function setBlackHolePos(val) { blackHolePos = val; } +export function setBlackHoleTimer(val) { blackHoleTimer = val; } + +// Right mouse state +export let isRightMouseDown = false; +export function setRightMouseDown(val) { isRightMouseDown = val; } + +// Interaction state +export const interactionState = { + isDragging: false, + draggedParticle: null, + isMouseDown: false, + mouseX: 0, + mouseY: 0, + prevMouseX: 0, + prevMouseY: 0, + velocityHistory: new Array(MAX_VELOCITY_HISTORY), + velocityIndex: 0, + velocityCount: 0, + lastMouseTime: 0 +}; diff --git a/public/js/triggers.js b/public/js/triggers.js new file mode 100644 index 0000000..104179f --- /dev/null +++ b/public/js/triggers.js @@ -0,0 +1,266 @@ +import { TWO_PI } from './constants.js'; +import * as state from './state.js'; +import { effectsManager, triggerScreenShake } from './effects.js'; +import { gameState } from './game.js'; +import { showAchievement } from './ui.js'; + +// Secret click patterns +export const secretPatterns = { + clicks: [], + lastClickTime: 0, + + addClick(x, y) { + if (!state.chaosMode) return; + + const now = performance.now(); + if (now - this.lastClickTime > 1000) { + this.clicks = []; + } + + this.clicks.push({ x, y, time: now }); + this.lastClickTime = now; + this.checkPatterns(); + + if (this.clicks.length > 10) this.clicks.shift(); + }, + + checkPatterns() { + // Triple click - explosion + if (this.clicks.length >= 3) { + const last3 = this.clicks.slice(-3); + const allClose = last3.every((c, i) => { + if (i === 0) return true; + const prev = last3[i - 1]; + return Math.abs(c.x - prev.x) < 30 && Math.abs(c.y - prev.y) < 30; + }); + + if (allClose && last3[2].time - last3[0].time < 600) { + triggerLocalExplosion(last3[0].x, last3[0].y); + this.clicks = []; + } + } + + // Circle pattern - black hole + if (this.clicks.length >= 5) { + const last5 = this.clicks.slice(-5); + if (this.isCirclePattern(last5)) { + activateBlackHole(this.getCenter(last5)); + this.clicks = []; + } + } + }, + + isCirclePattern(clicks) { + const center = this.getCenter(clicks); + const distances = clicks.map(c => + Math.sqrt((c.x - center.x) ** 2 + (c.y - center.y) ** 2) + ); + const avgDist = distances.reduce((a, b) => a + b) / distances.length; + return distances.every(d => Math.abs(d - avgDist) < 60) && avgDist > 60; + }, + + getCenter(clicks) { + const sumX = clicks.reduce((a, c) => a + c.x, 0); + const sumY = clicks.reduce((a, c) => a + c.y, 0); + return { x: sumX / clicks.length, y: sumY / clicks.length }; + } +}; + +export function triggerNuke() { + if (!state.chaosMode) return; + + const centerX = state.canvas.width / 2; + const centerY = state.canvas.height / 2; + + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + const dx = p.x - centerX; + const dy = p.y - centerY; + const distanceSq = dx * dx + dy * dy; + + if (distanceSq > 1) { + const invDist = 1 / Math.sqrt(distanceSq); + const force = 15; + p.vx += dx * invDist * force; + p.vy += dy * invDist * force; + } + } + + // Big explosion effect + for (let i = 0; i < 80; i++) { + const angle = (i / 80) * TWO_PI; + effectsManager.sparks.push({ + x: centerX, + y: centerY, + vx: Math.cos(angle) * (8 + Math.random() * 8), + vy: Math.sin(angle) * (8 + Math.random() * 8), + size: 3 + Math.random() * 3, + life: 1, + hue: Math.random() * 60 + }); + } + + effectsManager.explosions.push({ + x: centerX, + y: centerY, + radius: 10, + speed: 15, + alpha: 1, + hue: 30 + }); + + triggerScreenShake(20); + gameState.addScore(500, 'nuke'); + showAchievement('NUKE!', 'Total annihilation!'); +} + +export function spawnConfetti(x, y) { + if (!state.chaosMode) return; + + for (let i = 0; i < 30; i++) { + const angle = Math.random() * TWO_PI; + const speed = 3 + Math.random() * 5; + effectsManager.sparks.push({ + x, y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 3, + size: 3 + Math.random() * 3, + life: 1.5, + hue: Math.random() * 360 + }); + } +} + +export function triggerLocalExplosion(x, y) { + if (!state.chaosMode) return; + + const maxDistanceSq = 40000; // 200^2 + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + const dx = p.x - x; + const dy = p.y - y; + const distanceSq = dx * dx + dy * dy; + + if (distanceSq < maxDistanceSq && distanceSq > 1) { + const distance = Math.sqrt(distanceSq); + const force = (200 - distance) / 8; + p.vx += (dx / distance) * force; + p.vy += (dy / distance) * force; + } + } + + for (let i = 0; i < 40; i++) { + const angle = Math.random() * TWO_PI; + effectsManager.sparks.push({ + x, y, + vx: Math.cos(angle) * (4 + Math.random() * 8), + vy: Math.sin(angle) * (4 + Math.random() * 8), + size: 2 + Math.random() * 3, + life: 1, + hue: Math.random() * 60 + }); + } + + triggerScreenShake(12); + showAchievement('Kaboom!', 'Triple-click explosion!'); +} + +export function activateBlackHole(pos) { + if (!state.chaosMode) return; + + state.setBlackHoleActive(true); + state.setBlackHolePos(pos); + + clearTimeout(state.blackHoleTimer); + state.setBlackHoleTimer(setTimeout(() => { + state.setBlackHoleActive(false); + triggerLocalExplosion(state.blackHolePos.x, state.blackHolePos.y); + }, 5000)); + + showAchievement('Event Horizon', 'You summoned a black hole!'); +} + +export function activateKonamiMode() { + if (!state.chaosMode) return; + + gameState.score += 9999; + showAchievement('Konami Code', 'You know the code!'); + + for (let i = 0; i < state.particles.length; i++) { + state.particles[i].hue = (i * 20) % 360; + } + + for (let i = 0; i < 40; i++) { + const angle = (i / 40) * TWO_PI; + effectsManager.sparks.push({ + x: state.canvas.width / 2, + y: state.canvas.height / 2, + vx: Math.cos(angle) * 12, + vy: Math.sin(angle) * 12, + size: 4, + life: 1, + hue: (i * 9) % 360 + }); + } +} + +export function triggerYeet() { + if (!state.chaosMode) return; + + const centerX = state.canvas.width / 2; + const centerY = state.canvas.height / 2; + + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + const dx = p.x - centerX; + const dy = p.y - centerY; + const distanceSq = dx * dx + dy * dy; + + if (distanceSq > 1) { + const invDist = 1 / Math.sqrt(distanceSq); + const force = 30; + p.vx += dx * invDist * force; + p.vy += dy * invDist * force; + } + } + + for (let i = 0; i < 60; i++) { + const angle = (i / 60) * TWO_PI; + effectsManager.sparks.push({ + x: centerX, + y: centerY, + vx: Math.cos(angle) * 15, + vy: Math.sin(angle) * 15, + size: 4, + life: 1, + hue: 60 + }); + } + + triggerScreenShake(25); + showAchievement('YEET!', 'GET OUTTA HERE!'); + gameState.addScore(250, 'yeet'); +} + +export function triggerReverse() { + if (!state.chaosMode) return; + + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + p.vx = -p.vx; + p.vy = -p.vy; + } + + showAchievement('REVERSE!', 'Time goes backwards!'); + gameState.addScore(100, 'reverse'); +} + +export function maybeSpawnGoldenParticle() { + if (!state.chaosMode || Math.random() > 0.0005 || state.particles.length === 0) return; + + const randomParticle = state.particles[Math.floor(Math.random() * state.particles.length)]; + if (!randomParticle.isGolden) { + randomParticle.isGolden = true; + randomParticle.goldenTimer = 600; + } +} diff --git a/public/js/ui.js b/public/js/ui.js new file mode 100644 index 0000000..cd51ab5 --- /dev/null +++ b/public/js/ui.js @@ -0,0 +1,81 @@ +import * as state from './state.js'; + +// DOM elements (set by main.js) +let diagnosticsModal = null; +let helpModal = null; + +export function initModals(elements) { + diagnosticsModal = elements.diagnosticsModal; + helpModal = elements.helpModal; +} + +export function openDiagnostics() { + if (diagnosticsModal) diagnosticsModal.classList.add('active'); +} + +export function closeDiagnostics() { + if (diagnosticsModal) diagnosticsModal.classList.remove('active'); +} + +export function openHelp() { + if (helpModal) helpModal.classList.add('active'); +} + +export function closeHelp() { + if (helpModal) helpModal.classList.remove('active'); +} + +export function showAchievement(title, desc) { + if (!state.chaosMode) return; + + const existing = document.querySelector('.achievement-popup'); + if (existing) existing.remove(); + + const popup = document.createElement('div'); + popup.className = 'achievement-popup'; + popup.innerHTML = ` +
+
+
${title}
+
${desc}
+
+ `; + document.body.appendChild(popup); + + setTimeout(() => popup.classList.add('show'), 10); + + setTimeout(() => { + popup.classList.remove('show'); + setTimeout(() => popup.remove(), 500); + }, 3000); +} + +// Show achievement even if chaos mode just activated +export function showAchievementForce(title, desc) { + const existing = document.querySelector('.achievement-popup'); + if (existing) existing.remove(); + + const popup = document.createElement('div'); + popup.className = 'achievement-popup'; + popup.innerHTML = ` +
+
+
${title}
+
${desc}
+
+ `; + document.body.appendChild(popup); + + setTimeout(() => popup.classList.add('show'), 10); + + setTimeout(() => { + popup.classList.remove('show'); + setTimeout(() => popup.remove(), 500); + }, 3000); +} + +// Make functions available globally for onclick handlers in HTML +window.openDiagnostics = openDiagnostics; +window.closeDiagnostics = closeDiagnostics; +window.openHelp = openHelp; +window.closeHelp = closeHelp; diff --git a/public/style.css b/public/style.css index cd016e1..cd40854 100644 --- a/public/style.css +++ b/public/style.css @@ -85,3 +85,163 @@ a { color: #4b5563; text-decoration: none; border-bottom: 1px dotted #4b5563; } color: #333; margin-top: 1rem; } + +/* Chaos mode separator */ +.debug-separator { + color: #333; + margin: 0 0.3rem; +} + +/* Canvas cursor states */ +#network { cursor: default; } +#network.grabbing { cursor: grabbing; } + +/* Score container - hidden by default, shown in chaos mode */ +.score-container { + position: fixed; + top: 20px; + right: 20px; + text-align: right; + z-index: 100; + pointer-events: none; + opacity: 0; + transform: translateY(-10px); + transition: opacity 0.3s, transform 0.3s; +} + +.chaos-mode .score-container { + opacity: 1; + transform: translateY(0); +} + +#score { + font-size: 2rem; + font-weight: bold; + color: #4ade80; + text-shadow: 0 0 10px rgba(74, 222, 128, 0.5); + font-variant-numeric: tabular-nums; +} + +#combo { + font-size: 1rem; + color: #fbbf24; + opacity: 0; + transition: opacity 0.2s; + text-shadow: 0 0 8px rgba(251, 191, 36, 0.5); +} + +.high-score { + font-size: 0.75rem; + color: #666; + margin-top: 0.25rem; +} + +/* Achievement popup */ +.achievement-popup { + position: fixed; + bottom: -120px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border: 2px solid #4ade80; + border-radius: 12px; + padding: 16px 24px; + display: flex; + align-items: center; + gap: 14px; + z-index: 1000; + transition: bottom 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); + box-shadow: 0 0 30px rgba(74, 222, 128, 0.3), 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.achievement-popup.show { + bottom: 30px; +} + +.achievement-icon { + font-size: 2rem; + color: #fbbf24; + text-shadow: 0 0 10px rgba(251, 191, 36, 0.5); +} + +.achievement-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.achievement-title { + font-size: 1.1rem; + font-weight: bold; + color: #fff; +} + +.achievement-desc { + font-size: 0.8rem; + color: #9ca3af; +} + +/* Help modal */ +.help-content { + max-width: 700px; + max-height: 80vh; + overflow-y: auto; +} + +.help-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +@media (max-width: 600px) { + .help-grid { + grid-template-columns: 1fr; + } +} + +.help-column { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.help-section { + margin-bottom: 0; +} + +.help-section:last-child { + margin-bottom: 0; +} + +.help-section-title { + font-size: 0.75rem; + color: #4ade80; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.5rem; + padding-bottom: 0.25rem; + border-bottom: 1px solid #222; +} + +.help-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.35rem 0; + font-size: 0.8rem; +} + +.help-key { + color: #fff; + font-weight: 500; + background: #222; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-family: monospace; +} + +.help-desc { + color: #9ca3af; + text-align: right; +}