Add freshness bar, enhance overlays and renderers

- Add enemy freshness tracking (novelty bonus for repeated deploys)
- Add freshness bar to sidepanel enemy cards with penalty indicator
- Major overhaul of renderer-overlays.js (790+ lines for UI polish)
- Enhanced combat log, shop overlays, and inventory UI
- Improved weapon/upgrade display with partial ownership colors
- Added element icons and weakness/resistance indicators to cards
- Enhanced radial menu and tooltip system
- Add "stale/%" penalty text when freshness depleted
- Update play link to ffazeshift.net in index.html
This commit is contained in:
2026-06-17 11:58:17 -04:00
parent 6a710c3f03
commit 626879ed0c
21 changed files with 1884 additions and 312 deletions
+29 -29
View File
@@ -6,8 +6,8 @@
<title>SIEGE PROTOCOL</title> <title>SIEGE PROTOCOL</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&display=swap">
<link rel="stylesheet" href="css/main.css?v=20260615r"> <link rel="stylesheet" href="css/main.css?v=20260616z15">
<link rel="stylesheet" href="css/dev-console.css?v=20260615r"> <link rel="stylesheet" href="css/dev-console.css?v=20260616z15">
</head> </head>
<body> <body>
@@ -17,33 +17,33 @@
<div id="perf-overlay" aria-live="off"></div> <div id="perf-overlay" aria-live="off"></div>
<script defer src="js/utils.js?v=20260615r"></script> <script defer src="js/utils.js?v=20260616z15"></script>
<script defer src="js/defs.js?v=20260615r"></script> <script defer src="js/defs.js?v=20260616z15"></script>
<script defer src="js/state.js?v=20260615r"></script> <script defer src="js/state.js?v=20260616z15"></script>
<script defer src="js/audio.js?v=20260615r"></script> <script defer src="js/audio.js?v=20260616z15"></script>
<script defer src="js/particles.js?v=20260615r"></script> <script defer src="js/particles.js?v=20260616z15"></script>
<script defer src="js/elements.js?v=20260615r"></script> <script defer src="js/elements.js?v=20260616z15"></script>
<script defer src="js/portals.js?v=20260615r"></script> <script defer src="js/portals.js?v=20260616z15"></script>
<script defer src="js/enemies.js?v=20260615r"></script> <script defer src="js/enemies.js?v=20260616z15"></script>
<script defer src="js/weapons.js?v=20260615r"></script> <script defer src="js/weapons.js?v=20260616z15"></script>
<script defer src="js/weapon-fire.js?v=20260615r"></script> <script defer src="js/weapon-fire.js?v=20260616z15"></script>
<script defer src="js/weapon-projectiles.js?v=20260615r"></script> <script defer src="js/weapon-projectiles.js?v=20260616z15"></script>
<script defer src="js/upgrades.js?v=20260615r"></script> <script defer src="js/upgrades.js?v=20260616z15"></script>
<script defer src="js/inventory.js?v=20260615r"></script> <script defer src="js/inventory.js?v=20260616z15"></script>
<script defer src="js/renderer-world.js?v=20260615r"></script> <script defer src="js/renderer-world.js?v=20260616z15"></script>
<script defer src="js/renderer-tower.js?v=20260615r"></script> <script defer src="js/renderer-tower.js?v=20260616z15"></script>
<script defer src="js/renderer-combat.js?v=20260615r"></script> <script defer src="js/renderer-combat.js?v=20260616z15"></script>
<script defer src="js/renderer-inventory.js?v=20260615r"></script> <script defer src="js/renderer-inventory.js?v=20260616z15"></script>
<script defer src="js/renderer-shop-overlay.js?v=20260615r"></script> <script defer src="js/renderer-shop-overlay.js?v=20260616z15"></script>
<script defer src="js/renderer-shop-sections.js?v=20260615r"></script> <script defer src="js/renderer-shop-sections.js?v=20260616z15"></script>
<script defer src="js/renderer-hud.js?v=20260615r"></script> <script defer src="js/renderer-hud.js?v=20260616z15"></script>
<script defer src="js/renderer-sidepanel.js?v=20260615r"></script> <script defer src="js/renderer-sidepanel.js?v=20260616z15"></script>
<script defer src="js/renderer-overlays.js?v=20260615r"></script> <script defer src="js/renderer-overlays.js?v=20260616z15"></script>
<script defer src="js/renderer.js?v=20260615r"></script> <script defer src="js/renderer.js?v=20260616z15"></script>
<script defer src="js/shop.js?v=20260615r"></script> <script defer src="js/shop.js?v=20260616z15"></script>
<script defer src="js/input.js?v=20260615r"></script> <script defer src="js/input.js?v=20260616z15"></script>
<script defer src="js/main.js?v=20260615r"></script> <script defer src="js/main.js?v=20260616z15"></script>
<script defer src="js/dev-console.js?v=20260615r"></script> <script defer src="js/dev-console.js?v=20260616z15"></script>
<!-- ── DEV CONSOLE ── --> <!-- ── DEV CONSOLE ── -->
<div id="dev-console"> <div id="dev-console">
+94 -38
View File
@@ -14,13 +14,23 @@ const ELEMENTS = {
physical: { name: 'Physical', color: '#c8c8c8', glow: '#ffffff', icon: '💢' }, 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 ─────────────────────────────────────────────── // ── ENEMY TYPES ───────────────────────────────────────────────
const ENEMY_DEFS = [ const ENEMY_DEFS = [
{ {
id: 'grunt', id: 'grunt',
name: 'GRUNT', name: 'GRUNT',
desc: 'Basic foot soldier. No special traits.', 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', color: '#aaaaaa', glowColor: '#ffffff',
cost: 20, reward: 30, cost: 20, reward: 30,
resistances: {}, resistances: {},
@@ -32,7 +42,7 @@ const ENEMY_DEFS = [
id: 'runner', id: 'runner',
name: 'RUNNER', name: 'RUNNER',
desc: 'Very fast, low HP. Hard to track.', 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', color: '#00ff88', glowColor: '#00ff88',
cost: 30, reward: 44, cost: 30, reward: 44,
resistances: {}, resistances: {},
@@ -44,7 +54,7 @@ const ENEMY_DEFS = [
id: 'brute', id: 'brute',
name: 'BRUTE', name: 'BRUTE',
desc: 'High HP and armor. Slow mover.', 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', color: '#ff7043', glowColor: '#ff3300',
cost: 80, reward: 112, cost: 80, reward: 112,
resistances: { physical: 0.5 }, resistances: { physical: 0.5 },
@@ -56,7 +66,7 @@ const ENEMY_DEFS = [
id: 'swarm', id: 'swarm',
name: 'SWARM', name: 'SWARM',
desc: 'Spawns 6 tiny units at once.', 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', color: '#ce93d8', glowColor: '#cc00ff',
cost: 60, reward: 84, cost: 60, reward: 84,
resistances: {}, resistances: {},
@@ -68,7 +78,7 @@ const ENEMY_DEFS = [
id: 'phantom', id: 'phantom',
name: 'PHANTOM', name: 'PHANTOM',
desc: '40% dodge. Void-touched.', 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', color: '#c77dff', glowColor: '#9900ff',
cost: 100, reward: 138, cost: 100, reward: 138,
evasion: 0.3, evasion: 0.3,
@@ -81,7 +91,7 @@ const ENEMY_DEFS = [
id: 'iceling', id: 'iceling',
name: 'ICELING', name: 'ICELING',
desc: 'Ice elemental. Slows bullets on contact.', 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', color: '#7ecfff', glowColor: '#00cfff',
cost: 90, reward: 125, cost: 90, reward: 125,
resistances: { ice: 0.0 }, resistances: { ice: 0.0 },
@@ -93,7 +103,7 @@ const ENEMY_DEFS = [
id: 'sparkling', id: 'sparkling',
name: 'SPARKLING', name: 'SPARKLING',
desc: 'Lightning elemental. Fast and electric.', 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', color: '#ffe033', glowColor: '#ffcc00',
cost: 110, reward: 148, cost: 110, reward: 148,
resistances: { lightning: 0.0 }, resistances: { lightning: 0.0 },
@@ -105,7 +115,7 @@ const ENEMY_DEFS = [
id: 'venom', id: 'venom',
name: 'VENOM', name: 'VENOM',
desc: 'Poison elemental. Immune to DoT.', 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', color: '#7fff4f', glowColor: '#44ff00',
cost: 120, reward: 160, cost: 120, reward: 160,
resistances: { poison: 0.0, fire: 0.6 }, resistances: { poison: 0.0, fire: 0.6 },
@@ -117,7 +127,7 @@ const ENEMY_DEFS = [
id: 'titan', id: 'titan',
name: 'TITAN', name: 'TITAN',
desc: 'Massive HP, heavy armor, very slow.', 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', color: '#ff1744', glowColor: '#ff0000',
cost: 350, reward: 465, cost: 350, reward: 465,
resistances: { physical: 0.4, fire: 0.7 }, resistances: { physical: 0.4, fire: 0.7 },
@@ -129,7 +139,7 @@ const ENEMY_DEFS = [
id: 'wraith', id: 'wraith',
name: 'WRAITH', name: 'WRAITH',
desc: 'Void entity. Ignores 80% of armor.', 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', color: '#9900ff', glowColor: '#6600cc',
cost: 200, reward: 265, cost: 200, reward: 265,
armorPen: 0.8, armorPen: 0.8,
@@ -138,6 +148,53 @@ const ENEMY_DEFS = [
element: 'void', element: 'void',
count: 1, 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 ──────────────────────────────────────── // ── WEAPON DEFINITIONS ────────────────────────────────────────
@@ -147,14 +204,14 @@ const WEAPON_DEFS = [
name: 'CANNON', name: 'CANNON',
desc: 'Standard projectile. Reliable, upgradeable.', desc: 'Standard projectile. Reliable, upgradeable.',
icon: '💣', icon: '💣',
cost: 0, // starting weapon cost: 100,
defaultElement: 'physical', defaultElement: 'physical',
targeting: 'nearest', targeting: 'nearest',
fireRate: 72, // frames between shots fireRate: 72, // frames between shots
damage: 4, damage: 4,
projectileSpeed: 4.2, projectileSpeed: 4.2,
projectileRadius: 4, projectileRadius: 4,
range: 9999, range: 310,
color: '#c8c8c8', color: '#c8c8c8',
type: 'projectile', type: 'projectile',
}, },
@@ -185,6 +242,7 @@ const WEAPON_DEFS = [
damage: 4, damage: 4,
chains: 3, chains: 3,
chainRange: 120, chainRange: 120,
range: 340,
color: '#ffe033', color: '#ffe033',
type: 'chain', type: 'chain',
}, },
@@ -200,6 +258,7 @@ const WEAPON_DEFS = [
damage: 12, damage: 12,
aoeRadius: 60, aoeRadius: 60,
projectileSpeed: 2.8, projectileSpeed: 2.8,
range: 480,
color: '#ff7043', color: '#ff7043',
type: 'mortar', type: 'mortar',
}, },
@@ -213,7 +272,7 @@ const WEAPON_DEFS = [
targeting: 'furthest', targeting: 'furthest',
fireRate: 10, fireRate: 10,
damage: 1, damage: 1,
range: 9999, range: 390,
color: '#ff77e9', color: '#ff77e9',
type: 'beam', type: 'beam',
}, },
@@ -230,6 +289,7 @@ const WEAPON_DEFS = [
aoeRadius: 75, aoeRadius: 75,
freezeDuration: 180, freezeDuration: 180,
projectileSpeed: 2.4, projectileSpeed: 2.4,
range: 400,
color: '#7ecfff', color: '#7ecfff',
type: 'mortar', type: 'mortar',
}, },
@@ -246,6 +306,7 @@ const WEAPON_DEFS = [
armorShred: 5, armorShred: 5,
projectileSpeed: 1.8, projectileSpeed: 1.8,
projectileRadius: 12, projectileRadius: 12,
range: 360,
color: '#c77dff', color: '#c77dff',
type: 'projectile', type: 'projectile',
}, },
@@ -262,6 +323,7 @@ const WEAPON_DEFS = [
targets: 3, targets: 3,
aoeRadius: 36, aoeRadius: 36,
projectileSpeed: 3.6, projectileSpeed: 3.6,
range: 450,
color: '#ff4500', color: '#ff4500',
type: 'multi', type: 'multi',
}, },
@@ -277,6 +339,7 @@ const WEAPON_DEFS = [
damage: 2, damage: 2,
amplify: 0.25, amplify: 0.25,
projectileSpeed: 5.8, projectileSpeed: 5.8,
range: 280,
color: '#ff77e9', color: '#ff77e9',
type: 'projectile', type: 'projectile',
}, },
@@ -295,6 +358,7 @@ const WEAPON_DEFS = [
dotDuration: 180, dotDuration: 180,
aoeRadius: 55, aoeRadius: 55,
projectileSpeed: 2.2, projectileSpeed: 2.2,
range: 350,
color: '#7fff4f', color: '#7fff4f',
type: 'mortar', type: 'mortar',
}, },
@@ -387,35 +451,19 @@ const TOWER_UPGRADE_TREE = [
}, },
{ {
id: 'slot5', label: 'Weapon Slot V', desc: 'Unlock 5th weapon slot', 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', 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', 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', id: 'slot8', label: 'Weapon Slot VIII', desc: 'Unlock 8th weapon slot',
cost: 30000, requires: ['slot7'], effect: { weaponSlot: 8 }, cost: 30000, requires: ['slot7'], effect: { weaponSlot: 8 }, minTier: 4,
},
{
id: 'range1', label: 'Scanner I', desc: '+40 range',
cost: 180, requires: [], effect: { range: 40 },
},
{
id: 'range2', label: 'Scanner II', desc: '+60 range',
cost: 450, requires: ['range1'], effect: { range: 60 },
},
{
id: 'range3', label: 'Scanner III', desc: '+80 range',
cost: 1000, requires: ['range2'], effect: { range: 80 },
},
{
id: 'range4', label: 'Scanner IV', desc: '+100 range — max coverage',
cost: 2400, requires: ['range3'], effect: { range: 100 },
}, },
{ {
id: 'shield_dome', label: 'Dome Shield', desc: 'Buy dome shield system', 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: '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: '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' }, { 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: [ flamethrower: [
{ id: 'dmg1', label: 'Heat I', desc: '+1 dmg/tick', cost: 120, requires: [], effect: { damage: 1 }, category: 'Damage' }, { 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: '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: '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: '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: [ freezebomb: [
{ id: 'dmg1', label: 'Cold I', desc: '+3 damage', cost: 200, requires: [], effect: { damage: 3 }, category: 'Damage' }, { 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: '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: '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: '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: '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' }, { 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: '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: 'rate2', label: 'Reload II', desc: '-40 frames', cost: 700, requires: ['rate1'], effect: { fireRate: -40 }, category: 'Fire Rate' },
{ id: 'speed1', label: 'Velocity I', desc: '+2 proj spd', cost: 300, requires: [], effect: { projectileSpeed: 2 }, category: 'Velocity' }, { id: '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: [ arcaneturret: [
{ id: 'dmg1', label: 'Potency I', desc: '+1 damage', cost: 120, requires: [], effect: { damage: 1 }, category: 'Damage' }, { 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: '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: '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: '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: '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: '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: '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: 'el2', label: 'Infuse: Slot 2', desc: 'Unlock 2nd element slot', cost: 800, requires: ['el1'], effect: { canInfuse2: true }, category: 'Elements' },
+2 -1
View File
@@ -158,7 +158,8 @@ const DEV_MODE = true;
const ids = ['grunt','runner','brute','phantom']; const ids = ['grunt','runner','brute','phantom'];
ids.forEach(id => { ids.forEach(id => {
const def = ENEMY_DEFS.find(d => d.id === 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'); devLog(`Spawned wave: ${ids.join(', ')} ×${qty} each`, 'ok');
} }
+45
View File
@@ -73,6 +73,7 @@ function applyFreeze(enemy, duration) {
// Diminishing returns: each successive freeze is shorter // Diminishing returns: each successive freeze is shorter
// 60-frame immunity window after thawing // 60-frame immunity window after thawing
if (enemy._freezeImmune > 0) return; if (enemy._freezeImmune > 0) return;
if (enemy.immuneToFreeze) return;
const diminish = enemy._freezeCount ? Math.pow(0.75, enemy._freezeCount) : 1.0; const diminish = enemy._freezeCount ? Math.pow(0.75, enemy._freezeCount) : 1.0;
const actual = Math.max(30, Math.round(duration * diminish)); const actual = Math.max(30, Math.round(duration * diminish));
enemy.frozen = actual; enemy.frozen = actual;
@@ -133,6 +134,35 @@ function dealDamage(enemy, rawDamage, elements = ['physical'], canCrit = false,
} }
dmg = Math.round(dmg); 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.hp -= dmg;
enemy.hitFlash = 6; enemy.hitFlash = 6;
@@ -166,6 +196,9 @@ function applyElementalEffect(enemy, el) {
break; break;
case 'void': case 'void':
enemy.armor = Math.max(0, (enemy.armor || 0) - 1); enemy.armor = Math.max(0, (enemy.armor || 0) - 1);
if (enemy.defId === 'siegebreaker') {
enemy.regenPausedUntil = G.frame + 180; // pause regen 3 seconds
}
break; break;
case 'arcane': case 'arcane':
enemy.amplified = Math.min(1.5, (enemy.amplified || 0) + 0.1); enemy.amplified = Math.min(1.5, (enemy.amplified || 0) + 0.1);
@@ -227,4 +260,16 @@ function tickEnemyStatus(enemy) {
enemy.amplified *= 0.995; enemy.amplified *= 0.995;
if (enemy.amplified < 0.01) enemy.amplified = 0; 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;
}
}
}
} }
+76 -12
View File
@@ -4,7 +4,7 @@
// ============================================================ // ============================================================
// ── ENEMY SPAWNING ──────────────────────────────────────────── // ── 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 // For swarm units, offset position slightly around the portal
let spawnX = x, spawnY = y; let spawnX = x, spawnY = y;
if (offsetAngle !== null) { if (offsetAngle !== null) {
@@ -12,18 +12,26 @@ function spawnEnemy(def, x, y, rewardOverride = null, offsetAngle = null, costOv
spawnX = x + Math.cos(offsetAngle) * spread; spawnX = x + Math.cos(offsetAngle) * spread;
spawnY = y + Math.sin(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(), id: uid(),
defId: def.id, defId: def.id,
name: def.name, name: def.name,
x: spawnX, y: spawnY, x: spawnX, y: spawnY,
hp: def.hp, hp: scaledHp,
maxHp: def.hp, maxHp: scaledHp,
speed: def.speed, speed: scaledSpeed,
baseSpeed: def.speed, baseSpeed: scaledSpeed,
radius: def.radius, radius: def.radius,
armor: def.armor ?? 0, armor: scaledArmor,
baseArmor: def.armor ?? 0, baseArmor: scaledArmor,
evasion: def.evasion ?? 0, evasion: def.evasion ?? 0,
armorPen: def.armorPen ?? 0, armorPen: def.armorPen ?? 0,
color: def.color, color: def.color,
@@ -46,7 +54,11 @@ function spawnEnemy(def, x, y, rewardOverride = null, offsetAngle = null, costOv
angle: 0, angle: 0,
vx: 0, vx: 0,
vy: 0, vy: 0,
}); };
if (extraProps) Object.assign(instance, extraProps);
G.enemies.push(instance);
} }
// ── DEPLOY (player action) ──────────────────────────────────── // ── DEPLOY (player action) ────────────────────────────────────
@@ -54,6 +66,10 @@ function deployEnemy(defId, quantity = 1) {
const def = ENEMY_DEFS.find(e => e.id === defId); const def = ENEMY_DEFS.find(e => e.id === defId);
if (!def) return; 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; const totalCost = def.cost * quantity;
if (G.credits < totalCost) return; if (G.credits < totalCost) return;
if (G.gameOver) return; if (G.gameOver) return;
@@ -62,12 +78,20 @@ function deployEnemy(defId, quantity = 1) {
// Bonus reward multiplier and breach risk for sending multiples // 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 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; 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 // Freshness tracking — increment before deploy so bar reflects cost immediately
G.enemyFreshness[defId] = (G.enemyFreshness[defId] || 0) + quantity; 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') { if (def.id === 'swarm') {
// Each swarm card = one burst portal. Split rewards exactly across minis. // Each swarm card = one burst portal. Split rewards exactly across minis.
const swarmUnitCost = def.cost / def.count; const swarmUnitCost = def.cost / def.count;
@@ -85,7 +109,8 @@ function deployEnemy(defId, quantity = 1) {
const plural = quantity > 1 ? ` ×${quantity}` : ''; const plural = quantity > 1 ? ` ×${quantity}` : '';
const bonusStr = bonusMult > 1 ? ` (+${Math.round((bonusMult-1)*100)}% reward!)` : ''; const bonusStr = bonusMult > 1 ? ` (+${Math.round((bonusMult-1)*100)}% reward!)` : '';
const riskStr = breachRiskMult > 1 ? ` [risk x${breachRiskMult.toFixed(2)}]` : ''; 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(); updateHUD();
} }
@@ -203,6 +228,24 @@ function updateEnemies() {
const cx = ARENA_CX; const cx = ARENA_CX;
const cy = ARENA_CY; 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) { for (const e of G.enemies) {
if (!e.alive) continue; if (!e.alive) continue;
@@ -250,6 +293,24 @@ function updateEnemies() {
} }
compactLiveArray(G.enemies, e => e.alive); 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) { function killEnemy(enemy, giveReward) {
@@ -359,7 +420,10 @@ function breachTower(enemy) {
// Pick targeting for a weapon — only considers enemies within tower vision range // Pick targeting for a weapon — only considers enemies within tower vision range
function pickTarget(weapon) { function pickTarget(weapon) {
const cx = ARENA_CX, cy = ARENA_CY; 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; const towerRangeSq = towerRange * towerRange;
let targeting = weapon.targeting || 'nearest'; let targeting = weapon.targeting || 'nearest';
switch (targeting) { switch (targeting) {
+99 -20
View File
@@ -3,16 +3,48 @@
// INPUT.JS — Keyboard hotkeys, mouse interaction // 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 = { const HOTKEYS = {
'Space': () => G.shopOpen ? closeShop() : openShop(), 'Space': () => G.armoryOpen ? closeArmory() : openArmory(),
'KeyC': () => G.commandOpen ? closeCommand() : openCommand(),
'Escape': () => { 'Escape': () => {
if (document.body.classList.contains('inventory-open')) closeWeaponPicker(); if (G.weaponDetailSlot >= 0) closeWeaponDetail();
else if (G.shopOpen) closeShop(); 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(); else togglePause();
}, },
'KeyP': () => { if (!G.shopOpen && !document.body.classList.contains('inventory-open')) togglePause(); }, 'KeyP': () => {
'KeyI': () => { if (!G.shopOpen) document.body.classList.contains('inventory-open') ? closeWeaponPicker() : openWeaponPicker(-1); }, if (!G.armoryOpen && !G.commandOpen && !document.body.classList.contains('inventory-open')) togglePause();
'Tab': () => { if (G.shopOpen) cycleShopTab(); }, },
'KeyI': () => {
if (!G.armoryOpen && !G.commandOpen) {
document.body.classList.contains('inventory-open') ? closeWeaponPicker() : openWeaponPicker(-1);
}
},
}; };
// 10 keys for enemy deploy // 10 keys for enemy deploy
@@ -27,7 +59,7 @@ function initInput() {
// Enemy deploy hotkeys // Enemy deploy hotkeys
const idx = ENEMY_HOTKEYS.indexOf(e.code); 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(); e.preventDefault();
deployEnemy(ENEMY_DEFS[idx].id, G.sendQuantity); deployEnemy(ENEMY_DEFS[idx].id, G.sendQuantity);
return; return;
@@ -46,6 +78,12 @@ let _hoverPt = null;
let _dragWeapon = null; let _dragWeapon = null;
let _dragSource = null; let _dragSource = null;
let _suppressNextClick = false; 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; } function clearHitRegions() { _hitRegions.length = 0; }
@@ -59,11 +97,32 @@ function isHovered(x, y, w, h) {
_hoverPt.y >= y && _hoverPt.y < y + 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) { function canvasPt(e) {
const r = canvas.getBoundingClientRect(); 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 { return {
x: (e.clientX - r.left) * (GAME_W / r.width), x: cx,
y: (e.clientY - r.top) * (GAME_H / r.height), y: cy,
worldX: (cx - ARENA_CX) / zoom + ARENA_CX,
worldY: (cy - ARENA_CY) / zoom + ARENA_CY,
}; };
} }
@@ -79,9 +138,28 @@ function initCanvasMouse() {
return; 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 => { 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; if (!_dragWeapon) return;
const pt = canvasPt(e); const pt = canvasPt(e);
let dropped = false; let dropped = false;
@@ -105,8 +183,10 @@ function initCanvasMouse() {
if (!dropped) { if (!dropped) {
for (const zone of _mountDropZones) { 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) { 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); equipWeaponInstanceToSlot(zone.slotIndex, _dragWeapon.instanceId);
dropped = true; dropped = true;
break; break;
@@ -153,7 +233,9 @@ function initCanvasMouse() {
e.preventDefault(); e.preventDefault();
if (document.body.classList.contains('inventory-open')) { if (document.body.classList.contains('inventory-open')) {
_pickerScrollY = clamp(_pickerScrollY + e.deltaY * 0.5, 0, _pickerScrollMax); _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); _shopScrollY = clamp(_shopScrollY + e.deltaY * 0.5, 0, _shopScrollMax);
} else if (_hoverPt && _hoverPt.x >= 1330) { } else if (_hoverPt && _hoverPt.x >= 1330) {
const pt = _hoverPt; const pt = _hoverPt;
@@ -171,12 +253,17 @@ function initCanvasMouse() {
? steps[Math.min(idx + 1, steps.length - 1)] ? steps[Math.min(idx + 1, steps.length - 1)]
: steps[Math.max(idx - 1, 0)]; : 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 }); }, { passive: false });
canvas.addEventListener('contextmenu', e => { canvas.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
if (!G.shopOpen) return; if (!G.armoryOpen && !G.commandOpen && G.weaponDetailSlot < 0) return;
const pt = canvasPt(e); const pt = canvasPt(e);
for (const r of _shopRightClick) { 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) { 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;
}
+2 -1
View File
@@ -41,6 +41,7 @@ function openWeaponPicker(slotIndex) {
if (slotIndex >= G.tower.weaponSlots) return; if (slotIndex >= G.tower.weaponSlots) return;
_pickerSlot = slotIndex; _pickerSlot = slotIndex;
_pickerScrollY = 0; _pickerScrollY = 0;
_radialSlot = -1;
setPaused(true, false); setPaused(true, false);
document.body.classList.add('inventory-open'); document.body.classList.add('inventory-open');
G.weaponInventory = G.weaponInventory || []; G.weaponInventory = G.weaponInventory || [];
@@ -50,7 +51,7 @@ function closeWeaponPicker() {
document.body.classList.remove('inventory-open'); document.body.classList.remove('inventory-open');
_pickerSlot = -1; _pickerSlot = -1;
_pickerScrollY = 0; _pickerScrollY = 0;
if (!G.shopOpen) setPaused(false); if (!G.armoryOpen && !G.commandOpen) setPaused(false);
} }
function equipWeaponInstanceToSlot(slotIndex, instanceId) { function equipWeaponInstanceToSlot(slotIndex, instanceId) {
+128 -6
View File
@@ -51,7 +51,7 @@ function perfNow() {
function setPaused(paused, showOverlay = true) { function setPaused(paused, showOverlay = true) {
G.paused = paused; G.paused = paused;
document.body.classList.toggle('paused', document.body.classList.toggle('paused',
!!paused && showOverlay && !G.shopOpen && !!paused && showOverlay && !G.armoryOpen && !G.commandOpen &&
!document.body.classList.contains('inventory-open') && !G.gameOver); !document.body.classList.contains('inventory-open') && !G.gameOver);
} }
@@ -161,9 +161,13 @@ function updateHUD() {
// ── COMBAT LOG ──────────────────────────────────────────────── // ── COMBAT LOG ────────────────────────────────────────────────
function addLog(msg, type = '') { function addLog(msg, type = '') {
if (!G.logLines) G.logLines = []; if (!G.logLines) G.logLines = [];
G.logLines.unshift({ text: msg, type }); 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; if (G.logLines.length > 40) G.logLines.length = 40;
} }
}
// ── BANKRUPTCY CHECK ────────────────────────────────────────── // ── BANKRUPTCY CHECK ──────────────────────────────────────────
function checkBankruptcy() { function checkBankruptcy() {
@@ -189,7 +193,8 @@ function checkBankruptcy() {
// ── GAME OVER ───────────────────────────────────────────────── // ── GAME OVER ─────────────────────────────────────────────────
function endGame() { function endGame() {
if (G.shopOpen) closeShop(); if (G.armoryOpen) closeArmory();
if (G.commandOpen) closeCommand();
setPaused(false); setPaused(false);
G.gameOver = true; G.gameOver = true;
G.isBankrupt = false; G.isBankrupt = false;
@@ -201,7 +206,8 @@ function endGame() {
} }
function endBankrupt() { function endBankrupt() {
if (G.shopOpen) closeShop(); if (G.armoryOpen) closeArmory();
if (G.commandOpen) closeCommand();
setPaused(false); setPaused(false);
G.gameOver = true; G.gameOver = true;
G.isBankrupt = true; G.isBankrupt = true;
@@ -214,13 +220,130 @@ function endBankrupt() {
} }
function restartGame() { function restartGame() {
const savedBonuses = G.permanentBonuses ? { ...G.permanentBonuses } : {};
const savedTiers = G.unlockedTiers ? [...G.unlockedTiers] : [0];
const savedPrestige = G.prestigeLevel || 0;
G = makeGameState(); G = makeGameState();
G.permanentBonuses = savedBonuses;
G.unlockedTiers = savedTiers;
G.difficultyTier = savedTiers.includes(0) ? 0 : savedTiers[0];
G.prestigeLevel = savedPrestige;
applyPermanentBonuses();
setPaused(false); setPaused(false);
_sidePanelScrollY = 0; _sidePanelScrollY = 0;
_logScrollY = 0; _logScrollY = 0;
updateHUD(); updateHUD();
addLog('System online. Deploy enemies to earn credits.', 'info'); addLog('System online. Deploy enemies to earn credits.', 'info');
addLog('[10] 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 ────────────────────────────────────────────────────── // ── INIT ──────────────────────────────────────────────────────
@@ -229,7 +352,6 @@ function init() {
updateHUD(); updateHUD();
initInput(); initInput();
addLog('SIEGE PROTOCOL initialized.', 'info'); addLog('SIEGE PROTOCOL initialized.', 'info');
addLog('[10] deploy [scroll] qty [Space] shop [Esc/P] pause [I] inventory', 'info');
gameLoop(); gameLoop();
} }
+136
View File
@@ -113,6 +113,75 @@ function drawEnemyShape(e, bodyColor) {
break; 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: default:
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill();
} }
@@ -122,6 +191,52 @@ function drawEnemyShape(e, bodyColor) {
function drawEnemies() { function drawEnemies() {
const enemies = G.enemies; 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) // Pass 1: elemental glows (no shadow, just gradient blobs)
for (const e of enemies) { for (const e of enemies) {
if (!e.alive || !e.element) continue; 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 // HP bar — only when damaged
if (e.hp < e.maxHp) { if (e.hp < e.maxHp) {
const bw = e.radius * 2.4, bh = 3; const bw = e.radius * 2.4, bh = 3;
+195 -18
View File
@@ -10,6 +10,93 @@
// ── HUD ─────────────────────────────────────────────────────── // ── HUD ───────────────────────────────────────────────────────
const HUD_H = 64; 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) // Right-section column centers (canvas x coords, 1600px wide)
const _HUD_KILLS_CX = 1542; const _HUD_KILLS_CX = 1542;
const _HUD_SCORE_CX = 1468; const _HUD_SCORE_CX = 1468;
@@ -19,7 +106,7 @@ const _HUD_DIV2_X = 1204;
const _HUD_CRED_CX = 1118; const _HUD_CRED_CX = 1118;
function drawHUD() { function drawHUD() {
const W = canvas.width; const W = canvas.width, H = canvas.height;
const cheapest = cheapestEnemyCost(); const cheapest = cheapestEnemyCost();
// ── Background strip ───────────────────────────────────────── // ── Background strip ─────────────────────────────────────────
@@ -44,28 +131,50 @@ function drawHUD() {
const titleW = ctx.measureText('SIEGE PROTOCOL').width; const titleW = ctx.measureText('SIEGE PROTOCOL').width;
ctx.letterSpacing = '0px'; ctx.letterSpacing = '0px';
// ── LEFT: shop button ──────────────────────────────────────── // ── LEFT: THREAT, PRESTIGE buttons ───────────────────────────
const SBX = 20 + titleW + 16;
const SBY = 14;
const SBH = 36;
ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; ctx.font = '11px Orbitron, "Share Tech Mono", monospace';
const shopLabel = '⚙ SHOP [Space]'; const tierDef = DIFFICULTY_TIERS[G.difficultyTier || 0];
const SBW = ctx.measureText(shopLabel).width + 32; const tierName = tierDef?.name ?? 'NORMAL';
const shopHov = isHovered(SBX, SBY, SBW, SBH); const prestige = G.prestigeLevel || 0;
const tierActive = (G.difficultyTier || 0) > 0;
ctx.fillStyle = shopHov ? '#00d4ff' : 'transparent'; // THREAT button
ctx.strokeStyle = '#00d4ff'; 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.lineWidth = 1;
ctx.beginPath(); ctx.rect(SBX, SBY, SBW, SBH); ctx.fill(); ctx.stroke(); 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; }
if (shopHov) { ctx.shadowColor = '#00d4ff'; ctx.shadowBlur = 16; } ctx.fillStyle = (threatHov || threatOn) ? '#000000' : (tierActive ? '#ff6b35' : '#3a6080');
ctx.fillStyle = shopHov ? '#000000' : '#00d4ff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.textAlign = 'center'; ctx.fillText(threatLabel, TH_BX + TH_BW / 2, TH_BY + TH_BH / 2);
ctx.textBaseline = 'middle';
ctx.fillText(shopLabel, SBX + SBW / 2, SBY + SBH / 2);
ctx.shadowBlur = 0; 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 ─────────────────────────────────────────── // ── CENTER: HP bar ───────────────────────────────────────────
const CX = W / 2; const CX = W / 2;
@@ -179,6 +288,8 @@ function drawHUD() {
if (!rDownDis) addHitRegion(rDownX, RBY, RBW, RBH, () => adjustReserve(-10)); if (!rDownDis) addHitRegion(rDownX, RBY, RBW, RBH, () => adjustReserve(-10));
if (!rUpDis) addHitRegion(rUpX, 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); hudDivider(_HUD_DIV2_X);
@@ -200,9 +311,75 @@ function drawHUD() {
ctx.fillText(G.credits + '¢', CRED, HUD_H - 6); ctx.fillText(G.credits + '¢', CRED, HUD_H - 6);
ctx.shadowBlur = 0; 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(); 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 ───────────────────────────────────────────── // ── BROKE WARNING ─────────────────────────────────────────────
function drawBrokeWarning() { function drawBrokeWarning() {
const cheapest = cheapestEnemyCost(); const cheapest = cheapestEnemyCost();
+3 -3
View File
@@ -38,7 +38,7 @@ function drawInventoryOverlay() {
ctx.save(); ctx.save();
const titleSuffix = equipMode ? `- SLOT ${_pickerSlot + 1}` : '- BAG'; const titleSuffix = equipMode ? `- SLOT ${_pickerSlot + 1}` : '';
ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; ctx.font = '11px Orbitron, "Share Tech Mono", monospace';
ctx.letterSpacing = '3px'; ctx.letterSpacing = '3px';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
@@ -50,7 +50,7 @@ function drawInventoryOverlay() {
const invCount = (G.weaponInventory || []).length; const invCount = (G.weaponInventory || []).length;
ctx.font = '10px "Share Tech Mono", monospace'; ctx.font = '10px "Share Tech Mono", monospace';
ctx.fillStyle = '#3a6080'; 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.strokeStyle = '#1a3048';
ctx.lineWidth = 1; ctx.lineWidth = 1;
@@ -177,7 +177,7 @@ function drawInventoryOverlay() {
for (const w of (G.weaponInventory || [])) { for (const w of (G.weaponInventory || [])) {
const wRef = w; const wRef = w;
drawCard(w, getWeaponDef(w), { drawCard(w, getWeaponDef(w), {
location: 'BAG', location: 'INVENTORY',
source: { type: 'bag' }, source: { type: 'bag' },
action: equipMode action: equipMode
? () => { equipWeaponInstanceToSlot(_pickerSlot, wRef.instanceId); } ? () => { equipWeaponInstanceToSlot(_pickerSlot, wRef.instanceId); }
+760 -24
View File
@@ -3,6 +3,29 @@
// RENDERER OVERLAYS — game over, pause, mount drag UI // 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 ───────────────────────────── // ── GAME OVER / BANKRUPT OVERLAY ─────────────────────────────
function drawGameOverPanel() { function drawGameOverPanel() {
const W = canvas.width, H = canvas.height; 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.fillText(parts.join(' '), tx + 8, ty + 26);
ctx.fillStyle = '#3a6080'; 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(); ctx.restore();
} }
// ── MOUNT POINT INTERACTION (drawn after overlays so it sits on top) ───── // ── MOUNT POINT INTERACTION ───────────────────────────────────
function drawMountInteraction(cx, cy) { 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 invOpen = document.body.classList.contains('inventory-open');
const totalSlots = Math.max(1, G.tower.weaponSlots); const totalSlots = Math.max(1, G.tower.weaponSlots);
const hpRatio = G.tower.hp / G.tower.maxHp; const hpRatio = G.tower.hp / G.tower.maxHp;
const hpColor = hpRatio > 0.5 ? '#00d4ff' : hpRatio > 0.25 ? '#ffd700' : '#ff3355'; const hpColor = hpRatio > 0.5 ? '#00d4ff' : hpRatio > 0.25 ? '#ffd700' : '#ff3355';
const spreadMode = invOpen || _shiftHeld || _radialSlot >= 0;
for (let slotIndex = 0; slotIndex < totalSlots; slotIndex++) { for (let slotIndex = 0; slotIndex < totalSlots; slotIndex++) {
const weapon = G.weapons[slotIndex]; const weapon = G.weapons[slotIndex];
const installed = !!weapon; const installed = !!weapon;
const mountAngle = HARDPOINT_BASE_ANGLE + (slotIndex / totalSlots) * Math.PI * 2; const mountAngle = HARDPOINT_BASE_ANGLE + (slotIndex / totalSlots) * Math.PI * 2;
if (invOpen) { if (spreadMode) {
const actual = getSlotHardpoint(slotIndex, cx, cy, totalSlots); const actual = getSlotHardpoint(slotIndex, cx, cy, totalSlots);
const ORBIT = Math.max(78, 64 + totalSlots * 6); const ORBIT = Math.max(78, 64 + totalSlots * 6);
const mx = cx + Math.cos(mountAngle) * ORBIT; const mx = cx + Math.cos(mountAngle) * ORBIT; // world
const my = cy + Math.sin(mountAngle) * ORBIT; const my = cy + Math.sin(mountAngle) * ORBIT; // world
const smx = toSX(mx);
const smy = toSY(my);
const dropR = 20; 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; const dragging = _dragWeapon !== null;
// Dashed line from actual hardpoint to spread circle
ctx.save(); ctx.save();
ctx.strokeStyle = '#00aaff33'; ctx.strokeStyle = '#00aaff77';
ctx.lineWidth = 1; ctx.lineWidth = 1.5;
ctx.setLineDash([5, 6]); ctx.setLineDash([5, 6]);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(actual.x, actual.y); ctx.moveTo(toSX(actual.x), toSY(actual.y));
ctx.lineTo(mx, my); ctx.lineTo(smx, smy);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
ctx.restore(); ctx.restore();
if (dragging) { // Drag drop indicator
if ((invOpen || _shiftHeld) && dragging) {
ctx.save(); ctx.save();
ctx.shadowBlur = hov ? 20 : 8; ctx.shadowBlur = hov ? 20 : 8;
ctx.shadowColor = '#ffd700'; ctx.shadowColor = '#ffd700';
ctx.strokeStyle = hov ? '#ffd700' : '#00aaff55'; ctx.strokeStyle = hov ? '#ffd700' : '#00aaff55';
ctx.lineWidth = 2; 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.shadowBlur = 0;
ctx.restore(); ctx.restore();
} }
@@ -198,41 +233,742 @@ function drawMountInteraction(cx, cy) {
? (hov ? '#ffd700' : hpColor + 'aa') ? (hov ? '#ffd700' : hpColor + 'aa')
: (hov ? '#00aaff' : '#1a3240'); : (hov ? '#00aaff' : '#1a3240');
ctx.lineWidth = hov ? 2.5 : 1.5; 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.font = '8px Orbitron, monospace';
ctx.letterSpacing = '1px'; ctx.letterSpacing = '1px';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#3a6080'; 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'; ctx.letterSpacing = '0px';
if (installed) { 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); const def = getWeaponDef(weapon);
ctx.font = '14px monospace'; ctx.font = '14px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff'; 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 { } else {
ctx.font = '14px "Share Tech Mono", monospace'; ctx.font = '14px "Share Tech Mono", monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#1a3240'; 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 }); _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); if (invOpen) {
addHitRegion(smx - dropR, smy - dropR, dropR * 2, dropR * 2, () => openWeaponPicker(slotIndex));
} else { } else {
// inventory closed: invisible hit region — click opens picker for this slot const si = slotIndex;
const mount = getSlotHardpoint(slotIndex, cx, cy, totalSlots); if (installed) {
const r = 10; addHitRegion(smx - dropR, smy - dropR, dropR * 2, dropR * 2, () => {
addHitRegion(mount.x - r, mount.y - r, r * 2, r * 2, () => openWeaponPicker(slotIndex)); _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 ──────────────────────────────────────────────── // ── DRAG GHOST ────────────────────────────────────────────────
function drawDragGhost() { function drawDragGhost() {
+48 -61
View File
@@ -1,12 +1,11 @@
// ═══ renderer-shop-overlay.js ═══ // ═══ renderer-shop-overlay.js ═══
// ============================================================ // ============================================================
// RENDERER SHOP — canvas armory overlay // RENDERER SHOP — canvas armory / command overlays
// ============================================================ // ============================================================
// ── SHOP OVERLAY ──────────────────────────────────────────────
const _SH_HDR_H = 56; const _SH_HDR_H = 56;
const _SH_TAB_H = 38; 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_PAD = 24;
const _SH_UPG_W = 130; const _SH_UPG_W = 130;
const _SH_UPG_H = 78; const _SH_UPG_H = 78;
@@ -30,7 +29,7 @@ function _shopWrapText(text, maxW, maxLines) {
return lines; 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 hov = !bought && !locked && !cantAfford && isHovered(sx, screenY, _SH_UPG_W, _SH_UPG_H);
const border = bought ? '#1a5030' : locked ? '#0e1e28' : cantAfford ? '#1a2030' : hov ? '#ffd700' : '#1a3048'; const border = bought ? '#1a5030' : locked ? '#0e1e28' : cantAfford ? '#1a2030' : hov ? '#ffd700' : '#1a3048';
ctx.save(); 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.font = '10px "Share Tech Mono", monospace'; ctx.letterSpacing = '0px';
ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
if (bought) { ctx.fillStyle = '#1a4030'; ctx.fillText('✓ right-click refund', sx + 5, screenY + 58); } 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 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); } 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 }); if (onRefund) _shopRightClick.push({ x: sx, y: screenY, w: _SH_UPG_W, h: _SH_UPG_H, action: onRefund });
} }
function drawShopOverlay() { // ── Shared header for both overlays ───────────────────────────
if (!G.shopOpen) return; function _drawOverlayHeader(W, title, closeFn) {
_shopRightClick.length = 0;
const W = canvas.width, H = canvas.height;
const BODY_H = H - _SH_BODY_Y;
ctx.fillStyle = 'rgba(2,8,14,0.97)';
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.fillStyle = '#040c14'; ctx.fillStyle = '#040c14';
ctx.fillRect(0, 0, W, _SH_HDR_H); ctx.fillRect(0, 0, W, _SH_HDR_H);
ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1; 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.font = '900 15px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '6px';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#00d4ff'; ctx.shadowColor = '#00d4ff44'; ctx.shadowBlur = 14; 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.shadowBlur = 0; ctx.letterSpacing = '0px';
ctx.font = '18px Orbitron, "Share Tech Mono", monospace'; ctx.font = '18px Orbitron, "Share Tech Mono", monospace';
@@ -105,59 +94,57 @@ function drawShopOverlay() {
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ff3355'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ff3355';
ctx.fillText('✕ CLOSE [Esc]', CBX + CBW / 2, CBY + CBH / 2); ctx.fillText('✕ CLOSE [Esc]', CBX + CBW / 2, CBY + CBH / 2);
ctx.letterSpacing = '0px'; 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();
const equippedWeapons = getEquippedWeapons();
const tabDefs = [
{ id: 'tower', label: '🏰 TOWER' },
{ id: 'weapons', label: '🔧 BUY WEAPON' },
...equippedWeapons.map(w => {
const def = getWeaponDef(w);
return { id: w.instanceId, label: (def?.icon || '?') + ' ' + (def?.name || '?') };
}),
];
ctx.font = '11px Orbitron, "Share Tech Mono", monospace'; ctx.letterSpacing = '1.5px';
let tx = _SH_PAD;
const TB_Y = _SH_HDR_H + 4, TB_H = _SH_TAB_H - 4;
for (const tab of tabDefs) {
const tw = Math.ceil(ctx.measureText(tab.label).width) + 32;
const active = G.shopTab === tab.id;
const tabHov = isHovered(tx, TB_Y, tw, TB_H);
if (active) {
ctx.fillStyle = '#060e18'; ctx.strokeStyle = '#1a3048'; ctx.lineWidth = 1;
ctx.fillRect(tx, TB_Y, tw, TB_H + 2);
ctx.strokeRect(tx, TB_Y, tw, TB_H);
} }
ctx.fillStyle = active ? '#00d4ff' : (tabHov ? '#b8d8e8' : '#3a6080');
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // ── ARMORY OVERLAY — weapon buying + per-weapon upgrade trees ──
ctx.fillText(tab.label, tx + tw / 2, TB_Y + TB_H / 2); function drawArmoryOverlay() {
addHitRegion(tx, TB_Y, tw, TB_H, ((tid) => () => setShopTab(tid))(tab.id)); if (!G.armoryOpen) return;
tx += tw + 4; _shopRightClick.length = 0;
}
ctx.letterSpacing = '0px'; 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, 'ARMORY', closeArmory);
ctx.save(); ctx.save();
ctx.beginPath(); ctx.rect(0, _SH_BODY_Y, W, BODY_H); ctx.clip(); ctx.beginPath(); ctx.rect(0, _SH_BODY_Y, W, BODY_H); ctx.clip();
const bodyCX = _SH_PAD, bodyCW = W - _SH_PAD * 2; const bodyCX = _SH_PAD, bodyCW = W - _SH_PAD * 2;
let yOff = _SH_PAD; 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); yOff = _shopDrawBuyContent(yOff, bodyCX, bodyCW, H);
} else {
const w = equippedWeapons.find(w => w.instanceId === G.shopTab);
if (w) yOff = _shopDrawWeaponContent(yOff, bodyCX, bodyCW, H, w);
}
_shopScrollMax = Math.max(0, yOff - BODY_H + _SH_PAD); _shopScrollMax = Math.max(0, yOff - BODY_H + _SH_PAD);
ctx.restore(); 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(); ctx.restore();
} }
+16 -10
View File
@@ -11,7 +11,7 @@ function _shopDrawTowerContent(yOff, cx, cw, H) {
['HP', G.tower.hp + ' / ' + G.tower.maxHp], ['HP', G.tower.hp + ' / ' + G.tower.maxHp],
['Armor', G.tower.armor], ['Armor', G.tower.armor],
['Aim Speed', G.tower.aimSpeed.toFixed(3)], ['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], ['Slots', getEquippedWeapons().length + '/' + G.tower.weaponSlots],
['Shield', G.tower.shield ? G.tower.shield.toUpperCase() + ' (' + G.tower.shieldHp + '/' + G.tower.shieldMaxHp + ')' : 'None'], ['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'); (upg.id === 'shield_dir' && G.tower.shield === 'directional');
const effectiveBought = isBought || shieldConflict; const effectiveBought = isBought || shieldConflict;
const reqsMet = upg.requires.every(r => G.towerUpgradesBought.includes(r)); 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; const uid = upg.id;
_shopUpgNode(nx, sy(yOff), upg, effectiveBought, _shopUpgNode(nx, sy(yOff), upg, effectiveBought,
!reqsMet && !isBought, !canAfford && reqsMet && !isBought, tierLocked || (!reqsMet && !isBought), !canAfford && reqsMet && !isBought && !tierLocked,
(!effectiveBought && reqsMet && canAfford) ? () => buyTowerUpgrade(uid) : null, (!effectiveBought && reqsMet && canAfford && !tierLocked) ? () => buyTowerUpgrade(uid) : null,
(isBought && !upg.repeatable) ? () => refundTowerUpgrade(uid) : null (isBought && !upg.repeatable) ? () => refundTowerUpgrade(uid) : null,
tierLockLabel
); );
nx += _SH_UPG_W; nx += _SH_UPG_W;
} }
@@ -203,7 +206,7 @@ function _shopDrawBuyContent(yOff, cx, cw, H) {
ctx.restore(); ctx.restore();
ctx.font = '10px "Share Tech Mono", monospace'; 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.textBaseline = 'top'; ctx.textAlign = 'left';
ctx.fillText('Owned: ' + owned + '/' + MAX_WEAPONS_PER_TYPE + (atCap ? ' (MAX)' : ''), cardX + 8, csy + 88); ctx.fillText('Owned: ' + owned + '/' + MAX_WEAPONS_PER_TYPE + (atCap ? ' (MAX)' : ''), cardX + 8, csy + 88);
ctx.restore(); ctx.restore();
@@ -391,12 +394,15 @@ function _shopDrawWeaponContent(yOff, cx, cw, H, weapon) {
} }
const isBought = bought.includes(upg.id); const isBought = bought.includes(upg.id);
const reqsMet = upg.requires.every(r => bought.includes(r)); 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; const uid = upg.id, iid = weapon.instanceId;
_shopUpgNode(colX + (COL_W - _SH_UPG_W) / 2, sy(ny), upg, isBought, _shopUpgNode(colX + (COL_W - _SH_UPG_W) / 2, sy(ny), upg, isBought,
!reqsMet && !isBought, !canAfford && reqsMet && !isBought, tierLocked || (!reqsMet && !isBought), !canAfford && reqsMet && !isBought && !tierLocked,
(!isBought && reqsMet && canAfford) ? () => buyWeaponUpgrade(iid, uid) : null, (!isBought && reqsMet && canAfford && !tierLocked) ? () => buyWeaponUpgrade(iid, uid) : null,
(isBought && !upg.repeatable) ? () => refundWeaponUpgrade(iid, uid) : null (isBought && !upg.repeatable) ? () => refundWeaponUpgrade(iid, uid) : null,
tierLockLabel
); );
ny += _SH_UPG_H + 4; ny += _SH_UPG_H + 4;
} }
+59 -15
View File
@@ -50,10 +50,11 @@ function drawSidePanel() {
ctx.font = '13px Orbitron, monospace'; ctx.font = '13px Orbitron, monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ffd700'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ffd700';
ctx.fillText('×' + G.sendQuantity, QB_X + QB_W / 2, QB_Y + QB_H / 2); 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); const idx = SP_QTY_STEPS.indexOf(G.sendQuantity);
G.sendQuantity = SP_QTY_STEPS[(idx + 1) % SP_QTY_STEPS.length]; 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.font = '9px "Share Tech Mono", monospace'; ctx.letterSpacing = '2px';
ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#1a3048'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#1a3048';
@@ -66,9 +67,15 @@ function drawSidePanel() {
const qty = G.sendQuantity; 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 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++) { for (let i = 0; i < visibleDefs.length; i++) {
const def = ENEMY_DEFS[i]; const def = visibleDefs[i];
const totalCost = def.cost * qty; const totalCost = def.cost * qty;
const canDeploy = G.credits >= totalCost && !G.gameOver; const canDeploy = G.credits >= totalCost && !G.gameOver;
const cardY = ENEMY_Y + i * (SP_ENEMY_CARD_H + SP_ENEMY_CARD_GAP) - _sidePanelScrollY; const cardY = ENEMY_Y + i * (SP_ENEMY_CARD_H + SP_ENEMY_CARD_GAP) - _sidePanelScrollY;
@@ -91,7 +98,7 @@ function drawSidePanel() {
ctx.fillStyle = elColor; ctx.fillStyle = elColor;
ctx.fillRect(CX, cardY + 5, 3, SP_ENEMY_CARD_H - 10); 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 fresh = G.enemyFreshness[def.id] || 0;
const freshPct = Math.max(0, 1 - fresh / 17); const freshPct = Math.max(0, 1 - fresh / 17);
if (freshPct > 0) { if (freshPct > 0) {
@@ -100,9 +107,24 @@ function drawSidePanel() {
ctx.fillStyle = fbG; ctx.fillStyle = fbG;
ctx.fillRect(CX, cardY, CW * freshPct, 5); 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 // Hotkey — base 10 enemies keep 1-0, new enemies have no hotkey
const hotkey = i < 9 ? String(i + 1) : i === 9 ? '0' : ''; 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.font = '10px Orbitron, monospace'; ctx.letterSpacing = '0px';
ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#3a6080'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#3a6080';
ctx.fillText('[' + hotkey + ']', CX + 6, cardY + 8); ctx.fillText('[' + hotkey + ']', CX + 6, cardY + 8);
@@ -126,34 +148,55 @@ function drawSidePanel() {
const elIcon = def.element ? (ELEMENTS[def.element]?.icon || '') : ''; const elIcon = def.element ? (ELEMENTS[def.element]?.icon || '') : '';
if (elIcon) statParts.push(elIcon); if (elIcon) statParts.push(elIcon);
ctx.save(); 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.font = '11px "Share Tech Mono", monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#3a6080'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = '#3a6080';
ctx.fillText(statParts.join(' · '), CX + 6, cardY + 26); ctx.fillText(statParts.join(' · '), CX + 6, cardY + 26);
ctx.restore(); ctx.restore();
// Reward + profit // Elemental weakness/resistance icons (right side of stats row)
const rewardPerUnit = Math.round(def.reward * bonusMult); 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 profit = rewardPerUnit - def.cost;
const profitStr = (profit >= 0 ? '+' : '') + profit + '¢'; const profitStr = (profit >= 0 ? '+' : '') + profit + '¢';
ctx.font = '11px "Share Tech Mono", monospace'; ctx.font = '11px "Share Tech Mono", monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; ctx.fillStyle = '#00ff88'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; ctx.fillStyle = '#00ff88';
ctx.fillText('↑ ' + rewardPerUnit + '¢', CX + 6, cardY + SP_ENEMY_CARD_H - 6); ctx.fillText('↑ ' + rewardPerUnit + '¢', CX + 6, cardY + SP_ENEMY_CARD_H - 6);
ctx.fillStyle = profit >= 0 ? '#00ff88' : '#ff3355'; ctx.fillStyle = profit >= 0 ? '#00ff88' : '#ff3355';
ctx.fillText('(' + profitStr + ')', CX + 60, cardY + SP_ENEMY_CARD_H - 6); ctx.fillText('(' + profitStr + ')', CX + 62, cardY + SP_ENEMY_CARD_H - 6);
if (bonusMult > 1) { 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.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(); ctx.restore();
if (canDeploy) { if (canDeploy && !G.armoryOpen && !G.commandOpen) {
const dId = def.id; const dId = def.id;
addHitRegion(CX, cardY, CW, SP_ENEMY_CARD_H, () => deployEnemy(dId, G.sendQuantity)); 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); _sidePanelScrollMax = Math.max(0, totalCardH - ENEMY_AREA_H + 4);
ctx.restore(); // end enemy clip ctx.restore(); // end enemy clip
@@ -183,7 +226,8 @@ function drawSidePanel() {
ctx.font = '11px "Share Tech Mono", monospace'; ctx.font = '11px "Share Tech Mono", monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = logColorMap[logLines[i].type] || '#3a6080'; 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(); ctx.restore();
} }
+59 -44
View File
@@ -7,8 +7,7 @@
// Drawn once, re-rendered only on resize — never recomputed each frame // Drawn once, re-rendered only on resize — never recomputed each frame
let _bgCanvas = null; let _bgCanvas = null;
let _bgW = 0, _bgH = 0; let _bgW = 0, _bgH = 0;
let _fogCanvas = null; // per-weapon range rings drawn on alt-hold
let _fogW = 0, _fogH = 0, _fogRange = 0;
const _mountDropZones = []; const _mountDropZones = [];
const _bagDropZones = []; const _bagDropZones = [];
const _dragRegions = []; const _dragRegions = [];
@@ -25,15 +24,23 @@ function buildBackground(W, H) {
c.fillStyle = grad; c.fillStyle = grad;
c.fillRect(0, 0, W, H); 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; _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 ──────────────────────────── // ── CACHED ELEMENT GLOW GRADIENTS ────────────────────────────
// Keyed by "elementId:x:y:radius" — cleared each frame since positions change, // 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. // 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 ───────────────────────────────────── // ── ARENA OVERLAY HELPERS ─────────────────────────────────────
// Subtle dashed ring at tower's current vision range // Per-weapon range rings — visible while Alt is held
function drawArenaRangeLine(cx, cy) { const _RANGE_COLORS = ['#00d4ff', '#ff6b35', '#a855f7', '#22c55e', '#f59e0b', '#ec4899'];
const r = G.tower.range; 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;
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.save();
ctx.strokeStyle = 'rgba(0,180,255,0.07)'; ctx.strokeStyle = color;
ctx.lineWidth = 1; ctx.globalAlpha = 0.55;
ctx.setLineDash([6, 12]); ctx.lineWidth = 1.5;
ctx.setLineDash([8, 10]);
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
ctx.restore(); ctx.restore();
}
// Fog of war: sharp transition at tower range, flat dark zone beyond it // Label in screen space — constant size regardless of zoom
function drawFog(cx, cy) { // cx == ARENA_CX so screen x == cx; screen y = ARENA_CY - (r+6)*zoom
const r = G.tower.range; const sx = cx;
const W = canvas.width; const sy = ARENA_CY - (r + 6) * zoom;
const H = canvas.height; ctx.save();
if (!_fogCanvas || _fogW !== W || _fogH !== H || _fogRange !== r) { ctx.setTransform(1, 0, 0, 1, 0, 0);
_fogCanvas = document.createElement('canvas'); ctx.font = 'bold 11px "Share Tech Mono", monospace';
_fogCanvas.width = W; ctx.textAlign = 'center';
_fogCanvas.height = H; ctx.textBaseline = 'bottom';
const c = _fogCanvas.getContext('2d'); const tw = ctx.measureText(label).width;
// ponytail: cache the identical fog gradient; rebuild only when range/canvas changes. ctx.fillStyle = 'rgba(2,6,14,0.78)';
const fog = c.createRadialGradient(cx, cy, r * 0.94, cx, cy, r * 1.12); ctx.fillRect(sx - tw / 2 - 4, sy - 13, tw + 8, 14);
fog.addColorStop(0, 'rgba(2,6,12,0)'); ctx.fillStyle = color;
fog.addColorStop(1, 'rgba(2,6,12,0.62)'); ctx.globalAlpha = 0.95;
c.fillStyle = fog; ctx.fillText(label, sx, sy);
c.beginPath(); ctx.restore();
c.arc(cx, cy, ARENA_RADIUS, 0, Math.PI * 2); });
c.fill();
_fogW = W;
_fogH = H;
_fogRange = r;
}
ctx.drawImage(_fogCanvas, 0, 0);
} }
// Dark overlay outside the arena circle // Dark overlay outside the arena circle
function drawArenaMask(W, H, cx, cy) { function drawArenaMask(W, H, cx, cy, zoom = 1) {
ctx.fillStyle = '#010407'; ctx.fillStyle = '#010407';
ctx.beginPath(); ctx.beginPath();
ctx.rect(0, 0, W, H); 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'); ctx.fill('evenodd');
} }
// Subtle glowing border ring at arena edge // 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.save();
ctx.strokeStyle = 'rgba(0,100,180,0.14)'; ctx.strokeStyle = 'rgba(0,100,180,0.14)';
ctx.lineWidth = 10; ctx.lineWidth = 10;
ctx.beginPath(); 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.stroke();
ctx.strokeStyle = 'rgba(0,180,255,0.22)'; ctx.strokeStyle = 'rgba(0,180,255,0.22)';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, ARENA_RADIUS, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
} }
@@ -145,7 +159,8 @@ function drawStreak(cx, cy) {
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.shadowColor = color; ctx.shadowColor = color;
ctx.shadowBlur = 10; ctx.shadowBlur = 10;
ctx.fillText(text, cx, by + 21); ctx.textBaseline = 'middle';
ctx.fillText(text, cx, by + bh / 2);
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
ctx.restore(); ctx.restore();
} }
+27 -6
View File
@@ -14,7 +14,15 @@ function render() {
// Blit cached background — O(1) // Blit cached background — O(1)
ctx.drawImage(_bgCanvas, 0, 0); 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(); drawPortals();
drawAoeZones(); drawAoeZones();
drawEnemyTrails(); drawEnemyTrails();
@@ -22,14 +30,18 @@ function render() {
drawProjectiles(); drawProjectiles();
drawChainArcs(); drawChainArcs();
drawBeams(); drawBeams();
drawFog(cx, cy); // dim beyond tower range
drawTower(cx, cy); drawTower(cx, cy);
drawShield(cx, cy); drawShield(cx, cy);
drawParticles(); drawParticles();
drawFloaters(); drawFloaters();
drawArenaMask(W, H, cx, cy); // solid dark outside arena circle ctx.restore(); // end world layer
drawArenaRing(cx, cy); // glowing border ring
// Mask/ring/streak operate in screen space — drawn AFTER world transform
drawArenaMask(W, H, cx, cy, _zoom);
drawArenaRing(cx, cy, _zoom);
drawStreak(cx, cy); drawStreak(cx, cy);
// UI layer — no camera transform
clearHitRegions(); clearHitRegions();
_mountDropZones.length = 0; _mountDropZones.length = 0;
_bagDropZones.length = 0; _bagDropZones.length = 0;
@@ -38,9 +50,18 @@ function render() {
drawBrokeWarning(); drawBrokeWarning();
drawSidePanel(); drawSidePanel();
drawInventoryOverlay(); drawInventoryOverlay();
drawShopOverlay(); drawCommandOverlay();
drawArmoryOverlay();
drawWeaponDetailOverlay();
checkSellHold();
checkPrestigeHold();
drawThreatPanel();
drawPrestigeConfirm();
if (G.gameOver) drawGameOverPanel(); if (G.gameOver) drawGameOverPanel();
drawPauseOverlay(); drawPauseOverlay();
drawMountInteraction(cx, cy); drawMountInteraction(cx, cy);
drawDragGhost();
drawDragGhost(); // follows cursor — screen space, no zoom
drawTooltips();
} }
+70 -8
View File
@@ -1,24 +1,86 @@
// ═══ shop.js ═══ // ═══ 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; if (G.gameOver) return;
G.shopOpen = true; G.armoryOpen = true;
G.commandOpen = false;
G.threatOpen = false;
G.prestigeOpen = false;
_radialSlot = -1;
_shopScrollY = 0; _shopScrollY = 0;
setPaused(true, false); setPaused(true, false);
} }
function closeShop() { function closeArmory() {
G.shopOpen = false; G.armoryOpen = false;
_shopScrollY = 0; _shopScrollY = 0;
setPaused(false); setPaused(false);
} }
function setShopTab(tab) { function openCommand() {
G.shopTab = tab; if (G.gameOver) return;
G.commandOpen = true;
G.armoryOpen = false;
G.threatOpen = false;
G.prestigeOpen = false;
_radialSlot = -1;
_shopScrollY = 0; _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();
}
+18 -5
View File
@@ -4,7 +4,7 @@
// ============================================================ // ============================================================
// ── ARENA ───────────────────────────────────────────── // ── 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_CX = 665; // center x of play area: (1600 - 270) / 2
const ARENA_CY = 482; // center y of play area: 64 + (900 - 64) / 2 const ARENA_CY = 482; // center y of play area: 64 + (900 - 64) / 2
@@ -21,6 +21,9 @@ function makeGameState() {
_isNewBest: false, _isNewBest: false,
creditReserve: 50, // minimum credits kept in reserve — cannot spend upgrades below this 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
tower: { tower: {
hp: 20, hp: 20,
@@ -64,10 +67,10 @@ function makeGameState() {
selectedEnemyType: null, selectedEnemyType: null,
sendQuantity: 1, sendQuantity: 1,
// Shop // Overlay panels — armory (weapons) and command (tower upgrades)
shopOpen: false, armoryOpen: false,
shopTab: 'tower', // 'tower' | 'weapons' | weapon instance id commandOpen: false,
shopTreeWeapon: null, weaponDetailSlot: -1,
// Entity id counter // Entity id counter
nextId: 1, nextId: 1,
@@ -77,6 +80,16 @@ function makeGameState() {
// Enemy freshness (novelty bonus) — higher = less fresh = less bonus // Enemy freshness (novelty bonus) — higher = less fresh = less bonus
enemyFreshness: Object.fromEntries(ENEMY_DEFS.map(d => [d.id, 0])), 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,
}; };
} }
+3
View File
@@ -38,6 +38,8 @@ function buyTowerUpgrade(upgradeId) {
addLog('Tower already at full HP.', 'info'); addLog('Tower already at full HP.', 'info');
return; return;
} }
// Tier requirement
if ((upgrade.minTier ?? 0) > (G.difficultyTier || 0)) return;
for (const req of upgrade.requires) { for (const req of upgrade.requires) {
if (!G.towerUpgradesBought.includes(req)) return; if (!G.towerUpgradesBought.includes(req)) return;
} }
@@ -161,6 +163,7 @@ function buyWeaponUpgrade(instanceId, upgradeId) {
if (!upgrade) return; if (!upgrade) return;
const bought = G.weaponUpgradesBought[instanceId] || []; const bought = G.weaponUpgradesBought[instanceId] || [];
if (bought.includes(upgradeId)) return; if (bought.includes(upgradeId)) return;
if ((upgrade.minTier ?? 0) > (G.difficultyTier || 0)) return;
for (const req of upgrade.requires) { if (!bought.includes(req)) return; } for (const req of upgrade.requires) { if (!bought.includes(req)) return; }
if (spendableCredits() < upgrade.cost) return; if (spendableCredits() < upgrade.cost) return;
G.credits -= upgrade.cost; G.credits -= upgrade.cost;
+5 -1
View File
@@ -39,7 +39,11 @@ function clamp(value, min, max) {
} }
function cheapestEnemyCost() { 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() { function countAliveEnemies() {