commit 622a9fd17015f20e3121abaa5db561697ad1ee59 Author: 44r0n7 <44r0n7+gitea@pm.me> Date: Tue Jun 16 11:36:53 2026 -0400 Initial commit: Siege Protocol Inverted tower-defense browser game — deploy enemies yourself, tower auto-kills them, pocket credits, upgrade weapons. HTML + Canvas + vanilla JS, no build step. Co-Authored-By: claude-flow diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15b6119 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Local AI tooling — personal, not game code +CLAUDE.md +AGENTS.md +agentdb.db +*.db diff --git a/css/dev-console.css b/css/dev-console.css new file mode 100644 index 0000000..328aef9 --- /dev/null +++ b/css/dev-console.css @@ -0,0 +1,104 @@ +/* ── DEV CONSOLE ── */ +#dev-console { + display: none; + position: fixed; + bottom: 80px; left: 20px; + width: 520px; + max-height: 340px; + z-index: 500; + background: rgba(2,6,10,0.97); + border: 1px solid #00d4ff; + box-shadow: 0 0 24px rgba(0,212,255,0.3); + flex-direction: column; + font-family: 'Share Tech Mono', monospace; + font-size: 12px; +} +#dev-console.open { display: flex; } +#dev-console-header { + display: flex; justify-content: space-between; align-items: center; + padding: 6px 12px; + background: rgba(0,212,255,0.12); + border-bottom: 1px solid #00d4ff44; + color: #00d4ff; + font-size: 11px; + letter-spacing: 3px; +} +#dev-console-hint { font-size: 9px; opacity: 0.5; } +#dev-output { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + max-height: 260px; + display: flex; + flex-direction: column; + gap: 2px; +} +.dev-line { line-height: 1.6; white-space: pre-wrap; word-break: break-all; } +.dev-line.out { color: #b8d8e8; } +.dev-line.ok { color: #00ff88; } +.dev-line.err { color: #ff3355; } +.dev-line.info { color: #00d4ff; } +.dev-line.warn { color: #ffd700; } +#dev-input-row { + display: flex; align-items: center; gap: 8px; + padding: 6px 12px; + border-top: 1px solid #00d4ff22; +} +#dev-prompt { color: #00d4ff; opacity: 0.7; white-space: nowrap; } +#dev-input { + flex: 1; background: transparent; border: none; outline: none; + color: #fff; font-family: 'Share Tech Mono', monospace; font-size: 12px; + caret-color: #00d4ff; +} + +/* ── PERFORMANCE OVERLAY ── */ +#perf-overlay { + display: none; + position: fixed; + top: 74px; + left: 16px; + z-index: 80; + min-width: 140px; + pointer-events: none; + user-select: none; + font-family: 'Share Tech Mono', monospace; + font-size: 11px; + color: #b8d8e8; + background: rgba(2, 6, 10, 0.92); + border: 1px solid #00d4ff66; + box-shadow: 0 0 16px rgba(0, 212, 255, 0.25); + padding: 7px 9px; +} + +#perf-overlay .perf-title { + color: #00d4ff; + font-size: 10px; + letter-spacing: 2px; + margin-bottom: 4px; +} + +#perf-overlay .perf-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + line-height: 1.35; +} + +#perf-overlay .perf-row span:first-child { + color: #6ea0b8; +} + +#perf-overlay .perf-row span:last-child { + color: #d8ecff; +} + +@media (max-width: 900px) { + #perf-overlay { + top: 68px; + left: 8px; + font-size: 10px; + min-width: 124px; + padding: 6px 8px; + } +} diff --git a/css/main.css b/css/main.css new file mode 100644 index 0000000..849949c --- /dev/null +++ b/css/main.css @@ -0,0 +1,14 @@ +/* ── VARIABLES ── */ +:root { + --bg:#040a10; --panel:#060e16; --border:#122030; --border2:#1a3048; + --tower:#00d4ff; --tower-g:rgba(0,212,255,0.35); --health:#00ff88; + --danger:#ff3355; --credits:#ffd700; --text:#b8d8e8; --dim:#2a4a60; + --dim2:#3a6080; --bought:#1a3020; --locked:#0a1520; + --mono:'Share Tech Mono',monospace; --orb:'Orbitron',monospace; +} +*,*::before,*::after{margin:0;padding:0;box-sizing:border-box} +html,body{width:100%;height:100%;overflow:hidden;background:#020508;color:var(--text);font-family:var(--mono);font-size:13px;user-select:none} + +/* ── CANVAS: full viewport, behind everything ── */ +#canvas{position:fixed;top:0;left:0;z-index:0;display:block} + diff --git a/index.html b/index.html new file mode 100644 index 0000000..7bc773e --- /dev/null +++ b/index.html @@ -0,0 +1,61 @@ + + + + + + SIEGE PROTOCOL + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ▸ DEV CONSOLE + ~ to close +
+
+
+ siege> + +
+
+ + \ No newline at end of file diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..0a09712 --- /dev/null +++ b/js/audio.js @@ -0,0 +1,230 @@ +// ═══ audio.js ═══ +// ============================================================ +// AUDIO.JS — Web Audio API sound effects +// ============================================================ + +let _audioCtx = null; +let _activeNodes = 0; +const _MAX_NODES = 12; + +function getAudioCtx() { + if (!_audioCtx) { + try { _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } + catch (e) { _audioCtx = null; } + } + // Resume if suspended (browser autoplay policy) + if (_audioCtx && _audioCtx.state === 'suspended') _audioCtx.resume(); + return _audioCtx; +} + +// ── PRIMITIVE SYNTH ─────────────────────────────────────────── +// type: 'sine'|'square'|'sawtooth'|'triangle' +function playTone(freq, type, gainPeak, duration, opts = {}) { + const ctx = getAudioCtx(); + if (!ctx) return; + if (_activeNodes >= _MAX_NODES) return; + _activeNodes++; + const now = ctx.currentTime; + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + // Optional frequency sweep + osc.type = type; + osc.frequency.setValueAtTime(freq, now); + if (opts.freqEnd !== undefined) { + osc.frequency.linearRampToValueAtTime(opts.freqEnd, now + duration); + } + + const attack = opts.attack ?? 0.005; + const decay = opts.decay ?? duration * 0.3; + + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(gainPeak, now + attack); + gain.gain.exponentialRampToValueAtTime(0.001, now + duration); + + // Optional filter + if (opts.filterFreq) { + const filter = ctx.createBiquadFilter(); + filter.type = opts.filterType ?? 'lowpass'; + filter.frequency.setValueAtTime(opts.filterFreq, now); + if (opts.filterEnd) filter.frequency.linearRampToValueAtTime(opts.filterEnd, now + duration); + osc.connect(filter); + filter.connect(gain); + } else { + osc.connect(gain); + } + + gain.connect(ctx.destination); + osc.onended = () => { _activeNodes--; }; + osc.start(now); + osc.stop(now + duration + 0.01); +} + +function playNoise(gainPeak, duration, opts = {}) { + const ctx = getAudioCtx(); + if (!ctx) return; + if (_activeNodes >= _MAX_NODES) return; + _activeNodes++; + const now = ctx.currentTime; + const bufLen = Math.ceil(ctx.sampleRate * duration); + const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < bufLen; i++) data[i] = Math.random() * 2 - 1; + + const src = ctx.createBufferSource(); + src.buffer = buf; + + const gain = ctx.createGain(); + gain.gain.setValueAtTime(gainPeak, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + duration); + + if (opts.filterFreq) { + const filter = ctx.createBiquadFilter(); + filter.type = opts.filterType ?? 'bandpass'; + filter.frequency.setValueAtTime(opts.filterFreq, now); + filter.Q.setValueAtTime(opts.Q ?? 1, now); + src.connect(filter); + filter.connect(gain); + } else { + src.connect(gain); + } + + gain.connect(ctx.destination); + src.onended = () => { _activeNodes--; }; + src.start(now); +} + +// ── SOUND EFFECTS ───────────────────────────────────────────── + +function sfx_cannon() { + // Deep thud + sharp crack + playNoise(0.4, 0.12, { filterFreq: 180, filterType: 'lowpass', Q: 0.8 }); + playTone(120, 'sawtooth', 0.3, 0.08, { freqEnd: 40 }); +} + +function sfx_flamethrower() { + // Hissy noise burst + playNoise(0.25, 0.09, { filterFreq: 2200, filterType: 'bandpass', Q: 0.5 }); +} + +function sfx_lightning() { + // Sharp electric snap + playNoise(0.5, 0.06, { filterFreq: 4000, filterType: 'highpass' }); + playTone(800, 'sawtooth', 0.2, 0.05, { freqEnd: 200 }); +} + +function sfx_mortar_launch() { + // Low thump with whistle + playTone(80, 'sine', 0.35, 0.15, { freqEnd: 300 }); + playNoise(0.2, 0.15, { filterFreq: 300, filterType: 'lowpass' }); +} + +function sfx_mortar_explode() { + // Big boom + playNoise(0.7, 0.3, { filterFreq: 250, filterType: 'lowpass', Q: 0.5 }); + playTone(60, 'sine', 0.5, 0.25, { freqEnd: 20 }); +} + +function sfx_laser() { + // High-pitched zap + playTone(1200, 'sine', 0.15, 0.05, { freqEnd: 800 }); +} + +function sfx_freeze() { + // Icy crystalline sound + playTone(1800, 'sine', 0.2, 0.2, { freqEnd: 600, attack: 0.02 }); + playTone(2200, 'triangle', 0.1, 0.15, { freqEnd: 900 }); +} + +function sfx_void() { + // Deep rumbling whoosh + playTone(55, 'sawtooth', 0.3, 0.4, { freqEnd: 40, filterFreq: 200, filterEnd: 80 }); + playNoise(0.15, 0.4, { filterFreq: 120, filterType: 'lowpass' }); +} + +function sfx_missile_launch() { + // Whoosh + playNoise(0.3, 0.18, { filterFreq: 800, filterType: 'bandpass', Q: 0.6 }); + playTone(300, 'sawtooth', 0.2, 0.18, { freqEnd: 100 }); +} + +function sfx_arcane() { + // Magical chime + playTone(1400, 'sine', 0.15, 0.07, { freqEnd: 700 }); +} + +function sfx_poison_launch() { + // Bubbly squirt + playTone(400, 'sine', 0.2, 0.1, { freqEnd: 150 }); + playNoise(0.1, 0.1, { filterFreq: 600, filterType: 'bandpass' }); +} + +function sfx_enemy_die() { + // Short crunch + playNoise(0.3, 0.08, { filterFreq: 600, filterType: 'bandpass', Q: 1.5 }); + playTone(200, 'square', 0.15, 0.06, { freqEnd: 80 }); +} + +function sfx_enemy_breach() { + // Alarm-like hit + playTone(220, 'sawtooth', 0.5, 0.3, { freqEnd: 110 }); + playNoise(0.4, 0.2, { filterFreq: 400, filterType: 'lowpass' }); +} + +function sfx_buy() { + // Satisfying purchase chime + playTone(600, 'sine', 0.2, 0.08); + playTone(800, 'sine', 0.15, 0.08, { attack: 0.04 }); +} + +function sfx_refund() { + // Reverse chime + playTone(800, 'sine', 0.15, 0.08); + playTone(600, 'sine', 0.2, 0.08, { attack: 0.04 }); +} + +function sfx_error() { + // Low buzzer + playTone(180, 'square', 0.2, 0.12, { freqEnd: 160 }); +} + +function sfx_portal_open() { + // Whooshy portal open + playTone(300, 'sine', 0.15, 0.3, { freqEnd: 900, attack: 0.1 }); + playNoise(0.08, 0.3, { filterFreq: 1200, filterType: 'bandpass' }); +} + +function sfx_tower_damage() { + // Heavy metallic impact + playNoise(0.5, 0.18, { filterFreq: 350, filterType: 'lowpass' }); + playTone(100, 'sawtooth', 0.35, 0.15, { freqEnd: 50 }); +} + +// Map weapon def id → fire sfx +const WEAPON_SFX = { + cannon: sfx_cannon, + flamethrower: sfx_flamethrower, + chainlightning: sfx_lightning, + mortar: sfx_mortar_launch, + laser: sfx_laser, + freezebomb: sfx_freeze, + voidcannon: sfx_void, + missilepod: sfx_missile_launch, + arcaneturret: sfx_arcane, + poisoncloud: sfx_poison_launch, +}; + +function sfx_weapon_fire(defId) { + const fn = WEAPON_SFX[defId]; + if (fn) fn(); +} + +// Throttle repeated sounds (e.g. flamethrower fires every 8 frames) +const _sfxThrottle = {}; +function sfx_weapon_fire_throttled(defId, minInterval = 100) { + const now = Date.now(); + if (_sfxThrottle[defId] && now - _sfxThrottle[defId] < minInterval) return; + _sfxThrottle[defId] = now; + sfx_weapon_fire(defId); +} diff --git a/js/defs.js b/js/defs.js new file mode 100644 index 0000000..e5d7328 --- /dev/null +++ b/js/defs.js @@ -0,0 +1,601 @@ +// ═══ defs.js ═══ +// ============================================================ +// DEFINITIONS — elements, enemies, weapons, upgrades +// ============================================================ + +// ── ELEMENTS ───────────────────────────────────────────────── +const ELEMENTS = { + fire: { name: 'Fire', color: '#ff6b35', glow: '#ff3300', icon: '🔥' }, + ice: { name: 'Ice', color: '#7ecfff', glow: '#00cfff', icon: '❄️' }, + lightning: { name: 'Lightning', color: '#ffe033', glow: '#ffcc00', icon: '⚡' }, + poison: { name: 'Poison', color: '#7fff4f', glow: '#44ff00', icon: '☠️' }, + void: { name: 'Void', color: '#c77dff', glow: '#9900ff', icon: '🌀' }, + arcane: { name: 'Arcane', color: '#ff77e9', glow: '#ff00cc', icon: '✨' }, + physical: { name: 'Physical', color: '#c8c8c8', glow: '#ffffff', icon: '💢' }, +}; + +// ── ENEMY TYPES ─────────────────────────────────────────────── +const ENEMY_DEFS = [ + { + id: 'grunt', + name: 'GRUNT', + desc: 'Basic foot soldier. No special traits.', + hp: 8, speed: 0.9, radius: 7, armor: 0, + color: '#aaaaaa', glowColor: '#ffffff', + cost: 20, reward: 30, + resistances: {}, + weaknesses: { poison: 1.3 }, + element: null, + count: 1, + }, + { + id: 'runner', + name: 'RUNNER', + desc: 'Very fast, low HP. Hard to track.', + hp: 5, speed: 2.35, radius: 5, armor: 0, + color: '#00ff88', glowColor: '#00ff88', + cost: 30, reward: 44, + resistances: {}, + weaknesses: { lightning: 1.5, poison: 1.3 }, + element: null, + count: 1, + }, + { + id: 'brute', + name: 'BRUTE', + desc: 'High HP and armor. Slow mover.', + hp: 35, speed: 0.52, radius: 13, armor: 3, + color: '#ff7043', glowColor: '#ff3300', + cost: 80, reward: 112, + resistances: { physical: 0.5 }, + weaknesses: { void: 1.8, fire: 1.4, poison: 1.3 }, + element: null, + count: 1, + }, + { + id: 'swarm', + name: 'SWARM', + desc: 'Spawns 6 tiny units at once.', + hp: 3, speed: 1.85, radius: 4, armor: 0, + color: '#ce93d8', glowColor: '#cc00ff', + cost: 60, reward: 84, + resistances: {}, + weaknesses: { fire: 1.4, lightning: 1.4 }, + element: null, + count: 6, + }, + { + id: 'phantom', + name: 'PHANTOM', + desc: '40% dodge. Void-touched.', + hp: 6, speed: 1.7, radius: 7, armor: 0, + color: '#c77dff', glowColor: '#9900ff', + cost: 100, reward: 138, + evasion: 0.3, + resistances: { void: 0.3, arcane: 0.5 }, + weaknesses: { fire: 1.5 }, + element: 'void', + count: 1, + }, + { + id: 'iceling', + name: 'ICELING', + desc: 'Ice elemental. Slows bullets on contact.', + hp: 12, speed: 0.88, radius: 9, armor: 1, + color: '#7ecfff', glowColor: '#00cfff', + cost: 90, reward: 125, + resistances: { ice: 0.0 }, + weaknesses: { fire: 1.6, lightning: 1.3 }, + element: 'ice', + count: 1, + }, + { + id: 'sparkling', + name: 'SPARKLING', + desc: 'Lightning elemental. Fast and electric.', + hp: 7, speed: 2.1, radius: 6, armor: 0, + color: '#ffe033', glowColor: '#ffcc00', + cost: 110, reward: 148, + resistances: { lightning: 0.0 }, + weaknesses: { ice: 1.5, void: 1.3 }, + element: 'lightning', + count: 1, + }, + { + id: 'venom', + name: 'VENOM', + desc: 'Poison elemental. Immune to DoT.', + hp: 14, speed: 0.96, radius: 10, armor: 0, + color: '#7fff4f', glowColor: '#44ff00', + cost: 120, reward: 160, + resistances: { poison: 0.0, fire: 0.6 }, + weaknesses: { ice: 1.4, arcane: 1.6 }, + element: 'poison', + count: 1, + }, + { + id: 'titan', + name: 'TITAN', + desc: 'Massive HP, heavy armor, very slow.', + hp: 120, speed: 0.31, radius: 20, armor: 8, + color: '#ff1744', glowColor: '#ff0000', + cost: 350, reward: 465, + resistances: { physical: 0.4, fire: 0.7 }, + weaknesses: { void: 2.0, arcane: 1.5 }, + element: null, + count: 1, + }, + { + id: 'wraith', + name: 'WRAITH', + desc: 'Void entity. Ignores 80% of armor.', + hp: 18, speed: 1.38, radius: 9, armor: 0, + color: '#9900ff', glowColor: '#6600cc', + cost: 200, reward: 265, + armorPen: 0.8, + resistances: { void: 0.2, poison: 0.5 }, + weaknesses: { arcane: 2.0 }, + element: 'void', + count: 1, + }, +]; + +// ── WEAPON DEFINITIONS ──────────────────────────────────────── +const WEAPON_DEFS = [ + { + id: 'cannon', + name: 'CANNON', + desc: 'Standard projectile. Reliable, upgradeable.', + icon: '💣', + cost: 0, // starting weapon + defaultElement: 'physical', + targeting: 'nearest', + fireRate: 72, // frames between shots + damage: 4, + projectileSpeed: 4.2, + projectileRadius: 4, + range: 9999, + color: '#c8c8c8', + type: 'projectile', + }, + { + id: 'flamethrower', + name: 'FLAMETHROWER', + desc: 'Continuous cone of fire. Medium range, applies burn.', + icon: '🔥', + cost: 800, + defaultElement: 'fire', + targeting: 'nearest', + fireRate: 8, + damage: 1, + range: 230, + coneAngle: 0.35, + color: '#ff6b35', + type: 'cone', + }, + { + id: 'chainlightning', + name: 'CHAIN LIGHTNING', + desc: 'Arcs between up to 3 enemies.', + icon: '⚡', + cost: 1200, + defaultElement: 'lightning', + targeting: 'nearest', + fireRate: 70, + damage: 4, + chains: 3, + chainRange: 120, + color: '#ffe033', + type: 'chain', + }, + { + id: 'mortar', + name: 'MORTAR', + desc: 'Lobbed shell with AoE explosion. Slow reload.', + icon: '💥', + cost: 1500, + defaultElement: 'physical', + targeting: 'group', + fireRate: 180, + damage: 12, + aoeRadius: 60, + projectileSpeed: 2.8, + color: '#ff7043', + type: 'mortar', + }, + { + id: 'laser', + name: 'LASER', + desc: 'Continuous beam piercing all enemies in a line.', + icon: '🔴', + cost: 2000, + defaultElement: 'arcane', + targeting: 'furthest', + fireRate: 10, + damage: 1, + range: 9999, + color: '#ff77e9', + type: 'beam', + }, + { + id: 'freezebomb', + name: 'FREEZE BOMB', + desc: 'AoE ice explosion. Freezes enemies solid briefly.', + icon: '❄️', + cost: 1800, + defaultElement: 'ice', + targeting: 'group', + fireRate: 150, + damage: 5, + aoeRadius: 75, + freezeDuration: 180, + projectileSpeed: 2.4, + color: '#7ecfff', + type: 'mortar', + }, + { + id: 'voidcannon', + name: 'VOID CANNON', + desc: 'Massive slow projectile. Strips armor, bypasses resistance.', + icon: '🌀', + cost: 2000, + defaultElement: 'void', + targeting: 'strongest', + fireRate: 240, + damage: 20, + armorShred: 5, + projectileSpeed: 1.8, + projectileRadius: 12, + color: '#c77dff', + type: 'projectile', + }, + { + id: 'missilepod', + name: 'MISSILE POD', + desc: 'Fires homing AoE missiles at 3 targets simultaneously.', + icon: '🚀', + cost: 2400, + defaultElement: 'fire', + targeting: 'group', + fireRate: 120, + damage: 8, + targets: 3, + aoeRadius: 36, + projectileSpeed: 3.6, + color: '#ff4500', + type: 'multi', + }, + { + id: 'arcaneturret', + name: 'ARCANE TURRET', + desc: 'Rapid magic bolts. Applies Amplify debuff (+25% dmg taken).', + icon: '✨', + cost: 2200, + defaultElement: 'arcane', + targeting: 'nearest', + fireRate: 20, + damage: 2, + amplify: 0.25, + projectileSpeed: 5.8, + color: '#ff77e9', + type: 'projectile', + }, + { + id: 'poisoncloud', + name: 'POISON CLOUD', + desc: 'Launches a lingering poison gas cloud.', + icon: '☠️', + cost: 1600, + defaultElement: 'poison', + targeting: 'group', + fireRate: 130, + damage: 2, + dotDamage: 1, + dotInterval: 30, + dotDuration: 180, + aoeRadius: 55, + projectileSpeed: 2.2, + color: '#7fff4f', + type: 'mortar', + }, +]; + +const MAX_WEAPONS_PER_TYPE = 8; + +// ── UPGRADE TREES ───────────────────────────────────────────── +// Tower general upgrades +const TOWER_UPGRADE_TREE = [ + { + id: 'hp1', label: 'Reinforced Hull I', desc: '+5 Max HP', + cost: 100, requires: [], effect: { maxHp: 5 }, + }, + { + id: 'hp2', label: 'Reinforced Hull II', desc: '+10 Max HP', + cost: 250, requires: ['hp1'], effect: { maxHp: 10 }, + }, + { + id: 'hp3', label: 'Reinforced Hull III', desc: '+20 Max HP', + cost: 600, requires: ['hp2'], effect: { maxHp: 20 }, + }, + { + id: 'hp4', label: 'Reinforced Hull IV', desc: '+35 Max HP', + cost: 1400, requires: ['hp3'], effect: { maxHp: 35 }, + }, + { + id: 'hp5', label: 'Reinforced Hull V', desc: '+60 Max HP', + cost: 3000, requires: ['hp4'], effect: { maxHp: 60 }, + }, + { + id: 'armor1', label: 'Plating I', desc: '+1 Armor', + cost: 150, requires: [], effect: { armor: 1 }, + }, + { + id: 'armor2', label: 'Plating II', desc: '+2 Armor', + cost: 400, requires: ['armor1'], effect: { armor: 2 }, + }, + { + id: 'armor3', label: 'Plating III', desc: '+3 Armor', + cost: 900, requires: ['armor2'], effect: { armor: 3 }, + }, + { + id: 'armor4', label: 'Plating IV', desc: '+4 Armor', + cost: 2000, requires: ['armor3'], effect: { armor: 4 }, + }, + { + id: 'armor5', label: 'Plating V', desc: '+6 Armor', + cost: 4500, requires: ['armor4'], effect: { armor: 6 }, + }, + { + id: 'aim1', label: 'Servo Motors I', desc: '+0.015 Aim Speed', + cost: 100, requires: [], effect: { aimSpeed: 0.015 }, + }, + { + id: 'aim2', label: 'Servo Motors II', desc: '+0.02 Aim Speed', + cost: 200, requires: ['aim1'], effect: { aimSpeed: 0.02 }, + }, + { + id: 'aim3', label: 'Servo Motors III', desc: '+0.025 Aim Speed', + cost: 350, requires: ['aim2'], effect: { aimSpeed: 0.025 }, + }, + { + id: 'aim4', label: 'Servo Motors IV', desc: '+0.035 Aim Speed', + cost: 600, requires: ['aim3'], effect: { aimSpeed: 0.035 }, + }, + { + id: 'aim5', label: 'Servo Motors V', desc: '+0.05 Aim Speed', + cost: 1200, requires: ['aim4'], effect: { aimSpeed: 0.05 }, + }, + { + id: 'aim6', label: 'Servo Motors VI', desc: '+0.07 Aim Speed — Near instant', + cost: 2500, requires: ['aim5'], effect: { aimSpeed: 0.07 }, + }, + { + id: 'repair1', label: 'Field Repair', desc: 'Restore 5 HP', + cost: 80, requires: [], effect: { repair: 5 }, repeatable: true, + }, + { + id: 'slot2', label: 'Weapon Slot II', desc: 'Unlock 2nd weapon slot', + cost: 500, requires: [], effect: { weaponSlot: 2 }, + }, + { + id: 'slot3', label: 'Weapon Slot III', desc: 'Unlock 3rd weapon slot', + cost: 1500, requires: ['slot2'], effect: { weaponSlot: 3 }, + }, + { + id: 'slot4', label: 'Weapon Slot IV', desc: 'Unlock 4th weapon slot', + cost: 3500, requires: ['slot3'], effect: { weaponSlot: 4 }, + }, + { + id: 'slot5', label: 'Weapon Slot V', desc: 'Unlock 5th weapon slot', + cost: 7000, requires: ['slot4'], effect: { weaponSlot: 5 }, + }, + { + id: 'slot6', label: 'Weapon Slot VI', desc: 'Unlock 6th weapon slot', + cost: 12000, requires: ['slot5'], effect: { weaponSlot: 6 }, + }, + { + id: 'slot7', label: 'Weapon Slot VII', desc: 'Unlock 7th weapon slot', + cost: 20000, requires: ['slot6'], effect: { weaponSlot: 7 }, + }, + { + id: 'slot8', label: 'Weapon Slot VIII', desc: 'Unlock 8th weapon slot', + cost: 30000, requires: ['slot7'], effect: { weaponSlot: 8 }, + }, + { + id: 'range1', label: 'Scanner I', desc: '+40 range', + cost: 180, requires: [], effect: { range: 40 }, + }, + { + id: 'range2', label: 'Scanner II', desc: '+60 range', + cost: 450, requires: ['range1'], effect: { range: 60 }, + }, + { + id: 'range3', label: 'Scanner III', desc: '+80 range', + cost: 1000, requires: ['range2'], effect: { range: 80 }, + }, + { + id: 'range4', label: 'Scanner IV', desc: '+100 range — max coverage', + cost: 2400, requires: ['range3'], effect: { range: 100 }, + }, + { + id: 'shield_dome', label: 'Dome Shield', desc: 'Buy dome shield system', + cost: 800, requires: [], effect: { shield: 'dome' }, + }, + { + id: 'shield_dir', label: 'Directional Shield', desc: 'Buy directional shield', + cost: 600, requires: [], effect: { shield: 'directional' }, + }, +]; + +// Per-weapon upgrade trees — each node has a `category` for grouped display +const WEAPON_UPGRADE_TREES = { + cannon: [ + // Damage + { id: 'dmg1', label: 'Damage I', desc: '+2 damage', cost: 80, requires: [], effect: { damage: 2 }, category: 'Damage' }, + { id: 'dmg2', label: 'Damage II', desc: '+3 damage', cost: 200, requires: ['dmg1'], effect: { damage: 3 }, category: 'Damage' }, + { id: 'dmg3', label: 'Damage III', desc: '+5 damage', cost: 450, requires: ['dmg2'], effect: { damage: 5 }, category: 'Damage' }, + { id: 'dmg4', label: 'Damage IV', desc: '+8 damage', cost: 900, requires: ['dmg3'], effect: { damage: 8 }, category: 'Damage' }, + // Fire Rate + { id: 'rate1', label: 'Fire Rate I', desc: '-15 frames', cost: 100, requires: [], effect: { fireRate: -15 }, category: 'Fire Rate' }, + { id: 'rate2', label: 'Fire Rate II', desc: '-20 frames', cost: 250, requires: ['rate1'], effect: { fireRate: -20 }, category: 'Fire Rate' }, + { id: 'rate3', label: 'Fire Rate III',desc: '-25 frames', cost: 550, requires: ['rate2'], effect: { fireRate: -25 }, category: 'Fire Rate' }, + // Velocity + { id: 'speed1', label: 'Velocity I', desc: '+3 proj speed', cost: 90, requires: [], effect: { projectileSpeed: 3 }, category: 'Velocity' }, + { id: 'speed2', label: 'Velocity II', desc: '+4 proj speed', cost: 220, requires: ['speed1'], effect: { projectileSpeed: 4 }, category: 'Velocity' }, + { id: 'speed3', label: 'Velocity III',desc: '+5 proj speed', cost: 500, requires: ['speed2'], effect: { projectileSpeed: 5 }, category: 'Velocity' }, + // Special + { id: 'pierce1', label: 'Piercing I', desc: 'Pierce 1 extra', cost: 200, requires: ['dmg1'], effect: { pierce: 1 }, category: 'Special' }, + { id: 'pierce2', label: 'Piercing II', desc: 'Pierce 2 extra', cost: 500, requires: ['pierce1'], effect: { pierce: 1 }, category: 'Special' }, + { id: 'pierce3', label: 'Piercing III', desc: 'Pierce 3 extra', cost: 1100, requires: ['pierce2'], effect: { pierce: 1 }, category: 'Special' }, + { id: 'pierce4', label: 'Piercing IV', desc: 'Pierce 4 extra', cost: 2400, requires: ['pierce3'], effect: { pierce: 1 }, category: 'Special' }, + { id: 'pierce5', label: 'Piercing V', desc: 'Pierce 5 extra', cost: 5000, requires: ['pierce4'], effect: { pierce: 1 }, category: 'Special' }, + { id: 'crit1', label: 'Crit I', desc: '+10% crit', cost: 180, requires: ['dmg1'], effect: { critChance: 0.10 }, category: 'Special' }, + { id: 'crit2', label: 'Crit II', desc: '+15% crit', cost: 400, requires: ['crit1'], effect: { critChance: 0.15 }, category: 'Special' }, + { id: 'crit3', label: 'Crit III', desc: '+20% crit', cost: 900, requires: ['crit2'], effect: { critChance: 0.20 }, category: 'Special' }, + // Elements + { id: 'el1', label: 'Infuse: Slot 1', desc: 'Unlock 1st element slot', cost: 300, requires: [], effect: { canInfuse: true }, category: 'Elements' }, + { id: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' }, + { id: 'el3', label: 'Infuse: Slot 3', desc: 'Unlock 3rd element slot', cost: 2000, requires: ['el2'], effect: { canInfuse3: true }, category: 'Elements' }, + ], + flamethrower: [ + { id: 'dmg1', label: 'Heat I', desc: '+1 dmg/tick', cost: 120, requires: [], effect: { damage: 1 }, category: 'Damage' }, + { id: 'dmg2', label: 'Heat II', desc: '+2 dmg/tick', cost: 300, requires: ['dmg1'], effect: { damage: 2 }, category: 'Damage' }, + { id: 'dmg3', label: 'Heat III', desc: '+3 dmg/tick', cost: 700, requires: ['dmg2'], effect: { damage: 3 }, category: 'Damage' }, + { id: 'range1', label: 'Range I', desc: '+40 range', cost: 150, requires: [], effect: { range: 40 }, category: 'Range' }, + { id: 'range2', label: 'Range II', desc: '+60 range', cost: 350, requires: ['range1'], effect: { range: 60 }, category: 'Range' }, + { id: 'range3', label: 'Range III',desc: '+80 range', cost: 800, requires: ['range2'], effect: { range: 80 }, category: 'Range' }, + { id: 'cone1', label: 'Wide Cone I', desc: '+15° angle', cost: 200, requires: [], effect: { coneAngle: 0.07 }, category: 'Cone' }, + { id: 'cone2', label: 'Wide Cone II', desc: '+20° angle', cost: 500, requires: ['cone1'], effect: { coneAngle: 0.10 }, category: 'Cone' }, + { id: 'burn1', label: 'Burn I', desc: '+60 burn frames', cost: 180, requires: ['dmg1'], effect: { dotDuration: 60 }, category: 'Special' }, + { id: 'burn2', label: 'Burn II', desc: '+90 burn frames', cost: 400, requires: ['burn1'], effect: { dotDuration: 90 }, category: 'Special' }, + { id: 'el1', label: 'Infuse: Slot 1', desc: 'Unlock 1st element slot', cost: 300, requires: [], effect: { canInfuse: true }, category: 'Elements' }, + { id: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' }, + ], + chainlightning: [ + { id: 'dmg1', label: 'Voltage I', desc: '+2 damage', cost: 150, requires: [], effect: { damage: 2 }, category: 'Damage' }, + { id: 'dmg2', label: 'Voltage II', desc: '+4 damage', cost: 380, requires: ['dmg1'], effect: { damage: 4 }, category: 'Damage' }, + { id: 'dmg3', label: 'Voltage III',desc: '+6 damage', cost: 900, requires: ['dmg2'], effect: { damage: 6 }, category: 'Damage' }, + { id: 'chains1', label: 'Chain +1', desc: '+1 arc target', cost: 300, requires: [], effect: { chains: 1 }, category: 'Chains' }, + { id: 'chains2', label: 'Chain +2', desc: '+2 arc targets', cost: 700, requires: ['chains1'], effect: { chains: 2 }, category: 'Chains' }, + { id: 'chains3', label: 'Chain +3', desc: '+3 arc targets', cost: 1600, requires: ['chains2'],effect: { chains: 3 }, category: 'Chains' }, + { id: 'range1', label: 'Arc Range I', desc: '+50 chain range', cost: 200, requires: [], effect: { chainRange: 50 }, category: 'Range' }, + { id: 'range2', label: 'Arc Range II', desc: '+80 chain range', cost: 500, requires: ['range1'], effect: { chainRange: 80 }, category: 'Range' }, + { id: 'rate1', label: 'Frequency I', desc: '-15 frames', cost: 150, requires: [], effect: { fireRate: -15 }, category: 'Fire Rate' }, + { id: 'rate2', label: 'Frequency II', desc: '-20 frames', cost: 400, requires: ['rate1'], effect: { fireRate: -20 }, category: 'Fire Rate' }, + { id: 'el1', label: 'Infuse: Slot 1', desc: 'Unlock 1st element slot', cost: 300, requires: [], effect: { canInfuse: true }, category: 'Elements' }, + { id: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' }, + ], + mortar: [ + { id: 'dmg1', label: 'Payload I', desc: '+5 damage', cost: 200, requires: [], effect: { damage: 5 }, category: 'Damage' }, + { id: 'dmg2', label: 'Payload II', desc: '+8 damage', cost: 500, requires: ['dmg1'], effect: { damage: 8 }, category: 'Damage' }, + { id: 'dmg3', label: 'Payload III',desc: '+12 damage',cost: 1100, requires: ['dmg2'],effect: { damage: 12}, category: 'Damage' }, + { id: 'aoe1', label: 'Blast Radius I', desc: '+20 AoE', cost: 180, requires: [], effect: { aoeRadius: 20 }, category: 'Blast' }, + { id: 'aoe2', label: 'Blast Radius II', desc: '+30 AoE', cost: 450, requires: ['aoe1'], effect: { aoeRadius: 30 }, category: 'Blast' }, + { id: 'aoe3', label: 'Blast Radius III',desc: '+40 AoE', cost: 950, requires: ['aoe2'], effect: { aoeRadius: 40 }, category: 'Blast' }, + { id: 'rate1', label: 'Reload I', desc: '-40 frames', cost: 250, requires: [], effect: { fireRate: -40 }, category: 'Fire Rate' }, + { id: 'rate2', label: 'Reload II', desc: '-50 frames', cost: 600, requires: ['rate1'], effect: { fireRate: -50 }, category: 'Fire Rate' }, + { id: 'speed1', label: 'Velocity I', desc: '+1.5 proj spd', cost: 200, requires: [], effect: { projectileSpeed: 1.5 }, category: 'Velocity' }, + { id: 'el1', label: 'Infuse: Slot 1', desc: 'Unlock 1st element slot', cost: 300, requires: [], effect: { canInfuse: true }, category: 'Elements' }, + { id: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' }, + { id: 'el3', label: 'Infuse: Slot 3', desc: 'Unlock 3rd element slot', cost: 2000, requires: ['el2'], effect: { canInfuse3: true }, category: 'Elements' }, + ], + laser: [ + { id: 'dmg1', label: 'Focus I', desc: '+1 dmg/tick', cost: 180, requires: [], effect: { damage: 1 }, category: 'Damage' }, + { id: 'dmg2', label: 'Focus II', desc: '+2 dmg/tick', cost: 450, requires: ['dmg1'], effect: { damage: 2 }, category: 'Damage' }, + { id: 'dmg3', label: 'Focus III',desc: '+3 dmg/tick', cost: 1000, requires: ['dmg2'],effect: { damage: 3 }, category: 'Damage' }, + { id: 'rate1', label: 'Frequency I', desc: '-2 tick frames', cost: 200, requires: [], effect: { fireRate: -2 }, category: 'Fire Rate' }, + { id: 'rate2', label: 'Frequency II', desc: '-2 tick frames', cost: 500, requires: ['rate1'], effect: { fireRate: -2 }, category: 'Fire Rate' }, + { id: 'rate3', label: 'Frequency III',desc: '-2 tick frames', cost: 1200, requires: ['rate2'],effect: { fireRate: -2 }, category: 'Fire Rate' }, + { id: 'width1', label: 'Wide Beam I', desc: '+6 hit radius', cost: 300, requires: [], effect: { projectileRadius: 6 }, category: 'Width' }, + { id: 'width2', label: 'Wide Beam II', desc: '+8 hit radius', cost: 700, requires: ['width1'], effect: { projectileRadius: 8 }, category: 'Width' }, + { id: 'el1', label: 'Infuse: Slot 1', desc: 'Unlock 1st element slot', cost: 300, requires: [], effect: { canInfuse: true }, category: 'Elements' }, + { id: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' }, + ], + freezebomb: [ + { id: 'dmg1', label: 'Cold I', desc: '+3 damage', cost: 200, requires: [], effect: { damage: 3 }, category: 'Damage' }, + { id: 'dmg2', label: 'Cold II', desc: '+5 damage', cost: 500, requires: ['dmg1'], effect: { damage: 5 }, category: 'Damage' }, + { id: 'aoe1', label: 'Blast I', desc: '+25 AoE', cost: 220, requires: [], effect: { aoeRadius: 25 }, category: 'Blast' }, + { id: 'aoe2', label: 'Blast II', desc: '+35 AoE', cost: 550, requires: ['aoe1'], effect: { aoeRadius: 35 }, category: 'Blast' }, + { id: 'freeze1', label: 'Deep Freeze I', desc: '+60 freeze frames', cost: 280, requires: [], effect: { freezeDuration: 60 }, category: 'Freeze' }, + { id: 'freeze2', label: 'Deep Freeze II', desc: '+90 freeze frames', cost: 600, requires: ['freeze1'], effect: { freezeDuration: 90 }, category: 'Freeze' }, + { id: 'freeze3', label: 'Deep Freeze III',desc: '+120 freeze frames', cost: 1300, requires: ['freeze2'], effect: { freezeDuration: 120}, category: 'Freeze' }, + { id: 'rate1', label: 'Reload I', desc: '-40 frames', cost: 300, requires: [], effect: { fireRate: -40 }, category: 'Fire Rate' }, + { id: 'el1', label: 'Infuse: Slot 1', desc: 'Unlock 1st element slot', cost: 300, requires: [], effect: { canInfuse: true }, category: 'Elements' }, + { id: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' }, + ], + voidcannon: [ + { id: 'dmg1', label: 'Singularity I', desc: '+8 damage', cost: 300, requires: [], effect: { damage: 8 }, category: 'Damage' }, + { id: 'dmg2', label: 'Singularity II', desc: '+15 damage', cost: 750, requires: ['dmg1'], effect: { damage: 15 }, category: 'Damage' }, + { id: 'dmg3', label: 'Singularity III',desc: '+25 damage', cost: 1600, requires: ['dmg2'],effect: { damage: 25 }, category: 'Damage' }, + { id: 'shred1', label: 'Armor Shred I', desc: '+3 armor shred', cost: 400, requires: [], effect: { armorShred: 3 }, category: 'Special' }, + { id: 'shred2', label: 'Armor Shred II', desc: '+5 armor shred', cost: 900, requires: ['shred1'], effect: { armorShred: 5 }, category: 'Special' }, + { id: 'speed1', label: 'Velocity I', desc: '+1.5 proj spd', cost: 350, requires: [], effect: { projectileSpeed: 1.5 }, category: 'Velocity' }, + { id: 'speed2', label: 'Velocity II', desc: '+2 proj spd', cost: 800, requires: ['speed1'], effect: { projectileSpeed: 2 }, category: 'Velocity' }, + { id: 'size1', label: 'Projectile Size I', desc: '+4 proj radius', cost: 400, requires: [], effect: { projectileRadius: 4 }, category: 'Size' }, + { id: 'rate1', label: 'Reload I', desc: '-50 frames', cost: 500, requires: [], effect: { fireRate: -50 }, category: 'Fire Rate' }, + { id: 'el1', label: 'Infuse: Slot 1', desc: 'Unlock 1st element slot', cost: 300, requires: [], effect: { canInfuse: true }, category: 'Elements' }, + { id: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' }, + { id: 'el3', label: 'Infuse: Slot 3', desc: 'Unlock 3rd element slot', cost: 2000, requires: ['el2'], effect: { canInfuse3: true }, category: 'Elements' }, + ], + missilepod: [ + { id: 'dmg1', label: 'Warhead I', desc: '+4 damage', cost: 250, requires: [], effect: { damage: 4 }, category: 'Damage' }, + { id: 'dmg2', label: 'Warhead II', desc: '+6 damage', cost: 600, requires: ['dmg1'], effect: { damage: 6 }, category: 'Damage' }, + { id: 'dmg3', label: 'Warhead III',desc: '+10 damage',cost: 1400, requires: ['dmg2'],effect: { damage: 10}, category: 'Damage' }, + { id: 'targets1', label: '+1 Target', desc: 'Fire at 1 more', cost: 500, requires: [], effect: { targets: 1 }, category: 'Targets' }, + { id: 'targets2', label: '+2 Targets',desc: 'Fire at 2 more', cost: 1200, requires: ['targets1'], effect: { targets: 2 }, category: 'Targets' }, + { id: 'targets3', label: '+3 Targets',desc: 'Fire at 3 more', cost: 2800, requires: ['targets2'], effect: { targets: 3 }, category: 'Targets' }, + { id: 'rate1', label: 'Reload I', desc: '-30 frames', cost: 300, requires: [], effect: { fireRate: -30 }, category: 'Fire Rate' }, + { id: 'rate2', label: 'Reload II', desc: '-40 frames', cost: 700, requires: ['rate1'], effect: { fireRate: -40 }, category: 'Fire Rate' }, + { id: 'speed1', label: 'Velocity I', desc: '+2 proj spd', cost: 300, requires: [], effect: { projectileSpeed: 2 }, category: 'Velocity' }, + { id: 'el1', label: 'Infuse: Slot 1', desc: 'Unlock 1st element slot', cost: 300, requires: [], effect: { canInfuse: true }, category: 'Elements' }, + { id: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' }, + ], + arcaneturret: [ + { id: 'dmg1', label: 'Potency I', desc: '+1 damage', cost: 120, requires: [], effect: { damage: 1 }, category: 'Damage' }, + { id: 'dmg2', label: 'Potency II', desc: '+2 damage', cost: 300, requires: ['dmg1'], effect: { damage: 2 }, category: 'Damage' }, + { id: 'dmg3', label: 'Potency III',desc: '+3 damage', cost: 700, requires: ['dmg2'], effect: { damage: 3 }, category: 'Damage' }, + { id: 'rate1', label: 'Frequency I', desc: '-5 frames', cost: 150, requires: [], effect: { fireRate: -5 }, category: 'Fire Rate' }, + { id: 'rate2', label: 'Frequency II', desc: '-5 frames', cost: 380, requires: ['rate1'], effect: { fireRate: -5 }, category: 'Fire Rate' }, + { id: 'rate3', label: 'Frequency III',desc: '-4 frames', cost: 850, requires: ['rate2'], effect: { fireRate: -4 }, category: 'Fire Rate' }, + { id: 'amp1', label: 'Amplify I', desc: '+10% amp', cost: 300, requires: ['dmg1'], effect: { amplify: 0.10 }, category: 'Special' }, + { id: 'amp2', label: 'Amplify II', desc: '+15% amp', cost: 700, requires: ['amp1'], effect: { amplify: 0.15 }, category: 'Special' }, + { id: 'speed1',label: 'Velocity I', desc: '+3 proj spd',cost: 200, requires: [], effect: { projectileSpeed: 3 }, category: 'Velocity' }, + { id: 'el1', label: 'Infuse: Slot 1', desc: 'Unlock 1st element slot', cost: 300, requires: [], effect: { canInfuse: true }, category: 'Elements' }, + { id: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' }, + ], + poisoncloud: [ + { id: 'dmg1', label: 'Toxin I', desc: '+1 dot dmg', cost: 150, requires: [], effect: { dotDamage: 1 }, category: 'Damage' }, + { id: 'dmg2', label: 'Toxin II', desc: '+2 dot dmg', cost: 380, requires: ['dmg1'], effect: { dotDamage: 2 }, category: 'Damage' }, + { id: 'dmg3', label: 'Toxin III',desc: '+3 dot dmg', cost: 850, requires: ['dmg2'], effect: { dotDamage: 3 }, category: 'Damage' }, + { id: 'dur1', label: 'Duration I', desc: '+60 cloud frames', cost: 180, requires: [], effect: { dotDuration: 60 }, category: 'Duration' }, + { id: 'dur2', label: 'Duration II', desc: '+90 cloud frames', cost: 450, requires: ['dur1'], effect: { dotDuration: 90 }, category: 'Duration' }, + { id: 'dur3', label: 'Duration III',desc: '+120 cloud frames', cost: 1000, requires: ['dur2'], effect: { dotDuration: 120}, category: 'Duration' }, + { id: 'aoe1', label: 'Cloud I', desc: '+20 AoE', cost: 200, requires: [], effect: { aoeRadius: 20 }, category: 'Blast' }, + { id: 'aoe2', label: 'Cloud II', desc: '+30 AoE', cost: 500, requires: ['aoe1'], effect: { aoeRadius: 30 }, category: 'Blast' }, + { id: 'rate1', label: 'Reload I', desc: '-30 frames', cost: 250, requires: [], effect: { fireRate: -30 }, category: 'Fire Rate' }, + { id: 'el1', label: 'Infuse: Slot 1', desc: 'Unlock 1st element slot', cost: 300, requires: [], effect: { canInfuse: true }, category: 'Elements' }, + { id: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' }, + ], +}; + +// Shield upgrade trees +const SHIELD_UPGRADE_TREES = { + dome: [ + { id: 'cap1', label: 'Capacity I', desc: '+5 shield HP', cost: 200, requires: [], effect: { capacity: 5 } }, + { id: 'cap2', label: 'Capacity II', desc: '+10 shield HP', cost: 500, requires: ['cap1'], effect: { capacity: 10 } }, + { id: 'cap3', label: 'Capacity III', desc: '+20 shield HP', cost: 1100, requires: ['cap2'], effect: { capacity: 20 } }, + { id: 'regen1', label: 'Recharge I', desc: '-30 frames recharge', cost: 300, requires: [], effect: { rechargeDelay: -30 } }, + { id: 'regen2', label: 'Recharge II', desc: '-60 frames recharge', cost: 700, requires: ['regen1'], effect: { rechargeDelay: -60 } }, + { id: 'reflect1', label: 'Reflect', desc: 'Reflect 20% dmg back', cost: 1500, requires: ['cap2'], effect: { reflect: 0.2 } }, + ], + directional: [ + { id: 'arc1', label: 'Wide Arc I', desc: '+20° arc width', cost: 200, requires: [], effect: { arcWidth: 0.35 } }, + { id: 'arc2', label: 'Wide Arc II', desc: '+30° arc width', cost: 500, requires: ['arc1'], effect: { arcWidth: 0.52 } }, + { id: 'cap1', label: 'Capacity I', desc: '+8 shield HP', cost: 250, requires: [], effect: { capacity: 8 } }, + { id: 'cap2', label: 'Capacity II', desc: '+15 shield HP', cost: 600, requires: ['cap1'], effect: { capacity: 15 } }, + { id: 'speed1', label: 'Track Speed I', desc: '+50% shield rotation', cost: 300, requires: [], effect: { trackSpeed: 0.03 } }, + { id: 'speed2', label: 'Track Speed II', desc: '+100% shield rotation', cost: 700, requires: ['speed1'], effect: { trackSpeed: 0.06 } }, + { id: 'absorb1', label: 'Absorption I', desc: 'Block 25% more damage', cost: 400, requires: ['cap1'], effect: { absorption: 0.25 } }, + ], +}; + diff --git a/js/dev-console.js b/js/dev-console.js new file mode 100644 index 0000000..306cf9d --- /dev/null +++ b/js/dev-console.js @@ -0,0 +1,253 @@ +// ════════════════════════════════════════════════════════════ +// DEV CONSOLE — set DEV_MODE = false to disable entirely +// ════════════════════════════════════════════════════════════ +const DEV_MODE = true; + +(function() { + if (!DEV_MODE) return; + + const el = () => document.getElementById('dev-console'); + const inputEl = () => document.getElementById('dev-input'); + const outputEl = () => document.getElementById('dev-output'); + + let history = [], histIdx = -1; + + function devLog(msg, type = 'out') { + const d = outputEl(); + const line = document.createElement('div'); + line.className = 'dev-line ' + type; + line.textContent = msg; + d.appendChild(line); + d.scrollTop = d.scrollHeight; + } + + function devClear() { outputEl().innerHTML = ''; } + + // ── COMMANDS ──────────────────────────────────────────────── + const COMMANDS = { + + help: { + desc: 'List all commands. Usage: help [command]', + run(args) { + if (args.length) { + const c = COMMANDS[args[0]]; + if (!c) return devLog(`Unknown command: ${args[0]}`, 'err'); + devLog(`${args[0]} — ${c.desc}`, 'info'); + } else { + devLog('Available commands:', 'info'); + Object.entries(COMMANDS).forEach(([k,v]) => devLog(` ${k.padEnd(16)} ${v.desc}`, 'out')); + } + } + }, + + credits: { + desc: 'Set or add credits. Usage: credits | credits +500 | credits max', + run(args) { + if (!args.length) return devLog(`Current credits: ${G.credits}`, 'info'); + const raw = args[0]; + if (raw === 'max') { G.credits = 999999; } + else if (raw.startsWith('+')) { G.credits += parseInt(raw.slice(1)) || 0; } + else if (raw.startsWith('-')) { G.credits -= parseInt(raw.slice(1)) || 0; } + else { G.credits = parseInt(raw) || 0; } + G.credits = Math.max(0, Math.floor(G.credits)); + updateHUD(); + devLog(`Credits set to ${G.credits}`, 'ok'); + } + }, + + hp: { + desc: 'Set tower HP. Usage: hp | hp max | hp full', + run(args) { + if (!args.length) return devLog(`Tower HP: ${G.tower.hp}/${G.tower.maxHp}`, 'info'); + const raw = args[0]; + const val = (raw === 'max' || raw === 'full') ? G.tower.maxHp : parseInt(raw); + G.tower.hp = Math.max(1, Math.min(G.tower.maxHp, val || 1)); + updateHUD(); + devLog(`Tower HP set to ${G.tower.hp}`, 'ok'); + } + }, + + kill: { + desc: 'Kill all active enemies. Usage: kill | kill ', + run(args) { + let killed = 0; + for (const e of G.enemies) { + if (!e.alive) continue; + if (args.length && e.defId !== args[0]) continue; + killEnemy(e, false); + killed++; + } + devLog(`Killed ${killed} enemies`, 'ok'); + } + }, + + spawn: { + desc: 'Spawn enemies directly. Usage: spawn [qty] (ids: grunt runner brute swarm phantom iceling sparkling venom titan wraith)', + run(args) { + if (!args.length) return devLog('Usage: spawn [qty]', 'err'); + const id = args[0]; + const qty = parseInt(args[1]) || 1; + const def = ENEMY_DEFS.find(d => d.id === id); + if (!def) return devLog(`Unknown enemy: ${id}. Try: ${ENEMY_DEFS.map(d=>d.id).join(', ')}`, 'err'); + for (let i = 0; i < qty; i++) openPortal(def, 1, 0); + devLog(`Spawned ${qty}× ${def.name}`, 'ok'); + } + }, + + god: { + desc: 'Toggle tower invincibility.', + run() { + G._godMode = !G._godMode; + if (G._godMode) { + G._origBreachTower = window.breachTower; + window.breachTower = function(e) { killEnemy(e, true); }; + devLog('God mode ON — tower cannot take damage', 'warn'); + } else { + if (G._origBreachTower) window.breachTower = G._origBreachTower; + devLog('God mode OFF', 'ok'); + } + } + }, + + speed: { + desc: 'Set game speed multiplier. Usage: speed <0.1–5> | speed 1 to reset', + run(args) { + if (!args.length) return devLog(`Current speed: ${G._speedMult || 1}×`, 'info'); + const mult = parseFloat(args[0]); + if (isNaN(mult) || mult <= 0) return devLog('Invalid speed', 'err'); + G._speedMult = mult; + devLog(`Speed set to ${mult}×`, 'ok'); + } + }, + + perf: { + desc: 'Toggle perf overlay. Usage: perf on | perf off | perf toggle | perf', + run(args) { + const mode = (args[0] || '').toLowerCase(); + if (!mode) return devLog(`Perf overlay: ${G._showPerfOverlay ? 'ON' : 'OFF'}`, 'info'); + + if (mode === 'on' || mode === '1' || mode === 'true') { + G._showPerfOverlay = true; + devLog('Perf overlay ON', 'ok'); + return; + } + if (mode === 'off' || mode === '0' || mode === 'false') { + G._showPerfOverlay = false; + const overlay = document.getElementById('perf-overlay'); + if (overlay) overlay.style.display = 'none'; + devLog('Perf overlay OFF', 'ok'); + return; + } + if (mode === 'toggle') { + G._showPerfOverlay = !G._showPerfOverlay; + if (!G._showPerfOverlay) { + const overlay = document.getElementById('perf-overlay'); + if (overlay) overlay.style.display = 'none'; + } + devLog(`Perf overlay ${G._showPerfOverlay ? 'ON' : 'OFF'}`, 'ok'); + return; + } + devLog('Usage: perf on | perf off | perf toggle', 'err'); + } + }, + + wave: { + desc: 'Spawn a full wave of mixed enemies. Usage: wave [count_each]', + run(args) { + const qty = parseInt(args[0]) || 3; + const ids = ['grunt','runner','brute','phantom']; + ids.forEach(id => { + const def = ENEMY_DEFS.find(d => d.id === id); + if (def) openPortal(def, qty, 0); + }); + devLog(`Spawned wave: ${ids.join(', ')} ×${qty} each`, 'ok'); + } + }, + + state: { + desc: 'Dump key game state. Usage: state | state enemies | state tower | state weapons', + run(args) { + const key = args[0] || 'summary'; + if (key === 'enemies') return devLog(JSON.stringify(G.enemies.filter(e=>e.alive).map(e=>({id:e.defId,hp:e.hp,x:Math.round(e.x),y:Math.round(e.y)})),null,2), 'out'); + if (key === 'tower') return devLog(JSON.stringify({hp:G.tower.hp,maxHp:G.tower.maxHp,shield:G.tower.shield,cannonAngle:Math.round(G.tower.cannonAngle*57)+'°'},null,2), 'out'); + if (key === 'weapons') return devLog(JSON.stringify(G.weapons.filter(Boolean).map(w=>({id:w.defId,elements:w.elements,instanceId:w.instanceId})),null,2), 'out'); + devLog(`frame:${G.frame} credits:${G.credits} score:${G.score} kills:${G.totalKills} enemies:${G.enemies.filter(e=>e.alive).length} projectiles:${G.projectiles.length}`, 'info'); + } + }, + + clear: { + desc: 'Clear the console output.', + run() { devClear(); } + }, + + gameover: { + desc: 'Trigger game over screen immediately.', + run() { endGame(); devLog('Game over triggered', 'warn'); } + }, + + reset: { + desc: 'Restart the game (same as clicking Restart).', + run() { restartGame(); devLog('Game restarted', 'ok'); } + }, + + }; + + // ── INPUT HANDLER ──────────────────────────────────────────── + function runCommand(raw) { + const trimmed = raw.trim(); + if (!trimmed) return; + history.unshift(trimmed); + if (history.length > 50) history.pop(); + histIdx = -1; + + devLog('siege> ' + trimmed, 'info'); + const parts = trimmed.split(/\s+/); + const cmd = COMMANDS[parts[0].toLowerCase()]; + if (!cmd) { + devLog(`Unknown command: "${parts[0]}". Type "help" for list.`, 'err'); + } else { + try { cmd.run(parts.slice(1)); } + catch(e) { devLog('Error: ' + e.message, 'err'); } + } + } + + // ── KEYBOARD ───────────────────────────────────────────────── + document.addEventListener('keydown', function(e) { + if (!DEV_MODE) return; + + // Tilde opens/closes + if (e.key === '`' || e.key === '~') { + e.preventDefault(); + const con = el(); + con.classList.toggle('open'); + if (con.classList.contains('open')) { + inputEl().focus(); + devLog('Dev console ready. Type "help" for commands.', 'info'); + } + return; + } + + // Only intercept further keys when console is open + if (!el().classList.contains('open')) return; + + if (e.key === 'Enter') { + const inp = inputEl(); + runCommand(inp.value); + inp.value = ''; + e.preventDefault(); + } else if (e.key === 'Escape') { + el().classList.remove('open'); + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (histIdx < history.length - 1) histIdx++; + inputEl().value = history[histIdx] || ''; + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (histIdx > 0) histIdx--; + else { histIdx = -1; inputEl().value = ''; return; } + inputEl().value = history[histIdx] || ''; + } + }); + +})(); diff --git a/js/elements.js b/js/elements.js new file mode 100644 index 0000000..9939689 --- /dev/null +++ b/js/elements.js @@ -0,0 +1,230 @@ +// ═══ elements.js ═══ +// ============================================================ +// ELEMENTS.JS — Elemental damage, debuffs, DoT +// ============================================================ + +// Apply elemental hit effects to an enemy +function applyElementalEffects(enemy, elements, rawDamage) { + let totalDamage = rawDamage; + const results = { damage: 0, critted: false, effects: [] }; + + for (const el of elements) { + if (!el) continue; + const res = enemy.resistances?.[el] ?? 1.0; + const weak = enemy.weaknesses?.[el] ?? 1.0; + const mult = res * weak; + + switch (el) { + case 'fire': + applyDoT(enemy, 'burn', 'fire', 1, 30, 180, '#ff6b35'); + results.effects.push('burn'); + break; + case 'ice': + applySlow(enemy, 0.5, 120); + results.effects.push('slow'); + break; + case 'lightning': + chainLightningProc(enemy, totalDamage * 0.5, 100); + results.effects.push('chain'); + break; + case 'poison': + applyDoT(enemy, 'poison', 'poison', 1, 20, 240, '#7fff4f'); + results.effects.push('poison'); + break; + case 'void': + enemy.armor = Math.max(0, (enemy.armor || 0) - 1); + results.effects.push('shred'); + break; + case 'arcane': + enemy.amplified = (enemy.amplified || 0) + 0.25; + results.effects.push('amplify'); + break; + } + + totalDamage *= mult; + } + + results.damage = totalDamage; + return results; +} + +function applyDoT(enemy, type, element, dmg, interval, duration, color) { + if (!enemy.dots) enemy.dots = []; + // Refresh or add + const existing = enemy.dots.find(d => d.type === type); + if (existing) { + existing.remaining = Math.max(existing.remaining, duration); + existing.dmg = Math.max(existing.dmg, dmg); + existing.element = element; + } else { + enemy.dots.push({ type, element, dmg, interval, remaining: duration, tick: 0, color }); + } +} + +function applySlow(enemy, factor, duration) { + if (!enemy.slow || enemy.slow.factor > factor) { + enemy.slow = { factor, remaining: duration }; + } else { + enemy.slow.remaining = Math.max(enemy.slow.remaining, duration); + } +} + +function applyFreeze(enemy, duration) { + // Diminishing returns: each successive freeze is shorter + // 60-frame immunity window after thawing + if (enemy._freezeImmune > 0) return; + const diminish = enemy._freezeCount ? Math.pow(0.75, enemy._freezeCount) : 1.0; + const actual = Math.max(30, Math.round(duration * diminish)); + enemy.frozen = actual; + enemy._freezeCount = (enemy._freezeCount || 0) + 1; + if (!enemy.slow) enemy.slow = { factor: 0, remaining: actual }; + enemy.slow.factor = 0; + enemy.slow.remaining = actual; +} + +// Chain lightning proc — bounces to nearby enemies +function chainLightningProc(sourceEnemy, damage, range) { + if (!G || !G.enemies) return; + const rangeSq = range * range; + let seen = 0; + let target = null; + for (const e of G.enemies) { + if (!e.alive || e.id === sourceEnemy.id) continue; + if (distSq(e.x, e.y, sourceEnemy.x, sourceEnemy.y) >= rangeSq) continue; + seen++; + if (Math.random() < 1 / seen) target = e; + } + if (!target) return; + dealDamage(target, damage, ['lightning'], false); + // Spark visual + spawnChainArc(sourceEnemy.x, sourceEnemy.y, target.x, target.y, '#ffe033'); +} + +// Core damage dealer — handles armor, resist, amplify, crits +function dealDamage(enemy, rawDamage, elements = ['physical'], canCrit = false, critChance = 0, armorShred = 0) { + if (!enemy.alive) return 0; + + // Armor shred + if (armorShred > 0) { + enemy.armor = Math.max(0, (enemy.armor || 0) - armorShred); + } + + // Base damage after armor + let dmg = Math.max(1, rawDamage - (enemy.armor || 0)); + + // Elemental multipliers + let elemMult = 1; + for (const el of elements) { + if (!el || el === 'physical') continue; + const res = enemy.resistances?.[el] ?? 1.0; + const weak = enemy.weaknesses?.[el] ?? 1.0; + elemMult *= res * weak; + } + dmg *= elemMult; + + // Amplify debuff + if (enemy.amplified) dmg *= (1 + enemy.amplified); + + // Crit + let critted = false; + if (canCrit && Math.random() < critChance) { + dmg *= 2; + critted = true; + } + + dmg = Math.round(dmg); + enemy.hp -= dmg; + enemy.hitFlash = 6; + + // Show damage number for crits / elemental + const showNumber = critted || (elements.some(e => e && e !== 'physical')); + if (showNumber && dmg > 0) { + const color = critted ? '#ffffff' : (ELEMENTS[elements[0]]?.color || '#fff'); + const label = critted ? `${dmg}!` : `${dmg}`; + spawnFloater(enemy.x, enemy.y - enemy.radius - 4, label, color, critted ? 1.4 : 1.0); + } + + // Apply elemental side effects + for (const el of elements) { + if (!el || el === 'physical') continue; + applyElementalEffect(enemy, el); + } + + return dmg; +} + +function applyElementalEffect(enemy, el) { + switch (el) { + case 'fire': + applyDoT(enemy, 'burn', 'fire', 1, 30, 180, '#ff6b35'); + break; + case 'ice': + applySlow(enemy, 0.5, 120); + break; + case 'poison': + applyDoT(enemy, 'poison', 'poison', 1, 20, 240, '#7fff4f'); + break; + case 'void': + enemy.armor = Math.max(0, (enemy.armor || 0) - 1); + break; + case 'arcane': + enemy.amplified = Math.min(1.5, (enemy.amplified || 0) + 0.1); + break; + case 'lightning': + // chain handled separately in weapon logic + break; + } +} + +// Tick DoTs and debuffs on an enemy each frame +function tickEnemyStatus(enemy) { + // DoTs + if (enemy.dots) { + for (const dot of enemy.dots) { + dot.tick++; + if (dot.tick >= dot.interval) { + dot.tick = 0; + const res = enemy.resistances?.[dot.element] ?? 1.0; + const weak = enemy.weaknesses?.[dot.element] ?? 1.0; + const dmg = Math.max(0, Math.round(dot.dmg * res * weak)); + if (dmg > 0) { + enemy.hp -= dmg; + enemy.hitFlash = 3; + // Small particle tick + spawnParticle(enemy.x + (Math.random()-0.5)*8, enemy.y + (Math.random()-0.5)*8, + dot.color, 0, 0, 0.6, 3, 0.05); + } + } + dot.remaining--; + } + let write = 0; + for (let i = 0; i < enemy.dots.length; i++) { + const dot = enemy.dots[i]; + if (dot.remaining > 0) enemy.dots[write++] = dot; + } + enemy.dots.length = write; + } + + // Slow / freeze decay + if (enemy.frozen > 0) { + enemy.frozen--; + if (enemy.frozen === 0) { + if (enemy.slow) enemy.slow = null; + enemy._freezeImmune = 60; // 1 second immunity after thaw + } + } + if (enemy._freezeImmune > 0) enemy._freezeImmune--; + if (enemy.slow) { + enemy.slow.remaining--; + if (enemy.slow.remaining <= 0) enemy.slow = null; + } + + // Hit flash decay + if (enemy.hitFlash > 0) enemy.hitFlash--; + + // Amplify decay + if (enemy.amplified) { + enemy.amplified *= 0.995; + if (enemy.amplified < 0.01) enemy.amplified = 0; + } +} diff --git a/js/enemies.js b/js/enemies.js new file mode 100644 index 0000000..e8af273 --- /dev/null +++ b/js/enemies.js @@ -0,0 +1,431 @@ +// ═══ enemies.js ═══ +// ============================================================ +// ENEMIES.JS — Portal system, enemy spawning, movement +// ============================================================ + +// ── ENEMY SPAWNING ──────────────────────────────────────────── +function spawnEnemy(def, x, y, rewardOverride = null, offsetAngle = null, costOverride = null, breachRiskMult = 1) { + // For swarm units, offset position slightly around the portal + let spawnX = x, spawnY = y; + if (offsetAngle !== null) { + const spread = def.radius * 3.5; + spawnX = x + Math.cos(offsetAngle) * spread; + spawnY = y + Math.sin(offsetAngle) * spread; + } + G.enemies.push({ + id: uid(), + defId: def.id, + name: def.name, + x: spawnX, y: spawnY, + hp: def.hp, + maxHp: def.hp, + speed: def.speed, + baseSpeed: def.speed, + radius: def.radius, + armor: def.armor ?? 0, + baseArmor: def.armor ?? 0, + evasion: def.evasion ?? 0, + armorPen: def.armorPen ?? 0, + color: def.color, + glowColor: def.glowColor, + resistances: { ...(def.resistances || {}) }, + weaknesses: { ...(def.weaknesses || {}) }, + element: def.element, + reward: rewardOverride ?? def.reward, + cost: costOverride ?? def.cost, + breachRiskMult: breachRiskMult ?? 1, + alive: true, + reachedTower: false, + dots: [], + slow: null, + frozen: 0, + hitFlash: 0, + amplified: 0, + trail: [], + spawnImmunity: 60, // opening grace window: tower can acquire target before advance + angle: 0, + vx: 0, + vy: 0, + }); +} + +// ── DEPLOY (player action) ──────────────────────────────────── +function deployEnemy(defId, quantity = 1) { + const def = ENEMY_DEFS.find(e => e.id === defId); + if (!def) return; + + const totalCost = def.cost * quantity; + if (G.credits < totalCost) return; + if (G.gameOver) return; + + G.credits -= totalCost; + + // Bonus reward multiplier and breach risk for sending multiples + const bonusMult = quantity >= 50 ? 1.3 : quantity >= 25 ? 1.2 : quantity >= 10 ? 1.12 : quantity >= 5 ? 1.05 : 1.0; + const rewardPerUnit = Math.round(def.reward * bonusMult); + const breachRiskMult = quantity >= 50 ? 2.2 : quantity >= 25 ? 1.65 : quantity >= 10 ? 1.4 : quantity >= 5 ? 1.2 : 1.0; + + // Freshness tracking — increment before deploy so bar reflects cost immediately + G.enemyFreshness[defId] = (G.enemyFreshness[defId] || 0) + quantity; + + if (def.id === 'swarm') { + // Each swarm card = one burst portal. Split rewards exactly across minis. + const swarmUnitCost = def.cost / def.count; + for (let i = 0; i < quantity; i++) { + const rewardByMini = splitInteger(rewardPerUnit, def.count); + openPortal(def, def.count, null, true, swarmUnitCost, breachRiskMult, rewardByMini); + } + } else { + // one portal per enemy — each gets a unique random angle with collision avoidance + for (let i = 0; i < quantity; i++) { + openPortal(def, 1, rewardPerUnit, false, def.cost, breachRiskMult); + } + } + + const plural = quantity > 1 ? ` ×${quantity}` : ''; + const bonusStr = bonusMult > 1 ? ` (+${Math.round((bonusMult-1)*100)}% reward!)` : ''; + const riskStr = breachRiskMult > 1 ? ` [risk x${breachRiskMult.toFixed(2)}]` : ''; + addLog(`Deployed ${def.name}${plural} — ${totalCost}¢${bonusStr}${riskStr}`, 'info'); + updateHUD(); +} + +function getEnemyCurrentSpeed(enemy) { + if (enemy.frozen > 0) return 0; + return enemy.slow ? enemy.speed * enemy.slow.factor : enemy.speed; +} + +function getEnemyPathRemaining(enemy, cx, cy) { + let rem = 0; + let px = enemy.x; + let py = enemy.y; + + const path = Array.isArray(enemy.path) ? enemy.path : null; + let idx = Math.max(0, enemy.pathIndex || 0); + + if (path) { + while (idx < path.length) { + const node = path[idx]; + rem += Math.hypot(node.x - px, node.y - py); + px = node.x; + py = node.y; + idx++; + } + } + + rem += Math.hypot(cx - px, cy - py); + return rem; +} + +function moveEnemyAlongPath(enemy, step, cx, cy) { + let remaining = step; + let safety = 0; + const path = Array.isArray(enemy.path) ? enemy.path : null; + + while (remaining > 0 && safety++ < 8) { + const idx = Math.max(0, enemy.pathIndex || 0); + const node = (path && idx < path.length) ? path[idx] : { x: cx, y: cy }; + const dx = node.x - enemy.x; + const dy = node.y - enemy.y; + const dist = Math.hypot(dx, dy); + + if (dist < 0.0001) { + if (path && idx < path.length) { + enemy.pathIndex = idx + 1; + continue; + } + break; + } + + const travel = Math.min(remaining, dist); + enemy.x += (dx / dist) * travel; + enemy.y += (dy / dist) * travel; + enemy.angle = Math.atan2(dy, dx); + remaining -= travel; + + if (travel >= dist - 0.0001 && path && idx < path.length) { + enemy.pathIndex = idx + 1; + } else { + break; + } + } +} + +function predictEnemyPositionAlongPath(enemy, framesAhead, cx = ARENA_CX, cy = ARENA_CY) { + let x = enemy.x; + let y = enemy.y; + let remainingFrames = Math.max(0, framesAhead); + const path = Array.isArray(enemy.path) ? enemy.path : null; + let idx = Math.max(0, enemy.pathIndex || 0); + + if (enemy.spawnImmunity > 0) { + const wait = Math.min(remainingFrames, enemy.spawnImmunity); + remainingFrames -= wait; + if (remainingFrames <= 0) return { x, y }; + } + + const speed = getEnemyCurrentSpeed(enemy); + if (speed <= 0 || remainingFrames <= 0) return { x, y }; + + let safety = 0; + while (remainingFrames > 0 && safety++ < 64) { + const node = (path && idx < path.length) ? path[idx] : { x: cx, y: cy }; + const dx = node.x - x; + const dy = node.y - y; + const dist = Math.hypot(dx, dy); + if (dist < 0.0001) { + if (path && idx < path.length) { + idx++; + continue; + } + break; + } + + const travel = speed * remainingFrames; + if (travel >= dist) { + const dt = dist / speed; + x = node.x; + y = node.y; + remainingFrames -= dt; + if (path && idx < path.length) idx++; + } else { + const ratio = travel / dist; + x += dx * ratio; + y += dy * ratio; + remainingFrames = 0; + } + } + + return { x, y }; +} + +// ── ENEMY UPDATE ────────────────────────────────────────────── +function updateEnemies() { + const cx = ARENA_CX; + const cy = ARENA_CY; + + for (const e of G.enemies) { + if (!e.alive) continue; + + const prevX = e.x; + const prevY = e.y; + + // Spawn immunity countdown + if (e.spawnImmunity > 0) { + e.spawnImmunity--; + e.vx = 0; + e.vy = 0; + continue; + } + + // Tick status effects + tickEnemyStatus(e); + + if (e.hp <= 0) { + killEnemy(e, true); + continue; + } + + // Movement + const spd = getEnemyCurrentSpeed(e); + if (spd > 0) { + moveEnemyAlongPath(e, spd, cx, cy); + e.vx = e.x - prevX; + e.vy = e.y - prevY; + const breachRadius = 28 + e.radius; + if (distSq(cx, cy, e.x, e.y) < breachRadius * breachRadius) { + e.vx = 0; + e.vy = 0; + breachTower(e); + } + } else { + e.vx = 0; + e.vy = 0; + } + + e.pathRemaining = getEnemyPathRemaining(e, cx, cy); + + // Trail + e.trail.push({ x: e.x, y: e.y }); + if (e.trail.length > 10) e.trail.shift(); + } + + compactLiveArray(G.enemies, e => e.alive); +} + +function killEnemy(enemy, giveReward) { + enemy.alive = false; + if (giveReward) { + // Freshness bonus — reward multiplier based on how recently this type was sent + const freshness = G.enemyFreshness[enemy.defId] || 0; + const freshMult = Math.max(1.0, 1.35 - freshness * 0.02); + const baseReward = Math.round(enemy.reward); + const bonusReward = freshMult > 1.01 ? Math.round(baseReward * freshMult) - baseReward : 0; + const totalReward = baseReward + bonusReward; + + // Kill streak bonus + const framesSinceLast = G.frame - G.streak.lastKillFrame; + if (framesSinceLast <= 180) { + G.streak.count++; + } else { + G.streak.count = 1; + } + G.streak.lastKillFrame = G.frame; + const streakBonus = Math.min(10, Math.floor(G.streak.count / 3) * 2); + + G.credits += totalReward + streakBonus; + G.score += totalReward + streakBonus; + G.totalKills++; + sfx_enemy_die(); + spawnParticleBurst(enemy.x, enemy.y, enemy.color, 14); + + // Show reward floater — include freshness bonus if active + if (bonusReward > 0) { + spawnFloater(enemy.x, enemy.y - enemy.radius - 10, `+${totalReward}¢`, '#00d4ff', 1.1); + } else { + spawnFloater(enemy.x, enemy.y - enemy.radius - 10, `+${totalReward}¢`, '#ffd700', 1.1); + } + if (streakBonus > 0) { + spawnFloater(enemy.x, enemy.y - enemy.radius - 24, `+${streakBonus}¢ streak`, '#ffd700', 0.9); + } + + addLog(`${enemy.name} destroyed! +${totalReward}¢${streakBonus > 0 ? ` +${streakBonus}¢ streak` : ''}`, 'win'); + updateHUD(); + } +} + +function breachTower(enemy) { + enemy.alive = false; + enemy.reachedTower = true; + + const baseDamage = Math.max(1, Math.ceil(enemy.maxHp * 0.17 + 2) - G.tower.armor); + const threatMult = 1 + Math.min(0.9, (enemy.cost || 0) / 450) + (enemy.speed >= 2 ? 0.1 : 0); + const riskMult = 1 + Math.max(0, (enemy.breachRiskMult || 1) - 1) * 0.7; + const dmg = Math.max(1, Math.round(baseDamage * threatMult * riskMult)); + const creditLoss = Math.max(1, Math.round((enemy.cost || 0) * ( + 1 + (enemy.speed * 0.12) + (Math.max(0, (enemy.breachRiskMult || 1) - 1) * 0.8) + ))); + + // Shield absorbs first + let remaining = dmg; + const cx = ARENA_CX; + const cy = ARENA_CY; + const directionalCanBlock = (() => { + if (G.tower.shield !== 'directional') return true; + const arc = G.tower.shieldArcWidth ?? (Math.PI * 0.6); + const shieldAngle = G.tower.shieldAngle ?? 0; + const enemyAngle = Math.atan2(enemy.y - cy, enemy.x - cx); + let delta = enemyAngle - shieldAngle; + while (delta > Math.PI) delta -= Math.PI * 2; + while (delta < -Math.PI) delta += Math.PI * 2; + return Math.abs(delta) <= arc / 2; + })(); + const shieldBlocked = G.tower.shieldHp > 0 && directionalCanBlock; + + if (shieldBlocked) { + const absorbRate = 1.0 + (G.tower.shieldAbsorption ? G.tower.shieldAbsorption - 1.0 : 0); + const shieldBlock = Math.min(G.tower.shieldHp, remaining); + const absorbed = Math.ceil(shieldBlock * absorbRate); + G.tower.shieldHp -= shieldBlock; + remaining -= absorbed; + remaining = Math.max(0, remaining); + + // Dome shield reflect upgrade: blast nearby enemies when blocking. + if ((G.tower.shieldReflect || 0) > 0) { + const reflectDamage = Math.max(1, Math.round(dmg * G.tower.shieldReflect)); + const radiusSq = 140 * 140; + for (const target of G.enemies) { + if (!target.alive || target.id === enemy.id || target.spawnImmunity > 0) continue; + if (distSq(target.x, target.y, cx, cy) >= radiusSq) continue; + dealDamage(target, reflectDamage, ['arcane'], false); + if (target.hp <= 0) killEnemy(target, true); + } + } + } + + if (remaining > 0) { + sfx_tower_damage(); + G.tower.hp = Math.max(0, G.tower.hp - remaining); + spawnParticleBurst(cx, cy, '#ff3355', 18); + spawnFloater(cx, cy - 40, `-${remaining} HP`, '#ff3355', 1.2); + } + + G.credits = Math.max(0, G.credits - creditLoss); + addLog(`${enemy.name} breached! Tower -${remaining} HP. Lost ${creditLoss}¢`, 'lose'); + updateHUD(); + + if (G.tower.hp <= 0) endGame(); +} + +// Pick targeting for a weapon — only considers enemies within tower vision range +function pickTarget(weapon) { + const cx = ARENA_CX, cy = ARENA_CY; + const towerRange = G.tower.range ?? 9999; + const towerRangeSq = towerRange * towerRange; + let targeting = weapon.targeting || 'nearest'; + switch (targeting) { + case 'nearest': + case 'strongest': + case 'weakest': + case 'fastest': + case 'furthest': + case 'group': + break; + default: + targeting = 'nearest'; + } + let best = null; + let bestValue = targeting === 'strongest' || targeting === 'fastest' || targeting === 'furthest' || targeting === 'group' + ? -Infinity + : Infinity; + let bestRemaining = Infinity; + + const remaining = (e) => (typeof e.pathRemaining === 'number' + ? e.pathRemaining + : getEnemyPathRemaining(e, cx, cy)); + const currentSpeed = (e) => getEnemyCurrentSpeed(e); + + // ponytail: O(n²) group scan, swap to a spatial grid only if enemy counts prove it matters. + const groupScore = (e) => { + const r = weapon.aoeRadius || (weapon.chainRange ? weapon.chainRange * 0.5 : 80); + const rSq = r * r; + let count = 0; + for (const other of G.enemies) { + if (!other.alive || other.spawnImmunity > 0) continue; + if (distSq(other.x, other.y, cx, cy) > towerRangeSq) continue; + if (distSq(other.x, other.y, e.x, e.y) <= rSq) count++; + } + return count; + }; + + for (const e of G.enemies) { + if (!e.alive || e.spawnImmunity > 0) continue; + if (distSq(e.x, e.y, cx, cy) > towerRangeSq) continue; + + let value; + if (targeting === 'strongest') value = e.hp; + else if (targeting === 'weakest') value = e.hp; + else if (targeting === 'fastest') value = currentSpeed(e); + else if (targeting === 'furthest') value = remaining(e); + else if (targeting === 'group') value = groupScore(e); + else value = remaining(e); + + if (targeting === 'weakest' || targeting === 'nearest') { + if (value < bestValue) { + best = e; + bestValue = value; + } + } else if (targeting === 'group') { + const rem = remaining(e); + if (value > bestValue || (value === bestValue && rem < bestRemaining)) { + best = e; + bestValue = value; + bestRemaining = rem; + } + } else if (value > bestValue) { + best = e; + bestValue = value; + } + } + + return best; +} diff --git a/js/input.js b/js/input.js new file mode 100644 index 0000000..62a7173 --- /dev/null +++ b/js/input.js @@ -0,0 +1,195 @@ +// ═══ input.js ═══ +// ============================================================ +// INPUT.JS — Keyboard hotkeys, mouse interaction +// ============================================================ + +const HOTKEYS = { + 'Space': () => G.shopOpen ? closeShop() : openShop(), + 'Escape': () => { + if (document.body.classList.contains('inventory-open')) closeWeaponPicker(); + else if (G.shopOpen) closeShop(); + else togglePause(); + }, + 'KeyP': () => { if (!G.shopOpen && !document.body.classList.contains('inventory-open')) togglePause(); }, + 'KeyI': () => { if (!G.shopOpen) document.body.classList.contains('inventory-open') ? closeWeaponPicker() : openWeaponPicker(-1); }, + 'Tab': () => { if (G.shopOpen) cycleShopTab(); }, +}; + +// 1–0 keys for enemy deploy +const ENEMY_HOTKEYS = ['Digit1','Digit2','Digit3','Digit4','Digit5','Digit6','Digit7','Digit8','Digit9','Digit0']; + +function initInput() { + window.addEventListener('wheel', e => { if (e.ctrlKey) e.preventDefault(); }, { passive: false }); + + document.addEventListener('keydown', e => { + // Block all game input when dev console is open + if (DEV_MODE && document.getElementById('dev-console').classList.contains('open')) return; + + // Enemy deploy hotkeys + const idx = ENEMY_HOTKEYS.indexOf(e.code); + if (idx >= 0 && idx < ENEMY_DEFS.length && !G.shopOpen && !G.paused) { + e.preventDefault(); + deployEnemy(ENEMY_DEFS[idx].id, G.sendQuantity); + return; + } + + const fn = HOTKEYS[e.code]; + if (fn) { e.preventDefault(); fn(); } + }); + initCanvasMouse(); +} + +// ── CANVAS MOUSE INTERACTION ────────────────────────────────── +let _hitRegions = []; +let _hoverPt = null; + +let _dragWeapon = null; +let _dragSource = null; +let _suppressNextClick = false; + +function clearHitRegions() { _hitRegions.length = 0; } + +function addHitRegion(x, y, w, h, action) { + _hitRegions.push({ x, y, w, h, action }); +} + +function isHovered(x, y, w, h) { + return _hoverPt !== null && + _hoverPt.x >= x && _hoverPt.x < x + w && + _hoverPt.y >= y && _hoverPt.y < y + h; +} + +function canvasPt(e) { + const r = canvas.getBoundingClientRect(); + return { + x: (e.clientX - r.left) * (GAME_W / r.width), + y: (e.clientY - r.top) * (GAME_H / r.height), + }; +} + +function initCanvasMouse() { + canvas.addEventListener('mousedown', e => { + if (e.button !== 0) return; + if (DEV_MODE && document.getElementById('dev-console').classList.contains('open')) return; + const pt = canvasPt(e); + for (const r of _dragRegions) { + if (pt.x >= r.x && pt.x < r.x + r.w && pt.y >= r.y && pt.y < r.y + r.h) { + _dragWeapon = r.weapon; + _dragSource = r.source || null; + return; + } + } + }); + + canvas.addEventListener('mouseup', e => { + if (!_dragWeapon) return; + const pt = canvasPt(e); + let dropped = false; + + if (_dragSource?.type === 'slot') { + for (const zone of _bagDropZones) { + if (pt.x >= zone.x && pt.x < zone.x + zone.w && pt.y >= zone.y && pt.y < zone.y + zone.h) { + removeWeaponFromSlot(_dragSource.slotIndex); + dropped = true; + break; + } + } + } else { + for (const zone of _bagDropZones) { + if (pt.x >= zone.x && pt.x < zone.x + zone.w && pt.y >= zone.y && pt.y < zone.y + zone.h) { + dropped = true; + break; + } + } + } + + if (!dropped) { + for (const zone of _mountDropZones) { + const dx = pt.x - zone.x, dy = pt.y - zone.y; + if (dx * dx + dy * dy <= zone.r * zone.r) { + equipWeaponInstanceToSlot(zone.slotIndex, _dragWeapon.instanceId); + dropped = true; + break; + } + } + } + + if (dropped) _suppressNextClick = true; + _dragWeapon = _dragSource = null; + }); + + canvas.addEventListener('click', e => { + if (_suppressNextClick) { _suppressNextClick = false; return; } + if (DEV_MODE && document.getElementById('dev-console').classList.contains('open')) return; + const pt = canvasPt(e); + for (const r of _hitRegions) { + if (pt.x >= r.x && pt.x < r.x + r.w && pt.y >= r.y && pt.y < r.y + r.h) { + r.action(); + return; + } + } + }); + + canvas.addEventListener('mousemove', e => { + _hoverPt = canvasPt(e); + let pointer = false; + for (const r of _hitRegions) { + if (_hoverPt.x >= r.x && _hoverPt.x < r.x + r.w && + _hoverPt.y >= r.y && _hoverPt.y < r.y + r.h) { + pointer = true; + break; + } + } + if (_dragWeapon) pointer = true; + canvas.style.cursor = pointer ? 'pointer' : 'default'; + }); + + canvas.addEventListener('mouseleave', () => { + _hoverPt = null; + canvas.style.cursor = 'default'; + }); + + canvas.addEventListener('wheel', e => { + e.preventDefault(); + if (document.body.classList.contains('inventory-open')) { + _pickerScrollY = clamp(_pickerScrollY + e.deltaY * 0.5, 0, _pickerScrollMax); + } else if (G.shopOpen) { + _shopScrollY = clamp(_shopScrollY + e.deltaY * 0.5, 0, _shopScrollMax); + } else if (_hoverPt && _hoverPt.x >= 1330) { + const pt = _hoverPt; + if (pt.y >= 112 && pt.y < 722) { + // enemy cards area — scroll enemy list + _sidePanelScrollY = clamp(_sidePanelScrollY + e.deltaY * 0.5, 0, _sidePanelScrollMax); + } else if (pt.y >= 750 && pt.y < 900) { + // combat log area + _logScrollY = clamp(_logScrollY + e.deltaY * 0.5, 0, _logScrollMax); + } else if (pt.y >= 64 && pt.y < 112) { + // deploy header — cycle send quantity + const steps = [1, 5, 10, 25, 50]; + const idx = steps.indexOf(G.sendQuantity); + G.sendQuantity = e.deltaY > 0 + ? steps[Math.min(idx + 1, steps.length - 1)] + : steps[Math.max(idx - 1, 0)]; + } + } + }, { passive: false }); + + canvas.addEventListener('contextmenu', e => { + e.preventDefault(); + if (!G.shopOpen) return; + const pt = canvasPt(e); + for (const r of _shopRightClick) { + if (pt.x >= r.x && pt.x < r.x + r.w && pt.y >= r.y && pt.y < r.y + r.h) { + r.action(); return; + } + } + }); +} + +function cycleShopTab() { + const weaponTabs = getEquippedWeapons().map(w => w.instanceId); + const tabs = ['tower', 'weapons', ...weaponTabs]; + const idx = tabs.indexOf(G.shopTab); + G.shopTab = tabs[(Math.max(0, idx) + 1) % tabs.length]; + _shopScrollY = 0; +} diff --git a/js/inventory.js b/js/inventory.js new file mode 100644 index 0000000..4ab736c --- /dev/null +++ b/js/inventory.js @@ -0,0 +1,113 @@ +// ═══ inventory.js ═══ +// ============================================================ +// INVENTORY — weapon picker state and equip helpers +// ============================================================ + +const MAX_WEAPON_SLOTS = 8; + +let _pickerSlot = -1; +let _pickerScrollY = 0; +let _pickerScrollMax = 0; + +function weaponUpgradeCount(w) { + return (G.weaponUpgradesBought[w.instanceId] || []).length; +} + +function weaponElementLabel(w) { + return getWeaponElements(w) + .map(el => ELEMENTS[el]?.icon || '') + .join(''); +} + +function weaponStatRows(w) { + return [ + ['DMG', w.damage], + ['RATE', w.fireRate + 'f'], + w.aoeRadius ? ['AOE', w.aoeRadius] : null, + w.targets && w.targets > 1 ? ['TARGETS', w.targets] : null, + w.pierce ? ['PIERCE', w.pierce] : null, + w.chains ? ['CHAINS', w.chains] : null, + ['TARGET', (w.targeting || 'nearest').toUpperCase()], + ].filter(Boolean); +} + +function weaponUpgradeLabels(w) { + const bought = G.weaponUpgradesBought[w.instanceId] || []; + const tree = WEAPON_UPGRADE_TREES[w.defId] || []; + return bought.map(id => tree.find(u => u.id === id)?.label || id); +} + +function openWeaponPicker(slotIndex) { + if (slotIndex >= G.tower.weaponSlots) return; + _pickerSlot = slotIndex; + _pickerScrollY = 0; + setPaused(true, false); + document.body.classList.add('inventory-open'); + G.weaponInventory = G.weaponInventory || []; +} + +function closeWeaponPicker() { + document.body.classList.remove('inventory-open'); + _pickerSlot = -1; + _pickerScrollY = 0; + if (!G.shopOpen) setPaused(false); +} + +function equipWeaponInstanceToSlot(slotIndex, instanceId) { + if (slotIndex >= G.tower.weaponSlots) return; + G.weaponInventory = G.weaponInventory || []; + + const sourceSlot = G.weapons.slice(0, G.tower.weaponSlots).findIndex(w => w && w.instanceId === instanceId); + if (sourceSlot === slotIndex) return; + + const displaced = G.weapons[slotIndex]; + if (sourceSlot >= 0) { + G.weapons[slotIndex] = G.weapons[sourceSlot]; + G.weapons[sourceSlot] = displaced || null; + } else { + const invIdx = G.weaponInventory.findIndex(w => w.instanceId === instanceId); + if (invIdx === -1) return; + const invWeapon = G.weaponInventory.splice(invIdx, 1)[0]; + G.weapons[slotIndex] = invWeapon; + if (displaced) G.weaponInventory.push(displaced); + } + + addLog(getWeaponDef(G.weapons[slotIndex]).name + ' equipped.', 'info'); + updateHUD(); +} + +function removeWeaponFromSlot(slotIndex) { + if (slotIndex >= G.tower.weaponSlots) return; + const activeCount = G.weapons.slice(0, G.tower.weaponSlots).filter(w => w != null).length; + if (activeCount <= 1) { addLog('Cannot remove last weapon!', 'lose'); return; } + const w = G.weapons[slotIndex]; + if (!w) return; + G.weaponInventory = G.weaponInventory || []; + G.weaponInventory.push(w); + G.weapons[slotIndex] = null; + addLog(getWeaponDef(w).name + ' moved to inventory.', 'info'); + updateHUD(); +} + +function buyAndEquipWeapon(slotIndex, defId) { + const def = WEAPON_DEFS.find(w => w.id === defId); + if (!def || slotIndex >= G.tower.weaponSlots || spendableCredits() < def.cost) return; + if (!canBuyWeaponType(defId)) { + addLog(`${def.name} limit reached (${MAX_WEAPONS_PER_TYPE}/${MAX_WEAPONS_PER_TYPE}).`, 'lose'); + return; + } + G.credits -= def.cost; + const instance = makeWeaponInstance(defId); + G.weaponUpgradesBought[instance.instanceId] = []; + + const displaced = G.weapons[slotIndex]; + G.weapons[slotIndex] = instance; + if (displaced) { + G.weaponInventory = G.weaponInventory || []; + G.weaponInventory.push(displaced); + addLog(`Purchased ${def.name}! Swapped previous weapon to inventory.`, 'win'); + } else { + addLog(`Purchased ${def.name}!`, 'win'); + } + updateHUD(); +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..2524125 --- /dev/null +++ b/js/main.js @@ -0,0 +1,236 @@ +// ═══ main.js ═══ +// ============================================================ +// MAIN.JS — Game loop, init, HUD updates +// ============================================================ + +const canvas = document.getElementById('canvas'); +const ctx = canvas.getContext('2d'); + +// Fixed logical resolution — all gameplay runs at this size. +// The canvas is CSS-scaled to fill the viewport on resize. +const GAME_W = 1600; +const GAME_H = 900; +canvas.width = GAME_W; +canvas.height = GAME_H; + +function resize() { + const vw = window.innerWidth; + const vh = window.innerHeight; + const scale = Math.min(vw / GAME_W, vh / GAME_H); + const w = Math.floor(GAME_W * scale); + const h = Math.floor(GAME_H * scale); + canvas.style.width = w + 'px'; + canvas.style.height = h + 'px'; + canvas.style.left = Math.floor((vw - w) / 2) + 'px'; + canvas.style.top = Math.floor((vh - h) / 2) + 'px'; +} +window.addEventListener('resize', resize); +resize(); + +// ── HIGH SCORE ──────────────────────────────────────────────── +let _best = (() => { + try { return JSON.parse(localStorage.getItem('siegeprotocol_best')) || null; } catch(e) { return null; } +})(); + +const PERF_METRICS = { + lastStamp: null, + fps: 60, + frameMs: 16.7, + updateMs: 0, + renderMs: 0, + sampleMs: 0, + sampleFrames: 0, + uiTick: 0, + uiEveryFrames: 6, +}; + +function perfNow() { + return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); +} + +function setPaused(paused, showOverlay = true) { + G.paused = paused; + document.body.classList.toggle('paused', + !!paused && showOverlay && !G.shopOpen && + !document.body.classList.contains('inventory-open') && !G.gameOver); +} + +function togglePause() { + setPaused(!document.body.classList.contains('paused'), true); +} + +function updatePerfOverlay(frameStamp, updateMs, renderMs) { + const overlay = document.getElementById('perf-overlay'); + if (!overlay) return; + + if (!DEV_MODE || !G._showPerfOverlay) { + overlay.style.display = 'none'; + return; + } + overlay.style.display = 'block'; + + const frameDelta = PERF_METRICS.lastStamp == null ? 16.7 : (frameStamp - PERF_METRICS.lastStamp); + PERF_METRICS.lastStamp = frameStamp; + + PERF_METRICS.frameMs = PERF_METRICS.frameMs * 0.85 + frameDelta * 0.15; + PERF_METRICS.updateMs = PERF_METRICS.updateMs * 0.8 + updateMs * 0.2; + PERF_METRICS.renderMs = PERF_METRICS.renderMs * 0.8 + renderMs * 0.2; + + PERF_METRICS.sampleMs += frameDelta; + PERF_METRICS.sampleFrames += 1; + if (PERF_METRICS.sampleMs >= 250) { + PERF_METRICS.fps = (PERF_METRICS.sampleFrames * 1000) / PERF_METRICS.sampleMs; + PERF_METRICS.sampleMs = 0; + PERF_METRICS.sampleFrames = 0; + } + + PERF_METRICS.uiTick += 1; + if (PERF_METRICS.uiTick < PERF_METRICS.uiEveryFrames) return; + PERF_METRICS.uiTick = 0; + + let enemiesAlive = 0; + for (const e of G.enemies) if (e.alive) enemiesAlive++; + overlay.innerHTML = ` +
PERF
+
FPS${PERF_METRICS.fps.toFixed(1)}
+
Frame${PERF_METRICS.frameMs.toFixed(2)} ms
+
Logic${PERF_METRICS.updateMs.toFixed(2)} ms
+
Render${PERF_METRICS.renderMs.toFixed(2)} ms
+
Enemies${enemiesAlive}
+
Projectiles${G.projectiles.length}
+
Portals${G.portals.length}
+ `; +} + +// ── GAME LOOP ───────────────────────────────────────────────── +function gameLoop() { + requestAnimationFrame(gameLoop); + + const frameStamp = perfNow(); + const updateStart = frameStamp; + + if (!G.paused && !G.gameOver) { + const ticks = (DEV_MODE && G._speedMult && G._speedMult !== 1) + ? Math.max(1, Math.round(G._speedMult)) + : 1; + for (let t = 0; t < ticks; t++) { + G.frame++; + // Decay freshness every 5 seconds (300 frames) + if (G.frame % 300 === 0) { + for (const id in G.enemyFreshness) { + if (G.enemyFreshness[id] > 0) G.enemyFreshness[id]--; + } + } + updatePortals(); + updateEnemies(); + updateWeapons(); + updateShield(); + updateParticles(); + updateFloaters(); + if (G.paused || G.gameOver) break; + } + } + + checkBankruptcy(); + const updateEnd = perfNow(); + render(); + const renderEnd = perfNow(); + updatePerfOverlay(frameStamp, updateEnd - updateStart, renderEnd - updateEnd); +} + +// ── RESERVE ─────────────────────────────────────────────────── +function adjustReserve(delta) { + const cheapest = cheapestEnemyCost(); + G.creditReserve = clamp(G.creditReserve + delta, cheapest, G.credits); + updateHUD(); +} + +function spendableCredits() { + return Math.max(0, G.credits - G.creditReserve); +} + +// ── HUD ─────────────────────────────────────────────────────── +function updateHUD() { + // Clamp state — canvas renderer reads G.* directly each frame + G.credits = Math.floor(Math.max(0, G.credits)); + const cheapest = cheapestEnemyCost(); + G.creditReserve = clamp(G.creditReserve, cheapest, G.credits); + checkBankruptcy(); +} + +// ── COMBAT LOG ──────────────────────────────────────────────── +function addLog(msg, type = '') { + if (!G.logLines) G.logLines = []; + G.logLines.unshift({ text: msg, type }); + if (G.logLines.length > 40) G.logLines.length = 40; +} + +// ── BANKRUPTCY CHECK ────────────────────────────────────────── +function checkBankruptcy() { + if (G.gameOver) return; + + // Bankrupt = 0 credits AND no enemies alive (no pending rewards coming in) + // AND can't afford even the cheapest enemy + const cheapest = cheapestEnemyCost(); + const activeEnemies = countAliveEnemies(); + const pendingPortals = G.portals.length; + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + + if (G.credits < cheapest && activeEnemies === 0 && pendingPortals === 0) { + // Give a brief grace window — maybe enemies just died and reward came in. + if (!G._bankruptAt) G._bankruptAt = now; + if (now - G._bankruptAt > 3000) { + endBankrupt(); + } + } else { + G._bankruptAt = null; + } +} + +// ── GAME OVER ───────────────────────────────────────────────── +function endGame() { + if (G.shopOpen) closeShop(); + setPaused(false); + G.gameOver = true; + G.isBankrupt = false; + G._isNewBest = !_best || G.score > _best.score; + if (G._isNewBest) { + _best = { score: G.score, kills: G.totalKills }; + try { localStorage.setItem('siegeprotocol_best', JSON.stringify(_best)); } catch(e) {} + } +} + +function endBankrupt() { + if (G.shopOpen) closeShop(); + setPaused(false); + G.gameOver = true; + G.isBankrupt = true; + G._isNewBest = !_best || G.score > _best.score; + if (G._isNewBest) { + _best = { score: G.score, kills: G.totalKills }; + try { localStorage.setItem('siegeprotocol_best', JSON.stringify(_best)); } catch(e) {} + } + addLog('BANKRUPT. No credits, no enemies. Game over.', 'lose'); +} + +function restartGame() { + G = makeGameState(); + setPaused(false); + _sidePanelScrollY = 0; + _logScrollY = 0; + updateHUD(); + addLog('System online. Deploy enemies to earn credits.', 'info'); + addLog('[1–0] deploy [scroll] qty [Space] shop [Esc/P] pause [I] inventory', 'info'); +} + +// ── INIT ────────────────────────────────────────────────────── +function init() { + resize(); // set canvas size after full page load — guaranteed correct dimensions + updateHUD(); + initInput(); + addLog('SIEGE PROTOCOL initialized.', 'info'); + addLog('[1–0] deploy [scroll] qty [Space] shop [Esc/P] pause [I] inventory', 'info'); + gameLoop(); +} + +window.addEventListener('load', init); diff --git a/js/particles.js b/js/particles.js new file mode 100644 index 0000000..4bfe7e1 --- /dev/null +++ b/js/particles.js @@ -0,0 +1,59 @@ +// ═══ particles.js ═══ +// ============================================================ +// PARTICLES.JS — Particles, floaters, damage numbers +// ============================================================ + +const MAX_PARTICLES = 400; +const MAX_FLOATERS = 40; + +function spawnParticle(x, y, color, vx, vy, life, radius, decay) { + if (G.particles.length >= MAX_PARTICLES) return; + G.particles.push({ x, y, color, vx, vy, life, radius, decay }); +} + +function spawnParticleBurst(x, y, color, count) { + const free = MAX_PARTICLES - G.particles.length; + const actual = Math.min(count, free); + for (let i = 0; i < actual; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = 1 + Math.random() * 3.5; + spawnParticle( + x, y, color, + Math.cos(angle) * speed, + Math.sin(angle) * speed, + 0.9 + Math.random() * 0.1, + 2 + Math.random() * 4, + 0.03 + Math.random() * 0.04 + ); + } +} + +function spawnFloater(x, y, text, color, scale = 1.0) { + if (G.floaters.length >= MAX_FLOATERS) return; + G.floaters.push({ + x, y, text, color, scale, + life: 1, + vy: -1.0 - Math.random() * 0.5, + vx: (Math.random() - 0.5) * 0.8, + }); +} + +function updateParticles() { + compactLiveArray(G.particles, p => { + p.x += p.vx; + p.y += p.vy; + p.vx *= 0.91; + p.vy *= 0.91; + p.life -= p.decay; + return p.life > 0; + }); +} + +function updateFloaters() { + compactLiveArray(G.floaters, f => { + f.x += f.vx; + f.y += f.vy; + f.life -= 0.018; + return f.life > 0; + }); +} diff --git a/js/portals.js b/js/portals.js new file mode 100644 index 0000000..6a3b0a0 --- /dev/null +++ b/js/portals.js @@ -0,0 +1,90 @@ +// ═══ portals.js ═══ +// ============================================================ +// PORTALS — portal spawning and lifecycle +// ============================================================ + +const PORTAL_COUNT = 3; +const PORTAL_OPEN_TIME = 180; +const PORTAL_COOLDOWN_MIN = 300; +const PORTAL_COOLDOWN_MAX = 600; +const PORTAL_SPAWN_INTERVAL = 18; +const PORTAL_RADIUS = ARENA_RADIUS - 32; + +function splitInteger(total, parts) { + const base = Math.floor(total / parts); + let remainder = total - base * parts; + const out = []; + for (let i = 0; i < parts; i++) { + out.push(base + (remainder > 0 ? 1 : 0)); + if (remainder > 0) remainder--; + } + return out; +} + +// ── PORTAL MANAGEMENT ───────────────────────────────────────── +function updatePortals() { + // Tick existing portals + for (const p of G.portals) { + p.life--; + p.angle += 0.02; + + // Spawn enemies from open portals + if (p.spawnQueue.length > 0 && p.life > 0) { + // For swarm portals: burst spawn all at once in a ring + if (p.isBurst && p.spawnTimer <= 0) { + const total = p.spawnQueue.length; + p.spawnQueue.forEach((entry, i) => { + const angle = (i / total) * Math.PI * 2; + if (entry?.def) { + spawnEnemy(entry.def, p.x, p.y, entry.reward, angle, entry.cost, entry.breachRiskMult); + } + }); + p.spawnQueue = []; + } else { + p.spawnTimer--; + if (p.spawnTimer <= 0 && !p.isBurst) { + p.spawnTimer = PORTAL_SPAWN_INTERVAL; + const entry = p.spawnQueue.shift(); + if (entry?.def) { + spawnEnemy(entry.def, p.x, p.y, entry.reward, null, entry.cost, entry.breachRiskMult); + } + } + } + } + } + + compactLiveArray(G.portals, p => p.life > 0); +} + +function openPortal(enemyDef, count = 1, rewardPerUnit = null, isBurst = false, costPerUnit = null, breachRiskMult = 1, rewardSequence = null, fixedAngle = null) { + const cx = ARENA_CX, cy = ARENA_CY; + + let x, y, angle, attempts = 0; + do { + angle = fixedAngle !== null ? fixedAngle : Math.random() * Math.PI * 2; + x = cx + Math.cos(angle) * PORTAL_RADIUS; + y = cy + Math.sin(angle) * PORTAL_RADIUS; + attempts++; + } while (attempts < 20 && fixedAngle === null && G.portals.some(p => distSq(p.x, p.y, x, y) < 80 * 80)); + + const queue = []; + for (let i = 0; i < count; i++) { + const reward = Array.isArray(rewardSequence) ? rewardSequence[i] : rewardPerUnit; + queue.push({ def: enemyDef, reward, cost: costPerUnit, breachRiskMult }); + } + + G.portals.push({ + id: uid(), + x, y, + life: PORTAL_OPEN_TIME + (isBurst ? 10 : count * PORTAL_SPAWN_INTERVAL), + maxLife: PORTAL_OPEN_TIME + (isBurst ? 10 : count * PORTAL_SPAWN_INTERVAL), + angle: Math.random() * Math.PI * 2, + spawnQueue: queue, + spawnTimer: 30, + isBurst, + defId: enemyDef.id, + color: enemyDef.color, + }); + sfx_portal_open(); +} + diff --git a/js/renderer-combat.js b/js/renderer-combat.js new file mode 100644 index 0000000..23587f7 --- /dev/null +++ b/js/renderer-combat.js @@ -0,0 +1,310 @@ +// ═══ renderer-combat.js ═══ +// ============================================================ +// RENDERER COMBAT — enemies, weapons, tower, particles +// ============================================================ + +// ── AOE ZONES ───────────────────────────────────────────────── +function drawAoeZones() { + for (const z of G.aoeZones) { + ctx.save(); + ctx.globalAlpha = z.life * 0.25; + ctx.fillStyle = z.color; + ctx.beginPath(); + ctx.arc(z.x, z.y, z.radius, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = z.life * 0.5; + ctx.strokeStyle = z.color; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.restore(); + } +} + +// ── ENEMY TRAILS ────────────────────────────────────────────── +function drawEnemyTrails() { + // Batch all trails in one pass — no save/restore per trail + ctx.lineCap = 'round'; + for (const e of G.enemies) { + if (!e.alive || e.trail.length < 2) continue; + ctx.strokeStyle = e.color + '22'; + ctx.lineWidth = e.radius * 0.7; + ctx.beginPath(); + ctx.moveTo(e.trail[0].x, e.trail[0].y); + for (let i = 1; i < e.trail.length; i++) ctx.lineTo(e.trail[i].x, e.trail[i].y); + ctx.stroke(); + } + ctx.lineCap = 'butt'; +} + +// ── ENEMY SHAPES ────────────────────────────────────────────── +function drawEnemyShape(e, bodyColor) { + const x = e.x, y = e.y, r = e.radius; + const t = G.frame * 0.04; + + ctx.fillStyle = bodyColor; + ctx.strokeStyle = bodyColor; + + switch (e.defId) { + case 'grunt': + ctx.beginPath(); ctx.rect(x - r, y - r, r * 2, r * 2); ctx.fill(); + ctx.strokeStyle = '#ffffff33'; ctx.lineWidth = 1; ctx.stroke(); + break; + + case 'runner': { + const ang = Math.atan2(canvas.height/2 - y, canvas.width/2 - x); + ctx.save(); ctx.translate(x, y); ctx.rotate(ang); + ctx.beginPath(); ctx.moveTo(r*1.6,0); ctx.lineTo(0,r*0.65); ctx.lineTo(-r*0.8,0); ctx.lineTo(0,-r*0.65); ctx.closePath(); ctx.fill(); + ctx.restore(); break; + } + + case 'brute': + ctx.beginPath(); + for (let i = 0; i < 6; i++) { const a=(i/6)*Math.PI*2-Math.PI/6; i===0?ctx.moveTo(x+Math.cos(a)*r,y+Math.sin(a)*r):ctx.lineTo(x+Math.cos(a)*r,y+Math.sin(a)*r); } + ctx.closePath(); ctx.fill(); + ctx.strokeStyle='#ffffff33'; ctx.lineWidth=2; ctx.stroke(); + break; + + case 'swarm': { + const ang = Math.atan2(canvas.height/2 - y, canvas.width/2 - x); + ctx.save(); ctx.translate(x,y); ctx.rotate(ang); + ctx.beginPath(); ctx.moveTo(r*1.2,0); ctx.lineTo(-r*0.8,r*0.8); ctx.lineTo(-r*0.8,-r*0.8); ctx.closePath(); ctx.fill(); + ctx.restore(); break; + } + + case 'phantom': + ctx.globalAlpha *= 0.85 + Math.sin(t*3.1 + e.x*0.1)*0.15; + ctx.beginPath(); + for (let i=0;i<8;i++){const a=(i/8)*Math.PI*2,rad=i%2===0?r:r*0.45;i===0?ctx.moveTo(x+Math.cos(a)*rad,y+Math.sin(a)*rad):ctx.lineTo(x+Math.cos(a)*rad,y+Math.sin(a)*rad);} + ctx.closePath(); ctx.fill(); break; + + case 'iceling': + ctx.beginPath(); + for (let i=0;i<8;i++){const a=(i/8)*Math.PI*2+Math.PI/8;i===0?ctx.moveTo(x+Math.cos(a)*r,y+Math.sin(a)*r):ctx.lineTo(x+Math.cos(a)*r,y+Math.sin(a)*r);} + ctx.closePath(); ctx.fill(); + ctx.strokeStyle='#ffffff55'; ctx.lineWidth=1; + ctx.beginPath(); ctx.moveTo(x-r*.5,y-r*.5); ctx.lineTo(x+r*.5,y+r*.5); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(x+r*.5,y-r*.5); ctx.lineTo(x-r*.5,y+r*.5); ctx.stroke(); + break; + + case 'sparkling': + ctx.beginPath(); + for (let i=0;i<5;i++){const a=(i/5)*Math.PI*2-Math.PI/2,rad=(i%2===0?r:r*0.5)+r*0.12*Math.sin(t*5+i);i===0?ctx.moveTo(x+Math.cos(a)*rad,y+Math.sin(a)*rad):ctx.lineTo(x+Math.cos(a)*rad,y+Math.sin(a)*rad);} + ctx.closePath(); ctx.fill(); break; + + case 'venom': + ctx.beginPath(); + for (let i=0;i<=7;i++){const a=(i/7)*Math.PI*2,rad=r*(1+0.22*Math.sin(t*2+i*1.3+e.x*0.05));i===0?ctx.moveTo(x+Math.cos(a)*rad,y+Math.sin(a)*rad):ctx.lineTo(x+Math.cos(a)*rad,y+Math.sin(a)*rad);} + ctx.closePath(); ctx.fill(); break; + + case 'titan': + ctx.beginPath(); + for (let i=0;i<5;i++){const a=(i/5)*Math.PI*2-Math.PI/2;i===0?ctx.moveTo(x+Math.cos(a)*r,y+Math.sin(a)*r):ctx.lineTo(x+Math.cos(a)*r,y+Math.sin(a)*r);} + ctx.closePath(); ctx.fill(); + ctx.beginPath(); + for (let i=0;i<5;i++){const a=(i/5)*Math.PI*2-Math.PI/2;i===0?ctx.moveTo(x+Math.cos(a)*r*.6,y+Math.sin(a)*r*.6):ctx.lineTo(x+Math.cos(a)*r*.6,y+Math.sin(a)*r*.6);} + ctx.closePath(); ctx.strokeStyle='#ff000099'; ctx.lineWidth=2.5; ctx.stroke(); break; + + case 'wraith': { + const pulseR = r + Math.sin(t*2 + e.x*0.05)*2; + ctx.strokeStyle = bodyColor; ctx.lineWidth = 3; + ctx.beginPath(); ctx.arc(x, y, pulseR, 0, Math.PI*2); ctx.stroke(); + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(x, y, pulseR*0.5, 0, Math.PI*2); ctx.stroke(); + break; + } + + default: + ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill(); + } +} + +// ── ENEMIES ─────────────────────────────────────────────────── +function drawEnemies() { + const enemies = G.enemies; + + // Pass 1: elemental glows (no shadow, just gradient blobs) + for (const e of enemies) { + if (!e.alive || !e.element) continue; + const el = ELEMENTS[e.element]; + if (!el) continue; + ctx.save(); + const grd = getElemGrad(el, e.x, e.y, e.radius * 2.2); + ctx.fillStyle = grd; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.radius * 2.2, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + // Pass 2: shadows ON — draw all glowing shapes together + ctx.shadowBlur = 8; + for (const e of enemies) { + if (!e.alive) continue; + ctx.save(); + ctx.shadowColor = e.glowColor || e.color; + let bodyColor = e.color; + if (e.frozen > 0) bodyColor = '#7ecfff'; + else if (e.hitFlash > 0) bodyColor = '#ffffff'; + drawEnemyShape(e, bodyColor); + ctx.restore(); + } + ctx.shadowBlur = 0; // turn off once for all + + // Pass 3: overlays (armor ring, freeze tint, DoT dots) — no shadow needed + for (const e of enemies) { + if (!e.alive) continue; + + if (e.armor > 0) { + ctx.beginPath(); + ctx.arc(e.x, e.y, e.radius + 1.5, 0, Math.PI * 2); + ctx.strokeStyle = '#aaaaaa88'; + ctx.lineWidth = Math.min(e.armor * 0.8, 3.5); + ctx.stroke(); + } + + if (e.frozen > 0) { + ctx.globalAlpha = 0.35; + ctx.fillStyle = '#7ecfff'; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.radius, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + } + + if (e.slow && e.frozen <= 0) { + ctx.strokeStyle = '#7ecfffaa'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.radius + 4, 0, Math.PI * 2); + ctx.stroke(); + } + + if (e.amplified > 0) { + ctx.strokeStyle = '#ff77e9aa'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.radius + 7, 0, Math.PI * 2); + ctx.stroke(); + } + + if (e.dots && e.dots.length > 0) { + for (let i = 0; i < e.dots.length; i++) { + const angle = G.frame * 0.08 + i * (Math.PI * 2 / e.dots.length); + ctx.fillStyle = e.dots[i].color; + ctx.beginPath(); + ctx.arc(e.x + Math.cos(angle)*(e.radius+5), e.y + Math.sin(angle)*(e.radius+5), 2, 0, Math.PI*2); + ctx.fill(); + } + } + + // HP bar — only when damaged + if (e.hp < e.maxHp) { + const bw = e.radius * 2.4, bh = 3; + const bx = e.x - bw/2, by = e.y - e.radius - 8; + ctx.fillStyle = '#111'; ctx.fillRect(bx-1, by-1, bw+2, bh+2); + ctx.fillStyle = '#333'; ctx.fillRect(bx, by, bw, bh); + const pct = Math.max(0, e.hp / e.maxHp); + ctx.fillStyle = pct > 0.5 ? '#00ff88' : pct > 0.25 ? '#ffd700' : '#ff3355'; + ctx.fillRect(bx, by, bw * pct, bh); + } + } +} + +// ── PROJECTILES ─────────────────────────────────────────────── +function drawProjectiles() { + // Batch all projectiles under one shadowBlur pass + ctx.shadowBlur = 10; + for (const p of G.projectiles) { + if (p.life <= 0) continue; + ctx.shadowColor = p.glow; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.radius ?? 4, 0, Math.PI * 2); + ctx.fill(); + } + ctx.shadowBlur = 0; +} + +// ── BEAMS ───────────────────────────────────────────────────── +function drawChainArcs() { + if (!G.chainArcs || G.chainArcs.length === 0) return; + for (const arc of G.chainArcs) { + if (arc.life <= 0 || arc.pts.length < 2) continue; + ctx.save(); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + // Outer glow + ctx.globalAlpha = arc.life * 0.7; + ctx.strokeStyle = arc.color; + ctx.lineWidth = 5; + ctx.shadowColor = arc.color; + ctx.shadowBlur = 22; + ctx.beginPath(); + ctx.moveTo(arc.pts[0].x, arc.pts[0].y); + for (let i = 1; i < arc.pts.length; i++) ctx.lineTo(arc.pts[i].x, arc.pts[i].y); + ctx.stroke(); + // Bright white core + ctx.globalAlpha = arc.life; + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1.5; + ctx.shadowColor = '#ffffff'; + ctx.shadowBlur = 8; + ctx.beginPath(); + ctx.moveTo(arc.pts[0].x, arc.pts[0].y); + for (let i = 1; i < arc.pts.length; i++) ctx.lineTo(arc.pts[i].x, arc.pts[i].y); + ctx.stroke(); + ctx.restore(); + } + ctx.shadowBlur = 0; +} + +function drawBeams() { + const cx = ARENA_CX, cy = ARENA_CY; + ctx.shadowBlur = 15; + for (const b of G.beams) { + ctx.save(); + ctx.globalAlpha = b.life; + ctx.shadowColor = b.glow; + // Glow stroke + ctx.strokeStyle = b.color; + ctx.lineWidth = 3 * b.life; + ctx.beginPath(); + ctx.moveTo(b.x1, b.y1); + ctx.lineTo(b.x1 + Math.cos(b.angle)*b.length, b.y1 + Math.sin(b.angle)*b.length); + ctx.stroke(); + // Bright core + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1; + ctx.globalAlpha = b.life * 0.5; + ctx.beginPath(); + ctx.moveTo(b.x1, b.y1); + ctx.lineTo(b.x1 + Math.cos(b.angle)*b.length, b.y1 + Math.sin(b.angle)*b.length); + ctx.stroke(); + ctx.restore(); + } + ctx.shadowBlur = 0; +} + +// ── PARTICLES ───────────────────────────────────────────────── +function drawParticles() { + // All particles in one pass — no save/restore + for (const p of G.particles) { + const alpha = (p.life * 255 | 0).toString(16).padStart(2, '0'); + ctx.fillStyle = p.color + alpha; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.radius * p.life, 0, Math.PI * 2); + ctx.fill(); + } +} + +// ── FLOATERS ────────────────────────────────────────────────── +function drawFloaters() { + ctx.textAlign = 'center'; + for (const f of G.floaters) { + const alpha = (f.life * 255 | 0).toString(16).padStart(2, '0'); + ctx.font = `bold ${Math.round(11 * f.scale)}px "Share Tech Mono", monospace`; + ctx.fillStyle = f.color + alpha; + ctx.fillText(f.text, f.x, f.y); + } + ctx.textAlign = 'left'; +} diff --git a/js/renderer-hud.js b/js/renderer-hud.js new file mode 100644 index 0000000..795bd1e --- /dev/null +++ b/js/renderer-hud.js @@ -0,0 +1,232 @@ +// ═══ renderer-hud.js ═══ +// ============================================================ +// RENDERER HUD — top HUD, warnings +// ============================================================ + +// ============================================================ +// RENDERER HUD — top HUD, warnings +// ============================================================ + +// ── HUD ─────────────────────────────────────────────────────── +const HUD_H = 64; + +// Right-section column centers (canvas x coords, 1600px wide) +const _HUD_KILLS_CX = 1542; +const _HUD_SCORE_CX = 1468; +const _HUD_DIV1_X = 1424; +const _HUD_RSV_CX = 1318; +const _HUD_DIV2_X = 1204; +const _HUD_CRED_CX = 1118; + +function drawHUD() { + const W = canvas.width; + const cheapest = cheapestEnemyCost(); + + // ── Background strip ───────────────────────────────────────── + ctx.fillStyle = 'rgba(6,14,22,0.95)'; + ctx.fillRect(0, 0, W, HUD_H); + ctx.strokeStyle = '#122030'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, HUD_H); ctx.lineTo(W, HUD_H); ctx.stroke(); + + ctx.save(); + + // ── LEFT: title ────────────────────────────────────────────── + ctx.font = '900 15px Orbitron, "Share Tech Mono", monospace'; + ctx.letterSpacing = '5px'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#00d4ff'; + ctx.shadowColor = 'rgba(0,212,255,0.35)'; + ctx.shadowBlur = 14; + ctx.fillText('SIEGE PROTOCOL', 20, HUD_H / 2); + ctx.shadowBlur = 0; + const titleW = ctx.measureText('SIEGE PROTOCOL').width; + ctx.letterSpacing = '0px'; + + // ── LEFT: shop button ──────────────────────────────────────── + const SBX = 20 + titleW + 16; + const SBY = 14; + const SBH = 36; + ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; + const shopLabel = '⚙ SHOP [Space]'; + const SBW = ctx.measureText(shopLabel).width + 32; + const shopHov = isHovered(SBX, SBY, SBW, SBH); + + ctx.fillStyle = shopHov ? '#00d4ff' : 'transparent'; + ctx.strokeStyle = '#00d4ff'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(SBX, SBY, SBW, SBH); ctx.fill(); ctx.stroke(); + + if (shopHov) { ctx.shadowColor = '#00d4ff'; ctx.shadowBlur = 16; } + ctx.fillStyle = shopHov ? '#000000' : '#00d4ff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(shopLabel, SBX + SBW / 2, SBY + SBH / 2); + ctx.shadowBlur = 0; + + addHitRegion(SBX, SBY, SBW, SBH, () => G.shopOpen ? closeShop() : openShop()); + + // ── CENTER: HP bar ─────────────────────────────────────────── + const CX = W / 2; + const hpPct = G.tower.hp / G.tower.maxHp; + const hpColor = hpPct > 0.5 ? '#00ff88' : hpPct > 0.25 ? '#ffd700' : '#ff3355'; + + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#3a6080'; + ctx.fillText('TOWER INTEGRITY', CX, 7); + + const BAR_X = CX - 180, BAR_Y = 24, BAR_W = 360, BAR_H = 14; + ctx.fillStyle = '#0a1520'; + ctx.strokeStyle = '#1a3048'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(BAR_X, BAR_Y, BAR_W, BAR_H); ctx.fill(); ctx.stroke(); + + if (hpPct > 0) { + const grad = ctx.createLinearGradient(BAR_X, 0, BAR_X + BAR_W, 0); + if (hpPct > 0.5) { grad.addColorStop(0, '#00ff88'); grad.addColorStop(1, '#00ffaa'); } + else if (hpPct > 0.25) { grad.addColorStop(0, '#ffd700'); grad.addColorStop(1, '#ffaa00'); } + else { grad.addColorStop(0, '#ff3355'); grad.addColorStop(1, '#ff0033'); } + ctx.fillStyle = grad; + ctx.shadowColor = hpColor; + ctx.shadowBlur = 8; + ctx.fillRect(BAR_X, BAR_Y, BAR_W * Math.max(0, hpPct), BAR_H); + ctx.shadowBlur = 0; + } + + ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = hpColor; + ctx.fillText(`${G.tower.hp} / ${G.tower.maxHp} HP`, CX, HUD_H - 6); + + // ── RIGHT helpers ──────────────────────────────────────────── + function hudStat(label, value, valColor, cx) { + ctx.textAlign = 'center'; + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#3a6080'; + ctx.fillText(label, cx, 7); + ctx.font = '700 18px Orbitron, "Share Tech Mono", monospace'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = valColor; + ctx.fillText(String(value), cx, HUD_H - 6); + } + + function hudDivider(x) { + ctx.strokeStyle = '#1a3048'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(x, 12); ctx.lineTo(x, HUD_H - 12); ctx.stroke(); + } + + // KILLS / SCORE + hudStat('KILLS', G.totalKills, '#00d4ff', _HUD_KILLS_CX); + hudStat('SCORE', G.score, '#b8d8e8', _HUD_SCORE_CX); + hudDivider(_HUD_DIV1_X); + + // ── RESERVE ────────────────────────────────────────────────── + const RSV = _HUD_RSV_CX; + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#3a6080'; + ctx.fillText('RESERVE', RSV, 7); + + const RBW = 22, RBH = 22, RBY = 22; + const rDownX = RSV - 49; + const rUpX = RSV + 27; + const rDownDis = G.creditReserve <= cheapest; + const rUpDis = G.creditReserve >= G.credits; + + function reserveBtn(bx, label, disabled) { + const hov = isHovered(bx, RBY, RBW, RBH); + ctx.strokeStyle = disabled ? '#aaaaff22' : (hov ? '#aaaaff' : '#aaaaff44'); + ctx.fillStyle = (hov && !disabled) ? '#aaaaff22' : 'transparent'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(bx, RBY, RBW, RBH); ctx.fill(); ctx.stroke(); + ctx.font = '13px Orbitron, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = disabled ? '#aaaaff40' : '#aaaaff'; + ctx.fillText(label, bx + RBW / 2, RBY + RBH / 2); + } + + reserveBtn(rDownX, '−', rDownDis); + reserveBtn(rUpX, '+', rUpDis); + + ctx.font = '700 14px Orbitron, "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#aaaaff'; + ctx.fillText(G.creditReserve + '¢', RSV, RBY + RBH / 2); + + // Spendable line (two-color) + const sp = Math.max(0, G.credits - G.creditReserve); + const pfx = 'spendable: '; + const spv = sp + '¢'; + ctx.font = '10px "Share Tech Mono", monospace'; + const pfxW = ctx.measureText(pfx).width; + const spvW = ctx.measureText(spv).width; + const spX = RSV - (pfxW + spvW) / 2; + ctx.textAlign = 'left'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = '#3a6080'; + ctx.fillText(pfx, spX, HUD_H - 6); + ctx.fillStyle = sp <= 0 ? '#ff3355' : '#ffd700'; + ctx.fillText(spv, spX + pfxW, HUD_H - 6); + + if (!rDownDis) addHitRegion(rDownX, RBY, RBW, RBH, () => adjustReserve(-10)); + if (!rUpDis) addHitRegion(rUpX, RBY, RBW, RBH, () => adjustReserve(10)); + + hudDivider(_HUD_DIV2_X); + + // ── CREDITS ────────────────────────────────────────────────── + const CRED = _HUD_CRED_CX; + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#3a6080'; + ctx.fillText('CREDITS', CRED, 7); + + const credColor = G.credits === 0 ? '#ff3355' : G.credits < 50 ? '#ff8844' : '#ffd700'; + ctx.font = '900 26px Orbitron, "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = credColor; + ctx.shadowColor = 'rgba(255,215,0,0.6)'; + ctx.shadowBlur = 16; + ctx.fillText(G.credits + '¢', CRED, HUD_H - 6); + ctx.shadowBlur = 0; + + ctx.restore(); +} + +// ── BROKE WARNING ───────────────────────────────────────────── +function drawBrokeWarning() { + const cheapest = cheapestEnemyCost(); + if (G.credits >= cheapest || G.gameOver) return; + + const W = canvas.width, H = canvas.height; + const msg = '⚠ LOW CREDITS — if enemies breach you may go bankrupt'; + + ctx.save(); + ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; + const msgW = ctx.measureText(msg).width; + const BW = msgW + 44, BH = 30; + const BX = (W - BW) / 2; + const BY = H - 90; + + const alpha = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(G.frame * 0.08)); + ctx.globalAlpha = alpha; + ctx.fillStyle = 'rgba(4,10,16,0.92)'; + ctx.strokeStyle = '#ffd700'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(BX, BY, BW, BH); ctx.fill(); ctx.stroke(); + ctx.fillStyle = '#ffd700'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(msg, W / 2, BY + BH / 2); + ctx.restore(); +} diff --git a/js/renderer-inventory.js b/js/renderer-inventory.js new file mode 100644 index 0000000..b7784a7 --- /dev/null +++ b/js/renderer-inventory.js @@ -0,0 +1,255 @@ +// ═══ renderer-inventory.js ═══ +// ============================================================ +// RENDERER INVENTORY — bottom weapon drawer +// ============================================================ + +const _PK_X = 16, _PK_Y = 642, _PK_W = 1298, _PK_H = 244; +const _PK_PAD = 16; +const _PK_TITLE_H = 24; +const _PK_CLOSE_H = 30; +const _PK_BODY_Y = _PK_Y + _PK_PAD + _PK_TITLE_H; +const _PK_BODY_H = _PK_H - _PK_PAD * 2 - _PK_TITLE_H - _PK_CLOSE_H; +const _PK_DETAIL_W = 246; +const _PK_GAP = 12; +const _PK_LIST_X = _PK_X + _PK_PAD; +const _PK_LIST_W = _PK_W - _PK_PAD * 2 - _PK_GAP - _PK_DETAIL_W; +const _PK_DET_X = _PK_LIST_X + _PK_LIST_W + _PK_GAP; +const _PK_CARD_W = 92, _PK_CARD_H = 70, _PK_CARD_GAP = 8; +const _PK_SECT_H = 18, _PK_DROP_H = 32; +const _PK_CPR = Math.floor((_PK_LIST_W + _PK_CARD_GAP) / (_PK_CARD_W + _PK_CARD_GAP)); + +let _pickerHoverWeapon = null; +let _pickerHoverDef = null; + +function drawInventoryOverlay() { + if (!document.body.classList.contains('inventory-open')) return; + + const W = canvas.width, H = canvas.height; + const equipMode = _pickerSlot >= 0; + + ctx.fillStyle = 'rgba(0,0,0,0.42)'; + ctx.fillRect(0, 0, W, H); + + ctx.fillStyle = '#060e16'; + ctx.strokeStyle = '#1a3048'; + ctx.lineWidth = 1; + ctx.fillRect(_PK_X, _PK_Y, _PK_W, _PK_H); + ctx.strokeRect(_PK_X, _PK_Y, _PK_W, _PK_H); + + ctx.save(); + + const titleSuffix = equipMode ? `- SLOT ${_pickerSlot + 1}` : '- BAG'; + ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; + ctx.letterSpacing = '3px'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#00d4ff'; + ctx.fillText('WEAPON INVENTORY ' + titleSuffix, _PK_LIST_X, _PK_Y + _PK_PAD); + ctx.letterSpacing = '0px'; + + const invCount = (G.weaponInventory || []).length; + ctx.font = '10px "Share Tech Mono", monospace'; + ctx.fillStyle = '#3a6080'; + ctx.fillText(invCount + ' in bag', _PK_LIST_X + 270, _PK_Y + _PK_PAD + 1); + + ctx.strokeStyle = '#1a3048'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(_PK_X, _PK_BODY_Y - 8); + ctx.lineTo(_PK_X + _PK_W, _PK_BODY_Y - 8); + ctx.stroke(); + + const CB_W = 110, CB_H = 26; + const CB_X = _PK_X + _PK_W - _PK_PAD - CB_W; + const CB_Y = _PK_Y + 7; + const cbHov = isHovered(CB_X, CB_Y, CB_W, CB_H); + ctx.fillStyle = cbHov ? '#1a0808' : 'transparent'; + ctx.strokeStyle = cbHov ? '#ff4444' : '#3a6080'; + ctx.lineWidth = 1; + ctx.fillRect(CB_X, CB_Y, CB_W, CB_H); + ctx.strokeRect(CB_X, CB_Y, CB_W, CB_H); + ctx.font = '10px Orbitron, monospace'; + ctx.letterSpacing = '2px'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = cbHov ? '#ff4444' : '#3a6080'; + ctx.fillText('CLOSE', CB_X + CB_W / 2, CB_Y + CB_H / 2); + ctx.letterSpacing = '0px'; + addHitRegion(CB_X, CB_Y, CB_W, CB_H, closeWeaponPicker); + _bagDropZones.push({ x: _PK_X, y: _PK_Y, w: _PK_W, h: _PK_H }); + + let yOff = 0, col = 0; + _pickerHoverWeapon = null; + _pickerHoverDef = null; + + ctx.save(); + ctx.beginPath(); + ctx.rect(_PK_LIST_X, _PK_BODY_Y, _PK_LIST_W, _PK_BODY_H); + ctx.clip(); + + function flushRow() { + if (col > 0) { yOff += _PK_CARD_H + _PK_CARD_GAP; col = 0; } + } + + function drawCard(weapon, def, opts) { + const { isRemove, isEquipped, cantAfford, isBuy, location, action, source } = opts || {}; + const cx = _PK_LIST_X + col * (_PK_CARD_W + _PK_CARD_GAP); + const cy = _PK_BODY_Y + yOff - _pickerScrollY; + col++; + if (col >= _PK_CPR) { yOff += _PK_CARD_H + _PK_CARD_GAP; col = 0; } + + const visible = cy + _PK_CARD_H > _PK_BODY_Y && cy < _PK_BODY_Y + _PK_BODY_H; + if (!visible) return; + + const hov = isHovered(cx, cy, _PK_CARD_W, _PK_CARD_H); + if (hov && !cantAfford) { _pickerHoverWeapon = weapon; _pickerHoverDef = def; } + + const border = isRemove ? '#ff4444' : isEquipped ? '#00d4ff99' : (hov && !cantAfford) ? '#ffd700' : '#1a3048'; + const bg = isRemove ? '#150808' : isEquipped ? '#07101a' : (hov && !cantAfford) ? '#0c1820' : '#080f18'; + + ctx.save(); + if (cantAfford) ctx.globalAlpha = 0.4; + else if (isEquipped) ctx.globalAlpha = 0.72; + ctx.fillStyle = bg; ctx.strokeStyle = border; ctx.lineWidth = 1; + ctx.fillRect(cx, cy, _PK_CARD_W, _PK_CARD_H); + ctx.strokeRect(cx, cy, _PK_CARD_W, _PK_CARD_H); + + ctx.font = '22px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = isRemove ? '#ff4444' : '#ffffff'; + ctx.fillText(def?.icon || (isRemove ? 'X' : '?'), cx + _PK_CARD_W / 2, cy + 21); + + ctx.save(); + ctx.beginPath(); ctx.rect(cx + 4, cy + 36, _PK_CARD_W - 8, 16); ctx.clip(); + ctx.font = '9px Orbitron, "Share Tech Mono", monospace'; + ctx.letterSpacing = '1px'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = isRemove ? '#ff4444' : '#b8d8e8'; + ctx.fillText(isRemove ? 'REMOVE' : (def?.name || ''), cx + _PK_CARD_W / 2, cy + 44); + ctx.restore(); + + if (isEquipped) { + ctx.save(); + ctx.fillStyle = 'rgba(0,212,255,0.18)'; + ctx.fillRect(cx + 4, cy + 4, _PK_CARD_W - 8, 14); + ctx.font = '8px Orbitron, monospace'; + ctx.letterSpacing = '1px'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#7ecfff'; + ctx.fillText('EQUIPPED', cx + _PK_CARD_W / 2, cy + 11); + ctx.restore(); + } + + const sub = isRemove ? 'free' + : isBuy ? (cantAfford ? `Need ${def.cost - spendableCredits()}c` : `${def.cost}c`) + : (location || ''); + ctx.save(); + ctx.beginPath(); ctx.rect(cx + 2, cy + 54, _PK_CARD_W - 4, 14); ctx.clip(); + ctx.font = '9px "Share Tech Mono", monospace'; + ctx.letterSpacing = '0px'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = isBuy ? '#ffd700' : '#3a6080'; + ctx.fillText(sub, cx + _PK_CARD_W / 2, cy + 61); + ctx.restore(); + ctx.restore(); + + if (!cantAfford && action) addHitRegion(cx, cy, _PK_CARD_W, _PK_CARD_H, action); + if (!cantAfford && weapon && !isRemove && !isBuy) + _dragRegions.push({ x: cx, y: cy, w: _PK_CARD_W, h: _PK_CARD_H, weapon, source: source || null }); + } + + for (let i = 0; i < G.tower.weaponSlots; i++) { + const w = G.weapons[i]; + if (!w) continue; + const ii = i; + drawCard(w, getWeaponDef(w), { + location: `SOCKET ${i + 1}`, + isEquipped: true, + source: { type: 'slot', slotIndex: i }, + action: (equipMode && i !== _pickerSlot) + ? () => { equipWeaponInstanceToSlot(_pickerSlot, G.weapons[ii].instanceId); } + : null + }); + } + + for (const w of (G.weaponInventory || [])) { + const wRef = w; + drawCard(w, getWeaponDef(w), { + location: 'BAG', + source: { type: 'bag' }, + action: equipMode + ? () => { equipWeaponInstanceToSlot(_pickerSlot, wRef.instanceId); } + : null + }); + } + flushRow(); + + if (equipMode) { + for (const def of WEAPON_DEFS) { + const ownedCount = countOwnedWeaponType(def.id); + if (ownedCount >= MAX_WEAPONS_PER_TYPE) continue; + const cantAfford = spendableCredits() < def.cost; + const dRef = def; + drawCard(null, def, { + location: 'BUY', isBuy: true, cantAfford, ownedCount, + action: cantAfford ? null : () => { buyAndEquipWeapon(_pickerSlot, dRef.id); } + }); + } + flushRow(); + } + + _pickerScrollMax = Math.max(0, yOff - _PK_BODY_H + 20); + + ctx.restore(); + + const DX = _PK_DET_X, DY = _PK_BODY_Y, DH = _PK_BODY_H; + ctx.fillStyle = '#050a10'; + ctx.strokeStyle = '#122030'; + ctx.lineWidth = 1; + ctx.fillRect(DX, DY, _PK_DETAIL_W, DH); + ctx.strokeRect(DX, DY, _PK_DETAIL_W, DH); + + if (_pickerHoverWeapon || _pickerHoverDef) { + const hw = _pickerHoverWeapon, hd = _pickerHoverDef; + let dy = DY + 10; + ctx.font = '12px Orbitron, "Share Tech Mono", monospace'; + ctx.letterSpacing = '2px'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#00d4ff'; + ctx.fillText((hd?.icon || '') + ' ' + (hd?.name || ''), DX + 10, dy); + ctx.letterSpacing = '0px'; + dy += 28; + ctx.font = '10px "Share Tech Mono", monospace'; + const elLabel = hw ? weaponElementLabel(hw) : ''; + if (elLabel) { + ctx.fillStyle = '#3a6080'; ctx.textAlign = 'left'; + ctx.fillText('ELEMENTS', DX + 10, dy); + ctx.fillStyle = '#b8d8e8'; ctx.textAlign = 'right'; + ctx.fillText(elLabel, DX + _PK_DETAIL_W - 10, dy); + ctx.textAlign = 'left'; dy += 18; + } + for (const [k, v] of (hw ? weaponStatRows(hw) : [])) { + if (dy > DY + DH - 18) break; + ctx.strokeStyle = '#102030'; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(DX + 8, dy + 16); + ctx.lineTo(DX + _PK_DETAIL_W - 8, dy + 16); + ctx.stroke(); + ctx.fillStyle = '#3a6080'; ctx.textAlign = 'left'; ctx.fillText(k, DX + 10, dy); + ctx.fillStyle = '#b8d8e8'; ctx.textAlign = 'right'; ctx.fillText(String(v), DX + _PK_DETAIL_W - 10, dy); + dy += 18; + } + } else { + ctx.font = '11px Orbitron, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#1a3048'; + ctx.fillText('Drag weapons into sockets', DX + _PK_DETAIL_W / 2, DY + DH / 2 - 10); + ctx.fillText('or back into the bag', DX + _PK_DETAIL_W / 2, DY + DH / 2 + 10); + } + + ctx.restore(); +} diff --git a/js/renderer-overlays.js b/js/renderer-overlays.js new file mode 100644 index 0000000..54bf3a2 --- /dev/null +++ b/js/renderer-overlays.js @@ -0,0 +1,262 @@ +// ═══ renderer-overlays.js ═══ +// ============================================================ +// RENDERER OVERLAYS — game over, pause, mount drag UI +// ============================================================ + +// ── GAME OVER / BANKRUPT OVERLAY ───────────────────────────── +function drawGameOverPanel() { + const W = canvas.width, H = canvas.height; + + // Game over takes over hit detection — clear HUD regions + clearHitRegions(); + + // Full-screen dark tint + ctx.fillStyle = 'rgba(0,0,0,0.88)'; + ctx.fillRect(0, 0, W, H); + + const BW = 460, BH = 270; + const BX = (W - BW) / 2, BY = (H - BH) / 2; + ctx.fillStyle = '#060e16'; + ctx.strokeStyle = '#1a3048'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(BX, BY, BW, BH); ctx.fill(); ctx.stroke(); + + const isBankrupt = G.isBankrupt; + const titleColor = isBankrupt ? '#ffd700' : '#ff3355'; + const title = isBankrupt ? 'BANKRUPT' : 'TOWER FALLEN'; + const subText = isBankrupt + ? 'You ran out of credits with no way to recover.' + : 'The defense has been breached.'; + + ctx.save(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.font = '900 30px Orbitron, "Share Tech Mono", monospace'; + ctx.fillStyle = titleColor; + ctx.shadowColor = titleColor; + ctx.shadowBlur = 24; + ctx.fillText(title, W / 2, BY + 28); + ctx.shadowBlur = 0; + + ctx.font = '14px Orbitron, "Share Tech Mono", monospace'; + ctx.fillStyle = '#ffd700'; + ctx.fillText(`Score: ${G.score} — Kills: ${G.totalKills}`, W / 2, BY + 78); + + ctx.font = '12px "Share Tech Mono", monospace'; + if (G._isNewBest) { + ctx.fillStyle = '#ffd700'; + ctx.fillText('★ NEW BEST!', W / 2, BY + 108); + } else if (_best) { + ctx.fillStyle = '#3a6080'; + ctx.fillText(`Best: ${_best.score} — ${_best.kills} kills`, W / 2, BY + 108); + } + + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.fillStyle = '#3a6080'; + ctx.fillText(subText, W / 2, BY + 134); + + // RESTART button + const RBW = 220, RBH = 44; + const RBX = W / 2 - RBW / 2; + const RBY = BY + 192; + const restHov = isHovered(RBX, RBY, RBW, RBH); + ctx.fillStyle = restHov ? '#00d4ff' : 'transparent'; + ctx.strokeStyle = '#00d4ff'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(RBX, RBY, RBW, RBH); ctx.fill(); ctx.stroke(); + if (restHov) { ctx.shadowColor = 'rgba(0,212,255,0.35)'; ctx.shadowBlur = 20; } + ctx.font = '12px Orbitron, "Share Tech Mono", monospace'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = restHov ? '#000000' : '#00d4ff'; + ctx.fillText('RESTART MISSION', W / 2, RBY + RBH / 2); + ctx.shadowBlur = 0; + ctx.restore(); + + addHitRegion(RBX, RBY, RBW, RBH, restartGame); +} + +// ── PAUSE OVERLAY ───────────────────────────────────────────── +function drawPauseOverlay() { + if (!document.body.classList.contains('paused')) return; + + const W = canvas.width, H = canvas.height; + ctx.fillStyle = 'rgba(0,0,0,0.58)'; + ctx.fillRect(0, 0, W, H); + + const BW = 420, BH = 118; + const BX = (W - BW) / 2, BY = (H - BH) / 2; + ctx.save(); + ctx.fillStyle = 'rgba(6,14,22,0.94)'; + ctx.strokeStyle = '#1a3048'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(BX, BY, BW, BH); ctx.fill(); ctx.stroke(); + ctx.shadowColor = 'rgba(0,212,255,0.18)'; + ctx.shadowBlur = 34; + ctx.strokeRect(BX, BY, BW, BH); + ctx.shadowBlur = 0; + + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.font = '900 34px Orbitron, "Share Tech Mono", monospace'; + ctx.fillStyle = '#ffd700'; + ctx.shadowColor = '#ffd700'; + ctx.shadowBlur = 22; + ctx.fillText('PAUSED', W / 2, BY + 18); + ctx.shadowBlur = 0; + + ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; + ctx.fillStyle = '#3a6080'; + ctx.fillText('Esc resumes · Space opens armory · I opens inventory', W / 2, BY + 78); + ctx.restore(); +} + +// ── MOUNT POINT TOOLTIP ─────────────────────────────────────── +function _drawMountTooltip(mx, my, weapon, socketR) { + const def = getWeaponDef(weapon); + if (!def) return; + const TW = 168, TH = 58; + let tx = mx + socketR + 10; + let ty = my - TH / 2; + if (tx + TW > 1330) tx = mx - socketR - 10 - TW; + ty = Math.max(68, Math.min(900 - TH - 4, ty)); + + ctx.save(); + ctx.fillStyle = '#050d18'; + ctx.strokeStyle = '#00d4ff'; + ctx.lineWidth = 1; + ctx.fillRect(tx, ty, TW, TH); + ctx.strokeRect(tx, ty, TW, TH); + + ctx.font = '10px Orbitron, monospace'; + ctx.letterSpacing = '1px'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#00d4ff'; + ctx.fillText((def.icon || '') + ' ' + def.name, tx + 8, ty + 8); + ctx.letterSpacing = '0px'; + + const parts = []; + if (typeof def.damage === 'number') parts.push(`DMG ${def.damage}`); + if (typeof def.fireRate === 'number') parts.push(`RPM ${Math.round(3600 / def.fireRate)}`); + const el = weapon.elements?.[0] ? ELEMENTS[weapon.elements[0]] : null; + if (el) parts.push(el.icon + ' ' + el.name); + ctx.font = '9px "Share Tech Mono", monospace'; + ctx.fillStyle = '#7aaabb'; + ctx.fillText(parts.join(' '), tx + 8, ty + 26); + + ctx.fillStyle = '#3a6080'; + ctx.fillText('drag to move or bag', tx + 8, ty + 41); + ctx.restore(); +} + +// ── MOUNT POINT INTERACTION (drawn after overlays so it sits on top) ───── +function drawMountInteraction(cx, cy) { + if (G.shopOpen) return; + const invOpen = document.body.classList.contains('inventory-open'); + const totalSlots = Math.max(1, G.tower.weaponSlots); + const hpRatio = G.tower.hp / G.tower.maxHp; + const hpColor = hpRatio > 0.5 ? '#00d4ff' : hpRatio > 0.25 ? '#ffd700' : '#ff3355'; + + for (let slotIndex = 0; slotIndex < totalSlots; slotIndex++) { + const weapon = G.weapons[slotIndex]; + const installed = !!weapon; + const mountAngle = HARDPOINT_BASE_ANGLE + (slotIndex / totalSlots) * Math.PI * 2; + + if (invOpen) { + const actual = getSlotHardpoint(slotIndex, cx, cy, totalSlots); + const ORBIT = Math.max(78, 64 + totalSlots * 6); + const mx = cx + Math.cos(mountAngle) * ORBIT; + const my = cy + Math.sin(mountAngle) * ORBIT; + const dropR = 20; + const hov = isHovered(mx - dropR, my - dropR, dropR * 2, dropR * 2); + const dragging = _dragWeapon !== null; + + ctx.save(); + ctx.strokeStyle = '#00aaff33'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 6]); + ctx.beginPath(); + ctx.moveTo(actual.x, actual.y); + ctx.lineTo(mx, my); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + + if (dragging) { + ctx.save(); + ctx.shadowBlur = hov ? 20 : 8; + ctx.shadowColor = '#ffd700'; + ctx.strokeStyle = hov ? '#ffd700' : '#00aaff55'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(mx, my, dropR + 5, 0, Math.PI * 2); ctx.stroke(); + ctx.shadowBlur = 0; + ctx.restore(); + } + + ctx.fillStyle = hov ? '#0c1820' : '#050d16'; + ctx.strokeStyle = installed + ? (hov ? '#ffd700' : hpColor + 'aa') + : (hov ? '#00aaff' : '#1a3240'); + ctx.lineWidth = hov ? 2.5 : 1.5; + ctx.beginPath(); ctx.arc(mx, my, dropR, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + + ctx.font = '8px Orbitron, monospace'; + ctx.letterSpacing = '1px'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#3a6080'; + ctx.fillText(`S${slotIndex + 1}`, mx, my - (installed ? 8 : 0)); + ctx.letterSpacing = '0px'; + + if (installed) { + _dragRegions.push({ x: mx - dropR, y: my - dropR, w: dropR * 2, h: dropR * 2, weapon, source: { type: 'slot', slotIndex } }); + const def = getWeaponDef(weapon); + ctx.font = '14px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(def?.icon || '?', mx, my + 5); + } else { + ctx.font = '14px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#1a3240'; + ctx.fillText('+', mx, my); + } + + _mountDropZones.push({ x: mx, y: my, r: dropR, slotIndex }); + addHitRegion(mx - dropR, my - dropR, dropR * 2, dropR * 2, () => openWeaponPicker(slotIndex)); + if (hov && installed) _drawMountTooltip(mx, my, weapon, dropR); + } else { + // inventory closed: invisible hit region — click opens picker for this slot + const mount = getSlotHardpoint(slotIndex, cx, cy, totalSlots); + const r = 10; + addHitRegion(mount.x - r, mount.y - r, r * 2, r * 2, () => openWeaponPicker(slotIndex)); + } + } +} + +// ── DRAG GHOST ──────────────────────────────────────────────── +function drawDragGhost() { + if (!_dragWeapon || !_hoverPt) return; + const def = getWeaponDef(_dragWeapon); + if (!def) return; + const GW = 90, GH = 68; + const gx = _hoverPt.x - GW / 2; + const gy = _hoverPt.y - GH / 2; + ctx.save(); + ctx.globalAlpha = 0.85; + ctx.fillStyle = '#060e16'; + ctx.strokeStyle = '#ffd700'; + ctx.lineWidth = 2; + ctx.fillRect(gx, gy, GW, GH); + ctx.strokeRect(gx, gy, GW, GH); + ctx.globalAlpha = 1; + ctx.font = '22px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(def.icon || '?', gx + GW / 2, gy + GH / 2 - 8); + ctx.font = '8px Orbitron, monospace'; + ctx.fillStyle = '#b8d8e8'; + ctx.fillText(def.name, gx + GW / 2, gy + GH / 2 + 14); + ctx.restore(); +} diff --git a/js/renderer-shop-overlay.js b/js/renderer-shop-overlay.js new file mode 100644 index 0000000..478ab8a --- /dev/null +++ b/js/renderer-shop-overlay.js @@ -0,0 +1,163 @@ +// ═══ renderer-shop-overlay.js ═══ +// ============================================================ +// RENDERER SHOP — canvas armory overlay +// ============================================================ + +// ── SHOP OVERLAY ────────────────────────────────────────────── +const _SH_HDR_H = 56; +const _SH_TAB_H = 38; +const _SH_BODY_Y = _SH_HDR_H + _SH_TAB_H; // 94 +const _SH_PAD = 24; +const _SH_UPG_W = 130; +const _SH_UPG_H = 78; +const _SH_ARR_W = 26; + +let _shopScrollY = 0; +let _shopScrollMax = 0; +const _shopRightClick = []; + +function _shopWrapText(text, maxW, maxLines) { + if (!text) return []; + const words = text.split(' '); + const lines = []; + let cur = ''; + for (const w of words) { + const test = cur ? cur + ' ' + w : w; + if (ctx.measureText(test).width <= maxW) { cur = test; } + else { if (cur) lines.push(cur); cur = w; if (lines.length >= maxLines) break; } + } + if (cur && lines.length < maxLines) lines.push(cur); + return lines; +} + +function _shopUpgNode(sx, screenY, upg, bought, locked, cantAfford, onBuy, onRefund) { + const hov = !bought && !locked && !cantAfford && isHovered(sx, screenY, _SH_UPG_W, _SH_UPG_H); + const border = bought ? '#1a5030' : locked ? '#0e1e28' : cantAfford ? '#1a2030' : hov ? '#ffd700' : '#1a3048'; + ctx.save(); + ctx.globalAlpha = bought ? 0.75 : locked ? 0.32 : cantAfford ? 0.55 : 1; + ctx.fillStyle = bought ? '#071410' : '#060e18'; + ctx.strokeStyle = border; ctx.lineWidth = 1; + ctx.fillRect(sx, screenY, _SH_UPG_W, _SH_UPG_H); + ctx.strokeRect(sx, screenY, _SH_UPG_W, _SH_UPG_H); + + ctx.save(); + ctx.beginPath(); ctx.rect(sx + 5, screenY + 5, _SH_UPG_W - 10, 16); ctx.clip(); + ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '0.5px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillStyle = bought ? '#00ff88' : '#b8d8e8'; + ctx.fillText(upg.label, sx + 5, screenY + 5); + ctx.restore(); + + ctx.save(); + ctx.beginPath(); ctx.rect(sx + 5, screenY + 22, _SH_UPG_W - 10, 30); ctx.clip(); + ctx.font = '10px "Share Tech Mono", monospace'; ctx.letterSpacing = '0px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#3a6080'; + const lines = _shopWrapText(upg.desc, _SH_UPG_W - 12, 2); + for (let li = 0; li < lines.length; li++) ctx.fillText(lines[li], sx + 5, screenY + 22 + li * 14); + ctx.restore(); + + ctx.font = '10px "Share Tech Mono", monospace'; ctx.letterSpacing = '0px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + if (bought) { ctx.fillStyle = '#1a4030'; ctx.fillText('✓ right-click refund', sx + 5, screenY + 58); } + else if (locked) { ctx.fillStyle = '#1a2838'; ctx.fillText('🔒 locked', sx + 5, screenY + 58); } + else { ctx.fillStyle = cantAfford ? '#ff3355' : '#ffd700'; ctx.fillText(upg.cost + '¢', sx + 5, screenY + 58); } + + ctx.restore(); + if (onBuy) addHitRegion(sx, screenY, _SH_UPG_W, _SH_UPG_H, onBuy); + if (onRefund) _shopRightClick.push({ x: sx, y: screenY, w: _SH_UPG_W, h: _SH_UPG_H, action: onRefund }); +} + +function drawShopOverlay() { + if (!G.shopOpen) return; + + _shopRightClick.length = 0; + + const W = canvas.width, H = canvas.height; + const BODY_H = H - _SH_BODY_Y; + + ctx.fillStyle = 'rgba(2,8,14,0.97)'; + ctx.fillRect(0, 0, W, H); + + ctx.save(); + + ctx.fillStyle = '#040c14'; + ctx.fillRect(0, 0, W, _SH_HDR_H); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, _SH_HDR_H); ctx.lineTo(W, _SH_HDR_H); ctx.stroke(); + + ctx.font = '900 15px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '6px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#00d4ff'; ctx.shadowColor = '#00d4ff44'; ctx.shadowBlur = 14; + ctx.fillText('ARMORY', _SH_PAD, _SH_HDR_H / 2); + ctx.shadowBlur = 0; ctx.letterSpacing = '0px'; + + ctx.font = '18px Orbitron, "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; ctx.fillStyle = '#ffd700'; + ctx.fillText('💰 ' + G.credits + '¢', W / 2, _SH_HDR_H / 2); + + const CBW = 160, CBH = 32; + const CBX = W - _SH_PAD - CBW, CBY = (_SH_HDR_H - CBH) / 2; + const cbHov = isHovered(CBX, CBY, CBW, CBH); + ctx.fillStyle = cbHov ? '#3d0808' : 'transparent'; + ctx.strokeStyle = '#ff3355'; ctx.lineWidth = 1; + ctx.fillRect(CBX, CBY, CBW, CBH); ctx.strokeRect(CBX, CBY, CBW, CBH); + ctx.font = '11px Orbitron, monospace'; ctx.letterSpacing = '2px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ff3355'; + ctx.fillText('✕ CLOSE [Esc]', CBX + CBW / 2, CBY + CBH / 2); + ctx.letterSpacing = '0px'; + addHitRegion(CBX, CBY, CBW, CBH, closeShop); + + ctx.fillStyle = '#030a12'; + ctx.fillRect(0, _SH_HDR_H, W, _SH_TAB_H); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, _SH_BODY_Y); ctx.lineTo(W, _SH_BODY_Y); ctx.stroke(); + + const equippedWeapons = getEquippedWeapons(); + const tabDefs = [ + { id: 'tower', label: '🏰 TOWER' }, + { id: 'weapons', label: '🔧 BUY WEAPON' }, + ...equippedWeapons.map(w => { + const def = getWeaponDef(w); + return { id: w.instanceId, label: (def?.icon || '?') + ' ' + (def?.name || '?') }; + }), + ]; + + ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '1.5px'; + let tx = _SH_PAD; + const TB_Y = _SH_HDR_H + 4, TB_H = _SH_TAB_H - 4; + for (const tab of tabDefs) { + const tw = Math.ceil(ctx.measureText(tab.label).width) + 32; + const active = G.shopTab === tab.id; + const tabHov = isHovered(tx, TB_Y, tw, TB_H); + if (active) { + ctx.fillStyle = '#060e18'; ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(tx, TB_Y, tw, TB_H + 2); + ctx.strokeRect(tx, TB_Y, tw, TB_H); + } + ctx.fillStyle = active ? '#00d4ff' : (tabHov ? '#b8d8e8' : '#3a6080'); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(tab.label, tx + tw / 2, TB_Y + TB_H / 2); + addHitRegion(tx, TB_Y, tw, TB_H, ((tid) => () => setShopTab(tid))(tab.id)); + tx += tw + 4; + } + ctx.letterSpacing = '0px'; + + ctx.save(); + ctx.beginPath(); ctx.rect(0, _SH_BODY_Y, W, BODY_H); ctx.clip(); + const bodyCX = _SH_PAD, bodyCW = W - _SH_PAD * 2; + let yOff = _SH_PAD; + + if (G.shopTab === 'tower') { + yOff = _shopDrawTowerContent(yOff, bodyCX, bodyCW, H); + } else if (G.shopTab === 'weapons') { + yOff = _shopDrawBuyContent(yOff, bodyCX, bodyCW, H); + } else { + const w = equippedWeapons.find(w => w.instanceId === G.shopTab); + if (w) yOff = _shopDrawWeaponContent(yOff, bodyCX, bodyCW, H, w); + } + + _shopScrollMax = Math.max(0, yOff - BODY_H + _SH_PAD); + ctx.restore(); + + ctx.restore(); +} diff --git a/js/renderer-shop-sections.js b/js/renderer-shop-sections.js new file mode 100644 index 0000000..1c3cb0f --- /dev/null +++ b/js/renderer-shop-sections.js @@ -0,0 +1,408 @@ +// ═══ renderer-shop-sections.js ═══ +// ============================================================ +// RENDERER SHOP SECTIONS — armory tab content +// ============================================================ + +function _shopDrawTowerContent(yOff, cx, cw, H) { + const sy = y => _SH_BODY_Y + y - _shopScrollY; + const vis = (y, h) => sy(y) + h >= _SH_BODY_Y && sy(y) < H; + + const stats = [ + ['HP', G.tower.hp + ' / ' + G.tower.maxHp], + ['Armor', G.tower.armor], + ['Aim Speed', G.tower.aimSpeed.toFixed(3)], + ['Vision', G.tower.range + 'px'], + ['Slots', getEquippedWeapons().length + '/' + G.tower.weaponSlots], + ['Shield', G.tower.shield ? G.tower.shield.toUpperCase() + ' (' + G.tower.shieldHp + '/' + G.tower.shieldMaxHp + ')' : 'None'], + ]; + const STAT_W = Math.floor((cw - 5 * 10) / 6); + const STAT_H = 52; + if (vis(yOff, STAT_H)) { + for (let i = 0; i < stats.length; i++) { + const bx = cx + i * (STAT_W + 10); + ctx.fillStyle = '#060e18'; ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(bx, sy(yOff), STAT_W, STAT_H); + ctx.strokeRect(bx, sy(yOff), STAT_W, STAT_H); + ctx.font = '9px Orbitron, monospace'; ctx.letterSpacing = '1.5px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = '#3a6080'; + ctx.fillText(stats[i][0], bx + STAT_W / 2, sy(yOff) + 8); + ctx.font = '13px Orbitron, monospace'; ctx.letterSpacing = '0px'; + ctx.fillStyle = '#b8d8e8'; ctx.textBaseline = 'bottom'; + ctx.fillText(String(stats[i][1]), bx + STAT_W / 2, sy(yOff) + STAT_H - 8); + } + } + yOff += STAT_H + 20; + + const categories = { + 'Hull': ['hp1','hp2','hp3','hp4','hp5'], + 'Armor': ['armor1','armor2','armor3','armor4','armor5'], + 'Servo Motors': ['aim1','aim2','aim3','aim4','aim5','aim6'], + 'Vision Range': ['range1','range2','range3','range4'], + 'Weapon Slots': ['slot2','slot3','slot4','slot5','slot6','slot7','slot8'], + 'Shield': ['shield_dome','shield_dir'], + 'Utility': ['repair1'], + }; + + for (const [catName, ids] of Object.entries(categories)) { + const CAT_H = 22; + if (vis(yOff, CAT_H)) { + ctx.font = '9px Orbitron, monospace'; ctx.letterSpacing = '3px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080'; + ctx.fillText(catName.toUpperCase(), cx, sy(yOff) + CAT_H / 2); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(cx, sy(yOff) + CAT_H); ctx.lineTo(cx + cw, sy(yOff) + CAT_H); ctx.stroke(); + ctx.letterSpacing = '0px'; + } + yOff += CAT_H + 4; + + const available = ids.map(id => TOWER_UPGRADE_TREE.find(u => u.id === id)).filter(Boolean); + let rowW = 0; + for (let i = 0; i < available.length; i++) { + if (i > 0 && available[i].requires.length > 0) rowW += _SH_ARR_W; + rowW += _SH_UPG_W; + } + let nx = cx + Math.max(0, (cw - rowW) / 2); + + if (vis(yOff, _SH_UPG_H)) { + for (let i = 0; i < available.length; i++) { + const upg = available[i]; + if (i > 0 && upg.requires.length > 0) { + ctx.font = '13px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a5060'; + ctx.fillText('→', nx + _SH_ARR_W / 2, sy(yOff) + _SH_UPG_H / 2); + nx += _SH_ARR_W; + } + const isBought = G.towerUpgradesBought.includes(upg.id); + const shieldConflict = (upg.id === 'shield_dome' && G.tower.shield === 'dome') || + (upg.id === 'shield_dir' && G.tower.shield === 'directional'); + const effectiveBought = isBought || shieldConflict; + const reqsMet = upg.requires.every(r => G.towerUpgradesBought.includes(r)); + const canAfford = spendableCredits() >= upg.cost; + const uid = upg.id; + _shopUpgNode(nx, sy(yOff), upg, effectiveBought, + !reqsMet && !isBought, !canAfford && reqsMet && !isBought, + (!effectiveBought && reqsMet && canAfford) ? () => buyTowerUpgrade(uid) : null, + (isBought && !upg.repeatable) ? () => refundTowerUpgrade(uid) : null + ); + nx += _SH_UPG_W; + } + } + yOff += _SH_UPG_H + 14; + + if (catName === 'Shield' && G.tower.shield) { + const shTree = SHIELD_UPGRADE_TREES[G.tower.shield] || []; + if (shTree.length > 0) { + const SH_H = 18; + if (vis(yOff, SH_H)) { + ctx.font = '9px Orbitron, monospace'; ctx.letterSpacing = '2px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#1a5060'; + ctx.fillText(G.tower.shield.toUpperCase() + ' UPGRADES', cx, sy(yOff) + SH_H / 2); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(cx, sy(yOff) + SH_H); ctx.lineTo(cx + cw, sy(yOff) + SH_H); ctx.stroke(); + ctx.letterSpacing = '0px'; + } + yOff += SH_H + 4; + const shBought = G.shieldUpgradesBought || []; + let shRowW = 0; + for (let i = 0; i < shTree.length; i++) { + if (i > 0 && shTree[i].requires.length > 0) shRowW += _SH_ARR_W; + shRowW += _SH_UPG_W; + } + let shx = cx + Math.max(0, (cw - shRowW) / 2); + if (vis(yOff, _SH_UPG_H)) { + for (let i = 0; i < shTree.length; i++) { + const upg = shTree[i]; + if (i > 0 && upg.requires.length > 0) { + ctx.font = '13px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a5060'; + ctx.fillText('→', shx + _SH_ARR_W / 2, sy(yOff) + _SH_UPG_H / 2); + shx += _SH_ARR_W; + } + const b = shBought.includes(upg.id); + const rm = upg.requires.every(r => shBought.includes(r)); + const ca = spendableCredits() >= upg.cost; + const uid = upg.id; + _shopUpgNode(shx, sy(yOff), upg, b, !rm && !b, !ca && rm && !b, + (!b && rm && ca) ? () => buyShieldUpgrade(uid) : null, + (b && !upg.repeatable) ? () => refundShieldUpgrade(uid) : null + ); + shx += _SH_UPG_W; + } + } + yOff += _SH_UPG_H + 14; + } + } + } + return yOff; +} + +function _shopDrawBuyContent(yOff, cx, cw, H) { + const sy = y => _SH_BODY_Y + y - _shopScrollY; + const vis = (y, h) => sy(y) + h >= _SH_BODY_Y && sy(y) < H; + const equippedWeapons = getEquippedWeapons(); + + const INFO_H = 36; + if (vis(yOff, INFO_H)) { + ctx.fillStyle = '#060e18'; ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(cx, sy(yOff), cw, INFO_H); + ctx.strokeRect(cx, sy(yOff), cw, INFO_H); + ctx.font = '12px "Share Tech Mono", monospace'; ctx.letterSpacing = '0px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080'; + ctx.fillText('Weapon Slots: ', cx + 12, sy(yOff) + INFO_H / 2); + const lw = ctx.measureText('Weapon Slots: ').width; + ctx.fillStyle = '#b8d8e8'; + ctx.fillText(equippedWeapons.length + '/' + G.tower.weaponSlots, cx + 12 + lw, sy(yOff) + INFO_H / 2); + } + yOff += INFO_H + 14; + + const CARD_W = 188, CARD_H = 112, CARD_GAP = 12; + const cols = Math.max(1, Math.floor((cw + CARD_GAP) / (CARD_W + CARD_GAP))); + const actW = Math.floor((cw - (cols - 1) * CARD_GAP) / cols); + + for (let i = 0; i < WEAPON_DEFS.length; i++) { + const def = WEAPON_DEFS[i]; + const col = i % cols; + const row = Math.floor(i / cols); + const cardX = cx + col * (actW + CARD_GAP); + const cardYOff = yOff + row * (CARD_H + CARD_GAP); + if (!vis(cardYOff, CARD_H)) continue; + + const owned = countOwnedWeaponType(def.id); + const atCap = owned >= MAX_WEAPONS_PER_TYPE; + const canAfford = spendableCredits() >= def.cost; + const canBuy = canAfford && !atCap; + const csy = sy(cardYOff); + const hov = canBuy && isHovered(cardX, csy, actW, CARD_H); + + ctx.save(); + if (!canBuy) ctx.globalAlpha = 0.5; + ctx.fillStyle = hov ? '#0c1e30' : '#060e18'; + ctx.strokeStyle = hov ? '#ffd700' : (canBuy ? '#1a3048' : '#0e1e28'); + ctx.lineWidth = 1; + ctx.fillRect(cardX, csy, actW, CARD_H); + ctx.strokeRect(cardX, csy, actW, CARD_H); + + ctx.font = '26px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ffffff'; + ctx.fillText(def.icon, cardX + 30, csy + 30); + + ctx.font = '12px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '1px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#b8d8e8'; + ctx.fillText(def.name, cardX + 54, csy + 10); + ctx.letterSpacing = '0px'; + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.fillStyle = canAfford ? '#ffd700' : '#ff3355'; + ctx.fillText(def.cost + '¢', cardX + 54, csy + 28); + + ctx.save(); + ctx.beginPath(); ctx.rect(cardX + 8, csy + 50, actW - 16, 30); ctx.clip(); + ctx.font = '10px "Share Tech Mono", monospace'; + ctx.fillStyle = '#3a6080'; ctx.textBaseline = 'top'; ctx.textAlign = 'left'; + const dl = _shopWrapText(def.desc, actW - 20, 2); + for (let li = 0; li < dl.length; li++) ctx.fillText(dl[li], cardX + 8, csy + 52 + li * 14); + ctx.restore(); + + ctx.font = '10px "Share Tech Mono", monospace'; + ctx.fillStyle = atCap ? '#ff3355' : '#1a3048'; + ctx.textBaseline = 'top'; ctx.textAlign = 'left'; + ctx.fillText('Owned: ' + owned + '/' + MAX_WEAPONS_PER_TYPE + (atCap ? ' (MAX)' : ''), cardX + 8, csy + 88); + ctx.restore(); + + if (canBuy) { const dId = def.id; addHitRegion(cardX, csy, actW, CARD_H, () => buyWeapon(dId)); } + } + + const rows = Math.ceil(WEAPON_DEFS.length / cols); + yOff += rows * (CARD_H + CARD_GAP); + return yOff; +} + +function _shopDrawWeaponContent(yOff, cx, cw, H, weapon) { + const sy = y => _SH_BODY_Y + y - _shopScrollY; + const vis = (y, h) => sy(y) + h >= _SH_BODY_Y && sy(y) < H; + const def = getWeaponDef(weapon); + const bought = G.weaponUpgradesBought[weapon.instanceId] || []; + + const HDR_H = 80; + if (vis(yOff, HDR_H)) { + ctx.fillStyle = '#060e18'; ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(cx, sy(yOff), cw, HDR_H); + ctx.strokeRect(cx, sy(yOff), cw, HDR_H); + ctx.font = '22px monospace'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ffffff'; + ctx.fillText(def?.icon || '', cx + 12, sy(yOff) + 22); + ctx.font = '14px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '2px'; + ctx.fillStyle = '#b8d8e8'; + ctx.fillText(def?.name || '', cx + 38, sy(yOff) + 22); + ctx.letterSpacing = '0px'; + const statPairs = [ + ['DMG', weapon.damage], + ['RATE', weapon.fireRate + 'f'], + ...(weapon.pierce ? [['PIERCE', weapon.pierce]] : []), + ...(weapon.critChance ? [['CRIT', Math.round(weapon.critChance * 100) + '%']] : []), + ...(weapon.chains ? [['CHAINS', weapon.chains]] : []), + ...(weapon.aoeRadius ? [['AOE', weapon.aoeRadius]] : []), + ...(weapon.targets ? [['TGT', weapon.targets]] : []), + ]; + const spw = Math.min(110, Math.floor(cw / Math.max(1, statPairs.length))); + ctx.font = '10px "Share Tech Mono", monospace'; ctx.textBaseline = 'top'; + for (let i = 0; i < statPairs.length; i++) { + const bx = cx + 12 + i * spw; + ctx.fillStyle = '#3a6080'; ctx.textAlign = 'left'; ctx.fillText(statPairs[i][0], bx, sy(yOff) + 46); + ctx.fillStyle = '#b8d8e8'; ctx.fillText(String(statPairs[i][1]), bx, sy(yOff) + 60); + } + } + yOff += HDR_H + 14; + + const TGT_H = 42; + if (vis(yOff, TGT_H)) { + ctx.fillStyle = '#040a10'; ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(cx, sy(yOff), cw, TGT_H); + ctx.strokeRect(cx, sy(yOff), cw, TGT_H); + ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '2px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080'; + ctx.fillText('TARGET:', cx + 12, sy(yOff) + TGT_H / 2); + ctx.letterSpacing = '0px'; + const lw = ctx.measureText('TARGET:').width; + const TBTNS = ['nearest','strongest','weakest','fastest','furthest','group']; + const BTN_W = 92, BTN_H = 28; + let bx = cx + 12 + lw + 18; + for (const mode of TBTNS) { + const active = weapon.targeting === mode; + const hov = isHovered(bx, sy(yOff) + 7, BTN_W, BTN_H); + ctx.fillStyle = active ? '#00d4ff' : (hov ? '#0a1e30' : 'transparent'); + ctx.strokeStyle = active ? '#00d4ff' : (hov ? '#00d4ff' : '#1a3048'); + ctx.lineWidth = 1; + ctx.fillRect(bx, sy(yOff) + 7, BTN_W, BTN_H); + ctx.strokeRect(bx, sy(yOff) + 7, BTN_W, BTN_H); + ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '0.5px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = active ? '#000' : (hov ? '#00d4ff' : '#3a6080'); + ctx.fillText(mode.toUpperCase(), bx + BTN_W / 2, sy(yOff) + 7 + BTN_H / 2); + ctx.letterSpacing = '0px'; + const mRef = mode, iRef = weapon.instanceId; + addHitRegion(bx, sy(yOff) + 7, BTN_W, BTN_H, () => setWeaponTargeting(iRef, mRef)); + bx += BTN_W + 6; + } + } + yOff += TGT_H + 14; + + const infuseSlots = weapon.canInfuse3 ? 3 : weapon.canInfuse2 ? 2 : weapon.canInfuse ? 1 : 0; + if (infuseSlots > 0) { + const INF_H = 54; + if (vis(yOff, INF_H)) { + ctx.fillStyle = '#040a10'; ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(cx, sy(yOff), cw, INF_H); + ctx.strokeRect(cx, sy(yOff), cw, INF_H); + ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '2px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080'; + ctx.fillText('ELEMENTS:', cx + 12, sy(yOff) + INF_H / 2); + ctx.letterSpacing = '0px'; + const EB = 28; + let ex = cx + 130; + for (let i = 0; i < infuseSlots; i++) { + const cur = weapon.elements[i]; + const el = ELEMENTS[cur]; + ctx.font = '20px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = el?.color || '#444'; + ctx.fillText(el?.icon || '—', ex + EB / 2, sy(yOff) + 16); + ex += EB + 4; + for (const [elId, elDef] of Object.entries(ELEMENTS)) { + if (elId === 'physical') continue; + const active = cur === elId; + const hov2 = isHovered(ex, sy(yOff) + 2, EB, EB); + ctx.fillStyle = active ? elDef.color + '44' : (hov2 ? '#0a1828' : 'transparent'); + ctx.strokeStyle = active ? elDef.color : (hov2 ? elDef.color : '#1a2838'); + ctx.lineWidth = 1; + ctx.fillRect(ex, sy(yOff) + 2, EB, EB); + ctx.strokeRect(ex, sy(yOff) + 2, EB, EB); + ctx.font = '14px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = elDef.color; + ctx.fillText(elDef.icon, ex + EB / 2, sy(yOff) + 2 + EB / 2); + const iid2 = weapon.instanceId, slotI = i, eid = elId; + addHitRegion(ex, sy(yOff) + 2, EB, EB, () => setWeaponInfusion(iid2, slotI, eid)); + ex += EB + 2; + } + ex += 16; + } + } + yOff += INF_H + 14; + } + + const tree = WEAPON_UPGRADE_TREES[def?.id] || []; + if (tree.length === 0) return yOff; + + const TREE_HDR_H = 22; + if (vis(yOff, TREE_HDR_H)) { + ctx.font = '9px Orbitron, monospace'; ctx.letterSpacing = '3px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080'; + ctx.fillText('UPGRADE TREE', cx, sy(yOff) + TREE_HDR_H / 2); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(cx, sy(yOff) + TREE_HDR_H); ctx.lineTo(cx + cw, sy(yOff) + TREE_HDR_H); ctx.stroke(); + ctx.letterSpacing = '0px'; + } + yOff += TREE_HDR_H + 4; + + const HINT_H = 18; + if (vis(yOff, HINT_H)) { + ctx.font = '10px "Share Tech Mono", monospace'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#1a3048'; + ctx.fillText('Right-click a purchased upgrade to refund it (cascades dependents)', cx, sy(yOff) + HINT_H / 2); + } + yOff += HINT_H + 8; + + const catOrder = []; + const catMap = {}; + for (const upg of tree) { + const cat = upg.category || 'General'; + if (!catMap[cat]) { catMap[cat] = []; catOrder.push(cat); } + catMap[cat].push(upg); + } + + const COL_W = _SH_UPG_W + 16, COL_GAP = 10; + let maxColH = 0; + for (const cat of catOrder) { + let colH = 24; + for (const u of catMap[cat]) { if (u.requires.length > 0) colH += 20; colH += _SH_UPG_H + 4; } + maxColH = Math.max(maxColH, colH); + } + + if (vis(yOff, maxColH)) { + let colX = cx; + for (const cat of catOrder) { + const upgrades = catMap[cat]; + if (vis(yOff, 24)) { + ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '1px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080'; + ctx.fillText(cat, colX + COL_W / 2, sy(yOff) + 10); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(colX, sy(yOff) + 20); ctx.lineTo(colX + COL_W, sy(yOff) + 20); ctx.stroke(); + ctx.letterSpacing = '0px'; + } + let ny = yOff + 24; + for (const upg of upgrades) { + if (upg.requires.length > 0) { + if (vis(ny - 16, 16)) { + ctx.font = '13px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a5060'; + ctx.fillText('↓', colX + COL_W / 2, sy(ny) - 10); + } + ny += 20; + } + const isBought = bought.includes(upg.id); + const reqsMet = upg.requires.every(r => bought.includes(r)); + const canAfford = spendableCredits() >= upg.cost; + const uid = upg.id, iid = weapon.instanceId; + _shopUpgNode(colX + (COL_W - _SH_UPG_W) / 2, sy(ny), upg, isBought, + !reqsMet && !isBought, !canAfford && reqsMet && !isBought, + (!isBought && reqsMet && canAfford) ? () => buyWeaponUpgrade(iid, uid) : null, + (isBought && !upg.repeatable) ? () => refundWeaponUpgrade(iid, uid) : null + ); + ny += _SH_UPG_H + 4; + } + colX += COL_W + COL_GAP; + } + } + yOff += maxColH + 14; + return yOff; +} diff --git a/js/renderer-sidepanel.js b/js/renderer-sidepanel.js new file mode 100644 index 0000000..3ebf12c --- /dev/null +++ b/js/renderer-sidepanel.js @@ -0,0 +1,192 @@ +// ═══ renderer-sidepanel.js ═══ +// ============================================================ +// RENDERER SIDEPANEL — deploy controls and combat log +// ============================================================ + +// ── SIDE PANEL ──────────────────────────────────────────────── +const SP_W = 270; +const SP_DEPLOY_HDR_H = 48; +const SP_LOG_HDR_H = 28; +const SP_LOG_AREA_H = 150; +const SP_ENEMY_CARD_H = 74; +const SP_ENEMY_CARD_GAP = 6; +const SP_QTY_STEPS = [1, 5, 10, 25, 50]; + +let _sidePanelScrollY = 0; +let _sidePanelScrollMax = 0; +let _logScrollY = 0; +let _logScrollMax = 0; + +function drawSidePanel() { + const W = canvas.width, H = canvas.height; + const PX = W - SP_W; // 1330 + const PY = HUD_H; // 64 + const PH = H - HUD_H; // 836 + const ENEMY_AREA_H = PH - SP_DEPLOY_HDR_H - SP_LOG_HDR_H - SP_LOG_AREA_H; + const ENEMY_Y = PY + SP_DEPLOY_HDR_H; // 112 + const LOG_HDR_Y = ENEMY_Y + ENEMY_AREA_H; // 634 + const LOG_Y = LOG_HDR_Y + SP_LOG_HDR_H; // 662 + + // Background + left border + ctx.fillStyle = 'rgba(6,14,22,0.92)'; + ctx.fillRect(PX, PY, SP_W, PH); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(PX, PY); ctx.lineTo(PX, PY + PH); ctx.stroke(); + + // ── Deploy header ───────────────────────────────────────── + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(PX, PY + SP_DEPLOY_HDR_H); ctx.lineTo(PX + SP_W, PY + SP_DEPLOY_HDR_H); ctx.stroke(); + + ctx.font = '11px Orbitron, monospace'; ctx.letterSpacing = '3px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#3a6080'; + ctx.fillText('DEPLOY ENEMIES', PX + 14, PY + 10); + ctx.letterSpacing = '0px'; + + // Qty badge + const QB_W = 54, QB_H = 22; + const QB_X = PX + SP_W - 14 - QB_W, QB_Y = PY + 8; + ctx.fillStyle = '#0d1e30'; ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 1; + ctx.fillRect(QB_X, QB_Y, QB_W, QB_H); ctx.strokeRect(QB_X, QB_Y, QB_W, QB_H); + ctx.font = '13px Orbitron, monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ffd700'; + ctx.fillText('×' + G.sendQuantity, QB_X + QB_W / 2, QB_Y + QB_H / 2); + addHitRegion(QB_X, QB_Y, QB_W, QB_H, () => { + const idx = SP_QTY_STEPS.indexOf(G.sendQuantity); + G.sendQuantity = SP_QTY_STEPS[(idx + 1) % SP_QTY_STEPS.length]; + }); + + ctx.font = '9px "Share Tech Mono", monospace'; ctx.letterSpacing = '2px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#1a3048'; + ctx.fillText('scroll to set qty', PX + 14, PY + 30); + ctx.letterSpacing = '0px'; + + // ── Enemy cards (scrollable, clipped) ───────────────────── + ctx.save(); + ctx.beginPath(); ctx.rect(PX, ENEMY_Y, SP_W, ENEMY_AREA_H); ctx.clip(); + + const qty = G.sendQuantity; + const bonusMult = qty >= 50 ? 1.3 : qty >= 25 ? 1.2 : qty >= 10 ? 1.12 : qty >= 5 ? 1.05 : 1.0; + + for (let i = 0; i < ENEMY_DEFS.length; i++) { + const def = ENEMY_DEFS[i]; + const totalCost = def.cost * qty; + const canDeploy = G.credits >= totalCost && !G.gameOver; + const cardY = ENEMY_Y + i * (SP_ENEMY_CARD_H + SP_ENEMY_CARD_GAP) - _sidePanelScrollY; + const CX = PX + 8, CW = SP_W - 16; + + if (cardY + SP_ENEMY_CARD_H < ENEMY_Y || cardY > ENEMY_Y + ENEMY_AREA_H) continue; + + const hov = canDeploy && isHovered(CX, cardY, CW, SP_ENEMY_CARD_H); + const elColor = def.element ? (ELEMENTS[def.element]?.color || '#1a3048') : '#1a3048'; + + ctx.save(); + if (!canDeploy) ctx.globalAlpha = 0.32; + ctx.fillStyle = hov ? '#0c1828' : '#080f18'; + ctx.strokeStyle = hov ? '#ffd700' : '#1a3048'; + ctx.lineWidth = 1; + ctx.fillRect(CX, cardY, CW, SP_ENEMY_CARD_H); + ctx.strokeRect(CX, cardY, CW, SP_ENEMY_CARD_H); + + // Element accent bar + ctx.fillStyle = elColor; + ctx.fillRect(CX, cardY + 5, 3, SP_ENEMY_CARD_H - 10); + + // Freshness bar (5px strip at card top) + const fresh = G.enemyFreshness[def.id] || 0; + const freshPct = Math.max(0, 1 - fresh / 17); + if (freshPct > 0) { + const fbG = ctx.createLinearGradient(CX, 0, CX + CW * freshPct, 0); + fbG.addColorStop(0, '#00d4ff'); fbG.addColorStop(1, '#0055cc'); + ctx.fillStyle = fbG; + ctx.fillRect(CX, cardY, CW * freshPct, 5); + } + + // Hotkey + const hotkey = i < 9 ? String(i + 1) : i === 9 ? '0' : ''; + ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '0px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#3a6080'; + ctx.fillText('[' + hotkey + ']', CX + 6, cardY + 8); + + // Name + ctx.font = '13px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '0.5px'; + ctx.fillStyle = def.color || '#b8d8e8'; + ctx.fillText(def.name, CX + 30, cardY + 7); + ctx.letterSpacing = '0px'; + + // Cost + ctx.font = '13px Orbitron, monospace'; + ctx.textAlign = 'right'; ctx.fillStyle = '#ffd700'; + ctx.fillText(totalCost + '¢', CX + CW - 4, cardY + 7); + + // Stats row + const statParts = ['HP ' + def.hp, 'SPD ' + def.speed]; + if (def.armor) statParts.push('ARM ' + def.armor); + if (def.evasion) statParts.push('EVA ' + Math.round(def.evasion * 100) + '%'); + if (def.count > 1) statParts.push('×' + def.count); + const elIcon = def.element ? (ELEMENTS[def.element]?.icon || '') : ''; + if (elIcon) statParts.push(elIcon); + ctx.save(); + ctx.beginPath(); ctx.rect(CX + 6, cardY + 26, CW - 12, 16); ctx.clip(); + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#3a6080'; + ctx.fillText(statParts.join(' · '), CX + 6, cardY + 26); + ctx.restore(); + + // Reward + profit + const rewardPerUnit = Math.round(def.reward * bonusMult); + const profit = rewardPerUnit - def.cost; + const profitStr = (profit >= 0 ? '+' : '') + profit + '¢'; + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; ctx.fillStyle = '#00ff88'; + ctx.fillText('↑ ' + rewardPerUnit + '¢', CX + 6, cardY + SP_ENEMY_CARD_H - 6); + ctx.fillStyle = profit >= 0 ? '#00ff88' : '#ff3355'; + ctx.fillText('(' + profitStr + ')', CX + 60, cardY + SP_ENEMY_CARD_H - 6); + if (bonusMult > 1) { + ctx.fillStyle = '#ffd700'; ctx.font = '9px Orbitron, monospace'; + ctx.fillText('+' + Math.round((bonusMult - 1) * 100) + '%', CX + 114, cardY + SP_ENEMY_CARD_H - 6); + } + ctx.restore(); + + if (canDeploy) { + const dId = def.id; + addHitRegion(CX, cardY, CW, SP_ENEMY_CARD_H, () => deployEnemy(dId, G.sendQuantity)); + } + } + + const totalCardH = ENEMY_DEFS.length * (SP_ENEMY_CARD_H + SP_ENEMY_CARD_GAP); + _sidePanelScrollMax = Math.max(0, totalCardH - ENEMY_AREA_H + 4); + ctx.restore(); // end enemy clip + + // ── Log section header ───────────────────────────────────── + ctx.fillStyle = '#060e18'; + ctx.fillRect(PX, LOG_HDR_Y, SP_W, SP_LOG_HDR_H); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(PX, LOG_HDR_Y); ctx.lineTo(PX + SP_W, LOG_HDR_Y); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(PX, LOG_HDR_Y + SP_LOG_HDR_H); ctx.lineTo(PX + SP_W, LOG_HDR_Y + SP_LOG_HDR_H); ctx.stroke(); + ctx.font = '11px Orbitron, monospace'; ctx.letterSpacing = '3px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080'; + ctx.fillText('COMBAT LOG', PX + 14, LOG_HDR_Y + SP_LOG_HDR_H / 2); + ctx.letterSpacing = '0px'; + + // ── Log entries (scrollable, clipped) ───────────────────── + ctx.save(); + ctx.beginPath(); ctx.rect(PX, LOG_Y, SP_W, SP_LOG_AREA_H); ctx.clip(); + + const LOG_ENTRY_H = 18; + const logColorMap = { win: '#00ff88', lose: '#ff3355', info: '#00d4ff' }; + const logLines = G.logLines || []; + for (let i = 0; i < logLines.length; i++) { + const ly = LOG_Y + i * LOG_ENTRY_H - _logScrollY; + if (ly + LOG_ENTRY_H < LOG_Y || ly > LOG_Y + SP_LOG_AREA_H) continue; + ctx.save(); + ctx.beginPath(); ctx.rect(PX + 10, ly, SP_W - 16, LOG_ENTRY_H); ctx.clip(); + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillStyle = logColorMap[logLines[i].type] || '#3a6080'; + ctx.fillText('› ' + logLines[i].text, PX + 10, ly + 2); + ctx.restore(); + } + + _logScrollMax = Math.max(0, logLines.length * LOG_ENTRY_H - SP_LOG_AREA_H + 4); + ctx.restore(); // end log clip +} diff --git a/js/renderer-tower.js b/js/renderer-tower.js new file mode 100644 index 0000000..03ad026 --- /dev/null +++ b/js/renderer-tower.js @@ -0,0 +1,214 @@ +// ═══ renderer-tower.js ═══ +// ============================================================ +// RENDERER TOWER — tower hardpoints and shields +// ============================================================ + +// ── TOWER ───────────────────────────────────────────────────── +function drawTower(x, y) { + const t = G.frame; + const hpRatio = G.tower.hp / G.tower.maxHp; + const color = hpRatio > 0.5 ? '#00d4ff' : hpRatio > 0.25 ? '#ffd700' : '#ff3355'; + const pulse = 0.6 + 0.4 * Math.sin(t * 0.05); + const totalSlots = Math.max(1, G.tower.weaponSlots); + const hardpointOrbit = getHardpointOrbit(totalSlots); + + // Pulse ring + ctx.strokeStyle = color + '44'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(x, y, 36 + pulse * 4 + (hardpointOrbit - 20) * 0.45, 0, Math.PI*2); + ctx.stroke(); + + // Base chassis + ctx.fillStyle = '#050a0e'; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(x, y, 24, 0, Math.PI*2); ctx.fill(); ctx.stroke(); + + // Inner glow + const grd = ctx.createRadialGradient(x, y, 0, x, y, 20); + grd.addColorStop(0, color + '44'); + grd.addColorStop(1, 'transparent'); + ctx.fillStyle = grd; + ctx.beginPath(); ctx.arc(x, y, 20, 0, Math.PI*2); ctx.fill(); + + // Hardpoint ring + ctx.strokeStyle = color + '2d'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.beginPath(); + ctx.arc(x, y, hardpointOrbit, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + + // Draw all unlocked mount sockets (filled when weapon installed) + for (let slotIndex = 0; slotIndex < totalSlots; slotIndex++) { + const mount = getSlotHardpoint(slotIndex, x, y, totalSlots); + const installed = !!G.weapons[slotIndex]; + ctx.fillStyle = installed ? '#0d1f2a' : '#061018'; + ctx.strokeStyle = installed ? color + '88' : '#1a3240'; + ctx.lineWidth = installed ? 1.8 : 1.2; + ctx.beginPath(); + ctx.arc(mount.x, mount.y, installed ? 5.4 : 4.2, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + + // Weapon hardpoints and independent turrets + const hardpoints = getWeaponHardpoints(x, y); + ctx.shadowBlur = 7; + for (const hp of hardpoints) { + const w = hp.weapon; + const def = getWeaponDef(w); + if (!def) continue; + + const wColor = ELEMENTS[w.elements[0]]?.color || def.color || '#c8c8c8'; + const wGlow = ELEMENTS[w.elements[0]]?.glow || wColor; + const aim = typeof w.aimAngle === 'number' ? w.aimAngle : hp.mountAngle; + const barrelLen = getWeaponBarrelLength(w); + const recoilPx = (w.recoil || 0) * 3.2; + + // Brace arm + ctx.shadowBlur = 0; + ctx.strokeStyle = '#10293a'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(hp.x, hp.y); + ctx.stroke(); + + // Mount cap + ctx.fillStyle = '#07141d'; + ctx.strokeStyle = wColor; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(hp.x, hp.y, 5.8, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Turret barrel + ctx.shadowBlur = 8; + ctx.shadowColor = wGlow; + ctx.fillStyle = wColor; + ctx.save(); + ctx.translate(hp.x, hp.y); + ctx.rotate(aim); + const barrelDrawLen = Math.max(7, barrelLen - recoilPx); + let flashX = barrelDrawLen + 2; + let flashY = 0; + + switch (def.type) { + case 'beam': + ctx.fillRect(0, -3.2, barrelDrawLen, 1.9); + ctx.fillRect(0, 1.3, barrelDrawLen, 1.9); + ctx.fillStyle = '#9fe6ff'; + ctx.fillRect(barrelDrawLen - 3, -1.1, 3, 2.2); + break; + + case 'cone': + ctx.fillRect(0, -2.8, Math.max(7, barrelDrawLen * 0.58), 5.6); + ctx.beginPath(); + ctx.moveTo(barrelDrawLen * 0.56, -4.6); + ctx.lineTo(barrelDrawLen + 1, 0); + ctx.lineTo(barrelDrawLen * 0.56, 4.6); + ctx.closePath(); + ctx.fill(); + break; + + case 'mortar': + ctx.fillRect(-1, -4.8, Math.max(6, barrelDrawLen * 0.72), 9.6); + ctx.beginPath(); + ctx.arc(3, 0, 4.8, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#dff6ff'; + ctx.fillRect(Math.max(6, barrelDrawLen * 0.7) - 2, -1.6, 2, 3.2); + flashX = Math.max(6, barrelDrawLen * 0.72) + 1.5; + break; + + case 'multi': + ctx.fillRect(0, -4.2, barrelDrawLen, 2.6); + ctx.fillRect(0, 1.6, barrelDrawLen, 2.6); + ctx.fillStyle = '#dff6ff'; + ctx.fillRect(barrelDrawLen - 2, -4.1, 2, 2.4); + ctx.fillRect(barrelDrawLen - 2, 1.7, 2, 2.4); + break; + + case 'chain': + ctx.fillRect(0, -2.7, barrelDrawLen - 1, 5.4); + ctx.beginPath(); + ctx.moveTo(barrelDrawLen - 2, -2.7); + ctx.lineTo(barrelDrawLen + 1.8, -4.8); + ctx.lineTo(barrelDrawLen - 0.1, -0.7); + ctx.closePath(); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(barrelDrawLen - 2, 2.7); + ctx.lineTo(barrelDrawLen + 1.8, 4.8); + ctx.lineTo(barrelDrawLen - 0.1, 0.7); + ctx.closePath(); + ctx.fill(); + break; + + default: + ctx.fillRect(0, -2.9, barrelDrawLen, 5.8); + ctx.fillStyle = '#dff6ff'; + ctx.fillRect(barrelDrawLen - 2, -1.4, 2, 2.8); + break; + } + + if ((w.muzzleFlash || 0) > 0) { + const flashAlpha = Math.min(1, w.muzzleFlash / 4); + ctx.globalAlpha = flashAlpha; + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(flashX, flashY, 2.5 + flashAlpha * 2.5, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + } + ctx.restore(); + } + ctx.shadowBlur = 0; + + // Center hub + ctx.fillStyle = '#050a0e'; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(x, y, 10, 0, Math.PI*2); ctx.fill(); ctx.stroke(); + + ctx.textAlign = 'left'; +} + +// ── SHIELD ──────────────────────────────────────────────────── +function drawShield(x, y) { + if (!G.tower.shield || G.tower.shieldHp <= 0) return; + const alpha = G.tower.shieldHp / G.tower.shieldMaxHp; + + ctx.save(); + if (G.tower.shield === 'dome') { + const grd = ctx.createRadialGradient(x, y, 20, x, y, 50); + grd.addColorStop(0, 'transparent'); + grd.addColorStop(1, '#00d4ff'); + ctx.globalAlpha = 0.15 + alpha * 0.2; + ctx.fillStyle = grd; + ctx.beginPath(); ctx.arc(x, y, 46, 0, Math.PI*2); ctx.fill(); + ctx.globalAlpha = alpha * 0.7; + ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(x, y, 46, 0, Math.PI*2); ctx.stroke(); + + } else if (G.tower.shield === 'directional') { + const arc = G.tower.shieldArcWidth ?? (Math.PI * 0.6); + const angle = G.tower.shieldAngle; + ctx.globalAlpha = alpha * 0.7; + ctx.fillStyle = '#00d4ff22'; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.arc(x, y, 50, angle - arc/2, angle + arc/2); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(x, y, 50, angle - arc/2, angle + arc/2); + ctx.stroke(); + } + ctx.restore(); +} + diff --git a/js/renderer-world.js b/js/renderer-world.js new file mode 100644 index 0000000..2a371d0 --- /dev/null +++ b/js/renderer-world.js @@ -0,0 +1,192 @@ +// ═══ renderer-world.js ═══ +// ============================================================ +// RENDERER WORLD — background, arena, fog, portals +// ============================================================ + +// ── OFFSCREEN BACKGROUND CACHE ──────────────────────────────── +// Drawn once, re-rendered only on resize — never recomputed each frame +let _bgCanvas = null; +let _bgW = 0, _bgH = 0; +let _fogCanvas = null; +let _fogW = 0, _fogH = 0, _fogRange = 0; +const _mountDropZones = []; +const _bagDropZones = []; +const _dragRegions = []; + +function buildBackground(W, H) { + _bgCanvas = document.createElement('canvas'); + _bgCanvas.width = W; + _bgCanvas.height = H; + const c = _bgCanvas.getContext('2d'); + + const grad = c.createRadialGradient(W/2, H/2, 0, W/2, H/2, Math.max(W,H) * 0.7); + grad.addColorStop(0, '#060d14'); + grad.addColorStop(1, '#020508'); + c.fillStyle = grad; + c.fillRect(0, 0, W, H); + + c.strokeStyle = '#0a1520'; + c.lineWidth = 1; + const gs = 48; + for (let x = 0; x < W; x += gs) { c.beginPath(); c.moveTo(x, 0); c.lineTo(x, H); c.stroke(); } + for (let y = 0; y < H; y += gs) { c.beginPath(); c.moveTo(0, y); c.lineTo(W, y); c.stroke(); } + + _bgW = W; _bgH = H; +} + +// ── CACHED ELEMENT GLOW GRADIENTS ──────────────────────────── +// Keyed by "elementId:x:y:radius" — cleared each frame since positions change, +// but the gradient itself is reused within a single frame for same-element enemies. +// In practice we cache by element type at a unit radius and scale at draw time. +const _elemGradCache = {}; +function getElemGrad(el, x, y, r) { + const grd = ctx.createRadialGradient(x, y, 0, x, y, r); + grd.addColorStop(0, el.glow + '55'); + grd.addColorStop(1, 'transparent'); + return grd; +} + +// ── ARENA OVERLAY HELPERS ───────────────────────────────────── +// Subtle dashed ring at tower's current vision range +function drawArenaRangeLine(cx, cy) { + const r = G.tower.range; + ctx.save(); + ctx.strokeStyle = 'rgba(0,180,255,0.07)'; + ctx.lineWidth = 1; + ctx.setLineDash([6, 12]); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); +} + +// Fog of war: sharp transition at tower range, flat dark zone beyond it +function drawFog(cx, cy) { + const r = G.tower.range; + const W = canvas.width; + const H = canvas.height; + if (!_fogCanvas || _fogW !== W || _fogH !== H || _fogRange !== r) { + _fogCanvas = document.createElement('canvas'); + _fogCanvas.width = W; + _fogCanvas.height = H; + const c = _fogCanvas.getContext('2d'); + // ponytail: cache the identical fog gradient; rebuild only when range/canvas changes. + const fog = c.createRadialGradient(cx, cy, r * 0.94, cx, cy, r * 1.12); + fog.addColorStop(0, 'rgba(2,6,12,0)'); + fog.addColorStop(1, 'rgba(2,6,12,0.62)'); + c.fillStyle = fog; + c.beginPath(); + c.arc(cx, cy, ARENA_RADIUS, 0, Math.PI * 2); + c.fill(); + _fogW = W; + _fogH = H; + _fogRange = r; + } + ctx.drawImage(_fogCanvas, 0, 0); +} + +// Dark overlay outside the arena circle +function drawArenaMask(W, H, cx, cy) { + ctx.fillStyle = '#010407'; + ctx.beginPath(); + ctx.rect(0, 0, W, H); + ctx.arc(cx, cy, ARENA_RADIUS, 0, Math.PI * 2); // same dir as rect → evenodd cancels inside + ctx.fill('evenodd'); +} + +// Subtle glowing border ring at arena edge +function drawArenaRing(cx, cy) { + ctx.save(); + ctx.strokeStyle = 'rgba(0,100,180,0.14)'; + ctx.lineWidth = 10; + ctx.beginPath(); + ctx.arc(cx, cy, ARENA_RADIUS - 5, 0, Math.PI * 2); + ctx.stroke(); + ctx.strokeStyle = 'rgba(0,180,255,0.22)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(cx, cy, ARENA_RADIUS, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); +} + +// Streak indicator drawn on canvas; reads G.streak directly +function drawStreak(cx, cy) { + const streak = G.streak; + const framesSince = G.frame - streak.lastKillFrame; + if (streak.count < 3 || framesSince > 180) return; + + const alpha = Math.min(1, Math.min(framesSince / 8, (180 - framesSince) / 20)); + if (alpha <= 0) return; + + const high = streak.count >= 10; + const color = high ? '#ffd700' : '#00d4ff'; + const text = `x${streak.count} STREAK`; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.font = 'bold 18px Orbitron, "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; + + const tw = ctx.measureText(text).width; + const pad = 14; + const bw = tw + pad * 2; + const bh = 30; + const bx = cx - bw / 2; + const by = cy - 350; + + ctx.fillStyle = 'rgba(2,6,14,0.9)'; + ctx.fillRect(bx, by, bw, bh); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.strokeRect(bx, by, bw, bh); + + ctx.fillStyle = color; + ctx.shadowColor = color; + ctx.shadowBlur = 10; + ctx.fillText(text, cx, by + 21); + ctx.shadowBlur = 0; + ctx.restore(); +} + +// ── PORTALS ─────────────────────────────────────────────────── +function drawPortals() { + for (const p of G.portals) { + const alpha = Math.min(1, p.life / 30); + const fadeOut = Math.min(1, (p.life / p.maxLife) * 3); + const a = Math.min(alpha, fadeOut); + if (a <= 0) continue; + + ctx.save(); + + // Glow blob (no shadow — use radial gradient instead) + const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 30); + grd.addColorStop(0, p.color + '66'); + grd.addColorStop(1, 'transparent'); + ctx.globalAlpha = a; + ctx.fillStyle = grd; + ctx.beginPath(); + ctx.arc(p.x, p.y, 30, 0, Math.PI * 2); + ctx.fill(); + + // Outer ring + ctx.globalAlpha = a * 0.7; + ctx.strokeStyle = p.color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(p.x, p.y, 22 + Math.sin(G.frame * 0.08) * 3, 0, Math.PI * 2); + ctx.stroke(); + + // Spinning arc + ctx.translate(p.x, p.y); + ctx.rotate(p.angle); + ctx.globalAlpha = a * 0.9; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(0, 0, 14, 0, Math.PI * 1.5); + ctx.stroke(); + + ctx.restore(); + } +} diff --git a/js/renderer.js b/js/renderer.js new file mode 100644 index 0000000..e06f4ad --- /dev/null +++ b/js/renderer.js @@ -0,0 +1,46 @@ +// ═══ renderer.js ═══ +// ============================================================ +// RENDERER — draw order orchestration +// ============================================================ + +// ── MAIN RENDER ─────────────────────────────────────────────── +function render() { + const W = canvas.width, H = canvas.height; + const cx = ARENA_CX, cy = ARENA_CY; + + // Rebuild bg cache if size changed + if (!_bgCanvas || _bgW !== W || _bgH !== H) buildBackground(W, H); + + // Blit cached background — O(1) + ctx.drawImage(_bgCanvas, 0, 0); + + drawArenaRangeLine(cx, cy); // faint dashed ring at tower range + drawPortals(); + drawAoeZones(); + drawEnemyTrails(); + drawEnemies(); + drawProjectiles(); + drawChainArcs(); + drawBeams(); + drawFog(cx, cy); // dim beyond tower range + drawTower(cx, cy); + drawShield(cx, cy); + drawParticles(); + drawFloaters(); + drawArenaMask(W, H, cx, cy); // solid dark outside arena circle + drawArenaRing(cx, cy); // glowing border ring + drawStreak(cx, cy); + clearHitRegions(); + _mountDropZones.length = 0; + _bagDropZones.length = 0; + _dragRegions.length = 0; + drawHUD(); + drawBrokeWarning(); + drawSidePanel(); + drawInventoryOverlay(); + drawShopOverlay(); + if (G.gameOver) drawGameOverPanel(); + drawPauseOverlay(); + drawMountInteraction(cx, cy); + drawDragGhost(); +} diff --git a/js/shop.js b/js/shop.js new file mode 100644 index 0000000..fd9e552 --- /dev/null +++ b/js/shop.js @@ -0,0 +1,24 @@ +// ═══ shop.js ═══ +// ============================================================ +// SHOP.JS — Shop state management (canvas draws each frame) +// ============================================================ + +function openShop() { + if (G.gameOver) return; + G.shopOpen = true; + _shopScrollY = 0; + setPaused(true, false); +} + +function closeShop() { + G.shopOpen = false; + _shopScrollY = 0; + setPaused(false); +} + +function setShopTab(tab) { + G.shopTab = tab; + _shopScrollY = 0; +} + +function renderShop() { /* canvas draws shop each frame via drawShopOverlay() */ } diff --git a/js/state.js b/js/state.js new file mode 100644 index 0000000..a835bca --- /dev/null +++ b/js/state.js @@ -0,0 +1,153 @@ +// ═══ state.js ═══ +// ============================================================ +// STATE.JS — Central game state, constants, definitions +// ============================================================ + +// ── ARENA ───────────────────────────────────────────── +const ARENA_RADIUS = 400; // play area radius; enemies always spawn at this distance from tower +const ARENA_CX = 665; // center x of play area: (1600 - 270) / 2 +const ARENA_CY = 482; // center y of play area: 64 + (900 - 64) / 2 + +// ── INITIAL GAME STATE ──────────────────────────────────────── +function makeGameState() { + return { + credits: 150, + score: 0, + totalKills: 0, + frame: 0, + paused: false, + gameOver: false, + isBankrupt: false, + _isNewBest: false, + creditReserve: 50, // minimum credits kept in reserve — cannot spend upgrades below this + + // Tower + tower: { + hp: 20, + maxHp: 20, + armor: 0, + aimSpeed: 0.026, + range: 250, // vision + targeting radius; enemies outside are in fog and untargetable + cannonAngle: 0, + weaponSlots: 1, + shield: null, + shieldHp: 0, + shieldMaxHp: 0, + shieldAngle: 0, + shieldUnlocked: [], + }, + + // Weapons: array of active weapon instances + weapons: [ + makeWeaponInstance('cannon'), + ], + + // Upgrade tracking + towerUpgradesBought: [], // array of upgrade ids + weaponUpgradesBought: {}, // { weaponInstanceId: [upgradeIds] } + shieldUpgradesBought: [], // upgrade ids for current shield + + // Entities + enemies: [], + projectiles: [], + beams: [], + chainArcs: [], // lightning bolt visuals + aoeZones: [], // lingering AoE (poison clouds, freeze zones) + particles: [], + floaters: [], + portals: [], + + // Portal system + portalCooldown: 0, + + // Input state + selectedEnemyType: null, + sendQuantity: 1, + + // Shop + shopOpen: false, + shopTab: 'tower', // 'tower' | 'weapons' | weapon instance id + shopTreeWeapon: null, + + // Entity id counter + nextId: 1, + + // Kill streak + streak: { count: 0, lastKillFrame: -999 }, + + // Enemy freshness (novelty bonus) — higher = less fresh = less bonus + enemyFreshness: Object.fromEntries(ENEMY_DEFS.map(d => [d.id, 0])), + }; +} + +let G = makeGameState(); // global game state + +function countOwnedWeaponType(defId) { + let count = 0; + for (let i = 0; i < G.tower.weaponSlots; i++) { + const w = G.weapons[i]; + if (w && w.defId === defId) count++; + } + for (const w of (G.weaponInventory || [])) { + if (w && w.defId === defId) count++; + } + return count; +} + +function canBuyWeaponType(defId) { + return countOwnedWeaponType(defId) < MAX_WEAPONS_PER_TYPE; +} + +function makeWeaponInstance(defId) { + const def = WEAPON_DEFS.find(w => w.id === defId); + if (!def) return null; + return { + instanceId: 'w_' + defId + '_' + Date.now(), + defId, + defaultElement: def.defaultElement ?? 'physical', + // Live stats (modified by upgrades) + damage: def.damage, + fireRate: def.fireRate, + range: def.range ?? 9999, + projectileSpeed: def.projectileSpeed ?? 6, + projectileRadius: def.projectileRadius ?? 4, + coneAngle: def.coneAngle ?? 0, + chains: def.chains ?? 0, + chainRange: def.chainRange ?? 0, + aoeRadius: def.aoeRadius ?? 0, + freezeDuration: def.freezeDuration ?? 0, + armorShred: def.armorShred ?? 0, + targets: def.targets ?? 1, + amplify: def.amplify ?? 0, + dotDamage: def.dotDamage ?? 0, + dotInterval: def.dotInterval ?? 0, + dotDuration: def.dotDuration ?? 0, + pierce: 0, + critChance: 0, + elements: [], // no elements by default — must purchase infuse slots + targeting: def.targeting, + cooldown: 0, + aimAngle: -Math.PI / 2, + recoil: 0, + muzzleFlash: 0, + lastFireFrame: -9999, + canInfuse: false, + canInfuse2: false, + canInfuse3: false, + }; +} + +function getWeaponDef(instanceOrId) { + const id = typeof instanceOrId === 'string' ? instanceOrId : instanceOrId.defId; + return WEAPON_DEFS.find(w => w.id === id); +} + +function getWeaponElements(weapon) { + const base = weapon.defaultElement || getWeaponDef(weapon)?.defaultElement || 'physical'; + const elements = [...new Set([base, ...(weapon.elements || [])].filter(el => el && el !== 'physical'))]; + return elements.length ? elements : ['physical']; +} + +function uid() { + return G.nextId++; +} diff --git a/js/upgrades.js b/js/upgrades.js new file mode 100644 index 0000000..62394ab --- /dev/null +++ b/js/upgrades.js @@ -0,0 +1,359 @@ +// ═══ upgrades.js ═══ +// ============================================================ +// UPGRADES.JS — Purchase, refund, apply upgrade logic +// ============================================================ + +function collectUpgradeDependents(tree, upgradeId) { + const result = []; + function collect(id) { + for (const u of tree) { + if (u.requires.includes(id) && !result.includes(u.id)) { + result.push(u.id); + collect(u.id); + } + } + } + collect(upgradeId); + return result; +} + +function getTowerDependents(upgradeId) { + return collectUpgradeDependents(TOWER_UPGRADE_TREE, upgradeId); +} + +function getWeaponDependents(defId, upgradeId) { + return collectUpgradeDependents(WEAPON_UPGRADE_TREES[defId] || [], upgradeId); +} + +function getShieldDependents(upgradeId) { + const type = G.tower.shield; + return type ? collectUpgradeDependents(SHIELD_UPGRADE_TREES[type] || [], upgradeId) : []; +} + +function buyTowerUpgrade(upgradeId) { + const upgrade = TOWER_UPGRADE_TREE.find(u => u.id === upgradeId); + if (!upgrade) return; + if (!upgrade.repeatable && G.towerUpgradesBought.includes(upgradeId)) return; + if (upgrade.effect?.repair && G.tower.hp >= G.tower.maxHp) { + addLog('Tower already at full HP.', 'info'); + return; + } + for (const req of upgrade.requires) { + if (!G.towerUpgradesBought.includes(req)) return; + } + if (spendableCredits() < upgrade.cost) return; + G.credits -= upgrade.cost; + if (!upgrade.repeatable) G.towerUpgradesBought.push(upgradeId); + applyTowerUpgrade(upgrade.effect); + sfx_buy(); + addLog('Upgraded: ' + upgrade.label, 'info'); + updateHUD(); + renderShop(); +} + +function refundTowerUpgrade(upgradeId) { + const upgrade = TOWER_UPGRADE_TREE.find(u => u.id === upgradeId); + if (!upgrade || !G.towerUpgradesBought.includes(upgradeId)) return; + const toRefund = [upgradeId, ...getTowerDependents(upgradeId)] + .filter(id => G.towerUpgradesBought.includes(id)); + let total = 0; + for (const id of toRefund) { + const u = TOWER_UPGRADE_TREE.find(x => x.id === id); + if (!u) continue; + total += u.cost; + reverseTowerUpgrade(u.effect, id); + G.towerUpgradesBought = G.towerUpgradesBought.filter(x => x !== id); + } + G.credits += total; + sfx_refund(); + addLog('Refunded ' + toRefund.length + ' upgrade(s) — +' + total + 'c', 'info'); + updateHUD(); + renderShop(); +} + +function applyTowerUpgrade(effect) { + if (effect.maxHp) { G.tower.maxHp += effect.maxHp; G.tower.hp += effect.maxHp; } + if (effect.armor) G.tower.armor += effect.armor; + if (effect.aimSpeed) G.tower.aimSpeed += effect.aimSpeed; + if (effect.repair) G.tower.hp = Math.min(G.tower.maxHp, G.tower.hp + effect.repair); + if (effect.weaponSlot) G.tower.weaponSlots = Math.max(G.tower.weaponSlots, effect.weaponSlot); + if (effect.range) G.tower.range += effect.range; + if (effect.shield) { G.tower.shield = effect.shield; initShield(effect.shield); G.shieldUpgradesBought = []; } +} + +function reverseTowerUpgrade(effect, id) { + if (effect.maxHp) { G.tower.maxHp -= effect.maxHp; G.tower.hp = Math.min(G.tower.hp, G.tower.maxHp); } + if (effect.armor) G.tower.armor = Math.max(0, G.tower.armor - effect.armor); + if (effect.aimSpeed) G.tower.aimSpeed = Math.max(0.01, G.tower.aimSpeed - effect.aimSpeed); + if (effect.range) G.tower.range = Math.max(150, G.tower.range - effect.range); + if (effect.weaponSlot) { + const remaining = G.towerUpgradesBought + .filter(x => x !== id) + .map(x => TOWER_UPGRADE_TREE.find(u => u.id === x)) + .filter(u => u && u.effect && u.effect.weaponSlot) + .map(u => u.effect.weaponSlot); + G.tower.weaponSlots = remaining.length > 0 ? Math.max(...remaining) : 1; + while (G.weapons.filter(w=>w).length > G.tower.weaponSlots) G.weapons.pop(); + } + if (effect.shield) { + G.tower.shield = null; + G.tower.shieldHp = 0; + G.tower.shieldMaxHp = 0; + G.shieldUpgradesBought = []; + } +} + +function initShield(type) { + if (type === 'dome') { + G.tower.shieldMaxHp = 10; G.tower.shieldHp = 10; + G.tower.shieldRechargeDelay = 300; G.tower.shieldRechargeTimer = 0; + G.tower.shieldRechargeRate = 1; G.tower.shieldReflect = 0; + } else if (type === 'directional') { + G.tower.shieldMaxHp = 35; G.tower.shieldHp = 35; + G.tower.shieldArcWidth = Math.PI * 0.6; G.tower.shieldTrackSpeed = 0.06; + G.tower.shieldAngle = 0; G.tower.shieldAbsorption = 1.0; + G.tower.shieldRechargeDelay = 240; G.tower.shieldRechargeTimer = 0; + } +} + +function buyWeapon(defId) { + const def = WEAPON_DEFS.find(w => w.id === defId); + if (!def) return; + if (!canBuyWeaponType(defId)) { + addLog(`${def.name} limit reached (${MAX_WEAPONS_PER_TYPE}/${MAX_WEAPONS_PER_TYPE}).`, 'lose'); + return; + } + if (spendableCredits() < def.cost) return; + G.credits -= def.cost; + const instance = makeWeaponInstance(defId); + G.weaponUpgradesBought[instance.instanceId] = []; + sfx_buy(); + + const equippedCount = getEquippedWeapons().length; + if (equippedCount < G.tower.weaponSlots) { + let placedAt = -1; + for (let i = 0; i < G.tower.weaponSlots; i++) { + if (!G.weapons[i]) { + G.weapons[i] = instance; + placedAt = i; + break; + } + } + if (placedAt >= 0) addLog('Purchased ' + def.name + ' — equipped in slot ' + (placedAt + 1) + '!', 'win'); + else addLog('Purchased ' + def.name + ' — equipped!', 'win'); + } else { + // All slots full — add to inventory + G.weaponInventory = G.weaponInventory || []; + G.weaponInventory.push(instance); + addLog('Purchased ' + def.name + ' — stored in inventory. Open inventory.', 'win'); + } + + updateHUD(); + renderShop(); +} + +function buyWeaponUpgrade(instanceId, upgradeId) { + const weapon = findWeaponInstance(instanceId); + if (!weapon) return; + const tree = WEAPON_UPGRADE_TREES[weapon.defId]; + if (!tree) return; + const upgrade = tree.find(u => u.id === upgradeId); + if (!upgrade) return; + const bought = G.weaponUpgradesBought[instanceId] || []; + if (bought.includes(upgradeId)) return; + for (const req of upgrade.requires) { if (!bought.includes(req)) return; } + if (spendableCredits() < upgrade.cost) return; + G.credits -= upgrade.cost; + bought.push(upgradeId); + G.weaponUpgradesBought[instanceId] = bought; + applyWeaponUpgrade(weapon, upgrade.effect); + sfx_buy(); + addLog(getWeaponDef(weapon).name + ': ' + upgrade.label, 'info'); + updateHUD(); + renderShop(); +} + +function refundWeaponUpgrade(instanceId, upgradeId) { + const weapon = findWeaponInstance(instanceId); + if (!weapon) return; + const tree = WEAPON_UPGRADE_TREES[weapon.defId] || []; + const bought = G.weaponUpgradesBought[instanceId] || []; + if (!bought.includes(upgradeId)) return; + const toRefund = [upgradeId, ...getWeaponDependents(weapon.defId, upgradeId)] + .filter(id => bought.includes(id)); + let total = 0; + for (const id of toRefund) { + const u = tree.find(x => x.id === id); + if (!u) continue; + total += u.cost; + reverseWeaponUpgrade(weapon, u.effect); + G.weaponUpgradesBought[instanceId] = G.weaponUpgradesBought[instanceId].filter(x => x !== id); + } + G.credits += total; + sfx_refund(); + addLog('Refunded ' + toRefund.length + ' upgrade(s) — +' + total + 'c', 'info'); + updateHUD(); + renderShop(); +} + +function applyWeaponUpgrade(weapon, effect) { + if (effect.damage) weapon.damage += effect.damage; + if (effect.fireRate) weapon.fireRate = Math.max(4, weapon.fireRate + effect.fireRate); + if (effect.projectileSpeed) weapon.projectileSpeed += effect.projectileSpeed; + if (effect.pierce) weapon.pierce = (weapon.pierce||0) + effect.pierce; + if (effect.critChance) weapon.critChance = (weapon.critChance||0) + effect.critChance; + if (effect.range) weapon.range += effect.range; + if (effect.coneAngle) weapon.coneAngle += effect.coneAngle; + if (effect.dotDuration) weapon.dotDuration = (weapon.dotDuration||0) + effect.dotDuration; + if (effect.chains) weapon.chains = (weapon.chains||0) + effect.chains; + if (effect.chainRange) weapon.chainRange = (weapon.chainRange||0) + effect.chainRange; + if (effect.aoeRadius) weapon.aoeRadius = (weapon.aoeRadius||0) + effect.aoeRadius; + if (effect.freezeDuration) weapon.freezeDuration = (weapon.freezeDuration||0) + effect.freezeDuration; + if (effect.armorShred) weapon.armorShred = (weapon.armorShred||0) + effect.armorShred; + if (effect.targets) weapon.targets = (weapon.targets||1) + effect.targets; + if (effect.amplify) weapon.amplify = (weapon.amplify||0) + effect.amplify; + if (effect.dotDamage) weapon.dotDamage = (weapon.dotDamage||0) + effect.dotDamage; + if (effect.projectileRadius) weapon.projectileRadius = (weapon.projectileRadius||4) + effect.projectileRadius; + if (effect.canInfuse) weapon.canInfuse = true; + if (effect.canInfuse2) weapon.canInfuse2 = true; + if (effect.canInfuse3) weapon.canInfuse3 = true; +} + +function reverseWeaponUpgrade(weapon, effect) { + if (effect.damage) weapon.damage = Math.max(1, weapon.damage - effect.damage); + if (effect.fireRate) weapon.fireRate = Math.min(600, weapon.fireRate - effect.fireRate); + if (effect.projectileSpeed) weapon.projectileSpeed = Math.max(1, weapon.projectileSpeed - effect.projectileSpeed); + if (effect.pierce) weapon.pierce = Math.max(0, (weapon.pierce||0) - effect.pierce); + if (effect.critChance) weapon.critChance = Math.max(0, (weapon.critChance||0) - effect.critChance); + if (effect.range) weapon.range = Math.max(80, weapon.range - effect.range); + if (effect.coneAngle) weapon.coneAngle = Math.max(0.1, weapon.coneAngle - effect.coneAngle); + if (effect.dotDuration) weapon.dotDuration = Math.max(0, (weapon.dotDuration||0) - effect.dotDuration); + if (effect.chains) weapon.chains = Math.max(0, (weapon.chains||0) - effect.chains); + if (effect.chainRange) weapon.chainRange = Math.max(40, (weapon.chainRange||0) - effect.chainRange); + if (effect.aoeRadius) weapon.aoeRadius = Math.max(10, (weapon.aoeRadius||0) - effect.aoeRadius); + if (effect.freezeDuration) weapon.freezeDuration = Math.max(0, (weapon.freezeDuration||0) - effect.freezeDuration); + if (effect.armorShred) weapon.armorShred = Math.max(0, (weapon.armorShred||0) - effect.armorShred); + if (effect.targets) weapon.targets = Math.max(1, (weapon.targets||1) - effect.targets); + if (effect.amplify) weapon.amplify = Math.max(0, (weapon.amplify||0) - effect.amplify); + if (effect.dotDamage) weapon.dotDamage = Math.max(0, (weapon.dotDamage||0) - effect.dotDamage); + if (effect.projectileRadius) weapon.projectileRadius = Math.max(2, (weapon.projectileRadius||4) - effect.projectileRadius); + if (effect.canInfuse) { weapon.canInfuse = false; weapon.elements[0] = null; weapon.elements = weapon.elements.filter(Boolean); } + if (effect.canInfuse2) { weapon.canInfuse2 = false; weapon.elements[1] = null; weapon.elements = weapon.elements.filter(Boolean); } + if (effect.canInfuse3) { weapon.canInfuse3 = false; weapon.elements[2] = null; weapon.elements = weapon.elements.filter(Boolean); } +} + +function buyShieldUpgrade(upgradeId) { + const type = G.tower.shield; + if (!type) return; + const tree = SHIELD_UPGRADE_TREES[type]; + if (!tree) return; + const upgrade = tree.find(u => u.id === upgradeId); + if (!upgrade) return; + const bought = G.shieldUpgradesBought || []; + if (bought.includes(upgradeId)) return; + for (const req of upgrade.requires) { if (!bought.includes(req)) return; } + if (spendableCredits() < upgrade.cost) return; + G.credits -= upgrade.cost; + bought.push(upgradeId); + G.shieldUpgradesBought = bought; + applyShieldUpgrade(upgrade.effect); + sfx_buy(); + addLog('Shield: ' + upgrade.label, 'info'); + updateHUD(); + renderShop(); +} + +function refundShieldUpgrade(upgradeId) { + const type = G.tower.shield; + if (!type) return; + const tree = SHIELD_UPGRADE_TREES[type] || []; + const bought = G.shieldUpgradesBought || []; + if (!bought.includes(upgradeId)) return; + const toRefund = [upgradeId, ...getShieldDependents(upgradeId)] + .filter(id => bought.includes(id)); + let total = 0; + for (const id of toRefund) { + const u = tree.find(x => x.id === id); + if (!u) continue; + total += u.cost; + reverseShieldUpgrade(u.effect); + G.shieldUpgradesBought = G.shieldUpgradesBought.filter(x => x !== id); + } + G.credits += total; + sfx_refund(); + addLog('Shield refund: +' + total + 'c', 'info'); + updateHUD(); + renderShop(); +} + +function applyShieldUpgrade(effect) { + if (effect.capacity) { G.tower.shieldMaxHp += effect.capacity; G.tower.shieldHp += effect.capacity; } + if (effect.rechargeDelay) G.tower.shieldRechargeDelay = Math.max(60, (G.tower.shieldRechargeDelay||300) + effect.rechargeDelay); + if (effect.reflect) G.tower.shieldReflect = (G.tower.shieldReflect||0) + effect.reflect; + if (effect.arcWidth) G.tower.shieldArcWidth += effect.arcWidth; + if (effect.trackSpeed) G.tower.shieldTrackSpeed += effect.trackSpeed; + if (effect.absorption) G.tower.shieldAbsorption = (G.tower.shieldAbsorption||1.0) + effect.absorption; +} + +function reverseShieldUpgrade(effect) { + if (effect.capacity) { G.tower.shieldMaxHp = Math.max(1, G.tower.shieldMaxHp - effect.capacity); G.tower.shieldHp = Math.min(G.tower.shieldHp, G.tower.shieldMaxHp); } + if (effect.rechargeDelay) G.tower.shieldRechargeDelay = Math.min(600, (G.tower.shieldRechargeDelay||300) - effect.rechargeDelay); + if (effect.reflect) G.tower.shieldReflect = Math.max(0, (G.tower.shieldReflect||0) - effect.reflect); + if (effect.arcWidth) G.tower.shieldArcWidth = Math.max(0.3, G.tower.shieldArcWidth - effect.arcWidth); + if (effect.trackSpeed) G.tower.shieldTrackSpeed = Math.max(0.01, G.tower.shieldTrackSpeed - effect.trackSpeed); + if (effect.absorption) G.tower.shieldAbsorption = Math.max(1.0, (G.tower.shieldAbsorption||1.0) - effect.absorption); +} + +function setWeaponInfusion(instanceId, slotIndex, element) { + const weapon = findWeaponInstance(instanceId); + if (!weapon) return; + if (slotIndex === 0 && !weapon.canInfuse) return; + if (slotIndex === 1 && !weapon.canInfuse2) return; + if (slotIndex === 2 && !weapon.canInfuse3) return; + weapon.elements[slotIndex] = element; + renderShop(); +} + +function setWeaponTargeting(instanceId, targeting) { + const weapon = findWeaponInstance(instanceId); + if (!weapon) return; + weapon.targeting = targeting; +} + +function updateShield() { + if (!G.tower.shield) return; + if (G.tower.shield === 'directional') { + const nearest = []; + const cx = canvas.width/2, cy = canvas.height/2; + for (const e of G.enemies) { + if (!e.alive || e.spawnImmunity > 0) continue; + const d = distSq(e.x, e.y, cx, cy); + let insertAt = nearest.length; + while (insertAt > 0 && nearest[insertAt - 1].d > d) insertAt--; + if (insertAt >= 3) continue; + nearest.splice(insertAt, 0, { e, d }); + if (nearest.length > 3) nearest.length = 3; + } + if (nearest.length > 0) { + let avgX = 0; + let avgY = 0; + for (const item of nearest) { + avgX += item.e.x; + avgY += item.e.y; + } + avgX /= nearest.length; + avgY /= nearest.length; + const desired = Math.atan2(avgY-canvas.height/2, avgX-canvas.width/2); + const delta = normalizeAngle(desired - G.tower.shieldAngle); + G.tower.shieldAngle += Math.sign(delta)*Math.min(Math.abs(delta), G.tower.shieldTrackSpeed||0.06); + } + } + if (G.tower.shieldHp < G.tower.shieldMaxHp) { + G.tower.shieldRechargeTimer = (G.tower.shieldRechargeTimer||0) + 1; + if (G.tower.shieldRechargeTimer >= (G.tower.shieldRechargeDelay||300)) { + G.tower.shieldHp = Math.min(G.tower.shieldMaxHp, G.tower.shieldHp + 1); + } + } else { + G.tower.shieldRechargeTimer = 0; + } +} diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..329c441 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,59 @@ +// ═══ utils.js ═══ +// ============================================================ +// UTILS — shared pure helpers +// ============================================================ + +function distSq(ax, ay, bx, by) { + const dx = ax - bx; + const dy = ay - by; + return dx * dx + dy * dy; +} + +function normalizeAngle(angle) { + while (angle > Math.PI) angle -= Math.PI * 2; + while (angle < -Math.PI) angle += Math.PI * 2; + return angle; +} + +function shortestAngleDelta(from, to) { + return normalizeAngle(to - from); +} + +function stepTowardAngle(current, desired, maxStep) { + const delta = shortestAngleDelta(current, desired); + if (Math.abs(delta) <= maxStep) return normalizeAngle(desired); + return normalizeAngle(current + Math.sign(delta) * maxStep); +} + +function compactLiveArray(items, isLive) { + let write = 0; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (isLive(item)) items[write++] = item; + } + items.length = write; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function cheapestEnemyCost() { + return ENEMY_DEFS.reduce((min, d) => Math.min(min, d.cost), Infinity); +} + +function countAliveEnemies() { + let count = 0; + for (const e of G.enemies) if (e.alive) count++; + return count; +} + +function getEquippedWeapons() { + return G.weapons.slice(0, G.tower.weaponSlots).filter(Boolean); +} + +function findWeaponInstance(instanceId) { + for (const w of G.weapons) if (w && w.instanceId === instanceId) return w; + return null; +} + diff --git a/js/weapon-fire.js b/js/weapon-fire.js new file mode 100644 index 0000000..f1d1065 --- /dev/null +++ b/js/weapon-fire.js @@ -0,0 +1,254 @@ +// ═══ weapon-fire.js ═══ +// ============================================================ +// WEAPON FIRE — dispatch and per-weapon shot handlers +// ============================================================ + +function pushStandardProjectile(w, target, ox, oy, vx, vy, opts = {}) { + const elements = opts.elements || getWeaponElements(w); + const color = opts.color || getWeaponColor(w); + G.projectiles.push({ + id: uid(), + x: ox, y: oy, + vx, vy, + radius: opts.radius ?? w.projectileRadius ?? 4, + damage: opts.damage ?? w.damage, + elements, + targetId: target.id, + targetRef: target, + pierce: opts.pierce ?? w.pierce ?? 0, + pierceLeft: opts.pierceLeft ?? w.pierce ?? 0, + critChance: opts.critChance ?? w.critChance ?? 0, + armorShred: opts.armorShred ?? w.armorShred ?? 0, + color, + glow: ELEMENTS[elements[0]]?.glow || color, + life: 1, + weaponId: w.instanceId, + type: 'standard', + ...opts.extra, + }); +} + +function fireWeapon(w, target, ox, oy, aimAngle, aimPoint = null) { + const def = getWeaponDef(w); + // Sound — throttle rapid-fire weapons + const throttleMs = (def.fireRate < 15) ? 80 : 0; + if (throttleMs) sfx_weapon_fire_throttled(def.id, throttleMs); + else sfx_weapon_fire(def.id); + + switch (def.type) { + case 'projectile': fireProjectile(w, target, ox, oy, aimAngle); break; + case 'cone': fireCone(w, target, ox, oy, aimAngle); break; + case 'chain': fireChain(w, target, ox, oy); break; + case 'mortar': fireMortar(w, target, ox, oy, aimPoint); break; + case 'beam': fireBeam(w, target, ox, oy, aimAngle); break; + case 'multi': fireMulti(w, ox, oy); break; + } +} + +// ── PROJECTILE ──────────────────────────────────────────────── +function fireProjectile(w, target, ox, oy, aimAngle) { + const angle = typeof aimAngle === 'number' ? aimAngle : Math.atan2(target.y - oy, target.x - ox); + pushStandardProjectile( + w, target, ox, oy, + Math.cos(angle) * w.projectileSpeed, + Math.sin(angle) * w.projectileSpeed + ); +} + +// ── CONE (flamethrower) ─────────────────────────────────────── +function fireCone(w, target, ox, oy, aimAngle) { + const baseAngle = typeof aimAngle === 'number' ? aimAngle : Math.atan2(target.y - oy, target.x - ox); + const halfCone = w.coneAngle / 2; + const range = w.range; + const elements = getWeaponElements(w); + // Check all enemies in cone + for (const e of G.enemies) { + if (!e.alive || e.spawnImmunity > 0) continue; + const ex = e.x - ox, ey = e.y - oy; + if (ex * ex + ey * ey > range * range) continue; + const angle = Math.atan2(ey, ex); + const diff = normalizeAngle(angle - baseAngle); + if (Math.abs(diff) > halfCone) continue; + dealDamage(e, w.damage, elements, false, 0, 0); + } + // Visual: layered flame particles + // Core jet — bright yellow, large, near barrel + for (let i = 0; i < 5; i++) { + const a = baseAngle + (Math.random() - 0.5) * halfCone * 1.1; + const dist = 10 + Math.random() * range * 0.55; + const t = dist / range; + const color = t < 0.3 ? '#ffee55' : t < 0.55 ? '#ffaa22' : '#ff6600'; + spawnParticle( + ox + Math.cos(a) * dist, oy + Math.sin(a) * dist, + color, + Math.cos(a) * 0.6 + (Math.random()-0.5)*0.5, + Math.sin(a) * 0.6 + (Math.random()-0.5)*0.5 - 0.5, + 0.85 + Math.random() * 0.15, + (1 - t) * 8 + 3 + Math.random() * 3, + 0.06 + Math.random() * 0.04 + ); + } + // Body — orange, mid-range, billowing + for (let i = 0; i < 7; i++) { + const a = baseAngle + (Math.random() - 0.5) * halfCone * 2.4; + const dist = 20 + Math.random() * range * 0.80; + const t = dist / range; + const color = t < 0.45 ? '#ff8822' : t < 0.72 ? '#ff4400' : '#cc2200'; + spawnParticle( + ox + Math.cos(a) * dist, oy + Math.sin(a) * dist, + color, + (Math.random()-0.5) * 1.1, + (Math.random()-0.5) * 0.8 - 0.7, + 0.6 + Math.random() * 0.3, + (1 - t * 0.5) * 5 + 2 + Math.random() * 3, + 0.05 + Math.random() * 0.03 + ); + } + // Embers / smoke — dark red and charcoal, drift upward + for (let i = 0; i < 3; i++) { + const a = baseAngle + (Math.random() - 0.5) * halfCone * 2.8; + const dist = range * 0.45 + Math.random() * range * 0.55; + const color = Math.random() < 0.55 ? '#ff3300' : '#332211'; + spawnParticle( + ox + Math.cos(a) * dist, oy + Math.sin(a) * dist, + color, + (Math.random()-0.5) * 0.6, + -0.7 - Math.random() * 0.6, + 0.5 + Math.random() * 0.3, + 2 + Math.random() * 4, + 0.03 + Math.random() * 0.02 + ); + } +} + +// ── CHAIN LIGHTNING ─────────────────────────────────────────── +function fireChain(w, target, ox, oy) { + const elements = getWeaponElements(w); + let current = target; + const hit = new Set([target.id]); + let dmg = w.damage; + let prev = { x: ox, y: oy }; + + for (let i = 0; i <= w.chains; i++) { + dealDamage(current, dmg, elements, false); + spawnChainArc(prev.x, prev.y, current.x, current.y, getWeaponColor(w)); + prev = { x: current.x, y: current.y }; + dmg *= 0.7; + + let next = null; + let nextDist = w.chainRange * w.chainRange; + for (const e of G.enemies) { + if (!e.alive || hit.has(e.id)) continue; + const d = distSq(e.x, e.y, current.x, current.y); + if (d < nextDist) { + next = e; + nextDist = d; + } + } + if (!next) break; + hit.add(next.id); + current = next; + } +} + +// ── MORTAR / FREEZE BOMB / POISON CLOUD ────────────────────── +function fireMortar(w, target, ox, oy, aimPoint = null) { + const tx = aimPoint?.x ?? target.x; + const ty = aimPoint?.y ?? target.y; + const elements = getWeaponElements(w); + const color = getWeaponColor(w); + G.projectiles.push({ + id: uid(), + x: ox, y: oy, + tx, ty, + vx: 0, vy: 0, + radius: 6, + damage: w.damage, + elements, + aoeRadius: w.aoeRadius, + freezeDuration: w.freezeDuration ?? 0, + dotDamage: w.dotDamage ?? 0, + dotInterval: w.dotInterval ?? 0, + dotDuration: w.dotDuration ?? 0, + color, + glow: ELEMENTS[elements[0]]?.glow || color, + life: 1, + type: 'mortar', + weaponId: w.instanceId, + // Mortar arc + progress: 0, + speed: w.projectileSpeed ?? 3.5, + sx: ox, sy: oy, + peaked: false, + }); +} + +// ── BEAM (laser) ────────────────────────────────────────────── +function fireBeam(w, target, ox, oy, aimAngle) { + const angle = typeof aimAngle === 'number' ? aimAngle : Math.atan2(target.y - oy, target.x - ox); + const elements = getWeaponElements(w); + // Check all enemies along the beam line + for (const e of G.enemies) { + if (!e.alive || e.spawnImmunity > 0) continue; + if (isOnBeamLine(ox, oy, angle, e.x, e.y, e.radius + 8)) { + dealDamage(e, w.damage, elements, false); + } + } + // Visual beam flash + G.beams.push({ + id: uid(), + x1: ox, y1: oy, + angle, + length: Math.max(canvas.width, canvas.height), + color: getWeaponColor(w), + glow: ELEMENTS[elements[0]]?.glow || getWeaponColor(w), + life: 1, + decay: 0.15, + }); +} + +function isOnBeamLine(ox, oy, angle, px, py, radius) { + const dx = Math.cos(angle), dy = Math.sin(angle); + const ex = px - ox, ey = py - oy; + const t = ex * dx + ey * dy; + if (t < 0) return false; + const closestX = ox + dx * t; + const closestY = oy + dy * t; + return distSq(closestX, closestY, px, py) < radius * radius; +} + +// ── MULTI (missile pod) ─────────────────────────────────────── +function fireMulti(w, ox, oy) { + const targets = []; + const maxTargets = Math.max(1, w.targets || 1); + for (const e of G.enemies) { + if (!e.alive || e.spawnImmunity > 0) continue; + let insertAt = targets.length; + while (insertAt > 0 && targets[insertAt - 1].hp > e.hp) insertAt--; + if (insertAt >= maxTargets) continue; + targets.splice(insertAt, 0, e); + if (targets.length > maxTargets) targets.length = maxTargets; + } + if (targets.length === 0) return; + + const elements = getWeaponElements(w); + const color = getWeaponColor(w); + for (const t of targets) { + const angle = Math.atan2(t.y - oy, t.x - ox); + const spread = (Math.random() - 0.5) * 0.3; + pushStandardProjectile( + w, t, ox, oy, + Math.cos(angle + spread) * w.projectileSpeed, + Math.sin(angle + spread) * w.projectileSpeed, + { + radius: 4, + elements, + color, + pierce: 0, + pierceLeft: 0, + armorShred: 0, + extra: { homing: true, homingStrength: 0.12, aoeRadius: w.aoeRadius ?? 0 }, + } + ); + } +} diff --git a/js/weapon-projectiles.js b/js/weapon-projectiles.js new file mode 100644 index 0000000..27fd82d --- /dev/null +++ b/js/weapon-projectiles.js @@ -0,0 +1,211 @@ +// ═══ weapon-projectiles.js ═══ +// ============================================================ +// WEAPON PROJECTILES — projectile, beam, AoE, and chain updates +// ============================================================ + +// ── UPDATE PROJECTILES ──────────────────────────────────────── +function updateProjectiles() { + const W = canvas.width, H = canvas.height; + const cx = W / 2, cy = H / 2; + + for (const p of G.projectiles) { + if (p.life <= 0) continue; + + if (p.type === 'mortar') { + // Arc toward target + p.progress += (p.speed * 0.6) / Math.hypot(p.tx - p.sx, p.ty - p.sy); + p.progress = Math.min(p.progress, 1); + p.x = p.sx + (p.tx - p.sx) * p.progress; + p.y = p.sy + (p.ty - p.sy) * p.progress; + // Arc height + const arc = Math.sin(p.progress * Math.PI) * 60; + p.y -= arc; + + if (p.progress >= 1) { + // Explode + explodeMortar(p); + p.life = 0; + } + continue; + } + + // Homing + if (p.homing && p.targetId) { + let target = p.targetRef && p.targetRef.alive ? p.targetRef : null; + if (!target) { + for (const e of G.enemies) { + if (e.id === p.targetId && e.alive) { + target = e; + p.targetRef = e; + break; + } + } + } + if (target) { + const angle = Math.atan2(target.y - p.y, target.x - p.x); + const currentAngle = Math.atan2(p.vy, p.vx); + const delta = normalizeAngle(angle - currentAngle); + const newAngle = currentAngle + Math.sign(delta) * Math.min(Math.abs(delta), p.homingStrength); + const spd = Math.hypot(p.vx, p.vy); + p.vx = Math.cos(newAngle) * spd; + p.vy = Math.sin(newAngle) * spd; + } + } + + p.x += p.vx; + p.y += p.vy; + + // Out of bounds + if (p.x < -50 || p.x > W+50 || p.y < -50 || p.y > H+50) { + p.life = 0; + continue; + } + + // Hit detection + for (const e of G.enemies) { + if (!e.alive || e.spawnImmunity > 0) continue; + const hitRadius = e.radius + p.radius; + if (distSq(p.x, p.y, e.x, e.y) < hitRadius * hitRadius) { + // Evasion check + if (e.evasion && Math.random() < e.evasion) { + spawnFloater(e.x, e.y - 14, 'DODGE', '#aaaaaa', 0.9); + p.life = 0; + break; + } + if (p.aoeRadius > 0) { + explodeProjectile(p); + } else { + dealDamage(e, p.damage, p.elements, true, p.critChance, p.armorShred); + if (e.hp <= 0) killEnemy(e, true); + } + + if (p.pierceLeft > 0) { + p.pierceLeft--; + } else { + p.life = 0; + break; + } + } + } + } + + compactLiveArray(G.projectiles, p => p.life > 0); +} + +function explodeMortar(p) { + sfx_mortar_explode(); + spawnParticleBurst(p.x, p.y, p.color, 20); + // Screen shake feel: big particle burst + spawnParticleBurst(p.x, p.y, '#ffffff', 6); + + if (p.dotDamage > 0) { + // Lingering zone (poison cloud) + G.aoeZones.push({ + id: uid(), + x: p.x, y: p.y, + radius: p.aoeRadius, + color: p.color, + life: 1, + duration: p.dotDuration || 180, + remaining: p.dotDuration || 180, + dotDamage: p.dotDamage, + dotInterval: p.dotInterval || 30, + dotTick: 0, + elements: p.elements, + freezeDuration: p.freezeDuration || 0, + }); + return; + } + + for (const e of G.enemies) { + if (!e.alive || e.spawnImmunity > 0) continue; + const hitRadius = p.aoeRadius + e.radius; + if (distSq(e.x, e.y, p.x, p.y) < hitRadius * hitRadius) { + dealDamage(e, p.damage, p.elements, false); + if (p.freezeDuration > 0) applyFreeze(e, p.freezeDuration); + if (e.hp <= 0) killEnemy(e, true); + } + } +} + +function explodeProjectile(p) { + spawnParticleBurst(p.x, p.y, p.color, 24); + spawnParticleBurst(p.x, p.y, '#ffffff', 5); + + for (const e of G.enemies) { + if (!e.alive || e.spawnImmunity > 0) continue; + const hitRadius = p.aoeRadius + e.radius; + if (distSq(e.x, e.y, p.x, p.y) < hitRadius * hitRadius) { + dealDamage(e, p.damage, p.elements, true, p.critChance, p.armorShred); + if (e.hp <= 0) killEnemy(e, true); + } + } + p.life = 0; +} + +function updateBeams() { + compactLiveArray(G.beams, b => { + b.life -= b.decay; + return b.life > 0; + }); +} + +function updateAoeZones() { + for (const z of G.aoeZones) { + z.remaining--; + z.life = z.remaining / z.duration; + z.dotTick++; + if (z.dotTick >= z.dotInterval) { + z.dotTick = 0; + for (const e of G.enemies) { + if (!e.alive || e.spawnImmunity > 0) continue; + const hitRadius = z.radius + e.radius; + if (distSq(e.x, e.y, z.x, z.y) < hitRadius * hitRadius) { + dealDamage(e, z.dotDamage, z.elements, false); + if (e.hp <= 0) killEnemy(e, true); + } + } + } + } + compactLiveArray(G.aoeZones, z => z.remaining > 0); +} + +// Chain arc visual helper — pushes a glowing zigzag bolt to G.chainArcs +function spawnChainArc(x1, y1, x2, y2, color) { + const dx = x2 - x1, dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + const perpX = -dy / len, perpY = dx / len; + const steps = 10; + const pts = [{ x: x1, y: y1 }]; + for (let i = 1; i < steps; i++) { + const t = i / steps; + const maxOff = 26 * Math.sin(t * Math.PI); // taper to 0 at both ends + const off = (Math.random() - 0.5) * 2 * maxOff; + pts.push({ x: x1 + dx * t + perpX * off, y: y1 + dy * t + perpY * off }); + } + pts.push({ x: x2, y: y2 }); + G.chainArcs.push({ pts, color, life: 1.0, decay: 0.16 }); + + // Short branch off the midpoint for realism + const mid = pts[Math.floor(steps / 2)]; + const branchAngle = Math.atan2(dy, dx) + (Math.random() < 0.5 ? 1 : -1) * (Math.PI * 0.3 + Math.random() * 0.4); + const branchLen = len * 0.3; + const bpts = [{ x: mid.x, y: mid.y }]; + const bSteps = 4; + for (let i = 1; i <= bSteps; i++) { + const t = i / bSteps; + const off = (Math.random() - 0.5) * 12 * Math.sin(t * Math.PI); + bpts.push({ + x: mid.x + Math.cos(branchAngle) * branchLen * t + perpX * off, + y: mid.y + Math.sin(branchAngle) * branchLen * t + perpY * off, + }); + } + G.chainArcs.push({ pts: bpts, color, life: 0.65, decay: 0.20 }); +} + +function updateChainArcs() { + compactLiveArray(G.chainArcs, a => { + a.life -= a.decay; + return a.life > 0; + }); +} diff --git a/js/weapons.js b/js/weapons.js new file mode 100644 index 0000000..dc52399 --- /dev/null +++ b/js/weapons.js @@ -0,0 +1,208 @@ +// ═══ weapons.js ═══ +// ============================================================ +// WEAPONS.JS — Firing logic for all weapon types +// ============================================================ + +const AIM_THRESHOLD = 0.18; // radians - tighter aim requirement +const HARDPOINT_BASE_ANGLE = -Math.PI / 2; + +function getHardpointOrbit(totalSlots = Math.max(1, G.tower.weaponSlots)) { + // Wider orbit as slots increase so each hardpoint stays readable. + return 21 + Math.min(12, totalSlots * 1.6); +} + +function getSlotHardpoint(slotIndex, cx, cy, totalSlots = Math.max(1, G.tower.weaponSlots)) { + const orbit = getHardpointOrbit(totalSlots); + const mountAngle = HARDPOINT_BASE_ANGLE + (slotIndex / totalSlots) * Math.PI * 2; + return { + slotIndex, + totalSlots, + mountAngle, + x: cx + Math.cos(mountAngle) * orbit, + y: cy + Math.sin(mountAngle) * orbit, + orbit, + }; +} + +function getWeaponHardpoints(cx, cy) { + const totalSlots = Math.max(1, G.tower.weaponSlots); + const result = []; + for (let slotIndex = 0; slotIndex < totalSlots; slotIndex++) { + const weapon = G.weapons[slotIndex]; + if (!weapon) continue; + const mount = getSlotHardpoint(slotIndex, cx, cy, totalSlots); + result.push({ ...mount, weapon }); + } + return result; +} + +function getWeaponBarrelLength(weapon) { + const type = getWeaponDef(weapon)?.type; + if (type === 'beam') return 24; + if (type === 'mortar') return 14; + if (type === 'cone') return 15; + if (type === 'multi') return 19; + if (type === 'chain') return 18; + return 17; +} + +function getWeaponMuzzle(hardpoint) { + const w = hardpoint.weapon; + const aimAngle = typeof w.aimAngle === 'number' ? w.aimAngle : hardpoint.mountAngle; + const barrelLen = getWeaponBarrelLength(w); + const recoilOffset = (w.recoil || 0) * 3.2; + const reach = Math.max(7, barrelLen - recoilOffset); + return { + x: hardpoint.x + Math.cos(aimAngle) * reach, + y: hardpoint.y + Math.sin(aimAngle) * reach, + angle: aimAngle, + }; +} + +function clampAimPoint(x, y) { + const pad = 18; + return { + x: Math.max(pad, Math.min(canvas.width - pad, x)), + y: Math.max(pad, Math.min(canvas.height - pad, y)), + }; +} + +function predictEnemyFuturePoint(target, framesAhead) { + if (typeof predictEnemyPositionAlongPath === 'function') { + return predictEnemyPositionAlongPath(target, framesAhead); + } + return { + x: target.x + (target.vx || 0) * framesAhead, + y: target.y + (target.vy || 0) * framesAhead, + }; +} + +function predictInterceptPoint(ox, oy, target, projectileSpeed, maxLeadFrames = 180) { + const speed = Math.max(0.01, projectileSpeed); + + let t = Math.hypot(target.x - ox, target.y - oy) / speed; + t = Math.max(0, Math.min(maxLeadFrames, t)); + + for (let i = 0; i < 5; i++) { + const p = predictEnemyFuturePoint(target, t); + const dist = Math.hypot(p.x - ox, p.y - oy); + const nextT = Math.max(0, Math.min(maxLeadFrames, dist / speed)); + if (Math.abs(nextT - t) < 0.2) { + t = nextT; + break; + } + t = nextT; + } + + const predicted = predictEnemyFuturePoint(target, t); + return clampAimPoint(predicted.x, predicted.y); +} + +function getLeadAimPoint(w, target, ox, oy) { + if (!w || !target) return null; + const def = getWeaponDef(w); + if (!def) return { x: target.x, y: target.y }; + + if (def.type === 'projectile') { + return predictInterceptPoint(ox, oy, target, Math.max(0.1, w.projectileSpeed || 0.1), 220); + } + + if (def.type === 'mortar') { + // Mortar progress uses speed^2 * 0.6 in updateProjectiles. + const ps = Math.max(0.1, w.projectileSpeed || 0.1); + const planarSpeed = ps * ps * 0.6; + return predictInterceptPoint(ox, oy, target, planarSpeed, 260); + } + + return { x: target.x, y: target.y }; +} + +function getWeaponColor(w) { + const el = getWeaponElements(w)[0]; + return ELEMENTS[el]?.color || getWeaponDef(w)?.color || '#fff'; +} + +function getIdleScanAngle(hardpoint) { + const sweep = Math.PI / 4; // 90 degree cone, centered away from tower. + const phase = hardpoint.slotIndex * 1.7; + return normalizeAngle(hardpoint.mountAngle + Math.sin(G.frame * 0.0125 + phase) * sweep); +} + +function updateWeapons() { + const cx = ARENA_CX, cy = ARENA_CY; + const hardpoints = getWeaponHardpoints(cx, cy); + let hasPrimaryAim = false; + + for (const hardpoint of hardpoints) { + const w = hardpoint.weapon; + const mountAngle = hardpoint.mountAngle; + + if (typeof w.aimAngle !== 'number') w.aimAngle = mountAngle; + if (typeof w.cooldown !== 'number') w.cooldown = 0; + if (typeof w.recoil !== 'number') w.recoil = 0; + if (typeof w.muzzleFlash !== 'number') w.muzzleFlash = 0; + + w.recoil *= 0.68; + if (w.recoil < 0.01) w.recoil = 0; + if (w.muzzleFlash > 0) w.muzzleFlash--; + + const target = pickTarget(w); + let aimPoint = null; + let desiredAngle = null; + if (target) { + aimPoint = getLeadAimPoint(w, target, hardpoint.x, hardpoint.y); + desiredAngle = Math.atan2(aimPoint.y - hardpoint.y, aimPoint.x - hardpoint.x); + w.aimAngle = stepTowardAngle(w.aimAngle, desiredAngle, G.tower.aimSpeed); + } else { + const scanDelta = shortestAngleDelta(w.aimAngle, mountAngle); + if (Math.abs(scanDelta) > Math.PI / 4) w.aimAngle = mountAngle; + desiredAngle = getIdleScanAngle(hardpoint); + w.aimAngle = stepTowardAngle(w.aimAngle, desiredAngle, G.tower.aimSpeed * 0.45); + } + + if (!hasPrimaryAim) { + G.tower.cannonAngle = w.aimAngle; + hasPrimaryAim = true; + } + + if (w.cooldown > 0) { + w.cooldown--; + continue; + } + if (!target) continue; + + const dx = target.x - hardpoint.x; + const dy = target.y - hardpoint.y; + const dist = Math.hypot(dx, dy); + if (dist > w.range) continue; + + let fireAngle = w.aimAngle; + + // Aim check for directional weapons. + if (w.defId !== 'flamethrower' && w.defId !== 'laser') { + const aimDelta = shortestAngleDelta(w.aimAngle, desiredAngle); + if (Math.abs(aimDelta) > AIM_THRESHOLD) continue; + // Snap to exact target angle on shot so first-round accuracy is reliable. + fireAngle = desiredAngle; + w.aimAngle = desiredAngle; + } else if (desiredAngle != null) { + fireAngle = desiredAngle; + w.aimAngle = desiredAngle; + } + + const muzzle = getWeaponMuzzle(hardpoint); + fireWeapon(w, target, muzzle.x, muzzle.y, fireAngle, aimPoint); + w.cooldown = Math.max(4, w.fireRate); + w.recoil = 1; + w.muzzleFlash = 4; + w.lastFireFrame = G.frame; + } + + if (!hasPrimaryAim) G.tower.cannonAngle = normalizeAngle((G.tower.cannonAngle || 0) + 0.006); + + // Update projectiles + updateProjectiles(); + updateBeams(); + updateChainArcs(); + updateAoeZones(); +}