Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>ASTRAL COLONIZER - Procedural Space Simulation</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;600;700&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --neon-cyan: #00f3ff; | |
| --neon-pink: #ff00ff; | |
| --neon-amber: #ffaa00; | |
| --deep-space: #050510; | |
| --glass-bg: rgba(255, 255, 255, 0.03); | |
| --glass-border: rgba(255, 255, 255, 0.1); | |
| } | |
| body { | |
| background-color: var(--deep-space); | |
| color: white; | |
| font-family: 'Space Grotesk', sans-serif; | |
| overflow: hidden; | |
| user-select: none; | |
| } | |
| .mono { font-family: 'JetBrains Mono', monospace; } | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: #0a0a1a; } | |
| ::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--neon-cyan); } | |
| /* Glassmorphism */ | |
| .glass-panel { | |
| background: var(--glass-bg); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid var(--glass-border); | |
| box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5); | |
| } | |
| .glass-card { | |
| background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.01) 100%); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.05); | |
| transition: all 0.2s ease; | |
| } | |
| .glass-card:hover:not(:disabled) { | |
| border-color: var(--neon-cyan); | |
| background: rgba(0, 243, 255, 0.05); | |
| transform: translateY(-2px); | |
| } | |
| /* Animations */ | |
| @keyframes pulse-glow { | |
| 0%, 100% { box-shadow: 0 0 5px var(--neon-cyan); } | |
| 50% { box-shadow: 0 0 20px var(--neon-cyan); } | |
| } | |
| @keyframes scanline { | |
| 0% { transform: translateY(-100%); } | |
| 100% { transform: translateY(100%); } | |
| } | |
| .scan-overlay { | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: linear-gradient(to bottom, transparent 50%, rgba(0, 243, 255, 0.02) 51%); | |
| background-size: 100% 4px; | |
| pointer-events: none; | |
| z-index: 40; | |
| } | |
| .crt-flicker { | |
| animation: flicker 0.15s infinite; | |
| } | |
| @keyframes flicker { | |
| 0% { opacity: 0.97; } | |
| 100% { opacity: 1; } | |
| } | |
| /* Game UI Elements */ | |
| .resource-bar-fill { | |
| transition: width 0.3s ease-out; | |
| } | |
| .btn-action { | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .btn-action::after { | |
| content: ''; | |
| position: absolute; | |
| top: 0; left: -100%; | |
| width: 100%; height: 100%; | |
| background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); | |
| transition: 0.5s; | |
| } | |
| .btn-action:hover::after { | |
| left: 100%; | |
| } | |
| /* Tooltip */ | |
| #tooltip { | |
| position: absolute; | |
| pointer-events: none; | |
| z-index: 100; | |
| opacity: 0; | |
| transition: opacity 0.1s; | |
| transform: translate(15px, 15px); | |
| } | |
| canvas { | |
| image-rendering: pixelated; /* Retro feel */ | |
| } | |
| </style> | |
| </head> | |
| <body class="h-screen w-screen flex flex-col overflow-hidden crt-flicker"> | |
| <!-- Background Starfield Canvas --> | |
| <canvas id="bgCanvas" class="fixed top-0 left-0 w-full h-full z-0"></canvas> | |
| <!-- CRT Scanline Overlay --> | |
| <div class="scan-overlay"></div> | |
| <!-- Main Game Container --> | |
| <div class="relative z-10 flex flex-col h-full p-4 gap-4"> | |
| <!-- Header --> | |
| <header class="glass-panel rounded-lg p-3 flex justify-between items-center shrink-0"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-8 h-8 rounded bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center shadow-[0_0_15px_rgba(6,182,212,0.6)]"> | |
| <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"></path></svg> | |
| </div> | |
| <div> | |
| <h1 class="text-xl font-bold tracking-widest uppercase mono text-cyan-400">Astral Colonizer</h1> | |
| <div class="text-[10px] text-gray-400 mono flex gap-4"> | |
| <span>SECTOR: <span id="sector-id" class="text-white">ALPHA-9</span></span> | |
| <span>CYCLE: <span id="game-cycle" class="text-white">1</span></span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex gap-6 text-sm mono"> | |
| <div class="flex flex-col items-end w-32"> | |
| <div class="flex justify-between w-full text-xs text-gray-400 mb-1"> | |
| <span>ENERGY</span> | |
| <span id="val-energy">100</span> | |
| </div> | |
| <div class="w-full h-1.5 bg-gray-800 rounded-full overflow-hidden"> | |
| <div id="bar-energy" class="h-full bg-yellow-400 resource-bar-fill" style="width: 100%"></div> | |
| </div> | |
| </div> | |
| <div class="flex flex-col items-end w-32"> | |
| <div class="flex justify-between w-full text-xs text-gray-400 mb-1"> | |
| <span>MATTER</span> | |
| <span id="val-matter">50</span> | |
| </div> | |
| <div class="w-full h-1.5 bg-gray-800 rounded-full overflow-hidden"> | |
| <div id="bar-matter" class="h-full bg-blue-500 resource-bar-fill" style="width: 50%"></div> | |
| </div> | |
| </div> | |
| <div class="flex flex-col items-end w-32"> | |
| <div class="flex justify-between w-full text-xs text-gray-400 mb-1"> | |
| <span>POPULATION</span> | |
| <span id="val-pop">0</span> | |
| </div> | |
| <div class="w-full h-1.5 bg-gray-800 rounded-full overflow-hidden"> | |
| <div id="bar-pop" class="h-full bg-green-500 resource-bar-fill" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="text-[10px] mono text-gray-500 hover:text-cyan-400 transition-colors border border-white/10 px-2 py-1 rounded"> | |
| Built with anycoder | |
| </a> | |
| </header> | |
| <!-- Game Area --> | |
| <div class="flex flex-1 gap-4 min-h-0"> | |
| <!-- Left Panel: Controls & Log --> | |
| <aside class="w-64 flex flex-col gap-4 shrink-0"> | |
| <!-- Build Menu --> | |
| <div class="glass-panel rounded-lg p-4 flex-1 flex flex-col gap-2 overflow-y-auto"> | |
| <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 border-b border-white/10 pb-1">Construction</h3> | |
| <button onclick="game.selectTool('solar')" id="btn-solar" class="glass-card p-3 rounded flex items-center gap-3 text-left btn-action group"> | |
| <div class="w-8 h-8 rounded bg-yellow-500/20 text-yellow-400 flex items-center justify-center border border-yellow-500/30 group-hover:bg-yellow-500 group-hover:text-black transition-colors"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg> | |
| </div> | |
| <div> | |
| <div class="text-sm font-bold text-white">Solar Array</div> | |
| <div class="text-[10px] text-yellow-500 mono">Cost: 20 M</div> | |
| </div> | |
| </button> | |
| <button onclick="game.selectTool('mine')" id="btn-mine" class="glass-card p-3 rounded flex items-center gap-3 text-left btn-action group"> | |
| <div class="w-8 h-8 rounded bg-blue-500/20 text-blue-400 flex items-center justify-center border border-blue-500/30 group-hover:bg-blue-500 group-hover:text-black transition-colors"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg> | |
| </div> | |
| <div> | |
| <div class="text-sm font-bold text-white">Extractor</div> | |
| <div class="text-[10px] text-blue-500 mono">Cost: 30 M, 10 E</div> | |
| </div> | |
| </button> | |
| <button onclick="game.selectTool('habitat')" id="btn-habitat" class="glass-card p-3 rounded flex items-center gap-3 text-left btn-action group"> | |
| <div class="w-8 h-8 rounded bg-green-500/20 text-green-400 flex items-center justify-center border border-green-500/30 group-hover:bg-green-500 group-hover:text-black transition-colors"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg> | |
| </div> | |
| <div> | |
| <div class="text-sm font-bold text-white">Habitat</div> | |
| <div class="text-[10px] text-green-500 mono">Cost: 100 M, 50 E</div> | |
| </div> | |
| </button> | |
| <button onclick="game.selectTool('defense')" id="btn-defense" class="glass-card p-3 rounded flex items-center gap-3 text-left btn-action group"> | |
| <div class="w-8 h-8 rounded bg-red-500/20 text-red-400 flex items-center justify-center border border-red-500/30 group-hover:bg-red-500 group-hover:text-black transition-colors"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg> | |
| </div> | |
| <div> | |
| <div class="text-sm font-bold text-white">Laser Turret</div> | |
| <div class="text-[10px] text-red-500 mono">Cost: 80 M, 40 E</div> | |
| </div> | |
| </button> | |
| <div class="mt-auto pt-4 border-t border-white/10"> | |
| <button onclick="game.selectTool('demolish')" id="btn-demolish" class="w-full py-2 border border-red-900/50 bg-red-900/10 text-red-400 text-xs font-bold rounded hover:bg-red-900/30 transition-colors"> | |
| DEMOLISH MODE | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Event Log --> | |
| <div class="glass-panel rounded-lg p-3 h-48 flex flex-col"> | |
| <h3 class="text-[10px] font-bold text-gray-400 uppercase mb-2">System Log</h3> | |
| <div id="game-log" class="flex-1 overflow-y-auto text-[10px] mono space-y-1 pr-1"> | |
| <div class="text-cyan-400">> System initialized...</div> | |
| <div class="text-gray-400">> Awaiting commands.</div> | |
| </div> | |
| </div> | |
| </aside> | |
| <!-- Center: The World --> | |
| <main class="flex-1 glass-panel rounded-lg relative overflow-hidden cursor-crosshair" id="canvas-container"> | |
| <canvas id="gameCanvas" class="block w-full h-full"></canvas> | |
| <!-- Overlay Info --> | |
| <div class="absolute top-4 left-4 pointer-events-none"> | |
| <div class="bg-black/60 backdrop-blur px-3 py-1 rounded border border-white/10 text-xs mono"> | |
| <span class="text-gray-400">COORDS:</span> <span id="mouse-coords" class="text-white">0, 0</span> | |
| </div> | |
| </div> | |
| <!-- Game Over / Win Screen --> | |
| <div id="overlay-screen" class="absolute inset-0 bg-black/80 backdrop-blur-md z-50 flex flex-col items-center justify-center hidden"> | |
| <h2 id="overlay-title" class="text-4xl font-bold text-white mb-4 tracking-widest uppercase">GAME OVER</h2> | |
| <p id="overlay-msg" class="text-gray-300 mb-8 mono text-center max-w-md">The colony has fallen.</p> | |
| <button onclick="location.reload()" class="px-8 py-3 bg-cyan-600 hover:bg-cyan-500 text-white font-bold rounded shadow-[0_0_20px_rgba(8,145,178,0.6)] transition-all"> | |
| REBOOT SYSTEM | |
| </button> | |
| </div> | |
| </main> | |
| </div> | |
| </div> | |
| <!-- Tooltip Element --> | |
| <div id="tooltip" class="glass-panel p-2 rounded border border-cyan-500/30 text-xs max-w-[200px]"> | |
| <div id="tt-title" class="font-bold text-cyan-400 mb-1">Building</div> | |
| <div id="tt-desc" class="text-gray-300 leading-tight">Description here.</div> | |
| </div> | |
| <script> | |
| /** | |
| * UTILITIES & CONFIG | |
| */ | |
| const CONSTANTS = { | |
| TILE_SIZE: 40, | |
| GRID_W: 20, | |
| GRID_H: 15, | |
| COLORS: { | |
| VOID: '#050510', | |
| STAR: '#ffffff', | |
| PLANET_BASE: '#1a1a2e', | |
| PLANET_HIGHLIGHT: '#2a2a4e', | |
| SOLAR: '#fbbf24', | |
| MINE: '#3b82f6', | |
| HABITAT: '#22c55e', | |
| DEFENSE: '#ef4444', | |
| SELECTED: '#00f3ff' | |
| } | |
| }; | |
| // Simple PRNG for deterministic map generation if needed, but we'll use Math.random for gameplay variability | |
| const rand = (min, max) => Math.random() * (max - min) + min; | |
| const randInt = (min, max) => Math.floor(rand(min, max)); | |
| /** | |
| * AUDIO SYSTEM (Synthesizer) | |
| * Using Web Audio API to generate sounds without external assets | |
| */ | |
| const AudioSys = { | |
| ctx: null, | |
| init: function() { | |
| if (!this.ctx) { | |
| this.ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| }, | |
| playTone: function(freq, type, duration, vol = 0.1) { | |
| if (!this.ctx) return; | |
| const osc = this.ctx.createOscillator(); | |
| const gain = this.ctx.createGain(); | |
| osc.type = type; | |
| osc.frequency.setValueAtTime(freq, this.ctx.currentTime); | |
| gain.gain.setValueAtTime(vol, this.ctx.currentTime); | |
| gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration); | |
| osc.connect(gain); | |
| gain.connect(this.ctx.destination); | |
| osc.start(); | |
| osc.stop(this.ctx.currentTime + duration); | |
| }, | |
| playClick: () => AudioSys.playTone(800, 'sine', 0.1, 0.05), | |
| playBuild: () => { | |
| AudioSys.playTone(400, 'triangle', 0.1, 0.1); | |
| setTimeout(() => AudioSys.playTone(600, 'triangle', 0.2, 0.1), 100); | |
| }, | |
| playError: () => AudioSys.playTone(150, 'sawtooth', 0.3, 0.1), | |
| playShoot: () => { | |
| if(!AudioSys.ctx) return; | |
| const osc = AudioSys.ctx.createOscillator(); | |
| const gain = AudioSys.ctx.createGain(); | |
| osc.frequency.setValueAtTime(800, AudioSys.ctx.currentTime); | |
| osc.frequency.exponentialRampToValueAtTime(100, AudioSys.ctx.currentTime + 0.2); | |
| gain.gain.setValueAtTime(0.1, AudioSys.ctx.currentTime); | |
| gain.gain.exponentialRampToValueAtTime(0.01, AudioSys.ctx.currentTime + 0.2); | |
| osc.connect(gain); | |
| gain.connect(AudioSys.ctx.destination); | |
| osc.start(); | |
| osc.stop(AudioSys.ctx.currentTime + 0.2); | |
| } | |
| }; | |
| /** | |
| * GAME CLASSES | |
| */ | |
| class Particle { | |
| constructor(x, y, color, speed, life) { | |
| this.x = x; | |
| this.y = y; | |
| this.color = color; | |
| this.vx = (Math.random() - 0.5) * speed; | |
| this.vy = (Math.random() - 0.5) * speed; | |
| this.life = life; | |
| this.maxLife = life; | |
| this.size = Math.random() * 3 + 1; | |
| } | |
| update() { | |
| this.x += this.vx; | |
| this.y += this.vy; | |
| this.life--; | |
| this.size *= 0.95; | |
| } | |
| draw(ctx) { | |
| ctx.globalAlpha = this.life / this.maxLife; | |
| ctx.fillStyle = this.color; | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, this.size, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| class Projectile { | |
| constructor(x, y, target) { | |
| this.x = x; | |
| this.y = y; | |
| this.target = target; | |
| this.speed = 8; | |
| this.active = true; | |
| this.color = '#ff0000'; | |
| } | |
| update() { | |
| if (!this.target || this.target.hp <= 0) { | |
| this.active = false; | |
| return; | |
| } | |
| const dx = this.target.x - this.x; | |
| const dy = this.target.y - this.y; | |
| const dist = Math.hypot(dx, dy); | |
| if (dist < this.speed) { | |
| this.target.takeDamage(25); | |
| this.active = false; | |
| game.addParticles(this.x, this.y, '#ffaa00', 5); | |
| } else { | |
| this.x += (dx / dist) * this.speed; | |
| this.y += (dy / dist) * this.speed; | |
| } | |
| } | |
| draw(ctx) { | |
| ctx.strokeStyle = this.color; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(this.x, this.y); | |
| ctx.lineTo(this.x - (this.x - this.target.x)*0.2, this.y - (this.y - this.target.y)*0.2); // Trail | |
| ctx.stroke(); | |
| } | |
| } | |
| class Enemy { | |
| constructor(x, y) { | |
| this.x = x; | |
| this.y = y; | |
| this.hp = 100; | |
| this.maxHp = 100; | |
| this.speed = 0.5; | |
| this.radius = 8; | |
| this.targetX = 0; // Will be set to colony center | |
| this.targetY = 0; | |
| this.angle = 0; | |
| } | |
| update() { | |
| // Move towards center of map (approximate colony center) | |
| // In a real game, pathfinding would be better, but we move to center (0,0 relative to grid) | |
| // Actually, let's move towards the nearest building or center | |
| const dx = (game.canvas.width/2) - this.x; // Simplified target | |
| const dy = (game.canvas.height/2) - this.y; | |
| const dist = Math.hypot(dx, dy); | |
| this.x += (dx/dist) * this.speed; | |
| this.y += (dy/dist) * this.speed; | |
| this.angle += 0.05; | |
| // Collision with buildings | |
| // Simplified: if close to center, damage colony | |
| if (dist < 20) { | |
| game.resources.energy -= 10; | |
| game.resources.matter -= 10; | |
| this.hp = 0; // Kamikaze | |
| game.addLog("ALERT: Enemy breached defenses!", "red"); | |
| game.addParticles(this.x, this.y, '#ff0000', 10); | |
| AudioSys.playError(); | |
| } | |
| } | |
| takeDamage(amount) { | |
| this.hp -= amount; | |
| if (this.hp <= 0) { | |
| game.addParticles(this.x, this.y, '#aaffaa', 8); | |
| } | |
| } | |
| draw(ctx) { | |
| ctx.save(); | |
| ctx.translate(this.x, this.y); | |
| ctx.rotate(this.angle); | |
| ctx.fillStyle = '#ff0055'; | |
| ctx.shadowBlur = 10; | |
| ctx.shadowColor = '#ff0055'; | |
| // Draw spikey shape | |
| ctx.beginPath(); | |
| for(let i=0; i<5; i++) { | |
| ctx.lineTo(Math.cos(i*1.25)*this.radius, Math.sin(i*1.25)*this.radius); | |
| ctx.lineTo(Math.cos(i*1.25+0.6)*(this.radius/2), Math.sin(i*1.25+0.6)*(this.radius/2)); | |
| } | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // HP Bar | |
| ctx.rotate(-this.angle); // Unrotate for bar | |
| ctx.fillStyle = 'red'; | |
| ctx.fillRect(-10, -15, 20, 3); | |
| ctx.fillStyle = '#0f0'; | |
| ctx.fillRect(-10, -15, 20 * (this.hp/this.maxHp), 3); | |
| ctx.restore(); | |
| } | |
| } | |
| class Building { | |
| constructor(type, gridX, gridY) { | |
| this.type = type; | |
| this.gridX = gridX; | |
| this.gridY = gridY; | |
| this.level = 1; | |
| this.timer = 0; | |
| // Stats based on type | |
| switch(type) { | |
| case 'solar': | |
| this.production = {e: 2, m: 0}; | |
| this.color = CONSTANTS.COLORS.SOLAR; | |
| break; | |
| case 'mine': | |
| this.production = {e: -1, m: 3}; | |
| this.color = CONSTANTS.COLORS.MINE; | |
| break; | |
| case 'habitat': | |
| this.production = {e: -2, m: 0}; | |
| this.population = 10; | |
| this.color = CONSTANTS.COLORS.HABITAT; | |
| break; | |
| case 'defense': | |
| this.production = {e: -3, m: 0}; | |
| this.range = 200; | |
| this.cooldown = 0; | |
| this.color = CONSTANTS.COLORS.DEFENSE; | |
| break; | |
| } | |
| } | |
| update() { | |
| // Production happens in main loop based on production stats | |
| // Defense logic | |
| if (this.type === 'defense') { | |
| this.cooldown--; | |
| if (this.cooldown <= 0) { | |
| // Find target | |
| const target = game.findEnemyInRange(this.x, this.y, this.range); | |
| if (target) { | |
| game.projectiles.push(new Projectile(this.x, this.y, target)); | |
| this.cooldown = 60; // 1 sec at 60fps | |
| AudioSys.playShoot(); | |
| } | |
| } | |
| } | |
| } | |
| draw(ctx, screenX, screenY) { | |
| this.x = screenX; | |
| this.y = screenY; | |
| const size = CONSTANTS.TILE_SIZE * 0.8; | |
| ctx.save(); | |
| ctx.translate(screenX, screenY); | |
| // Glow | |
| ctx.shadowBlur = 15; | |
| ctx.shadowColor = this.color; | |
| // Base | |
| ctx.fillStyle = 'rgba(0,0,0,0.5)'; | |
| ctx.fillRect(-size/2, -size/2, size, size); | |
| ctx.strokeStyle = this.color; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(-size/2, -size/2, size, size); | |
| // Icon/Detail | |
| ctx.fillStyle = this.color; | |
| if (this.type === 'solar') { | |
| // Sun symbol | |
| ctx.beginPath(); | |
| ctx.arc(0, 0, size/4, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Rays | |
| for(let i=0; i<4; i++) { | |
| ctx.rotate(Math.PI/2); | |
| ctx.fillRect(-2, -size/3, 4, size/6); | |
| } | |
| } else if (this.type === 'mine') { | |
| // Drill | |
| ctx.beginPath(); | |
| ctx.moveTo(0, -size/3); | |
| ctx.lineTo(size/3, size/3); | |
| ctx.lineTo(-size/3, size/3); | |
| ctx.fill(); | |
| } else if (this.type === 'habitat') { | |
| // Dome | |
| ctx.beginPath(); | |
| ctx.arc(0, size/4, size/3, Math.PI, 0); | |
| ctx.fill(); | |
| ctx.fillRect(-size/3, size/4, size/1.5, size/4); | |
| } else if (this.type === 'defense') { | |
| // Turret | |
| ctx.fillRect(-size/4, -size/4, size/2, size/2); | |
| ctx.fillStyle = '#fff'; | |
| ctx.beginPath(); | |
| ctx.arc(0, 0, size/6, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Range indicator (faint) | |
| if (game.selectedTool === 'defense') { | |
| ctx.beginPath(); | |
| ctx.arc(0, 0, this.range, 0, Math.PI*2); | |
| ctx.strokeStyle = 'rgba(255,0,0,0.1)'; | |
| ctx.stroke(); | |
| } | |
| } | |
| // Level indicator | |
| if (this.level > 1) { | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = '10px monospace'; | |
| ctx.fillText('+' + (this.level-1), -size/2 + 2, -size/2 + 10); | |
| } | |
| ctx.restore(); | |
| } | |
| } | |
| /** | |
| * MAIN GAME ENGINE | |
| */ | |
| class Game { | |
| constructor() { | |
| this.canvas = document.getElementById('gameCanvas'); | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.bgCanvas = document.getElementById('bgCanvas'); | |
| this.bgCtx = this.bgCanvas.getContext('2d'); | |
| this.resize(); | |
| window.addEventListener('resize', () => this.resize()); | |
| this.grid = []; // 2D array | |
| this.buildings = []; | |
| this.enemies = []; | |
| this.projectiles = []; | |
| this.particles = []; | |
| this.resources = { | |
| energy: 100, | |
| matter: 200, | |
| population: 0, | |
| maxPop: 0 | |
| }; | |
| this.selectedTool = null; | |
| this.cycle = 1; | |
| this.gameOver = false; | |
| // Mouse interaction | |
| this.mouse = { x: 0, y: 0, gridX: 0, gridY: 0 }; | |
| this.canvas.addEventListener('mousemove', e => this.handleMouseMove(e)); | |
| this.canvas.addEventListener('click', e => this.handleClick(e)); | |
| this.canvas.addEventListener('mouseleave', () => { | |
| document.getElementById('tooltip').style.opacity = 0; | |
| }); | |
| // Start loops | |
| this.lastTime = 0; | |
| this.spawnTimer = 0; | |
| this.productionTimer = 0; | |
| this.initGrid(); | |
| this.generateStars(); | |
| requestAnimationFrame(t => this.loop(t)); | |
| // Initial Log | |
| this.addLog("Colony vessel landed."); | |
| this.addLog("Mission: Survive."); | |
| } | |
| resize() { | |
| const container = document.getElementById('canvas-container'); | |
| this.canvas.width = container.clientWidth; | |
| this.canvas.height = container.clientHeight; | |
| this.bgCanvas.width = window.innerWidth; | |
| this.bgCanvas.height = window.innerHeight; | |
| } | |
| initGrid() { | |
| // Initialize empty grid | |
| // Center the grid on screen | |
| this.offsetX = (this.canvas.width - (CONSTANTS.GRID_W * CONSTANTS.TILE_SIZE)) / 2; | |
| this.offsetY = (this.canvas.height - (CONSTANTS.GRID_H * CONSTANTS.TILE_SIZE)) / 2; | |
| } | |
| generateStars() { | |
| this.bgCtx.fillStyle = '#050510'; | |
| this.bgCtx.fillRect(0, 0, this.bgCanvas.width, this.bgCanvas.height); | |
| for(let i=0; i<200; i++) { | |
| const x = Math.random() * this.bgCanvas.width; | |
| const y = Math.random() * this.bgCanvas.height; | |
| const size = Math.random() * 2; | |
| const opacity = Math.random(); | |
| this.bgCtx.fillStyle = `rgba(255, 255, 255, ${opacity})`; | |
| this.bgCtx.beginPath(); | |
| this.bgCtx.arc(x, y, size, 0, Math.PI*2); | |
| this.bgCtx.fill(); | |
| } | |
| } | |
| selectTool(tool) { | |
| AudioSys.init(); // Ensure audio context is started on user interaction | |
| AudioSys.playClick(); | |
| this.selectedTool = tool; | |
| // UI Update | |
| document.querySelectorAll('.glass-card').forEach(el => { | |
| el.classList.remove('border-cyan-400', 'bg-cyan-900/20'); | |
| }); | |
| if (tool) { | |
| const btn = document.getElementById('btn-' + tool); | |
| if (btn) btn.classList.add('border-cyan-400', 'bg-cyan-900/20'); | |
| } | |
| } | |
| handleMouseMove(e) { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| this.mouse.x = e.clientX - rect.left; | |
| this.mouse.y = e.clientY - rect.top; | |
| // Calculate Grid Coords | |
| this.mouse.gridX = Math.floor((this.mouse.x - this.offsetX) / CONSTANTS.TILE_SIZE); | |
| this.mouse.gridY = Math.floor((this.mouse.y - this.offsetY) / CONSTANTS.TILE_SIZE); | |
| document.getElementById('mouse-coords').innerText = `${this.mouse.gridX}, ${this.mouse.gridY}`; | |
| // Tooltip logic | |
| const b = this.getBuildingAt(this.mouse.gridX, this.mouse.gridY); | |
| const tt = document.getElementById('tooltip'); | |
| if (b) { | |
| tt.style.opacity = 1; | |
| tt.style.left = (e.pageX + 15) + 'px'; | |
| tt.style.top = (e.pageY + 15) + 'px'; | |
| document.getElementById('tt-title').innerText = b.type.toUpperCase() + (b.level > 1 ? ' MK.'+b.level : ''); | |
| let desc = ''; | |
| if(b.type === 'solar') desc = `Generates +${b.production.e} Energy/cycle.`; | |
| if(b.type === 'mine') desc = `Generates +${b.production.m} Matter/cycle. Consumes ${Math.abs(b.production.e)} Energy.`; | |
| if(b.type === 'habitat') desc = `Houses ${b.population} colonists. Consumes Energy.`; | |
| if(b.type === 'defense') desc = `Automated defense turret. High Energy upkeep.`; | |
| document.getElementById('tt-desc').innerText = desc; | |
| } else { | |
| tt.style.opacity = 0; | |
| } | |
| } | |
| handleClick(e) { | |
| if (this.gameOver) return; | |
| AudioSys.init(); | |
| const gx = this.mouse.gridX; | |
| const gy = this.mouse.gridY; | |
| // Bounds check | |
| if (gx < 0 || gx >= CONSTANTS.GRID_W || gy < 0 || gy >= CONSTANTS.GRID_H) return; | |
| const existing = this.getBuildingAt(gx, gy); | |
| if (this.selectedTool === 'demolish') { | |
| if (existing) { | |
| this.buildings = this.buildings.filter(b => b !== existing); | |
| this.addLog(`Structure demolished at ${gx},${gy}`); | |
| this.addParticles(this.offsetX + gx*CONSTANTS.TILE_SIZE + CONSTANTS.TILE_SIZE/2, this.offsetY + gy*CONSTANTS.TILE_SIZE + CONSTANTS.TILE_SIZE/2, '#fff', 10); | |
| AudioSys.playClick(); | |
| } | |
| return; | |
| } | |
| if (!existing && this.selectedTool) { | |
| this.attemptBuild(this.selectedTool, gx, gy); | |
| } else if (existing && this.selectedTool === existing.type) { | |
| // Upgrade logic could go here | |
| // For now, just log | |
| // this.addLog("Upgrade logic not implemented in demo."); | |
| } | |
| } | |
| attemptBuild(type, gx, gy) { | |
| let costE = 0, costM = 0; | |
| if (type === 'solar') { costM = 20; } | |
| else if (type === 'mine') { costM = 30; costE = 10; } | |
| else if (type === 'habitat') { costM = 100; costE = 50; } | |
| else if (type === 'defense') { costM = 80; costE = 40; } | |
| if (this.resources.energy >= costE && this.resources.matter >= costM) { | |
| this.resources.energy -= costE; | |
| this.resources.matter -= costM; | |
| const b = new Building(type, gx, gy); | |
| this.buildings.push(b); | |
| // Visuals | |
| const sx = this.offsetX + gx * CONSTANTS.TILE_SIZE + CONSTANTS.TILE_SIZE/2; | |
| const sy = this.offsetY + gy * CONSTANTS.TILE_SIZE + CONSTANTS.TILE_SIZE/2; | |
| this.addParticles(sx, sy, b.color, 15); | |
| AudioSys.playBuild(); | |
| this.addLog(`Constructed ${type.toUpperCase()}`); | |
| } else { | |
| AudioSys.playError(); | |
| this.addLog("Insufficient resources!", "red"); | |
| } | |
| } | |
| getBuildingAt(gx, gy) { | |
| return this.buildings.find(b => b.gridX === gx && b.gridY === gy); | |
| } | |
| findEnemyInRange(x, y, range) { | |
| // Simple distance check | |
| let closest = null; | |
| let minDst = range; | |
| for (let e of this.enemies) { | |
| const d = Math.hypot(e.x - x, e.y - y); | |
| if (d < minDst) { | |
| minDst = d; | |
| closest = e; | |
| } | |
| } | |
| return closest; | |
| } | |
| addParticles(x, y, color, count) { | |
| for(let i=0; i<count; i++) { | |
| this.particles.push(new Particle(x, y, color, 3, 30)); | |
| } | |
| } | |
| addLog(msg, color="gray") { | |
| const log = document.getElementById('game-log'); | |
| const div = document.createElement('div'); | |
| div.className = `text-${color}-400`; | |
| div.innerText = `> ${msg}`; | |
| log.appendChild(div); | |
| log.scrollTop = log.scrollHeight; | |
| } | |
| update(dt) { | |
| if (this.gameOver) return; | |
| // Production Loop (Every 2 seconds approx) | |
| this.productionTimer += dt; | |
| if (this.productionTimer > 2000) { | |
| this.productionTimer = 0; | |
| this.cycle++; | |
| document.getElementById('game-cycle').innerText = this.cycle; | |
| // Calculate Net Production | |
| let netE = 0; | |
| let netM = 0; | |
| let maxPop = 0; | |
| this.buildings.forEach(b => { | |
| netE += b.production.e; | |
| netM += b.production.m; | |
| if (b.type === 'habitat') maxPop += b.population; | |
| }); | |
| // Population Growth | |
| if (this.resources.energy > 0 && this.resources.matter > 0 && this.resources.population < maxPop) { | |
| if (Math.random() > 0.5) this.resources.population++; | |
| } | |
| // Consume resources for population | |
| if (this.resources.population > 0) { | |
| netE -= Math.floor(this.resources.population / 5); // Pop consumes energy | |
| } | |
| this.resources.energy += netE; | |
| this.resources.matter += netM; | |
| this.resources.maxPop = maxPop; | |
| // Cap resources | |
| if (this.resources.matter > 999) this.resources.matter = 999; | |
| if (this.resources.energy > 999) this.resources.energy = 999; | |
| // Check Loss | |
| if (this.resources.energy < 0) { | |
| this.resources.energy = 0; | |
| // Penalty? | |
| if (this.resources.population > 0 && Math.random() > 0.7) { | |
| this.resources.population--; | |
| this.addLog("Colonists lost due to energy shortage!", "red"); | |
| } | |
| } | |
| // Win/Loss Check | |
| if (this.resources.population >= 100) { | |
| this.triggerEnd("VICTORY", "Colony population reached sustainable levels. The future is secured."); | |
| } else if (this.resources.population <= 0 && this.cycle > 10 && this.buildings.length > 0) { | |
| // If you have buildings but no people for a while, you lose? | |
| // Let's make it stricter: if pop is 0 and you have habitats, you are failing. | |
| // If pop is 0 and no habitats, just starting. | |
| const hasHabs = this.buildings.some(b => b.type === 'habitat'); | |
| if (hasHabs) { | |
| this.triggerEnd("COLONY FAILED", "All colonists have perished."); | |
| } | |
| } | |
| } | |
| // Spawning Logic | |
| this.spawnTimer += dt; | |
| // Spawn rate increases with cycle | |
| const spawnRate = Math.max(500, 5000 - (this.cycle * 200)); | |
| if (this.spawnTimer > spawnRate) { | |
| this.spawnTimer = 0; | |
| this.spawnEnemy(); | |
| } | |
| // Updates | |
| this.buildings.forEach(b => b.update()); | |
| this.enemies.forEach(e => e.update()); | |
| this.enemies = this.enemies.filter(e => e.hp > 0); | |
| this.projectiles.forEach(p => p.update()); | |
| this.projectiles = this.projectiles.filter(p => p.active); | |
| this.particles.forEach(p => p.update()); | |
| this.particles = this.particles.filter(p => p.life > 0); | |
| // UI Updates | |
| this.updateUI(); | |
| } | |
| spawnEnemy() { | |
| // Spawn at edge | |
| const edge = randInt(0, 4); // 0:top, 1:right, 2:bottom, 3:left | |
| let x, y; | |
| const margin = 50; | |
| if (edge === 0) { x = rand(0, this.canvas.width); y = -margin; } | |
| else if (edge === 1) { x = this.canvas.width + margin; y = rand(0, this.canvas.height); } | |
| else if (edge === 2) { x = rand(0, this.canvas.width); y = this.canvas.height + margin; } | |
| else { x = -margin; y = rand(0, this.canvas.height); } | |
| this.enemies.push(new Enemy(x, y)); | |
| // this.addLog("Incoming signature detected..."); | |
| } | |
| updateUI() { | |
| document.getElementById('val-energy').innerText = Math.floor(this.resources.energy); | |
| document.getElementById('bar-energy').style.width = Math.min(100, (this.resources.energy / 200) * 100) + '%'; | |
| document.getElementById('val-matter').innerText = Math.floor(this.resources.matter); | |
| document.getElementById('bar-matter').style.width = Math.min(100, (this.resources.matter / 500) * 100) + '%'; | |
| document.getElementById('val-pop').innerText = this.resources.population + '/' + this.resources.maxPop; | |
| document.getElementById('bar-pop').style.width = this.resources.maxPop > 0 ? (this.resources.population / this.resources.maxPop) * 100 + '%' : '0%'; | |
| } | |
| triggerEnd(title, msg) { | |
| this.gameOver = true; | |
| document.getElementById('overlay-title').innerText = title; | |
| document.getElementById('overlay-msg').innerText = msg; | |
| document.getElementById('overlay-screen').classList.remove('hidden'); | |
| AudioSys.playTone(200, 'sawtooth', 1, 0.2); | |
| } | |
| draw() { | |
| // Clear | |
| this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
| // Draw Grid | |
| this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'; | |
| this.ctx.lineWidth = 1; | |
| for (let x = 0; x <= CONSTANTS.GRID_W |