diff --git a/index.html b/index.html index 7bc773e..2345bf1 100644 --- a/index.html +++ b/index.html @@ -6,8 +6,8 @@ SIEGE PROTOCOL - - + + @@ -17,33 +17,33 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/js/defs.js b/js/defs.js index e5d7328..2846e2e 100644 --- a/js/defs.js +++ b/js/defs.js @@ -14,13 +14,23 @@ const ELEMENTS = { physical: { name: 'Physical', color: '#c8c8c8', glow: '#ffffff', icon: '๐Ÿ’ข' }, }; +// โ”€โ”€ DIFFICULTY TIERS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const DIFFICULTY_TIERS = [ + { id: 0, name: 'NORMAL', unlockCost: 0, hpMult: 1.00, speedMult: 1.00, armorMult: 1.00, rewardMult: 1.00 }, + { id: 1, name: 'SKIRMISH', unlockCost: 500, hpMult: 1.25, speedMult: 1.15, armorMult: 1.20, rewardMult: 1.40 }, + { id: 2, name: 'ASSAULT', unlockCost: 2000, hpMult: 1.60, speedMult: 1.30, armorMult: 1.50, rewardMult: 1.90 }, + { id: 3, name: 'SIEGE', unlockCost: 6000, hpMult: 2.10, speedMult: 1.50, armorMult: 2.00, rewardMult: 2.50 }, + { id: 4, name: 'ONSLAUGHT', unlockCost: 15000, hpMult: 2.85, speedMult: 1.75, armorMult: 2.80, rewardMult: 3.50 }, + { id: 5, name: 'CATACLYSM', unlockCost: 40000, hpMult: 3.80, speedMult: 2.00, armorMult: 4.00, rewardMult: 4.70 }, +]; + // โ”€โ”€ 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, + hp: 8, speed: 0.72, radius: 7, armor: 0, color: '#aaaaaa', glowColor: '#ffffff', cost: 20, reward: 30, resistances: {}, @@ -32,7 +42,7 @@ const ENEMY_DEFS = [ id: 'runner', name: 'RUNNER', desc: 'Very fast, low HP. Hard to track.', - hp: 5, speed: 2.35, radius: 5, armor: 0, + hp: 5, speed: 1.88, radius: 5, armor: 0, color: '#00ff88', glowColor: '#00ff88', cost: 30, reward: 44, resistances: {}, @@ -44,7 +54,7 @@ const ENEMY_DEFS = [ id: 'brute', name: 'BRUTE', desc: 'High HP and armor. Slow mover.', - hp: 35, speed: 0.52, radius: 13, armor: 3, + hp: 35, speed: 0.42, radius: 13, armor: 3, color: '#ff7043', glowColor: '#ff3300', cost: 80, reward: 112, resistances: { physical: 0.5 }, @@ -56,7 +66,7 @@ const ENEMY_DEFS = [ id: 'swarm', name: 'SWARM', desc: 'Spawns 6 tiny units at once.', - hp: 3, speed: 1.85, radius: 4, armor: 0, + hp: 3, speed: 1.48, radius: 4, armor: 0, color: '#ce93d8', glowColor: '#cc00ff', cost: 60, reward: 84, resistances: {}, @@ -68,7 +78,7 @@ const ENEMY_DEFS = [ id: 'phantom', name: 'PHANTOM', desc: '40% dodge. Void-touched.', - hp: 6, speed: 1.7, radius: 7, armor: 0, + hp: 6, speed: 1.36, radius: 7, armor: 0, color: '#c77dff', glowColor: '#9900ff', cost: 100, reward: 138, evasion: 0.3, @@ -81,7 +91,7 @@ const ENEMY_DEFS = [ id: 'iceling', name: 'ICELING', desc: 'Ice elemental. Slows bullets on contact.', - hp: 12, speed: 0.88, radius: 9, armor: 1, + hp: 12, speed: 0.70, radius: 9, armor: 1, color: '#7ecfff', glowColor: '#00cfff', cost: 90, reward: 125, resistances: { ice: 0.0 }, @@ -93,7 +103,7 @@ const ENEMY_DEFS = [ id: 'sparkling', name: 'SPARKLING', desc: 'Lightning elemental. Fast and electric.', - hp: 7, speed: 2.1, radius: 6, armor: 0, + hp: 7, speed: 1.68, radius: 6, armor: 0, color: '#ffe033', glowColor: '#ffcc00', cost: 110, reward: 148, resistances: { lightning: 0.0 }, @@ -105,7 +115,7 @@ const ENEMY_DEFS = [ id: 'venom', name: 'VENOM', desc: 'Poison elemental. Immune to DoT.', - hp: 14, speed: 0.96, radius: 10, armor: 0, + hp: 14, speed: 0.77, radius: 10, armor: 0, color: '#7fff4f', glowColor: '#44ff00', cost: 120, reward: 160, resistances: { poison: 0.0, fire: 0.6 }, @@ -117,7 +127,7 @@ const ENEMY_DEFS = [ id: 'titan', name: 'TITAN', desc: 'Massive HP, heavy armor, very slow.', - hp: 120, speed: 0.31, radius: 20, armor: 8, + hp: 120, speed: 0.25, radius: 20, armor: 8, color: '#ff1744', glowColor: '#ff0000', cost: 350, reward: 465, resistances: { physical: 0.4, fire: 0.7 }, @@ -129,7 +139,7 @@ const ENEMY_DEFS = [ id: 'wraith', name: 'WRAITH', desc: 'Void entity. Ignores 80% of armor.', - hp: 18, speed: 1.38, radius: 9, armor: 0, + hp: 18, speed: 1.10, radius: 9, armor: 0, color: '#9900ff', glowColor: '#6600cc', cost: 200, reward: 265, armorPen: 0.8, @@ -138,6 +148,53 @@ const ENEMY_DEFS = [ element: 'void', count: 1, }, + { + id: 'commander', name: 'COMMANDER', + desc: 'Aura: nearby enemies AND self gain +30% damage resistance. Weak to arcane.', + hp: 160, speed: 0.60, radius: 11, armor: 2, + color: '#ffc107', glowColor: '#ff8f00', + cost: 220, reward: 280, + resistances: {}, weaknesses: { arcane: 2.0 }, + element: null, count: 1, minTier: 3, + }, + { + id: 'juggernaut', name: 'JUGGERNAUT', + desc: 'Max 12 damage per hit. Immune to freeze. Lightning partially bypasses cap.', + hp: 280, speed: 0.32, radius: 16, armor: 4, + color: '#bf360c', glowColor: '#ff3d00', + cost: 400, reward: 600, + resistances: {}, weaknesses: { lightning: 1.6 }, + element: null, count: 1, minTier: 4, + damageCapPerHit: 12, immuneToFreeze: true, + }, + { + id: 'siegebreaker', name: 'SIEGE BREAKER', + desc: 'Regenerates 5 HP/sec. Immune to poison/freeze. Void pauses regen 3s.', + hp: 400, speed: 0.22, radius: 22, armor: 6, + color: '#880e4f', glowColor: '#ff0077', + cost: 500, reward: 900, + resistances: { poison: 0.0 }, weaknesses: {}, + element: null, count: 1, minTier: 5, + hpRegenPerSec: 5, immuneToFreeze: true, + }, + { + id: 'echo', name: 'ECHO', + desc: 'Splits into 2 copies on single-hit damage >30. Each copy earns a reward.', + hp: 100, speed: 0.96, radius: 8, armor: 0, + color: '#29b6f6', glowColor: '#0288d1', + cost: 180, reward: 250, echoReward: 120, + resistances: {}, weaknesses: { fire: 1.4 }, + element: null, count: 1, minPrestige: 1, + }, + { + id: 'voidherald', name: 'VOID HERALD', + desc: 'Shield absorbs 60% dmg when 3+ weapons hit per frame. Deploy in crowds.', + hp: 200, speed: 0.68, radius: 12, armor: 0, + color: '#7c4dff', glowColor: '#651fff', + cost: 300, reward: 480, + resistances: {}, weaknesses: { void: 1.5 }, + element: 'void', count: 1, minPrestige: 2, + }, ]; // โ”€โ”€ WEAPON DEFINITIONS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -147,14 +204,14 @@ const WEAPON_DEFS = [ name: 'CANNON', desc: 'Standard projectile. Reliable, upgradeable.', icon: '๐Ÿ’ฃ', - cost: 0, // starting weapon + cost: 100, defaultElement: 'physical', targeting: 'nearest', fireRate: 72, // frames between shots damage: 4, projectileSpeed: 4.2, projectileRadius: 4, - range: 9999, + range: 310, color: '#c8c8c8', type: 'projectile', }, @@ -185,6 +242,7 @@ const WEAPON_DEFS = [ damage: 4, chains: 3, chainRange: 120, + range: 340, color: '#ffe033', type: 'chain', }, @@ -200,6 +258,7 @@ const WEAPON_DEFS = [ damage: 12, aoeRadius: 60, projectileSpeed: 2.8, + range: 480, color: '#ff7043', type: 'mortar', }, @@ -213,7 +272,7 @@ const WEAPON_DEFS = [ targeting: 'furthest', fireRate: 10, damage: 1, - range: 9999, + range: 390, color: '#ff77e9', type: 'beam', }, @@ -230,6 +289,7 @@ const WEAPON_DEFS = [ aoeRadius: 75, freezeDuration: 180, projectileSpeed: 2.4, + range: 400, color: '#7ecfff', type: 'mortar', }, @@ -246,6 +306,7 @@ const WEAPON_DEFS = [ armorShred: 5, projectileSpeed: 1.8, projectileRadius: 12, + range: 360, color: '#c77dff', type: 'projectile', }, @@ -262,6 +323,7 @@ const WEAPON_DEFS = [ targets: 3, aoeRadius: 36, projectileSpeed: 3.6, + range: 450, color: '#ff4500', type: 'multi', }, @@ -277,6 +339,7 @@ const WEAPON_DEFS = [ damage: 2, amplify: 0.25, projectileSpeed: 5.8, + range: 280, color: '#ff77e9', type: 'projectile', }, @@ -295,6 +358,7 @@ const WEAPON_DEFS = [ dotDuration: 180, aoeRadius: 55, projectileSpeed: 2.2, + range: 350, color: '#7fff4f', type: 'mortar', }, @@ -387,35 +451,19 @@ const TOWER_UPGRADE_TREE = [ }, { id: 'slot5', label: 'Weapon Slot V', desc: 'Unlock 5th weapon slot', - cost: 7000, requires: ['slot4'], effect: { weaponSlot: 5 }, + cost: 7000, requires: ['slot4'], effect: { weaponSlot: 5 }, minTier: 1, }, { id: 'slot6', label: 'Weapon Slot VI', desc: 'Unlock 6th weapon slot', - cost: 12000, requires: ['slot5'], effect: { weaponSlot: 6 }, + cost: 12000, requires: ['slot5'], effect: { weaponSlot: 6 }, minTier: 2, }, { id: 'slot7', label: 'Weapon Slot VII', desc: 'Unlock 7th weapon slot', - cost: 20000, requires: ['slot6'], effect: { weaponSlot: 7 }, + cost: 20000, requires: ['slot6'], effect: { weaponSlot: 7 }, minTier: 3, }, { 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 }, + cost: 30000, requires: ['slot7'], effect: { weaponSlot: 8 }, minTier: 4, }, { id: 'shield_dome', label: 'Dome Shield', desc: 'Buy dome shield system', @@ -456,6 +504,10 @@ const WEAPON_UPGRADE_TREES = { { 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' }, + // Range + { id: 'range1', label: 'Barrel Ext I', desc: '+50 range', cost: 200, requires: [], effect: { range: 50 }, category: 'Range' }, + { id: 'range2', label: 'Barrel Ext II', desc: '+80 range', cost: 550, requires: ['range1'], effect: { range: 80 }, category: 'Range' }, + { id: 'range3', label: 'Barrel Ext III', desc: '+110 range', cost: 1400, requires: ['range2'], effect: { range: 110 }, category: 'Range' }, ], flamethrower: [ { id: 'dmg1', label: 'Heat I', desc: '+1 dmg/tick', cost: 120, requires: [], effect: { damage: 1 }, category: 'Damage' }, @@ -510,6 +562,10 @@ const WEAPON_UPGRADE_TREES = { { 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' }, + // Range + { id: 'range1', label: 'Focal Lens I', desc: '+60 range', cost: 250, requires: [], effect: { range: 60 }, category: 'Range' }, + { id: 'range2', label: 'Focal Lens II', desc: '+100 range', cost: 750, requires: ['range1'], effect: { range: 100 }, category: 'Range' }, + { id: 'range3', label: 'Focal Lens III', desc: '+140 range', cost: 1800, requires: ['range2'], effect: { range: 140 }, category: 'Range' }, ], freezebomb: [ { id: 'dmg1', label: 'Cold I', desc: '+3 damage', cost: 200, requires: [], effect: { damage: 3 }, category: 'Damage' }, @@ -542,8 +598,8 @@ const WEAPON_UPGRADE_TREES = { { 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: 'targets2', label: '+2 Targets',desc: 'Fire at 2 more', cost: 1200, requires: ['targets1'], effect: { targets: 2 }, category: 'Targets', minTier: 2 }, + { id: 'targets3', label: '+3 Targets',desc: 'Fire at 3 more', cost: 2800, requires: ['targets2'], effect: { targets: 3 }, category: 'Targets', minTier: 2 }, { 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' }, @@ -553,12 +609,12 @@ const WEAPON_UPGRADE_TREES = { 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: 'dmg3', label: 'Potency III',desc: '+3 damage', cost: 700, requires: ['dmg2'], effect: { damage: 3 }, category: 'Damage', minTier: 2 }, { 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: 'rate3', label: 'Frequency III',desc: '-4 frames', cost: 850, requires: ['rate2'], effect: { fireRate: -4 }, category: 'Fire Rate', minTier: 2 }, { 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: 'amp2', label: 'Amplify II', desc: '+15% amp', cost: 700, requires: ['amp1'], effect: { amplify: 0.15 }, category: 'Special', minTier: 2 }, { 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' }, diff --git a/js/dev-console.js b/js/dev-console.js index 306cf9d..8bce560 100644 --- a/js/dev-console.js +++ b/js/dev-console.js @@ -158,7 +158,8 @@ const DEV_MODE = true; const ids = ['grunt','runner','brute','phantom']; ids.forEach(id => { const def = ENEMY_DEFS.find(d => d.id === id); - if (def) openPortal(def, qty, 0); + if (!def) return; + for (let i = 0; i < qty; i++) openPortal(def, 1, 0); }); devLog(`Spawned wave: ${ids.join(', ')} ร—${qty} each`, 'ok'); } diff --git a/js/elements.js b/js/elements.js index 9939689..e3ba28b 100644 --- a/js/elements.js +++ b/js/elements.js @@ -73,6 +73,7 @@ function applyFreeze(enemy, duration) { // Diminishing returns: each successive freeze is shorter // 60-frame immunity window after thawing if (enemy._freezeImmune > 0) return; + if (enemy.immuneToFreeze) 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; @@ -133,6 +134,35 @@ function dealDamage(enemy, rawDamage, elements = ['physical'], canCrit = false, } dmg = Math.round(dmg); + + // โ”€โ”€ Special enemy modifiers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Commander aura: -30% incoming damage + if (enemy.commanderAuraDR > 0) { + dmg = Math.max(1, Math.round(dmg * (1 - enemy.commanderAuraDR))); + } + + // Juggernaut damage cap (12 per hit; lightning doubles cap) + if (enemy.defId === 'juggernaut') { + const baseCap = ENEMY_DEFS.find(d => d.id === 'juggernaut')?.damageCapPerHit ?? 12; + const cap = elements.includes('lightning') ? baseCap * 2 : baseCap; + dmg = Math.min(dmg, cap); + } + + // Void Herald shield: absorbs 60% damage when 3+ weapons hit in same frame + if (enemy.defId === 'voidherald') { + enemy._voidHitsThisFrame = (enemy._voidHitsThisFrame || 0) + 1; + if (enemy._voidHitsThisFrame >= 3) { + dmg = Math.max(1, Math.round(dmg * 0.4)); + } + } + + // Echo split: on burst hit >30 raw damage, queue a split (once per echo) + if (enemy.defId === 'echo' && !enemy._echoSplit && rawDamage > 30) { + enemy._echoSplit = true; + G._pendingEchoSpawns = G._pendingEchoSpawns || []; + G._pendingEchoSpawns.push({ x: enemy.x, y: enemy.y, copyHp: Math.round(enemy.maxHp * 0.4) }); + } + enemy.hp -= dmg; enemy.hitFlash = 6; @@ -166,6 +196,9 @@ function applyElementalEffect(enemy, el) { break; case 'void': enemy.armor = Math.max(0, (enemy.armor || 0) - 1); + if (enemy.defId === 'siegebreaker') { + enemy.regenPausedUntil = G.frame + 180; // pause regen 3 seconds + } break; case 'arcane': enemy.amplified = Math.min(1.5, (enemy.amplified || 0) + 0.1); @@ -227,4 +260,16 @@ function tickEnemyStatus(enemy) { enemy.amplified *= 0.995; if (enemy.amplified < 0.01) enemy.amplified = 0; } + + // Siege Breaker HP regen: +5 HP/sec; void damage pauses for 3s + if (enemy.defId === 'siegebreaker' && enemy.hp > 0) { + if (!enemy.regenPausedUntil || G.frame >= enemy.regenPausedUntil) { + enemy._regenAcc = (enemy._regenAcc || 0) + 5 / 60; + if (enemy._regenAcc >= 1) { + const regen = Math.floor(enemy._regenAcc); + enemy.hp = Math.min(enemy.maxHp, enemy.hp + regen); + enemy._regenAcc -= regen; + } + } + } } diff --git a/js/enemies.js b/js/enemies.js index e8af273..bde3d0d 100644 --- a/js/enemies.js +++ b/js/enemies.js @@ -4,7 +4,7 @@ // ============================================================ // โ”€โ”€ ENEMY SPAWNING โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function spawnEnemy(def, x, y, rewardOverride = null, offsetAngle = null, costOverride = null, breachRiskMult = 1) { +function spawnEnemy(def, x, y, rewardOverride = null, offsetAngle = null, costOverride = null, breachRiskMult = 1, extraProps = null) { // For swarm units, offset position slightly around the portal let spawnX = x, spawnY = y; if (offsetAngle !== null) { @@ -12,18 +12,26 @@ function spawnEnemy(def, x, y, rewardOverride = null, offsetAngle = null, costOv spawnX = x + Math.cos(offsetAngle) * spread; spawnY = y + Math.sin(offsetAngle) * spread; } - G.enemies.push({ + + // Apply tier multipliers โ€” skip for echo copies which carry pre-scaled HP + const tierDef = DIFFICULTY_TIERS[G.difficultyTier || 0]; + const skipScale = extraProps?._echoSplit; + const scaledHp = skipScale ? def.hp : Math.round(def.hp * (tierDef?.hpMult ?? 1)); + const scaledSpeed = skipScale ? def.speed : def.speed * (tierDef?.speedMult ?? 1); + const scaledArmor = skipScale ? (def.armor ?? 0) : Math.round((def.armor ?? 0) * (tierDef?.armorMult ?? 1)); + + const instance = { id: uid(), defId: def.id, name: def.name, x: spawnX, y: spawnY, - hp: def.hp, - maxHp: def.hp, - speed: def.speed, - baseSpeed: def.speed, + hp: scaledHp, + maxHp: scaledHp, + speed: scaledSpeed, + baseSpeed: scaledSpeed, radius: def.radius, - armor: def.armor ?? 0, - baseArmor: def.armor ?? 0, + armor: scaledArmor, + baseArmor: scaledArmor, evasion: def.evasion ?? 0, armorPen: def.armorPen ?? 0, color: def.color, @@ -46,7 +54,11 @@ function spawnEnemy(def, x, y, rewardOverride = null, offsetAngle = null, costOv angle: 0, vx: 0, vy: 0, - }); + }; + + if (extraProps) Object.assign(instance, extraProps); + + G.enemies.push(instance); } // โ”€โ”€ DEPLOY (player action) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -54,6 +66,10 @@ function deployEnemy(defId, quantity = 1) { const def = ENEMY_DEFS.find(e => e.id === defId); if (!def) return; + // Tier and prestige gate + if ((def.minTier ?? 0) > (G.difficultyTier || 0)) return; + if ((def.minPrestige ?? 0) > (G.prestigeLevel || 0)) return; + const totalCost = def.cost * quantity; if (G.credits < totalCost) return; if (G.gameOver) return; @@ -62,12 +78,20 @@ function deployEnemy(defId, quantity = 1) { // 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 tierDef = DIFFICULTY_TIERS[G.difficultyTier || 0]; + const tierRewardMult = tierDef?.rewardMult ?? 1; + const rewardPerUnit = Math.round(def.reward * bonusMult * tierRewardMult); 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; + // First-deploy tip for Void Herald + if (def.id === 'voidherald' && !G._voidHeraldTipShown) { + G._voidHeraldTipShown = true; + addLog('VOID HERALD: Deploy in a crowd โ€” shield activates only when 3+ weapons target it at once.', 'info'); + } + if (def.id === 'swarm') { // Each swarm card = one burst portal. Split rewards exactly across minis. const swarmUnitCost = def.cost / def.count; @@ -85,7 +109,8 @@ function deployEnemy(defId, quantity = 1) { 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'); + const tierStr = tierRewardMult > 1 ? ` [${tierDef.name}: ร—${tierRewardMult.toFixed(1)}]` : ''; + addLog(`Deployed ${def.name}${plural} โ€” ${totalCost}ยข${bonusStr}${tierStr}${riskStr}`, 'info'); updateHUD(); } @@ -203,6 +228,24 @@ function updateEnemies() { const cx = ARENA_CX; const cy = ARENA_CY; + // Reset per-frame flags for special enemies + for (const e of G.enemies) { + if (!e.alive) continue; + e.commanderAuraDR = 0; + if (e.defId === 'voidherald') e._voidHitsThisFrame = 0; + } + + // Commander aura: all alive enemies within 80px gain +30% DR (including self) + const auraRadSq = 80 * 80; + for (const cmd of G.enemies) { + if (!cmd.alive || cmd.defId !== 'commander') continue; + for (const t of G.enemies) { + if (t.alive && distSq(cmd.x, cmd.y, t.x, t.y) <= auraRadSq) { + t.commanderAuraDR = 0.3; + } + } + } + for (const e of G.enemies) { if (!e.alive) continue; @@ -250,6 +293,24 @@ function updateEnemies() { } compactLiveArray(G.enemies, e => e.alive); + + // Process Echo split queue โ€” spawn 2 copies per pending split + if (G._pendingEchoSpawns?.length) { + const echoDef = ENEMY_DEFS.find(d => d.id === 'echo'); + const tierDef = DIFFICULTY_TIERS[G.difficultyTier || 0]; + const tierRewardMult = tierDef?.rewardMult ?? 1; + for (const spawn of G._pendingEchoSpawns) { + for (let i = 0; i < 2; i++) { + const copyReward = Math.round((echoDef.echoReward ?? 120) * tierRewardMult); + spawnEnemy( + { ...echoDef, hp: spawn.copyHp }, + spawn.x, spawn.y, copyReward, + Math.random() * Math.PI * 2, echoDef.cost, 1, { _echoSplit: true } + ); + } + } + G._pendingEchoSpawns = []; + } } function killEnemy(enemy, giveReward) { @@ -359,7 +420,10 @@ function breachTower(enemy) { // 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 _activeWeapons = (G.weapons || []).filter(w => w); + const towerRange = _activeWeapons.length > 0 + ? Math.max(..._activeWeapons.map(w => w.range ?? 0)) + : 9999; const towerRangeSq = towerRange * towerRange; let targeting = weapon.targeting || 'nearest'; switch (targeting) { diff --git a/js/input.js b/js/input.js index 62a7173..2864f1f 100644 --- a/js/input.js +++ b/js/input.js @@ -3,16 +3,48 @@ // INPUT.JS โ€” Keyboard hotkeys, mouse interaction // ============================================================ +let _shiftHeld = false; +document.addEventListener('keydown', e => { + if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') _shiftHeld = true; +}); +document.addEventListener('keyup', e => { + if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') _shiftHeld = false; +}); + +let _altHeld = false; +document.addEventListener('keydown', e => { + if (e.code === 'AltLeft' || e.code === 'AltRight') { e.preventDefault(); _altHeld = true; } +}); +document.addEventListener('keyup', e => { + if (e.code === 'AltLeft' || e.code === 'AltRight') _altHeld = false; +}); + +window.addEventListener('blur', () => { _radialSlot = -1; _shiftHeld = false; _altHeld = false; _prestigeHoldMs = -1; }); +document.addEventListener('visibilitychange', () => { + if (document.hidden) { _radialSlot = -1; _shiftHeld = false; _altHeld = false; _prestigeHoldMs = -1; } +}); + const HOTKEYS = { - 'Space': () => G.shopOpen ? closeShop() : openShop(), + 'Space': () => G.armoryOpen ? closeArmory() : openArmory(), + 'KeyC': () => G.commandOpen ? closeCommand() : openCommand(), 'Escape': () => { - if (document.body.classList.contains('inventory-open')) closeWeaponPicker(); - else if (G.shopOpen) closeShop(); + if (G.weaponDetailSlot >= 0) closeWeaponDetail(); + else if (typeof _radialSlot !== 'undefined' && _radialSlot >= 0) { _radialSlot = -1; } + else if (document.body.classList.contains('inventory-open')) closeWeaponPicker(); + else if (G.armoryOpen) closeArmory(); + else if (G.commandOpen) closeCommand(); + else if (G.threatOpen) closeThreatPanel(); + else if (G.prestigeOpen) closePrestigeDialog(); 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(); }, + 'KeyP': () => { + if (!G.armoryOpen && !G.commandOpen && !document.body.classList.contains('inventory-open')) togglePause(); + }, + 'KeyI': () => { + if (!G.armoryOpen && !G.commandOpen) { + document.body.classList.contains('inventory-open') ? closeWeaponPicker() : openWeaponPicker(-1); + } + }, }; // 1โ€“0 keys for enemy deploy @@ -27,7 +59,7 @@ function initInput() { // Enemy deploy hotkeys const idx = ENEMY_HOTKEYS.indexOf(e.code); - if (idx >= 0 && idx < ENEMY_DEFS.length && !G.shopOpen && !G.paused) { + if (idx >= 0 && idx < ENEMY_DEFS.length && !G.armoryOpen && !G.commandOpen && !G.paused) { e.preventDefault(); deployEnemy(ENEMY_DEFS[idx].id, G.sendQuantity); return; @@ -46,6 +78,12 @@ let _hoverPt = null; let _dragWeapon = null; let _dragSource = null; let _suppressNextClick = false; +let _sellHoldSlot = -1; +let _sellHoldMs = 0; +const SELL_HOLD_DURATION = 1000; + +let _prestigeHoldMs = -1; +const PRESTIGE_HOLD_DURATION = 1200; function clearHitRegions() { _hitRegions.length = 0; } @@ -59,11 +97,32 @@ function isHovered(x, y, w, h) { _hoverPt.y >= y && _hoverPt.y < y + h; } +// For elements drawn in world space inside the camera transform +function isHoveredWorld(x, y, w, h) { + if (!_hoverPt) return false; + const wx = _hoverPt.worldX ?? _hoverPt.x; + const wy = _hoverPt.worldY ?? _hoverPt.y; + return wx >= x && wx < x + w && wy >= y && wy < y + h; +} + +// Hit region stored in canvas (screen) space, converting from world space +function addHitRegionWorld(x, y, w, h, action) { + const zoom = G?.camera?.zoom ?? 1.0; + const sx = (x - ARENA_CX) * zoom + ARENA_CX; + const sy = (y - ARENA_CY) * zoom + ARENA_CY; + _hitRegions.push({ x: sx, y: sy, w: w * zoom, h: h * zoom, action }); +} + function canvasPt(e) { const r = canvas.getBoundingClientRect(); + const zoom = G?.camera?.zoom ?? 1.0; + const cx = (e.clientX - r.left) * (GAME_W / r.width); + const cy = (e.clientY - r.top) * (GAME_H / r.height); return { - x: (e.clientX - r.left) * (GAME_W / r.width), - y: (e.clientY - r.top) * (GAME_H / r.height), + x: cx, + y: cy, + worldX: (cx - ARENA_CX) / zoom + ARENA_CX, + worldY: (cy - ARENA_CY) / zoom + ARENA_CY, }; } @@ -79,9 +138,28 @@ function initCanvasMouse() { return; } } + // Sell hold detection + if (_sellRegion && pt.x >= _sellRegion.x && pt.x < _sellRegion.x + _sellRegion.w && + pt.y >= _sellRegion.y && pt.y < _sellRegion.y + _sellRegion.h) { + _sellHoldSlot = _sellRegion.slot; + _sellHoldMs = Date.now(); + } + // Prestige hold detection + if (_prestigeHoldRegion && pt.x >= _prestigeHoldRegion.x && pt.x < _prestigeHoldRegion.x + _prestigeHoldRegion.w && + pt.y >= _prestigeHoldRegion.y && pt.y < _prestigeHoldRegion.y + _prestigeHoldRegion.h) { + _prestigeHoldMs = Date.now(); + } }); canvas.addEventListener('mouseup', e => { + // Cancel sell hold if released too early + if (_sellHoldSlot >= 0 && Date.now() - _sellHoldMs < SELL_HOLD_DURATION) { + _sellHoldSlot = -1; + } + // Cancel prestige hold if released too early + if (_prestigeHoldMs > 0 && Date.now() - _prestigeHoldMs < PRESTIGE_HOLD_DURATION) { + _prestigeHoldMs = -1; + } if (!_dragWeapon) return; const pt = canvasPt(e); let dropped = false; @@ -105,8 +183,10 @@ function initCanvasMouse() { if (!dropped) { for (const zone of _mountDropZones) { - const dx = pt.x - zone.x, dy = pt.y - zone.y; + const dx = (pt.worldX ?? pt.x) - zone.x, dy = (pt.worldY ?? pt.y) - zone.y; if (dx * dx + dy * dy <= zone.r * zone.r) { + // Dropping back onto source slot = click, not drag โ€” let the click handler fire + if (_dragSource?.type === 'slot' && zone.slotIndex === _dragSource.slotIndex) break; equipWeaponInstanceToSlot(zone.slotIndex, _dragWeapon.instanceId); dropped = true; break; @@ -153,7 +233,9 @@ function initCanvasMouse() { e.preventDefault(); if (document.body.classList.contains('inventory-open')) { _pickerScrollY = clamp(_pickerScrollY + e.deltaY * 0.5, 0, _pickerScrollMax); - } else if (G.shopOpen) { + } else if (G.weaponDetailSlot >= 0) { + _weaponDetailScrollY = clamp(_weaponDetailScrollY + e.deltaY * 0.5, 0, _weaponDetailScrollMax); + } else if (G.armoryOpen || G.commandOpen) { _shopScrollY = clamp(_shopScrollY + e.deltaY * 0.5, 0, _shopScrollMax); } else if (_hoverPt && _hoverPt.x >= 1330) { const pt = _hoverPt; @@ -171,12 +253,17 @@ function initCanvasMouse() { ? steps[Math.min(idx + 1, steps.length - 1)] : steps[Math.max(idx - 1, 0)]; } + } else if (_hoverPt && _hoverPt.x < 1330 && G?.camera) { + // arena area โ€” zoom in/out + const cam = G.camera; + const delta = -e.deltaY * 0.0012; + cam.zoom = Math.max(cam.minZoom, Math.min(cam.maxZoom, cam.zoom + delta)); } }, { passive: false }); canvas.addEventListener('contextmenu', e => { e.preventDefault(); - if (!G.shopOpen) return; + if (!G.armoryOpen && !G.commandOpen && G.weaponDetailSlot < 0) 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) { @@ -185,11 +272,3 @@ function initCanvasMouse() { } }); } - -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 index 4ab736c..21e866e 100644 --- a/js/inventory.js +++ b/js/inventory.js @@ -41,6 +41,7 @@ function openWeaponPicker(slotIndex) { if (slotIndex >= G.tower.weaponSlots) return; _pickerSlot = slotIndex; _pickerScrollY = 0; + _radialSlot = -1; setPaused(true, false); document.body.classList.add('inventory-open'); G.weaponInventory = G.weaponInventory || []; @@ -50,7 +51,7 @@ function closeWeaponPicker() { document.body.classList.remove('inventory-open'); _pickerSlot = -1; _pickerScrollY = 0; - if (!G.shopOpen) setPaused(false); + if (!G.armoryOpen && !G.commandOpen) setPaused(false); } function equipWeaponInstanceToSlot(slotIndex, instanceId) { diff --git a/js/main.js b/js/main.js index 2524125..e58afb8 100644 --- a/js/main.js +++ b/js/main.js @@ -51,7 +51,7 @@ function perfNow() { function setPaused(paused, showOverlay = true) { G.paused = paused; document.body.classList.toggle('paused', - !!paused && showOverlay && !G.shopOpen && + !!paused && showOverlay && !G.armoryOpen && !G.commandOpen && !document.body.classList.contains('inventory-open') && !G.gameOver); } @@ -161,8 +161,12 @@ function updateHUD() { // โ”€โ”€ 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; + if (G.logLines.length > 0 && G.logLines[0].text === msg && G.logLines[0].type === type) { + G.logLines[0].count = (G.logLines[0].count || 1) + 1; + } else { + G.logLines.unshift({ text: msg, type, count: 1 }); + if (G.logLines.length > 40) G.logLines.length = 40; + } } // โ”€โ”€ BANKRUPTCY CHECK โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -189,7 +193,8 @@ function checkBankruptcy() { // โ”€โ”€ GAME OVER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function endGame() { - if (G.shopOpen) closeShop(); + if (G.armoryOpen) closeArmory(); + if (G.commandOpen) closeCommand(); setPaused(false); G.gameOver = true; G.isBankrupt = false; @@ -201,7 +206,8 @@ function endGame() { } function endBankrupt() { - if (G.shopOpen) closeShop(); + if (G.armoryOpen) closeArmory(); + if (G.commandOpen) closeCommand(); setPaused(false); G.gameOver = true; G.isBankrupt = true; @@ -214,13 +220,130 @@ function endBankrupt() { } function restartGame() { + const savedBonuses = G.permanentBonuses ? { ...G.permanentBonuses } : {}; + const savedTiers = G.unlockedTiers ? [...G.unlockedTiers] : [0]; + const savedPrestige = G.prestigeLevel || 0; G = makeGameState(); + G.permanentBonuses = savedBonuses; + G.unlockedTiers = savedTiers; + G.difficultyTier = savedTiers.includes(0) ? 0 : savedTiers[0]; + G.prestigeLevel = savedPrestige; + applyPermanentBonuses(); 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'); + if (savedPrestige > 0) addLog(`PRESTIGE ${savedPrestige} active โ€” permanent bonuses applied.`, 'win'); +} + +// โ”€โ”€ THREAT / PRESTIGE PANEL CONTROLS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function openThreatPanel() { + if (G.gameOver) return; + G.threatOpen = true; + G.prestigeOpen = false; +} +function closeThreatPanel() { + G.threatOpen = false; +} +function openPrestigeDialog() { + if (G.gameOver) return; + G.prestigeOpen = true; + G.threatOpen = false; +} +function closePrestigeDialog() { + G.prestigeOpen = false; +} + +// โ”€โ”€ THREAT LEVEL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function unlockThreatTier(tierId) { + const tierDef = DIFFICULTY_TIERS[tierId]; + if (!tierDef) return; + if (G.unlockedTiers.includes(tierId)) { + // Already unlocked โ€” just switch + setThreatTier(tierId); + return; + } + if (spendableCredits() < tierDef.unlockCost) { + addLog(`Need ${tierDef.unlockCost}ยข to unlock ${tierDef.name}.`, 'info'); + return; + } + G.credits -= tierDef.unlockCost; + G.unlockedTiers.push(tierId); + setThreatTier(tierId); + addLog(`THREAT LEVEL: ${tierDef.name} unlocked and activated.`, 'win'); + updateHUD(); + renderShop(); +} + +function setThreatTier(tierId) { + if (!G.unlockedTiers.includes(tierId)) return; + G.difficultyTier = tierId; + const tierDef = DIFFICULTY_TIERS[tierId]; + if (tierDef && tierId > 0) { + addLog(`THREAT LEVEL: ${tierDef.name} โ€” enemies at ร—${tierDef.hpMult} HP, ร—${tierDef.rewardMult} rewards.`, 'info'); + } else { + addLog('THREAT LEVEL: NORMAL โ€” standard difficulty.', 'info'); + } + updateHUD(); + renderShop(); +} + +// โ”€โ”€ PRESTIGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function prestigeCost() { + const base = [5000, 25000, 60000, 120000, 200000]; + const lvl = G.prestigeLevel || 0; + return base[lvl] ?? (200000 + (lvl - 4) * 100000); +} + +function applyPermanentBonuses() { + const b = G.permanentBonuses || {}; + if (b.maxHp) { G.tower.maxHp += Math.floor(b.maxHp); G.tower.hp = Math.min(G.tower.hp, G.tower.maxHp); } + if (b.armor) { G.tower.armor += Math.floor(b.armor); } + if (b.range) { G.tower.range += Math.floor(b.range); } + if (b.aimSpeed) { G.tower.aimSpeed += b.aimSpeed; } +} + +function prestige() { + const cost = prestigeCost(); + if (G.credits < cost) { + addLog(`Need ${cost}ยข to prestige.`, 'info'); + return; + } + + const FRACTION = 0.25; + const newBonuses = { ...(G.permanentBonuses || {}) }; + + // Convert all owned tower upgrades into fractional permanent bonuses + for (const upg of TOWER_UPGRADE_TREE) { + if (!G.towerUpgradesBought.includes(upg.id) || upg.repeatable) continue; + const e = upg.effect; + if (e.maxHp) newBonuses.maxHp = (newBonuses.maxHp || 0) + e.maxHp * FRACTION; + if (e.armor) newBonuses.armor = (newBonuses.armor || 0) + e.armor * FRACTION; + if (e.aimSpeed) newBonuses.aimSpeed = (newBonuses.aimSpeed || 0) + e.aimSpeed * FRACTION; + if (e.range) newBonuses.range = (newBonuses.range || 0) + e.range * FRACTION; + } + + const prevTiers = [...G.unlockedTiers]; + const newPrestige = (G.prestigeLevel || 0) + 1; + + G = makeGameState(); + G.permanentBonuses = newBonuses; + G.unlockedTiers = prevTiers; + G.difficultyTier = prevTiers.includes(0) ? 0 : prevTiers[0]; + G.prestigeLevel = newPrestige; + applyPermanentBonuses(); + + setPaused(false); + _sidePanelScrollY = 0; + _logScrollY = 0; + updateHUD(); + renderShop(); + + addLog(`PRESTIGE ${newPrestige} โ€” permanent bonuses banked. New run starts at 150ยข.`, 'win'); + if (newBonuses.maxHp) addLog(` Hull: +${Math.floor(newBonuses.maxHp)} HP permanent`, 'win'); + if (newBonuses.armor) addLog(` Plating: +${Math.floor(newBonuses.armor)} armor permanent`, 'win'); + if (newBonuses.range) addLog(` Scanner: +${Math.floor(newBonuses.range)} range permanent`, 'win'); } // โ”€โ”€ INIT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -229,7 +352,6 @@ function init() { updateHUD(); initInput(); addLog('SIEGE PROTOCOL initialized.', 'info'); - addLog('[1โ€“0] deploy [scroll] qty [Space] shop [Esc/P] pause [I] inventory', 'info'); gameLoop(); } diff --git a/js/renderer-combat.js b/js/renderer-combat.js index 23587f7..a171431 100644 --- a/js/renderer-combat.js +++ b/js/renderer-combat.js @@ -113,6 +113,75 @@ function drawEnemyShape(e, bodyColor) { break; } + case 'commander': { + // Pentagon body with rank stripes + 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.strokeStyle='#ffffff44'; ctx.lineWidth=1.5; ctx.stroke(); + // Rank stripes + ctx.strokeStyle='#000000aa'; ctx.lineWidth=1.5; + ctx.beginPath(); ctx.moveTo(x-r*0.5,y-r*0.2); ctx.lineTo(x+r*0.5,y-r*0.2); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(x-r*0.35,y+r*0.1); ctx.lineTo(x+r*0.35,y+r*0.1); ctx.stroke(); + break; + } + + case 'juggernaut': { + // Heavy octagon + ctx.beginPath(); + for (let i=0;i<8;i++){const a=(i/8)*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.strokeStyle='#ff000066'; ctx.lineWidth=3; ctx.stroke(); + ctx.strokeStyle='#ffffff22'; ctx.lineWidth=1; + ctx.beginPath(); + for (let i=0;i<8;i++){const a=(i/8)*Math.PI*2;i===0?ctx.moveTo(x+Math.cos(a)*r*0.55,y+Math.sin(a)*r*0.55):ctx.lineTo(x+Math.cos(a)*r*0.55,y+Math.sin(a)*r*0.55);} + ctx.closePath(); ctx.stroke(); + break; + } + + case 'siegebreaker': { + // Cross / plus shape (large, slow tank) + const arm = r * 0.55; + ctx.beginPath(); + ctx.moveTo(x-arm,y-r); ctx.lineTo(x+arm,y-r); + ctx.lineTo(x+arm,y-arm); ctx.lineTo(x+r,y-arm); + ctx.lineTo(x+r,y+arm); ctx.lineTo(x+arm,y+arm); + ctx.lineTo(x+arm,y+r); ctx.lineTo(x-arm,y+r); + ctx.lineTo(x-arm,y+arm); ctx.lineTo(x-r,y+arm); + ctx.lineTo(x-r,y-arm); ctx.lineTo(x-arm,y-arm); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle='#ffffff33'; ctx.lineWidth=2; ctx.stroke(); + // Regen pulse ring when regen is active + if (!e.regenPausedUntil || G.frame >= e.regenPausedUntil) { + const regenAlpha = 0.15 + 0.15 * Math.sin(t * 3); + ctx.strokeStyle = `rgba(136,14,79,${regenAlpha})`; + ctx.lineWidth = 3; + ctx.beginPath(); ctx.arc(x, y, r * 1.4, 0, Math.PI*2); ctx.stroke(); + } + break; + } + + case 'echo': { + // Twin overlapping rings โ€” split visual + const off = r * 0.3; + ctx.beginPath(); ctx.arc(x-off, y, r*0.8, 0, Math.PI*2); ctx.fill(); + ctx.beginPath(); ctx.arc(x+off, y, r*0.8, 0, Math.PI*2); ctx.fill(); + ctx.strokeStyle='#ffffff44'; ctx.lineWidth=1; + ctx.beginPath(); ctx.arc(x-off, y, r*0.8, 0, Math.PI*2); ctx.stroke(); + ctx.beginPath(); ctx.arc(x+off, y, r*0.8, 0, Math.PI*2); ctx.stroke(); + break; + } + + case 'voidherald': { + // Diamond with void inner ring + ctx.beginPath(); + ctx.moveTo(x, y-r); ctx.lineTo(x+r*0.8, y); ctx.lineTo(x, y+r); ctx.lineTo(x-r*0.8, y); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle=bodyColor+'88'; ctx.lineWidth=1.5; + ctx.beginPath(); ctx.arc(x, y, r*0.45, 0, Math.PI*2); ctx.stroke(); + break; + } + default: ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill(); } @@ -122,6 +191,52 @@ function drawEnemyShape(e, bodyColor) { function drawEnemies() { const enemies = G.enemies; + // Pass 0: Commander plasma links + self-glow (drawn under all bodies) + for (const e of enemies) { + if (!e.alive || e.defId !== 'commander') continue; + const auraRadSq = 80 * 80; + const linked = enemies.filter(t => t.alive && t !== e && distSq(e.x, e.y, t.x, t.y) <= auraRadSq); + const cap = Math.min(linked.length, 8); + + // Plasma links to each buffed enemy + if (cap > 0) { + ctx.save(); + ctx.shadowColor = 'rgba(255,200,50,0.8)'; + ctx.shadowBlur = 8; + ctx.strokeStyle = 'rgba(255,200,50,0.55)'; + ctx.lineWidth = 1; + for (let i = 0; i < cap; i++) { + const t = linked[i]; + const mx = (e.x + t.x) / 2; + const my = (e.y + t.y) / 2; + const dx = t.x - e.x, dy = t.y - e.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const perp = Math.sin(G.frame * 0.04 + i * 1.3) * 18; + const cpx = mx - (dy / len) * perp; + const cpy = my + (dx / len) * perp; + ctx.beginPath(); + ctx.moveTo(e.x, e.y); + ctx.quadraticCurveTo(cpx, cpy, t.x, t.y); + ctx.stroke(); + } + ctx.shadowBlur = 0; + ctx.restore(); + } + + // Self-glow: pulses large and bright when alone, dims as links form + const glowR = linked.length === 0 ? 40 : Math.max(14, 28 - linked.length * 2); + const selfAlpha = linked.length === 0 + ? 0.35 + 0.15 * Math.sin(G.frame * 0.05) + : Math.max(0.05, 0.18 - linked.length * 0.02); + const grd = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, glowR); + grd.addColorStop(0, `rgba(255,200,50,${selfAlpha.toFixed(2)})`); + grd.addColorStop(1, 'rgba(255,200,50,0)'); + ctx.fillStyle = grd; + ctx.beginPath(); + ctx.arc(e.x, e.y, glowR, 0, Math.PI * 2); + ctx.fill(); + } + // Pass 1: elemental glows (no shadow, just gradient blobs) for (const e of enemies) { if (!e.alive || !e.element) continue; @@ -197,6 +312,27 @@ function drawEnemies() { } } + // Void Herald shield bubble + if (e.defId === 'voidherald') { + const shieldActive = (e._voidHitsThisFrame || 0) >= 3; + if (shieldActive) { + ctx.save(); + ctx.strokeStyle = '#c77dff'; + ctx.lineWidth = 3; + ctx.shadowColor = '#c77dff'; + ctx.shadowBlur = 18; + ctx.beginPath(); ctx.arc(e.x, e.y, e.radius + 5, 0, Math.PI*2); ctx.stroke(); + ctx.globalAlpha = 0.15; + ctx.fillStyle = '#c77dff'; + ctx.beginPath(); ctx.arc(e.x, e.y, e.radius + 5, 0, Math.PI*2); ctx.fill(); + ctx.restore(); + } else { + ctx.strokeStyle = 'rgba(124,77,255,0.22)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(e.x, e.y, e.radius + 4, 0, Math.PI*2); ctx.stroke(); + } + } + // HP bar โ€” only when damaged if (e.hp < e.maxHp) { const bw = e.radius * 2.4, bh = 3; diff --git a/js/renderer-hud.js b/js/renderer-hud.js index 795bd1e..b669a0d 100644 --- a/js/renderer-hud.js +++ b/js/renderer-hud.js @@ -10,6 +10,93 @@ // โ”€โ”€ HUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const HUD_H = 64; +// โ”€โ”€ TOOLTIP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const _TT_DELAY = 700; // ms of continuous hover before tooltip shows +let _ttTarget = null; // button key currently hovered +let _ttHoverMs = 0; // timestamp when hover on _ttTarget began +let _ttBx = 0, _ttBy = 0, _ttBw = 0, _ttBh = 0; + +function _tickTooltip(key, x, y, w, h) { + if (isHovered(x, y, w, h)) { + if (_ttTarget !== key) { _ttTarget = key; _ttHoverMs = Date.now(); _ttBx = x; _ttBy = y; _ttBw = w; _ttBh = h; } + } else if (_ttTarget === key) { + _ttTarget = null; + } +} + +function _drawHudTooltip(key, W, H) { + // [description, hotkey | null] + const tips = { + armory: ['Buy and upgrade weapons', 'Space'], + command: ['Upgrade tower hull, armor & more', 'C'], + threat: ['Change enemy difficulty tier', null], + prestige: ['Reset for permanent bonuses', null], + inventory: ['Mount and manage weapons', 'I'], + rdown: ['Decrease credit reserve', null], + rup: ['Increase credit reserve', null], + qty: ['Click to cycle qty ยท scroll adjust', null], + }; + const tip = tips[key]; + if (!tip) return; + const [desc, hotkey] = tip; + + ctx.save(); + ctx.letterSpacing = '0px'; + + ctx.font = '11px "Share Tech Mono", monospace'; + const descW = ctx.measureText(desc).width; + + const KEY_PAD = 10, KEY_H = 18; + let keyBadgeW = 0; + if (hotkey) { + ctx.font = '10px Orbitron, monospace'; + keyBadgeW = ctx.measureText(hotkey).width + KEY_PAD * 2; + } + + const H_PAD = 12, GAP = hotkey ? 8 : 0; + const TW = H_PAD * 2 + descW + GAP + keyBadgeW; + const TH = 28; + + const isBottom = _ttBy > H / 2; + const tx = Math.min(Math.max(_ttBx + _ttBw / 2 - TW / 2, 8), W - TW - 8); + const ty = isBottom ? _ttBy - TH - 6 : _ttBy + _ttBh + 6; + + // Background pill + ctx.fillStyle = 'rgba(4,10,18,0.97)'; + ctx.strokeStyle = '#1a4060'; + ctx.lineWidth = 1; + ctx.fillRect(tx, ty, TW, TH); + ctx.strokeRect(tx, ty, TW, TH); + + // Description + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.fillStyle = '#b8d8e8'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(desc, tx + H_PAD, ty + TH / 2); + + // Keycap badge + if (hotkey) { + const bx = tx + H_PAD + descW + GAP; + const by = ty + (TH - KEY_H) / 2; + ctx.fillStyle = '#060e18'; + ctx.strokeStyle = '#00d4ff66'; + ctx.lineWidth = 1; + ctx.fillRect(bx, by, keyBadgeW, KEY_H); + ctx.strokeRect(bx, by, keyBadgeW, KEY_H); + // Inner highlight line at top (keycap bevel) + ctx.strokeStyle = '#00d4ff33'; + ctx.beginPath(); ctx.moveTo(bx + 2, by + 2); ctx.lineTo(bx + keyBadgeW - 2, by + 2); ctx.stroke(); + ctx.font = '10px Orbitron, monospace'; + ctx.fillStyle = '#00d4ff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(hotkey, bx + keyBadgeW / 2, by + KEY_H / 2); + } + + ctx.restore(); +} + // Right-section column centers (canvas x coords, 1600px wide) const _HUD_KILLS_CX = 1542; const _HUD_SCORE_CX = 1468; @@ -19,7 +106,7 @@ const _HUD_DIV2_X = 1204; const _HUD_CRED_CX = 1118; function drawHUD() { - const W = canvas.width; + const W = canvas.width, H = canvas.height; const cheapest = cheapestEnemyCost(); // โ”€โ”€ Background strip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -44,28 +131,50 @@ function drawHUD() { const titleW = ctx.measureText('SIEGE PROTOCOL').width; ctx.letterSpacing = '0px'; - // โ”€โ”€ LEFT: shop button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const SBX = 20 + titleW + 16; - const SBY = 14; - const SBH = 36; + // โ”€โ”€ LEFT: THREAT, PRESTIGE buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 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); + const tierDef = DIFFICULTY_TIERS[G.difficultyTier || 0]; + const tierName = tierDef?.name ?? 'NORMAL'; + const prestige = G.prestigeLevel || 0; + const tierActive = (G.difficultyTier || 0) > 0; - ctx.fillStyle = shopHov ? '#00d4ff' : 'transparent'; - ctx.strokeStyle = '#00d4ff'; + // THREAT button + const threatLabel = 'โš  ' + tierName; + const TH_BW = Math.ceil(ctx.measureText(threatLabel).width) + 28; + const TH_BX = 20 + titleW + 16; + const TH_BY = 14, TH_BH = 36; + const threatHov = isHovered(TH_BX, TH_BY, TH_BW, TH_BH); + const threatOn = G.threatOpen; + ctx.fillStyle = (threatHov || threatOn) ? (tierActive ? '#ff6b35' : '#00d4ff') : 'transparent'; + ctx.strokeStyle = tierActive ? '#ff6b35' : (threatOn ? '#00d4ff' : '#3a5060'); 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.beginPath(); ctx.rect(TH_BX, TH_BY, TH_BW, TH_BH); ctx.fill(); ctx.stroke(); + if (threatHov || threatOn) { ctx.shadowColor = tierActive ? '#ff6b35' : '#00d4ff'; ctx.shadowBlur = 12; } + ctx.fillStyle = (threatHov || threatOn) ? '#000000' : (tierActive ? '#ff6b35' : '#3a6080'); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(threatLabel, TH_BX + TH_BW / 2, TH_BY + TH_BH / 2); ctx.shadowBlur = 0; + addHitRegion(TH_BX, TH_BY, TH_BW, TH_BH, () => G.threatOpen ? closeThreatPanel() : openThreatPanel()); + _tickTooltip('threat', TH_BX, TH_BY, TH_BW, TH_BH); - addHitRegion(SBX, SBY, SBW, SBH, () => G.shopOpen ? closeShop() : openShop()); + // PRESTIGE button + const prestigeLabel = prestige > 0 ? ('โœถP' + prestige + ' PRESTIGE') : 'PRESTIGE'; + const PS_BX = TH_BX + TH_BW + 8; + const PS_BW = Math.ceil(ctx.measureText(prestigeLabel).width) + 28; + const PS_BY = 14, PS_BH = 36; + const prestigeHov = isHovered(PS_BX, PS_BY, PS_BW, PS_BH); + const prestigeOn = G.prestigeOpen; + ctx.fillStyle = (prestigeHov || prestigeOn) ? '#c77dff' : 'transparent'; + ctx.strokeStyle = prestige > 0 ? '#c77dff' : (prestigeOn ? '#c77dff' : '#3a4060'); + ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(PS_BX, PS_BY, PS_BW, PS_BH); ctx.fill(); ctx.stroke(); + if (prestigeHov || prestigeOn) { ctx.shadowColor = '#c77dff'; ctx.shadowBlur = 12; } + ctx.fillStyle = (prestigeHov || prestigeOn) ? '#000000' : (prestige > 0 ? '#c77dff' : '#3a4060'); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(prestigeLabel, PS_BX + PS_BW / 2, PS_BY + PS_BH / 2); + ctx.shadowBlur = 0; + addHitRegion(PS_BX, PS_BY, PS_BW, PS_BH, () => G.prestigeOpen ? closePrestigeDialog() : openPrestigeDialog()); + _tickTooltip('prestige', PS_BX, PS_BY, PS_BW, PS_BH); // โ”€โ”€ CENTER: HP bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const CX = W / 2; @@ -179,6 +288,8 @@ function drawHUD() { if (!rDownDis) addHitRegion(rDownX, RBY, RBW, RBH, () => adjustReserve(-10)); if (!rUpDis) addHitRegion(rUpX, RBY, RBW, RBH, () => adjustReserve(10)); + _tickTooltip('rdown', rDownX, RBY, RBW, RBH); + _tickTooltip('rup', rUpX, RBY, RBW, RBH); hudDivider(_HUD_DIV2_X); @@ -200,9 +311,75 @@ function drawHUD() { ctx.fillText(G.credits + 'ยข', CRED, HUD_H - 6); ctx.shadowBlur = 0; + // โ”€โ”€ BOTTOM-LEFT: ARMORY button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const BTN_H = 34; + const BTN_Y = H - 14 - BTN_H; + ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; + const armoryLabel = 'โš™ ARMORY'; + const ARM_BW = Math.ceil(ctx.measureText(armoryLabel).width) + 32; + const ARM_BX = 20, ARM_BY = BTN_Y; + const armoryHov = isHovered(ARM_BX, ARM_BY, ARM_BW, BTN_H); + const armoryOn = G.armoryOpen; + ctx.fillStyle = (armoryHov || armoryOn) ? '#00d4ff' : 'transparent'; + ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(ARM_BX, ARM_BY, ARM_BW, BTN_H); ctx.fill(); ctx.stroke(); + if (armoryHov || armoryOn) { ctx.shadowColor = '#00d4ff'; ctx.shadowBlur = 16; } + ctx.fillStyle = (armoryHov || armoryOn) ? '#000000' : '#00d4ff'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(armoryLabel, ARM_BX + ARM_BW / 2, ARM_BY + BTN_H / 2); + ctx.shadowBlur = 0; + // Don't register when open โ€” overlay is full-screen and its regions must take priority + if (!armoryOn) addHitRegion(ARM_BX, ARM_BY, ARM_BW, BTN_H, openArmory); + _tickTooltip('armory', ARM_BX, ARM_BY, ARM_BW, BTN_H); + + // โ”€โ”€ BOTTOM-LEFT: COMMAND button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; + const cmdLabel = '๐Ÿฐ COMMAND'; + const CMD_BW = Math.ceil(ctx.measureText(cmdLabel).width) + 28; + const CMD_BX = ARM_BX + ARM_BW + 8, CMD_BY = BTN_Y; + const cmdHov = isHovered(CMD_BX, CMD_BY, CMD_BW, BTN_H); + const cmdOn = G.commandOpen; + ctx.fillStyle = (cmdHov || cmdOn) ? '#00d4ff' : 'transparent'; + ctx.strokeStyle = cmdOn ? '#00d4ff' : '#1a4060'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(CMD_BX, CMD_BY, CMD_BW, BTN_H); ctx.fill(); ctx.stroke(); + if (cmdHov || cmdOn) { ctx.shadowColor = '#00d4ff'; ctx.shadowBlur = 16; } + ctx.fillStyle = (cmdHov || cmdOn) ? '#000000' : '#3a8090'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(cmdLabel, CMD_BX + CMD_BW / 2, CMD_BY + BTN_H / 2); + ctx.shadowBlur = 0; + if (!cmdOn) addHitRegion(CMD_BX, CMD_BY, CMD_BW, BTN_H, openCommand); + _tickTooltip('command', CMD_BX, CMD_BY, CMD_BW, BTN_H); + + // โ”€โ”€ BOTTOM-RIGHT: INVENTORY button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; + const invLabel = 'INVENTORY'; + const INV_BW = Math.ceil(ctx.measureText(invLabel).width) + 28; + const INV_BH = BTN_H; + const INV_BX = (W - SP_W) - 14 - INV_BW; + const INV_BY = BTN_Y; + const invOpen = document.body.classList.contains('inventory-open'); + const invHov = isHovered(INV_BX, INV_BY, INV_BW, INV_BH); + ctx.fillStyle = (invHov || invOpen) ? '#00d4ff' : 'transparent'; + ctx.strokeStyle = invOpen ? '#00d4ff' : '#1a4060'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(INV_BX, INV_BY, INV_BW, INV_BH); ctx.fill(); ctx.stroke(); + if (invHov || invOpen) { ctx.shadowColor = '#00d4ff'; ctx.shadowBlur = 12; } + ctx.fillStyle = (invHov || invOpen) ? '#000000' : '#3a6080'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(invLabel, INV_BX + INV_BW / 2, INV_BY + INV_BH / 2); + ctx.shadowBlur = 0; + if (!invOpen) addHitRegion(INV_BX, INV_BY, INV_BW, INV_BH, () => openWeaponPicker(-1)); + _tickTooltip('inventory', INV_BX, INV_BY, INV_BW, INV_BH); + ctx.restore(); } +// Called last in render() so tooltips always appear on top +function drawTooltips() { + if (_ttTarget && Date.now() - _ttHoverMs >= _TT_DELAY) { + _drawHudTooltip(_ttTarget, canvas.width, canvas.height); + } +} + // โ”€โ”€ BROKE WARNING โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function drawBrokeWarning() { const cheapest = cheapestEnemyCost(); diff --git a/js/renderer-inventory.js b/js/renderer-inventory.js index b7784a7..c132863 100644 --- a/js/renderer-inventory.js +++ b/js/renderer-inventory.js @@ -38,7 +38,7 @@ function drawInventoryOverlay() { ctx.save(); - const titleSuffix = equipMode ? `- SLOT ${_pickerSlot + 1}` : '- BAG'; + const titleSuffix = equipMode ? `- SLOT ${_pickerSlot + 1}` : ''; ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '3px'; ctx.textAlign = 'left'; @@ -50,7 +50,7 @@ function drawInventoryOverlay() { 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.fillText(invCount + ' in inventory', _PK_LIST_X + 270, _PK_Y + _PK_PAD + 1); ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; @@ -177,7 +177,7 @@ function drawInventoryOverlay() { for (const w of (G.weaponInventory || [])) { const wRef = w; drawCard(w, getWeaponDef(w), { - location: 'BAG', + location: 'INVENTORY', source: { type: 'bag' }, action: equipMode ? () => { equipWeaponInstanceToSlot(_pickerSlot, wRef.instanceId); } diff --git a/js/renderer-overlays.js b/js/renderer-overlays.js index 54bf3a2..c430740 100644 --- a/js/renderer-overlays.js +++ b/js/renderer-overlays.js @@ -3,6 +3,29 @@ // RENDERER OVERLAYS โ€” game over, pause, mount drag UI // ============================================================ +// โ”€โ”€ WEAPON RADIAL + DETAIL STATE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +let _radialSlot = -1; // which slot has radial menu open (-1 = none) +let _weaponDetailScrollY = 0; +let _weaponDetailScrollMax = 0; +let _sellRegion = null; // {x,y,w,h,slot} โ€” written each frame, read by input.js for hold-to-sell +let _prestigeHoldRegion = null; // {x,y,w,h} โ€” written each frame by drawPrestigeConfirm, read by input.js + +function checkSellHold() { + if (_sellHoldSlot >= 0 && Date.now() - _sellHoldMs >= SELL_HOLD_DURATION) { + const slot = _sellHoldSlot; + _sellHoldSlot = -1; + sellWeapon(slot); + } +} + +function checkPrestigeHold() { + if (_prestigeHoldMs > 0 && Date.now() - _prestigeHoldMs >= PRESTIGE_HOLD_DURATION) { + _prestigeHoldMs = -1; + prestige(); + closePrestigeDialog(); + } +} + // โ”€โ”€ GAME OVER / BANKRUPT OVERLAY โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function drawGameOverPanel() { const W = canvas.width, H = canvas.height; @@ -145,50 +168,62 @@ function _drawMountTooltip(mx, my, weapon, socketR) { ctx.fillText(parts.join(' '), tx + 8, ty + 26); ctx.fillStyle = '#3a6080'; - ctx.fillText('drag to move or bag', tx + 8, ty + 41); + ctx.fillText('Shift+click to interact', tx + 8, ty + 41); ctx.restore(); } -// โ”€โ”€ MOUNT POINT INTERACTION (drawn after overlays so it sits on top) โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ MOUNT POINT INTERACTION โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function drawMountInteraction(cx, cy) { - if (G.shopOpen) return; + if (G.armoryOpen || G.commandOpen) return; + if (G.weaponDetailSlot >= 0) return; + + // All drawing uses screen-space coords so UI stays fixed-size at any zoom level + const zoom = G?.camera?.zoom ?? 1.0; + const toSX = wx => (wx - cx) * zoom + cx; + const toSY = wy => (wy - cy) * zoom + cy; + 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'; + const spreadMode = invOpen || _shiftHeld || _radialSlot >= 0; for (let slotIndex = 0; slotIndex < totalSlots; slotIndex++) { - const weapon = G.weapons[slotIndex]; - const installed = !!weapon; + const weapon = G.weapons[slotIndex]; + const installed = !!weapon; const mountAngle = HARDPOINT_BASE_ANGLE + (slotIndex / totalSlots) * Math.PI * 2; - if (invOpen) { + if (spreadMode) { 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 ORBIT = Math.max(78, 64 + totalSlots * 6); + const mx = cx + Math.cos(mountAngle) * ORBIT; // world + const my = cy + Math.sin(mountAngle) * ORBIT; // world + const smx = toSX(mx); + const smy = toSY(my); const dropR = 20; - const hov = isHovered(mx - dropR, my - dropR, dropR * 2, dropR * 2); + const hov = isHovered(smx - dropR, smy - dropR, dropR * 2, dropR * 2); const dragging = _dragWeapon !== null; + // Dashed line from actual hardpoint to spread circle ctx.save(); - ctx.strokeStyle = '#00aaff33'; - ctx.lineWidth = 1; + ctx.strokeStyle = '#00aaff77'; + ctx.lineWidth = 1.5; ctx.setLineDash([5, 6]); ctx.beginPath(); - ctx.moveTo(actual.x, actual.y); - ctx.lineTo(mx, my); + ctx.moveTo(toSX(actual.x), toSY(actual.y)); + ctx.lineTo(smx, smy); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); - if (dragging) { + // Drag drop indicator + if ((invOpen || _shiftHeld) && 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.beginPath(); ctx.arc(smx, smy, dropR + 5, 0, Math.PI * 2); ctx.stroke(); ctx.shadowBlur = 0; ctx.restore(); } @@ -198,40 +233,741 @@ function drawMountInteraction(cx, cy) { ? (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.beginPath(); ctx.arc(smx, smy, 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.fillText(`S${slotIndex + 1}`, smx, smy - (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 } }); + if (invOpen || _shiftHeld) { + _dragRegions.push({ x: smx - dropR, y: smy - 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); + ctx.fillText(def?.icon || '?', smx, smy + 5); + + if ((_shiftHeld || _radialSlot >= 0) && !invOpen && hov) { + _drawMountTooltip(smx, smy, weapon, dropR); + } } else { ctx.font = '14px "Share Tech Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#1a3240'; - ctx.fillText('+', mx, my); + ctx.fillText('+', smx, smy); } + // _mountDropZones stay in world space โ€” tested against pt.worldX/worldY _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)); + + if (invOpen) { + addHitRegion(smx - dropR, smy - dropR, dropR * 2, dropR * 2, () => openWeaponPicker(slotIndex)); + } else { + const si = slotIndex; + if (installed) { + addHitRegion(smx - dropR, smy - dropR, dropR * 2, dropR * 2, () => { + _radialSlot = (_radialSlot === si) ? -1 : si; + }); + } else { + addHitRegion(smx - dropR, smy - dropR, dropR * 2, dropR * 2, () => openWeaponPicker(si)); + } + } } } + + // โ”€โ”€ Radial menu โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (_radialSlot >= 0) { + const totalSlotsR = Math.max(1, G.tower.weaponSlots); + const mountAngleR = HARDPOINT_BASE_ANGLE + (_radialSlot / totalSlotsR) * Math.PI * 2; + const ORBIT_R = Math.max(78, 64 + totalSlotsR * 6); + const rmx = cx + Math.cos(mountAngleR) * ORBIT_R; // world + const rmy = cy + Math.sin(mountAngleR) * ORBIT_R; // world + const srmx = toSX(rmx); + const srmy = toSY(rmy); + const weapon = G.weapons[_radialSlot]; + const si = _radialSlot; + const w = weapon; + + const outerR = 90; + const innerR = 22; + const iconR = 68; + const iconBtnR = 22; + + const curMode = weapon ? (weapon.targeting || 'nearest') : 'nearest'; + const opts = [ + { key: 'upgrades', angle: -Math.PI / 2, label: 'UPGRADES', icon: 'โš™', color: '#00d4ff' }, + { key: 'target', angle: 0, label: curMode.toUpperCase(), icon: '๐ŸŽฏ', color: '#ffd700' }, + { key: 'sell', angle: Math.PI / 2, label: 'SELL', icon: '๐Ÿ’ฐ', color: '#ff3355' }, + { key: 'inventory', angle: Math.PI, label: 'INVENTORY', icon: '๐Ÿ“ฆ', color: '#00aaff' }, + ]; + + let hovKey = null; + if (_hoverPt) { + const dx = _hoverPt.x - srmx, dy = _hoverPt.y - srmy; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist >= innerR && dist <= outerR) { + const angle = Math.atan2(dy, dx); + for (const opt of opts) { + let diff = angle - opt.angle; + while (diff > Math.PI) diff -= 2 * Math.PI; + while (diff < -Math.PI) diff += 2 * Math.PI; + if (Math.abs(diff) < Math.PI / 4) { hovKey = opt.key; break; } + } + } + } + + ctx.save(); + + ctx.fillStyle = 'rgba(4,12,22,0.92)'; + ctx.beginPath(); + ctx.arc(srmx, srmy, outerR, 0, Math.PI * 2); + ctx.fill(); + + for (const opt of opts) { + const startAngle = opt.angle - Math.PI / 4; + const endAngle = opt.angle + Math.PI / 4; + const hov = hovKey === opt.key; + ctx.beginPath(); + ctx.moveTo(srmx, srmy); + ctx.arc(srmx, srmy, outerR - 1, startAngle, endAngle); + ctx.closePath(); + if (hov) { ctx.fillStyle = opt.color + '28'; ctx.fill(); } + ctx.strokeStyle = '#1a3048'; + ctx.lineWidth = 1; + ctx.stroke(); + } + + ctx.strokeStyle = '#2a4060'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(srmx, srmy, outerR, 0, Math.PI * 2); + ctx.stroke(); + + ctx.fillStyle = 'rgba(4,12,22,0.95)'; + ctx.beginPath(); + ctx.arc(srmx, srmy, innerR, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#1a3048'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(srmx, srmy, innerR, 0, Math.PI * 2); + ctx.stroke(); + + if (weapon) { + const def = getWeaponDef(weapon); + ctx.font = '22px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(def?.icon || '?', srmx, hovKey ? srmy - 7 : srmy); + if (hovKey) { + const hovOpt = opts.find(o => o.key === hovKey); + ctx.font = '8px Orbitron, monospace'; + ctx.letterSpacing = '1px'; + ctx.fillStyle = hovOpt.color; + ctx.fillText(hovOpt.label, srmx, srmy + 10); + ctx.letterSpacing = '0px'; + } + } + + _sellRegion = null; + for (const opt of opts) { + const ix = srmx + Math.cos(opt.angle) * iconR; + const iy = srmy + Math.sin(opt.angle) * iconR; + const hov = hovKey === opt.key; + + let holdProgress = 0; + if (opt.key === 'sell' && _sellHoldSlot === si) { + holdProgress = Math.min(1, (Date.now() - _sellHoldMs) / SELL_HOLD_DURATION); + } + + ctx.font = (hov ? '22px' : '18px') + ' monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = hov ? opt.color : '#b8d8e8'; + ctx.globalAlpha = hov ? 1 : 0.82; + ctx.fillText(opt.icon, ix, iy + 1); + ctx.globalAlpha = 1; + + if (opt.key === 'sell' && holdProgress > 0) { + ctx.strokeStyle = '#ff3355'; + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.arc(ix, iy, 16, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * holdProgress); + ctx.stroke(); + ctx.lineCap = 'butt'; + } + + if (opt.key === 'sell') { + _sellRegion = { x: srmx - outerR, y: srmy, w: outerR * 2, h: outerR, slot: si }; + } + } + + ctx.restore(); + + const HR = iconBtnR; + addHitRegion(srmx - HR, srmy - iconR - HR, HR * 2, HR * 2, () => { if (w) openWeaponDetail(si); }); + const modes = ['nearest','strongest','weakest','fastest','furthest','group']; + addHitRegion(srmx + iconR - HR, srmy - HR, HR * 2, HR * 2, () => { + if (!w) return; + const idx = modes.indexOf(w.targeting || 'nearest'); + setWeaponTargeting(w.instanceId, modes[(idx + 1) % modes.length]); + }); + addHitRegion(srmx - HR, srmy + iconR - HR, HR * 2, HR * 2, () => { /* hold to sell */ }); + addHitRegion(srmx - iconR - HR, srmy - HR, HR * 2, HR * 2, () => { + removeWeaponFromSlot(si); + _radialSlot = -1; + }); + + addHitRegion(0, 0, canvas.width, canvas.height, () => { _radialSlot = -1; }); + } +} +function drawWeaponDetailOverlay() { + if (G.weaponDetailSlot < 0) { _sellRegion = null; return; } + _shopRightClick.length = 0; + const slot = G.weaponDetailSlot; + const weapon = G.weapons[slot]; + if (!weapon) { closeWeaponDetail(); return; } + + const def = getWeaponDef(weapon); + const W = canvas.width, H = canvas.height; + + const PX = 235, PW = 860; + const PY = HUD_H + 8; + const PH = H - HUD_H - 16; + + // Background dim + ctx.fillStyle = 'rgba(0,0,0,0.42)'; + ctx.fillRect(0, 0, W, H); + + // Panel bg + border + ctx.fillStyle = '#050c16'; + ctx.strokeStyle = '#1a3048'; + ctx.lineWidth = 1; + ctx.fillRect(PX, PY, PW, PH); + ctx.strokeRect(PX, PY, PW, PH); + + const PAD = 16; + let y = PY; + + // โ”€โ”€ Header row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const HDR_H = 48; + ctx.fillStyle = '#040c14'; + ctx.fillRect(PX, y, PW, HDR_H); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(PX, y + HDR_H); ctx.lineTo(PX + PW, y + HDR_H); ctx.stroke(); + + // โ—€ button + const NAV_W = 36, NAV_H = 30; + const prevHov = isHovered(PX + PAD, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H); + ctx.fillStyle = prevHov ? '#1a2838' : 'transparent'; + ctx.strokeStyle = prevHov ? '#00d4ff' : '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(PX + PAD, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H); + ctx.strokeRect(PX + PAD, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H); + ctx.font = '14px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = prevHov ? '#00d4ff' : '#3a6080'; + ctx.fillText('โ—€', PX + PAD + NAV_W / 2, y + HDR_H / 2); + addHitRegion(PX + PAD, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H, () => { + const filled = G.weapons.slice(0, G.tower.weaponSlots).map((w, i) => w ? i : -1).filter(i => i >= 0); + const idx = filled.indexOf(slot); + if (idx > 0) G.weaponDetailSlot = filled[idx - 1]; + else if (filled.length > 1) G.weaponDetailSlot = filled[filled.length - 1]; + _weaponDetailScrollY = 0; + }); + + // Weapon icon + name + element icons + const titleX = PX + PAD + NAV_W + 10; + ctx.font = '20px monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#ffffff'; ctx.fillText(def?.icon || '?', titleX, y + HDR_H / 2); + const iconW = 24; + ctx.font = '900 14px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '2px'; + ctx.fillStyle = '#b8d8e8'; ctx.fillText(def?.name || '', titleX + iconW + 6, y + HDR_H / 2); + ctx.letterSpacing = '0px'; + + // Element icons (skip physical โ€” it's the default, not meaningful to display) + const elIcons = getWeaponElements(weapon).filter(el => el !== 'physical').map(el => ELEMENTS[el]?.icon || '').join(' '); + if (elIcons.trim()) { + ctx.font = '13px monospace'; ctx.textBaseline = 'middle'; + ctx.font = '900 14px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '2px'; + const nameW = ctx.measureText(def?.name || '').width; + ctx.letterSpacing = '0px'; ctx.font = '13px monospace'; + ctx.fillStyle = '#b8d8e8'; ctx.fillText(elIcons, titleX + iconW + 6 + nameW + 10, y + HDR_H / 2); + } + + // Slot label centered + ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '2px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080'; + ctx.fillText('ยท SOCKET ' + (slot + 1), PX + PW / 2, y + HDR_H / 2); + ctx.letterSpacing = '0px'; + + // โœ• close button โ€” calculated first so โ–ถ can be placed to its left + const CBW = 80, CBH = 28; + const CBX = PX + PW - PAD - CBW, CBY = y + (HDR_H - CBH) / 2; + + // โ–ถ button โ€” 8px left of โœ• close so they don't overlap + const nextX = CBX - 8 - NAV_W; + const nextHov = isHovered(nextX, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H); + ctx.fillStyle = nextHov ? '#1a2838' : 'transparent'; + ctx.strokeStyle = nextHov ? '#00d4ff' : '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(nextX, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H); + ctx.strokeRect(nextX, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H); + ctx.font = '14px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = nextHov ? '#00d4ff' : '#3a6080'; + ctx.fillText('โ–ถ', nextX + NAV_W / 2, y + HDR_H / 2); + addHitRegion(nextX, y + (HDR_H - NAV_H) / 2, NAV_W, NAV_H, () => { + const filled = G.weapons.slice(0, G.tower.weaponSlots).map((w, i) => w ? i : -1).filter(i => i >= 0); + const idx = filled.indexOf(slot); + if (idx >= 0 && idx < filled.length - 1) G.weaponDetailSlot = filled[idx + 1]; + else if (filled.length > 1) G.weaponDetailSlot = filled[0]; + _weaponDetailScrollY = 0; + }); + 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 = '10px Orbitron, monospace'; ctx.letterSpacing = '1px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ff3355'; + ctx.fillText('โœ• CLOSE', CBX + CBW / 2, CBY + CBH / 2); + ctx.letterSpacing = '0px'; + addHitRegion(CBX, CBY, CBW, CBH, closeWeaponDetail); + + y += HDR_H; + + // โ”€โ”€ Stats row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const STAT_H = 40; + ctx.fillStyle = '#040a10'; + ctx.fillRect(PX, y, PW, STAT_H); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(PX, y + STAT_H); ctx.lineTo(PX + PW, y + STAT_H); ctx.stroke(); + + 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 > 1 ? [['TARGETS', weapon.targets]] : []), + ]; + const SPW = Math.floor(PW / Math.max(1, statPairs.length)); + ctx.font = '9px "Share Tech Mono", monospace'; + ctx.textBaseline = 'top'; + for (let i = 0; i < statPairs.length; i++) { + const sx = PX + i * SPW; + ctx.fillStyle = '#3a6080'; ctx.textAlign = 'left'; ctx.fillText(statPairs[i][0], sx + PAD, y + 8); + ctx.fillStyle = '#b8d8e8'; ctx.fillText(String(statPairs[i][1]), sx + PAD, y + 22); + } + + y += STAT_H; + + // โ”€โ”€ Targeting row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const TGT_H = 40; + ctx.fillStyle = '#030810'; + ctx.fillRect(PX, y, PW, TGT_H); + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(PX, y + TGT_H); ctx.lineTo(PX + PW, y + TGT_H); ctx.stroke(); + + ctx.font = '9px Orbitron, monospace'; ctx.letterSpacing = '2px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080'; + ctx.fillText('TARGET:', PX + PAD, y + TGT_H / 2); + ctx.letterSpacing = '0px'; + const lblW = ctx.measureText('TARGET: ').width; + + const tgtModes = ['nearest','strongest','weakest','fastest','furthest','group']; + const curMode = weapon.targeting || 'nearest'; + const TPILL_W = 160, TPILL_H = 26; + const tpillX = PX + PAD + lblW + 10; + const tpillY = y + (TGT_H - TPILL_H) / 2; + const tpHov = isHovered(tpillX, tpillY, TPILL_W, TPILL_H); + ctx.fillStyle = tpHov ? '#0c1e30' : '#060e18'; + ctx.strokeStyle = tpHov ? '#00d4ff' : '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(tpillX, tpillY, TPILL_W, TPILL_H); + ctx.strokeRect(tpillX, tpillY, TPILL_W, TPILL_H); + ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '0.5px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = tpHov ? '#00d4ff' : '#b8d8e8'; + ctx.fillText(curMode.toUpperCase() + ' โ–ธ', tpillX + TPILL_W / 2, tpillY + TPILL_H / 2); + ctx.letterSpacing = '0px'; + const wid = weapon.instanceId; + addHitRegion(tpillX, tpillY, TPILL_W, TPILL_H, () => { + const idx = tgtModes.indexOf(G.weapons[G.weaponDetailSlot]?.targeting || 'nearest'); + setWeaponTargeting(wid, tgtModes[(idx + 1) % tgtModes.length]); + }); + + y += TGT_H; + + // โ”€โ”€ Infuse slots (if weapon has infuse capability) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const infuseSlots = weapon.canInfuse3 ? 3 : weapon.canInfuse2 ? 2 : weapon.canInfuse ? 1 : 0; + if (infuseSlots > 0) { + const INF_H = 54; + ctx.fillStyle = '#040a10'; ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(PX, y, PW, INF_H); ctx.strokeRect(PX, y, PW, INF_H); + ctx.font = '9px Orbitron, monospace'; ctx.letterSpacing = '2px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a6080'; + ctx.fillText('ELEMENTS:', PX + PAD, y + INF_H / 2); + ctx.letterSpacing = '0px'; + const infW2 = ctx.measureText('ELEMENTS: ').width; + const SLT_W = 100, SLT_H = 32, SLT_GAP = 8; + for (let si = 0; si < infuseSlots; si++) { + const sx = PX + PAD + infW2 + 16 + si * (SLT_W + SLT_GAP); + const sy2 = y + (INF_H - SLT_H) / 2; + const el = weapon.elements?.[si]; + const elDef = el ? ELEMENTS[el] : null; + const sHov = isHovered(sx, sy2, SLT_W, SLT_H); + ctx.fillStyle = el ? '#0a1828' : '#060e18'; + ctx.strokeStyle = el ? (ELEMENTS[el]?.color || '#1a3048') : (sHov ? '#00aaff' : '#1a3048'); + ctx.lineWidth = 1; + ctx.fillRect(sx, sy2, SLT_W, SLT_H); ctx.strokeRect(sx, sy2, SLT_W, SLT_H); + ctx.font = '11px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = el ? '#ffffff' : '#1a3240'; + ctx.fillText(elDef ? elDef.icon + ' ' + elDef.name : '+ INFUSE', sx + SLT_W / 2, sy2 + SLT_H / 2); + } + ctx.beginPath(); ctx.moveTo(PX, y + INF_H); ctx.lineTo(PX + PW, y + INF_H); ctx.stroke(); + y += INF_H; + } + + // โ”€โ”€ Scrollable upgrades body โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const FOOTER_H = 52; + const bodyY = y; + const bodyH = PH - (y - PY) - FOOTER_H; + + ctx.save(); + ctx.beginPath(); ctx.rect(PX, bodyY, PW, bodyH); ctx.clip(); + + const upgTree = WEAPON_UPGRADE_TREES[weapon.defId] || []; + const bought = G.weaponUpgradesBought[weapon.instanceId] || []; + + // Group upgrades into chains based on `requires` โ€” find root nodes (no requirements) + let ugY = bodyY + PAD - _weaponDetailScrollY; + const cx2 = PX + PAD, cw2 = PW - PAD * 2; + + if (upgTree.length === 0) { + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#1a3048'; + ctx.fillText('No upgrades available for this weapon.', PX + PW / 2, bodyY + bodyH / 2); + } else { + // Build chains: group nodes into linear sequences + const visited = new Set(); + const chains = []; + + const buildChain = (startNode) => { + const chain = []; + let cur = startNode; + while (cur && !visited.has(cur.id)) { + visited.add(cur.id); + chain.push(cur); + const next = upgTree.find(u => u.requires && u.requires.includes(cur.id) && !visited.has(u.id)); + cur = next || null; + } + return chain; + }; + + // Iterate and build all chains + for (const root of upgTree) { + if (!visited.has(root.id)) { + chains.push(buildChain(root)); + } + } + + for (const chain of chains) { + if (chain.length === 0) continue; + // Calculate row width + let rowW = 0; + for (let i = 0; i < chain.length; i++) { + if (i > 0) rowW += _SH_ARR_W; + rowW += _SH_UPG_W; + } + let nx = cx2 + Math.max(0, (cw2 - rowW) / 2); + const screenY = ugY; + + if (screenY + _SH_UPG_H >= bodyY && screenY < bodyY + bodyH) { + for (let i = 0; i < chain.length; i++) { + const upg = chain[i]; + if (i > 0) { + ctx.font = '13px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#3a5060'; + ctx.fillText('โ†’', nx + _SH_ARR_W / 2, screenY + _SH_UPG_H / 2); + nx += _SH_ARR_W; + } + const isBought = bought.includes(upg.id); + const reqsMet = !upg.requires || upg.requires.every(r => bought.includes(r)); + const cantAfford = !isBought && reqsMet && spendableCredits() < upg.cost; + const locked = !isBought && !reqsMet; + const uid2 = upg.id; + const wIid = weapon.instanceId; + _shopUpgNode( + nx, screenY, upg, isBought, locked, cantAfford, + (!isBought && reqsMet && !cantAfford) ? () => buyWeaponUpgrade(wIid, uid2) : null, + (isBought && !upg.repeatable) ? () => refundWeaponUpgrade(wIid, uid2) : null + ); + nx += _SH_UPG_W; + } + } + ugY += _SH_UPG_H + 14; + } + + _weaponDetailScrollMax = Math.max(0, ugY + _weaponDetailScrollY - (bodyY + bodyH - PAD)); + } + + ctx.restore(); + + // โ”€โ”€ Footer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const fy = PY + PH - FOOTER_H; + ctx.fillStyle = '#040c14'; + ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(PX, fy, PW, FOOTER_H); + ctx.beginPath(); ctx.moveTo(PX, fy); ctx.lineTo(PX + PW, fy); ctx.stroke(); + + // UNEQUIP TO INVENTORY button + const UBW = 200, UBH = 32; + const UBX = PX + PAD, UBY = fy + (FOOTER_H - UBH) / 2; + const ubHov = isHovered(UBX, UBY, UBW, UBH); + ctx.fillStyle = ubHov ? '#0c1e30' : 'transparent'; + ctx.strokeStyle = ubHov ? '#00d4ff' : '#1a3048'; ctx.lineWidth = 1; + ctx.fillRect(UBX, UBY, UBW, UBH); ctx.strokeRect(UBX, UBY, UBW, UBH); + ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '1px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = ubHov ? '#00d4ff' : '#3a6080'; + ctx.fillText('UNEQUIP TO INVENTORY', UBX + UBW / 2, UBY + UBH / 2); + ctx.letterSpacing = '0px'; + addHitRegion(UBX, UBY, UBW, UBH, () => { + removeWeaponFromSlot(slot); + closeWeaponDetail(); + }); + + // SELL button with hold bar + const SBW = 180, SBH = 32; + const SBX = PX + PW - PAD - SBW, SBY = fy + (FOOTER_H - SBH) / 2; + const sellPrice = calcSellPrice(weapon); + + let sellHoldP = 0; + if (_sellHoldSlot === slot) { + sellHoldP = Math.min(1, (Date.now() - _sellHoldMs) / SELL_HOLD_DURATION); + } + + ctx.fillStyle = '#0a0808'; + ctx.strokeStyle = '#ff3355'; ctx.lineWidth = 1; + ctx.fillRect(SBX, SBY, SBW, SBH); ctx.strokeRect(SBX, SBY, SBW, SBH); + + if (sellHoldP > 0) { + ctx.fillStyle = '#ff335566'; + ctx.fillRect(SBX + 1, SBY + 1, (SBW - 2) * sellHoldP, SBH - 2); + } + + ctx.font = '10px Orbitron, monospace'; ctx.letterSpacing = '1px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ff3355'; + ctx.fillText('SELL โ€” ' + sellPrice + 'ยข', SBX + SBW / 2, SBY + SBH / 2); + ctx.letterSpacing = '0px'; + + _sellRegion = { x: SBX, y: SBY, w: SBW, h: SBH, slot }; + + // Click outside panel = close + addHitRegion(PX, PY, PW, PH, () => {}); + addHitRegion(0, 0, canvas.width, canvas.height, closeWeaponDetail); +} + +// โ”€โ”€ THREAT LEVEL PANEL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function drawThreatPanel() { + if (!G.threatOpen) return; + const W = canvas.width, H = canvas.height; + const PW = 800, PX = (W - PW) / 2; + const HDR_H = 54, TL_ROW_H = 50, GAP = 4; + const PH = HDR_H + 14 + DIFFICULTY_TIERS.length * (TL_ROW_H + GAP) + 20; + const PY = HUD_H + 12; + + ctx.fillStyle = 'rgba(0,0,0,0.62)'; + ctx.fillRect(0, 0, W, H); + + ctx.save(); + ctx.fillStyle = '#040c14'; ctx.strokeStyle = '#ff6b3544'; ctx.lineWidth = 1; + ctx.fillRect(PX, PY, PW, PH); ctx.strokeRect(PX, PY, PW, PH); + + ctx.fillStyle = '#060e18'; + ctx.fillRect(PX, PY, PW, HDR_H); + ctx.strokeStyle = '#ff6b3544'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(PX, PY + HDR_H); ctx.lineTo(PX + PW, PY + HDR_H); ctx.stroke(); + + ctx.font = '900 14px Orbitron, monospace'; ctx.letterSpacing = '5px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#ff6b35'; ctx.shadowColor = '#ff6b3544'; ctx.shadowBlur = 10; + ctx.fillText('THREAT LEVEL', PX + 24, PY + HDR_H / 2); + ctx.shadowBlur = 0; ctx.letterSpacing = '0px'; + + ctx.font = '15px Orbitron, monospace'; + ctx.textAlign = 'center'; ctx.fillStyle = '#ffd700'; + ctx.fillText('๐Ÿ’ฐ ' + G.credits + 'ยข', PX + PW / 2, PY + HDR_H / 2); + + const CBW = 100, CBH = 30, CBX = PX + PW - 18 - CBW, CBY = PY + (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 = '10px Orbitron, monospace'; ctx.letterSpacing = '1.5px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ff3355'; + ctx.fillText('โœ• CLOSE', CBX + CBW / 2, CBY + CBH / 2); + ctx.letterSpacing = '0px'; + addHitRegion(CBX, CBY, CBW, CBH, closeThreatPanel); + + const cx = PX + 18, cw = PW - 36; + let ry = PY + HDR_H + 14; + + for (const tier of DIFFICULTY_TIERS) { + const isActive = G.difficultyTier === tier.id; + const isUnlocked = G.unlockedTiers.includes(tier.id); + const canAfford = tier.id === 0 || spendableCredits() >= tier.unlockCost; + const rowHov = !isActive && isHovered(cx, ry, cw, TL_ROW_H); + + ctx.fillStyle = isActive ? '#0c2010' : (rowHov ? '#090f18' : '#060e18'); + ctx.strokeStyle = isActive ? '#00ff88' : (rowHov ? '#ff6b35' : '#1a3048'); + ctx.lineWidth = 1; + ctx.fillRect(cx, ry, cw, TL_ROW_H); ctx.strokeRect(cx, ry, cw, TL_ROW_H); + + ctx.font = '12px Orbitron, monospace'; ctx.letterSpacing = '1px'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillStyle = isActive ? '#00ff88' : (isUnlocked ? '#b8d8e8' : '#3a6080'); + ctx.fillText(tier.name, cx + 14, ry + TL_ROW_H / 2 - 9); + ctx.letterSpacing = '0px'; + + ctx.font = '10px "Share Tech Mono", monospace'; + ctx.fillStyle = '#3a6080'; + const multStr = tier.id === 0 + ? 'Base difficulty โ€” standard enemy stats and rewards' + : `HP ร—${tier.hpMult} Spd ร—${tier.speedMult} Arm ร—${tier.armorMult} Rew ร—${tier.rewardMult}`; + ctx.fillText(multStr, cx + 14, ry + TL_ROW_H / 2 + 9); + + const BW = 140, BH = 34, BX = cx + cw - BW - 12, BY = ry + (TL_ROW_H - BH) / 2; + if (isActive) { + ctx.fillStyle = '#0c2010'; ctx.strokeStyle = '#00ff88'; ctx.lineWidth = 1; + ctx.fillRect(BX, BY, BW, BH); ctx.strokeRect(BX, BY, BW, BH); + ctx.font = '10px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#00ff88'; ctx.fillText('ACTIVE', BX + BW / 2, BY + BH / 2); + } else if (isUnlocked) { + const bhov = isHovered(BX, BY, BW, BH); + ctx.fillStyle = bhov ? '#0c1e30' : 'transparent'; ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 1; + ctx.fillRect(BX, BY, BW, BH); ctx.strokeRect(BX, BY, BW, BH); + ctx.font = '10px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#00d4ff'; ctx.fillText('SWITCH', BX + BW / 2, BY + BH / 2); + const tid = tier.id; + addHitRegion(BX, BY, BW, BH, () => setThreatTier(tid)); + } else { + const bhov = isHovered(BX, BY, BW, BH); + ctx.fillStyle = (bhov && canAfford) ? '#120800' : 'transparent'; + ctx.strokeStyle = canAfford ? '#ff6b35' : '#1a2838'; ctx.lineWidth = 1; + ctx.fillRect(BX, BY, BW, BH); ctx.strokeRect(BX, BY, BW, BH); + ctx.font = '10px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = canAfford ? '#ff6b35' : '#3a2010'; + ctx.fillText('UNLOCK ' + tier.unlockCost + 'ยข', BX + BW / 2, BY + BH / 2); + if (canAfford) { const tid = tier.id; addHitRegion(BX, BY, BW, BH, () => unlockThreatTier(tid)); } + } + + ry += TL_ROW_H + GAP; + } + ctx.restore(); +} + +// โ”€โ”€ PRESTIGE CONFIRMATION DIALOG โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function drawPrestigeConfirm() { + if (!G.prestigeOpen) return; + const W = canvas.width, H = canvas.height; + const PW = 580, PH = 320; + const PX = (W - PW) / 2, PY = (H - PH) / 2; + const cost = prestigeCost(); + const canAfford = G.credits >= cost; + const lvl = G.prestigeLevel || 0; + + ctx.fillStyle = 'rgba(0,0,0,0.78)'; + ctx.fillRect(0, 0, W, H); + + ctx.save(); + ctx.fillStyle = '#040c14'; ctx.strokeStyle = '#c77dff44'; ctx.lineWidth = 1; + ctx.fillRect(PX, PY, PW, PH); ctx.strokeRect(PX, PY, PW, PH); + + const HDR_H = 52; + ctx.fillStyle = '#060e18'; ctx.fillRect(PX, PY, PW, HDR_H); + ctx.strokeStyle = '#c77dff44'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(PX, PY + HDR_H); ctx.lineTo(PX + PW, PY + HDR_H); ctx.stroke(); + + ctx.font = '900 13px Orbitron, monospace'; ctx.letterSpacing = '4px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#c77dff'; ctx.shadowColor = '#c77dff44'; ctx.shadowBlur = 12; + ctx.fillText('PRESTIGE CONFIRMATION', W / 2, PY + HDR_H / 2); + ctx.shadowBlur = 0; ctx.letterSpacing = '0px'; + + let ty = PY + HDR_H + 20; + + ctx.font = '13px Orbitron, monospace'; ctx.letterSpacing = '1px'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillStyle = '#c77dff'; + ctx.fillText(`LEVEL ${lvl} โ†’ ${lvl + 1}`, W / 2, ty); + ctx.letterSpacing = '0px'; ty += 26; + + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.fillStyle = '#ffd700'; + ctx.fillText('Cost: ' + cost + 'ยข' + (!canAfford ? ' (insufficient credits)' : ''), W / 2, ty); ty += 22; + + ctx.fillStyle = '#ff6b35'; + ctx.fillText('RESETS: credits โ†’ 150ยข ยท all tower and weapon upgrades', W / 2, ty); ty += 18; + + ctx.fillStyle = '#00d4ff'; + ctx.fillText('KEEPS: unlocked threat tiers ยท permanent stat bonuses', W / 2, ty); ty += 26; + + // Bonus preview + const FRACTION = 0.25; + const preview = { ...(G.permanentBonuses || {}) }; + for (const upg of TOWER_UPGRADE_TREE) { + if (!G.towerUpgradesBought.includes(upg.id) || upg.repeatable) continue; + const e = upg.effect; + if (e.maxHp) preview.maxHp = (preview.maxHp || 0) + e.maxHp * FRACTION; + if (e.armor) preview.armor = (preview.armor || 0) + e.armor * FRACTION; + if (e.aimSpeed) preview.aimSpeed = (preview.aimSpeed || 0) + e.aimSpeed * FRACTION; + if (e.range) preview.range = (preview.range || 0) + e.range * FRACTION; + } + const parts = []; + if (preview.maxHp) parts.push(`+${Math.floor(preview.maxHp)} HP`); + if (preview.armor) parts.push(`+${Math.floor(preview.armor)} Armor`); + if (preview.range) parts.push(`+${Math.floor(preview.range)} Range`); + if (preview.aimSpeed) parts.push(`+aim`); + + ctx.fillStyle = parts.length > 0 ? '#c77dff' : '#3a6080'; + ctx.fillText(parts.length > 0 + ? 'NEW PERMANENT BONUSES: ' + parts.join(' ') + : 'No upgrades owned โ€” no bonuses will bank this prestige', + W / 2, ty); + + // Buttons + const BTN_W = 190, BTN_H = 42; + const BTN_Y = PY + PH - 58; + const CANCEL_X = W / 2 - BTN_W - 14; + const CONFIRM_X = W / 2 + 14; + + const cancelHov = isHovered(CANCEL_X, BTN_Y, BTN_W, BTN_H); + ctx.fillStyle = cancelHov ? '#1a0808' : 'transparent'; ctx.strokeStyle = '#ff3355'; ctx.lineWidth = 1; + ctx.fillRect(CANCEL_X, BTN_Y, BTN_W, BTN_H); ctx.strokeRect(CANCEL_X, BTN_Y, BTN_W, BTN_H); + ctx.font = '11px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#ff3355'; ctx.fillText('CANCEL', CANCEL_X + BTN_W / 2, BTN_Y + BTN_H / 2); + addHitRegion(CANCEL_X, BTN_Y, BTN_W, BTN_H, closePrestigeDialog); + + const confirmHov = canAfford && isHovered(CONFIRM_X, BTN_Y, BTN_W, BTN_H); + ctx.fillStyle = confirmHov ? '#1a0830' : 'transparent'; + ctx.strokeStyle = canAfford ? '#c77dff' : '#3a2050'; ctx.lineWidth = 1; + ctx.fillRect(CONFIRM_X, BTN_Y, BTN_W, BTN_H); ctx.strokeRect(CONFIRM_X, BTN_Y, BTN_W, BTN_H); + // Hold progress fill + if (canAfford && _prestigeHoldMs > 0) { + const holdProgress = Math.min(1, (Date.now() - _prestigeHoldMs) / PRESTIGE_HOLD_DURATION); + ctx.fillStyle = '#c77dff33'; + ctx.fillRect(CONFIRM_X, BTN_Y, BTN_W * holdProgress, BTN_H); + } + ctx.font = '11px Orbitron, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = canAfford ? '#c77dff' : '#3a2050'; + ctx.fillText(canAfford ? 'HOLD TO CONFIRM' : 'CONFIRM PRESTIGE', CONFIRM_X + BTN_W / 2, BTN_Y + BTN_H / 2); + if (canAfford) _prestigeHoldRegion = { x: CONFIRM_X, y: BTN_Y, w: BTN_W, h: BTN_H }; + else _prestigeHoldRegion = null; + + ctx.restore(); } // โ”€โ”€ DRAG GHOST โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/js/renderer-shop-overlay.js b/js/renderer-shop-overlay.js index 478ab8a..9107d44 100644 --- a/js/renderer-shop-overlay.js +++ b/js/renderer-shop-overlay.js @@ -1,12 +1,11 @@ // โ•โ•โ• renderer-shop-overlay.js โ•โ•โ• // ============================================================ -// RENDERER SHOP โ€” canvas armory overlay +// RENDERER SHOP โ€” canvas armory / command overlays // ============================================================ -// โ”€โ”€ 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_BODY_Y = _SH_HDR_H + _SH_TAB_H; // 94 โ€” armory body start (below tabs) const _SH_PAD = 24; const _SH_UPG_W = 130; const _SH_UPG_H = 78; @@ -30,7 +29,7 @@ function _shopWrapText(text, maxW, maxLines) { return lines; } -function _shopUpgNode(sx, screenY, upg, bought, locked, cantAfford, onBuy, onRefund) { +function _shopUpgNode(sx, screenY, upg, bought, locked, cantAfford, onBuy, onRefund, lockLabel = null) { 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(); @@ -59,6 +58,7 @@ function _shopUpgNode(sx, screenY, upg, bought, locked, cantAfford, onBuy, onRef 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 && lockLabel) { ctx.fillStyle = '#ff6b3566'; ctx.fillText('Req: ' + lockLabel, 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); } @@ -67,19 +67,8 @@ function _shopUpgNode(sx, screenY, upg, bought, locked, cantAfford, onBuy, onRef 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(); - +// โ”€โ”€ Shared header for both overlays โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function _drawOverlayHeader(W, title, closeFn) { ctx.fillStyle = '#040c14'; ctx.fillRect(0, 0, W, _SH_HDR_H); ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; @@ -88,7 +77,7 @@ function drawShopOverlay() { 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.fillText(title, _SH_PAD, _SH_HDR_H / 2); ctx.shadowBlur = 0; ctx.letterSpacing = '0px'; ctx.font = '18px Orbitron, "Share Tech Mono", monospace'; @@ -105,59 +94,57 @@ function drawShopOverlay() { 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); + addHitRegion(CBX, CBY, CBW, CBH, closeFn); +} - 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(); +// โ”€โ”€ ARMORY OVERLAY โ€” weapon buying + per-weapon upgrade trees โ”€โ”€ +function drawArmoryOverlay() { + if (!G.armoryOpen) return; + _shopRightClick.length = 0; - 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 || '?') }; - }), - ]; + const W = canvas.width, H = canvas.height; + const BODY_H = H - _SH_BODY_Y; - 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.fillStyle = 'rgba(2,8,14,0.97)'; + ctx.fillRect(0, 0, W, H); + ctx.save(); + + _drawOverlayHeader(W, 'ARMORY', closeArmory); 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); - } + yOff = _shopDrawBuyContent(yOff, bodyCX, bodyCW, H); + + _shopScrollMax = Math.max(0, yOff - BODY_H + _SH_PAD); + ctx.restore(); + ctx.restore(); +} + +// โ”€โ”€ COMMAND OVERLAY โ€” tower/base upgrades โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function drawCommandOverlay() { + if (!G.commandOpen) 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(); + + _drawOverlayHeader(W, 'COMMAND', closeCommand); + + 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; + + yOff = _shopDrawTowerContent(yOff, bodyCX, bodyCW, H); _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 index 1c3cb0f..1f48f56 100644 --- a/js/renderer-shop-sections.js +++ b/js/renderer-shop-sections.js @@ -11,7 +11,7 @@ function _shopDrawTowerContent(yOff, cx, cw, H) { ['HP', G.tower.hp + ' / ' + G.tower.maxHp], ['Armor', G.tower.armor], ['Aim Speed', G.tower.aimSpeed.toFixed(3)], - ['Vision', G.tower.range + 'px'], + ['Vision', (() => { const ws = (G.weapons||[]).filter(w=>w); return (ws.length > 0 ? Math.max(...ws.map(w=>w.range??0)) : 0) + 'px'; })()], ['Slots', getEquippedWeapons().length + '/' + G.tower.weaponSlots], ['Shield', G.tower.shield ? G.tower.shield.toUpperCase() + ' (' + G.tower.shieldHp + '/' + G.tower.shieldMaxHp + ')' : 'None'], ]; @@ -77,12 +77,15 @@ function _shopDrawTowerContent(yOff, cx, cw, H) { (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 tierLocked = (upg.minTier ?? 0) > (G.difficultyTier || 0); + const canAfford = !tierLocked && spendableCredits() >= upg.cost; + const tierLockLabel = tierLocked ? (DIFFICULTY_TIERS[upg.minTier]?.name ?? '') : null; 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 + tierLocked || (!reqsMet && !isBought), !canAfford && reqsMet && !isBought && !tierLocked, + (!effectiveBought && reqsMet && canAfford && !tierLocked) ? () => buyTowerUpgrade(uid) : null, + (isBought && !upg.repeatable) ? () => refundTowerUpgrade(uid) : null, + tierLockLabel ); nx += _SH_UPG_W; } @@ -203,7 +206,7 @@ function _shopDrawBuyContent(yOff, cx, cw, H) { ctx.restore(); ctx.font = '10px "Share Tech Mono", monospace'; - ctx.fillStyle = atCap ? '#ff3355' : '#1a3048'; + ctx.fillStyle = atCap ? '#ff3355' : owned > 0 ? '#8ab8d0' : '#3a6080'; ctx.textBaseline = 'top'; ctx.textAlign = 'left'; ctx.fillText('Owned: ' + owned + '/' + MAX_WEAPONS_PER_TYPE + (atCap ? ' (MAX)' : ''), cardX + 8, csy + 88); ctx.restore(); @@ -391,12 +394,15 @@ function _shopDrawWeaponContent(yOff, cx, cw, H, weapon) { } const isBought = bought.includes(upg.id); const reqsMet = upg.requires.every(r => bought.includes(r)); - const canAfford = spendableCredits() >= upg.cost; + const tierLocked = (upg.minTier ?? 0) > (G.difficultyTier || 0); + const canAfford = !tierLocked && spendableCredits() >= upg.cost; + const tierLockLabel = tierLocked ? (DIFFICULTY_TIERS[upg.minTier]?.name ?? '') : null; 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 + tierLocked || (!reqsMet && !isBought), !canAfford && reqsMet && !isBought && !tierLocked, + (!isBought && reqsMet && canAfford && !tierLocked) ? () => buyWeaponUpgrade(iid, uid) : null, + (isBought && !upg.repeatable) ? () => refundWeaponUpgrade(iid, uid) : null, + tierLockLabel ); ny += _SH_UPG_H + 4; } diff --git a/js/renderer-sidepanel.js b/js/renderer-sidepanel.js index 3ebf12c..d0ccfba 100644 --- a/js/renderer-sidepanel.js +++ b/js/renderer-sidepanel.js @@ -50,10 +50,11 @@ function drawSidePanel() { 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, () => { + if (!G.armoryOpen && !G.commandOpen) 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]; }); + _tickTooltip('qty', QB_X, QB_Y, QB_W, QB_H); ctx.font = '9px "Share Tech Mono", monospace'; ctx.letterSpacing = '2px'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#1a3048'; @@ -66,9 +67,15 @@ function drawSidePanel() { const qty = G.sendQuantity; const bonusMult = qty >= 50 ? 1.3 : qty >= 25 ? 1.2 : qty >= 10 ? 1.12 : qty >= 5 ? 1.05 : 1.0; + const tierDef = DIFFICULTY_TIERS[G.difficultyTier || 0]; + const tierRewardMult = tierDef?.rewardMult ?? 1; + const visibleDefs = ENEMY_DEFS.filter(d => + (d.minTier ?? 0) <= (G.difficultyTier || 0) && + (d.minPrestige ?? 0) <= (G.prestigeLevel || 0) + ); - for (let i = 0; i < ENEMY_DEFS.length; i++) { - const def = ENEMY_DEFS[i]; + for (let i = 0; i < visibleDefs.length; i++) { + const def = visibleDefs[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; @@ -91,7 +98,7 @@ function drawSidePanel() { ctx.fillStyle = elColor; ctx.fillRect(CX, cardY + 5, 3, SP_ENEMY_CARD_H - 10); - // Freshness bar (5px strip at card top) + // Freshness bar (5px strip at card top) โ€” shows novelty bonus remaining const fresh = G.enemyFreshness[def.id] || 0; const freshPct = Math.max(0, 1 - fresh / 17); if (freshPct > 0) { @@ -100,9 +107,24 @@ function drawSidePanel() { ctx.fillStyle = fbG; ctx.fillRect(CX, cardY, CW * freshPct, 5); } + if (freshPct < 1) { + // Red fill for depleted portion โ€” always visible + ctx.fillStyle = '#ff335555'; + ctx.fillRect(CX + CW * freshPct, cardY, CW * (1 - freshPct), 5); + // Penalty text inside the bar (only when meaningfully stale) + if (fresh > 3) { + const penalty = Math.round((1 - freshPct) * 35); + ctx.font = 'bold 9px "Share Tech Mono", monospace'; + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#ff3355dd'; + ctx.fillText('-' + penalty + '%', CX + CW - 3, cardY + 2.5); + ctx.textAlign = 'left'; + } + } - // Hotkey - const hotkey = i < 9 ? String(i + 1) : i === 9 ? '0' : ''; + // Hotkey โ€” base 10 enemies keep 1-0, new enemies have no hotkey + const baseIndex = ENEMY_DEFS.findIndex(d => d.id === def.id); + const hotkey = baseIndex < 9 ? String(baseIndex + 1) : baseIndex === 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); @@ -126,34 +148,55 @@ function drawSidePanel() { 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.beginPath(); ctx.rect(CX + 6, cardY + 26, CW - 70, 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); + // Elemental weakness/resistance icons (right side of stats row) + ctx.font = '11px monospace'; ctx.textBaseline = 'top'; + let iconX = CX + CW - 6; + const weakEntries = Object.entries(def.weaknesses || {}).filter(([,m]) => m > 1); + const resEntries = Object.entries(def.resistances || {}).filter(([,m]) => m < 1); + for (const [elId] of weakEntries) { + const elD = ELEMENTS[elId]; if (!elD) continue; + iconX -= ctx.measureText(elD.icon).width + 2; + ctx.fillStyle = '#ff6b35'; ctx.textAlign = 'left'; + ctx.fillText(elD.icon, iconX, cardY + 26); + } + for (const [elId] of resEntries) { + const elD = ELEMENTS[elId]; if (!elD) continue; + iconX -= ctx.measureText(elD.icon).width + 2; + ctx.fillStyle = '#1a4060'; ctx.textAlign = 'left'; + ctx.fillText(elD.icon, iconX, cardY + 26); + } + + // Reward + profit (tier-scaled) + const rewardPerUnit = Math.round(def.reward * bonusMult * tierRewardMult); 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.fillText('(' + profitStr + ')', CX + 62, cardY + SP_ENEMY_CARD_H - 6); + if (tierRewardMult > 1) { + ctx.fillStyle = '#ff6b35'; ctx.font = '9px Orbitron, monospace'; + ctx.fillText('ร—' + tierRewardMult.toFixed(1), CX + 124, cardY + SP_ENEMY_CARD_H - 6); + } else 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.fillText('+' + Math.round((bonusMult - 1) * 100) + '%', CX + 124, cardY + SP_ENEMY_CARD_H - 6); } ctx.restore(); - if (canDeploy) { + if (canDeploy && !G.armoryOpen && !G.commandOpen) { 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); + const totalCardH = visibleDefs.length * (SP_ENEMY_CARD_H + SP_ENEMY_CARD_GAP); _sidePanelScrollMax = Math.max(0, totalCardH - ENEMY_AREA_H + 4); ctx.restore(); // end enemy clip @@ -183,7 +226,8 @@ function drawSidePanel() { 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); + const cnt = logLines[i].count || 1; + ctx.fillText('โ€บ ' + logLines[i].text + (cnt > 1 ? ` ร—${cnt}` : ''), PX + 10, ly + 2); ctx.restore(); } diff --git a/js/renderer-world.js b/js/renderer-world.js index 2a371d0..826661e 100644 --- a/js/renderer-world.js +++ b/js/renderer-world.js @@ -7,8 +7,7 @@ // 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; +// per-weapon range rings drawn on alt-hold const _mountDropZones = []; const _bagDropZones = []; const _dragRegions = []; @@ -25,15 +24,23 @@ function buildBackground(W, H) { 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; } +function drawGrid(cx, cy) { + const gs = 48; + const x0 = Math.floor((cx - ARENA_RADIUS - gs) / gs) * gs; + const x1 = Math.ceil((cx + ARENA_RADIUS + gs) / gs) * gs; + const y0 = Math.floor((cy - ARENA_RADIUS - gs) / gs) * gs; + const y1 = Math.ceil((cy + ARENA_RADIUS + gs) / gs) * gs; + ctx.strokeStyle = '#0a1520'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = x0; x <= x1; x += gs) { ctx.moveTo(x, y0); ctx.lineTo(x, y1); } + for (let y = y0; y <= y1; y += gs) { ctx.moveTo(x0, y); ctx.lineTo(x1, y); } + ctx.stroke(); +} + // โ”€โ”€ 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. @@ -47,66 +54,73 @@ function getElemGrad(el, x, y, r) { } // โ”€โ”€ 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(); -} +// Per-weapon range rings โ€” visible while Alt is held +const _RANGE_COLORS = ['#00d4ff', '#ff6b35', '#a855f7', '#22c55e', '#f59e0b', '#ec4899']; +function drawWeaponRanges(cx, cy) { + if (!_altHeld) return; + const weapons = (G.weapons || []).filter(w => w); + if (!weapons.length) return; + const zoom = G?.camera?.zoom ?? 1.0; -// 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); + weapons.forEach((w, i) => { + const r = w.range ?? 0; + if (!r || r >= 9000) return; + const color = _RANGE_COLORS[i % _RANGE_COLORS.length]; + const label = (getWeaponDef(w)?.name ?? w.defId).toUpperCase(); + + // Ring drawn in world space โ€” scales naturally with zoom + ctx.save(); + ctx.strokeStyle = color; + ctx.globalAlpha = 0.55; + ctx.lineWidth = 1.5; + ctx.setLineDash([8, 10]); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + + // Label in screen space โ€” constant size regardless of zoom + // cx == ARENA_CX so screen x == cx; screen y = ARENA_CY - (r+6)*zoom + const sx = cx; + const sy = ARENA_CY - (r + 6) * zoom; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.font = 'bold 11px "Share Tech Mono", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + const tw = ctx.measureText(label).width; + ctx.fillStyle = 'rgba(2,6,14,0.78)'; + ctx.fillRect(sx - tw / 2 - 4, sy - 13, tw + 8, 14); + ctx.fillStyle = color; + ctx.globalAlpha = 0.95; + ctx.fillText(label, sx, sy); + ctx.restore(); + }); } // Dark overlay outside the arena circle -function drawArenaMask(W, H, cx, cy) { +function drawArenaMask(W, H, cx, cy, zoom = 1) { 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.arc(cx, cy, ARENA_RADIUS * zoom, 0, Math.PI * 2); ctx.fill('evenodd'); } // Subtle glowing border ring at arena edge -function drawArenaRing(cx, cy) { +function drawArenaRing(cx, cy, zoom = 1) { + const r = ARENA_RADIUS * zoom; 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.arc(cx, cy, r - 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.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } @@ -145,7 +159,8 @@ function drawStreak(cx, cy) { ctx.fillStyle = color; ctx.shadowColor = color; ctx.shadowBlur = 10; - ctx.fillText(text, cx, by + 21); + ctx.textBaseline = 'middle'; + ctx.fillText(text, cx, by + bh / 2); ctx.shadowBlur = 0; ctx.restore(); } diff --git a/js/renderer.js b/js/renderer.js index e06f4ad..84b7936 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -14,7 +14,15 @@ function render() { // Blit cached background โ€” O(1) ctx.drawImage(_bgCanvas, 0, 0); - drawArenaRangeLine(cx, cy); // faint dashed ring at tower range + // World layer โ€” apply camera zoom centred on tower + const _zoom = G.camera?.zoom ?? 1.0; + ctx.save(); + ctx.translate(cx, cy); + ctx.scale(_zoom, _zoom); + ctx.translate(-cx, -cy); + + drawGrid(cx, cy); + drawWeaponRanges(cx, cy); // alt-hold: per-weapon range rings drawPortals(); drawAoeZones(); drawEnemyTrails(); @@ -22,14 +30,18 @@ function render() { 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 + ctx.restore(); // end world layer + + // Mask/ring/streak operate in screen space โ€” drawn AFTER world transform + drawArenaMask(W, H, cx, cy, _zoom); + drawArenaRing(cx, cy, _zoom); drawStreak(cx, cy); + + // UI layer โ€” no camera transform clearHitRegions(); _mountDropZones.length = 0; _bagDropZones.length = 0; @@ -38,9 +50,18 @@ function render() { drawBrokeWarning(); drawSidePanel(); drawInventoryOverlay(); - drawShopOverlay(); + drawCommandOverlay(); + drawArmoryOverlay(); + drawWeaponDetailOverlay(); + checkSellHold(); + checkPrestigeHold(); + drawThreatPanel(); + drawPrestigeConfirm(); if (G.gameOver) drawGameOverPanel(); drawPauseOverlay(); + drawMountInteraction(cx, cy); - drawDragGhost(); + + drawDragGhost(); // follows cursor โ€” screen space, no zoom + drawTooltips(); } diff --git a/js/shop.js b/js/shop.js index fd9e552..d806e25 100644 --- a/js/shop.js +++ b/js/shop.js @@ -1,24 +1,86 @@ // โ•โ•โ• shop.js โ•โ•โ• // ============================================================ -// SHOP.JS โ€” Shop state management (canvas draws each frame) +// SHOP.JS โ€” Armory / Command overlay state management // ============================================================ -function openShop() { +function openArmory() { if (G.gameOver) return; - G.shopOpen = true; + G.armoryOpen = true; + G.commandOpen = false; + G.threatOpen = false; + G.prestigeOpen = false; + _radialSlot = -1; _shopScrollY = 0; setPaused(true, false); } -function closeShop() { - G.shopOpen = false; +function closeArmory() { + G.armoryOpen = false; _shopScrollY = 0; setPaused(false); } -function setShopTab(tab) { - G.shopTab = tab; +function openCommand() { + if (G.gameOver) return; + G.commandOpen = true; + G.armoryOpen = false; + G.threatOpen = false; + G.prestigeOpen = false; + _radialSlot = -1; _shopScrollY = 0; + setPaused(true, false); } -function renderShop() { /* canvas draws shop each frame via drawShopOverlay() */ } +function closeCommand() { + G.commandOpen = false; + _shopScrollY = 0; + setPaused(false); +} + +function openWeaponDetail(slotIndex) { + if (!G.weapons[slotIndex]) return; + G.weaponDetailSlot = slotIndex; + _radialSlot = -1; + G.armoryOpen = false; + G.commandOpen = false; + G.threatOpen = false; + G.prestigeOpen = false; + _weaponDetailScrollY = 0; + setPaused(true, false); +} + +function closeWeaponDetail() { + G.weaponDetailSlot = -1; + _weaponDetailScrollY = 0; + if (!G.armoryOpen && !G.commandOpen) setPaused(false); +} + +function calcSellPrice(weapon) { + const def = WEAPON_DEFS.find(d => d.id === weapon.defId); + const baseSell = Math.floor((def?.cost ?? 0) * 0.5); + const upgTree = WEAPON_UPGRADE_TREES[weapon.defId] || []; + const upgSell = (G.weaponUpgradesBought[weapon.instanceId] || []).reduce((sum, id) => { + const u = upgTree.find(u => u.id === id); + return sum + Math.floor((u?.cost ?? 0) * 0.5); + }, 0); + return baseSell + upgSell; +} + +function renderShop() { /* no-op โ€” render loop redraws every frame */ } + +function sellWeapon(slotIndex) { + const w = G.weapons[slotIndex]; + if (!w) return; + const activeCount = (G.weapons || []).slice(0, G.tower.weaponSlots).filter(x => x != null).length; + if (activeCount <= 1) { addLog('Cannot sell last weapon!', 'lose'); return; } + const price = calcSellPrice(w); + removeWeaponFromSlot(slotIndex); + const invIdx = (G.weaponInventory || []).findIndex(x => x.instanceId === w.instanceId); + if (invIdx >= 0) G.weaponInventory.splice(invIdx, 1); + G.credits += price; + _radialSlot = -1; + _sellHoldSlot = -1; + closeWeaponDetail(); + addLog('Sold ' + getWeaponDef(w).name + ' for ' + price + 'ยข.', 'win'); + updateHUD(); +} diff --git a/js/state.js b/js/state.js index a835bca..93a9877 100644 --- a/js/state.js +++ b/js/state.js @@ -4,7 +4,7 @@ // ============================================================ // โ”€โ”€ ARENA โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -const ARENA_RADIUS = 400; // play area radius; enemies always spawn at this distance from tower +const ARENA_RADIUS = 720; // 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 @@ -21,6 +21,9 @@ function makeGameState() { _isNewBest: false, creditReserve: 50, // minimum credits kept in reserve โ€” cannot spend upgrades below this + // Camera + camera: { zoom: 0.85, minZoom: 0.57, maxZoom: 1.8 }, + // Tower tower: { hp: 20, @@ -64,10 +67,10 @@ function makeGameState() { selectedEnemyType: null, sendQuantity: 1, - // Shop - shopOpen: false, - shopTab: 'tower', // 'tower' | 'weapons' | weapon instance id - shopTreeWeapon: null, + // Overlay panels โ€” armory (weapons) and command (tower upgrades) + armoryOpen: false, + commandOpen: false, + weaponDetailSlot: -1, // Entity id counter nextId: 1, @@ -77,6 +80,16 @@ function makeGameState() { // Enemy freshness (novelty bonus) โ€” higher = less fresh = less bonus enemyFreshness: Object.fromEntries(ENEMY_DEFS.map(d => [d.id, 0])), + + // Difficulty / Prestige + difficultyTier: 0, + unlockedTiers: [0], + prestigeLevel: 0, + permanentBonuses: {}, + + // Other overlay panels + threatOpen: false, + prestigeOpen: false, }; } diff --git a/js/upgrades.js b/js/upgrades.js index 62394ab..88280d9 100644 --- a/js/upgrades.js +++ b/js/upgrades.js @@ -38,6 +38,8 @@ function buyTowerUpgrade(upgradeId) { addLog('Tower already at full HP.', 'info'); return; } + // Tier requirement + if ((upgrade.minTier ?? 0) > (G.difficultyTier || 0)) return; for (const req of upgrade.requires) { if (!G.towerUpgradesBought.includes(req)) return; } @@ -161,6 +163,7 @@ function buyWeaponUpgrade(instanceId, upgradeId) { if (!upgrade) return; const bought = G.weaponUpgradesBought[instanceId] || []; if (bought.includes(upgradeId)) return; + if ((upgrade.minTier ?? 0) > (G.difficultyTier || 0)) return; for (const req of upgrade.requires) { if (!bought.includes(req)) return; } if (spendableCredits() < upgrade.cost) return; G.credits -= upgrade.cost; diff --git a/js/utils.js b/js/utils.js index 329c441..cd3fa4e 100644 --- a/js/utils.js +++ b/js/utils.js @@ -39,7 +39,11 @@ function clamp(value, min, max) { } function cheapestEnemyCost() { - return ENEMY_DEFS.reduce((min, d) => Math.min(min, d.cost), Infinity); + const tier = G?.difficultyTier ?? 0; + const prestige = G?.prestigeLevel ?? 0; + return ENEMY_DEFS + .filter(d => (d.minTier ?? 0) <= tier && (d.minPrestige ?? 0) <= prestige) + .reduce((min, d) => Math.min(min, d.cost), Infinity); } function countAliveEnemies() {