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:
+29
-29
@@ -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
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1–0 keys for enemy deploy
|
// 1–0 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
@@ -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
@@ -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('[1–0] deploy [scroll] qty [Space] shop [Esc/P] pause [I] inventory', 'info');
|
if (savedPrestige > 0) addLog(`PRESTIGE ${savedPrestige} active — permanent bonuses applied.`, 'win');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── THREAT / PRESTIGE PANEL CONTROLS ─────────────────────────
|
||||||
|
function openThreatPanel() {
|
||||||
|
if (G.gameOver) return;
|
||||||
|
G.threatOpen = true;
|
||||||
|
G.prestigeOpen = false;
|
||||||
|
}
|
||||||
|
function closeThreatPanel() {
|
||||||
|
G.threatOpen = false;
|
||||||
|
}
|
||||||
|
function openPrestigeDialog() {
|
||||||
|
if (G.gameOver) return;
|
||||||
|
G.prestigeOpen = true;
|
||||||
|
G.threatOpen = false;
|
||||||
|
}
|
||||||
|
function closePrestigeDialog() {
|
||||||
|
G.prestigeOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── THREAT LEVEL ─────────────────────────────────────────────
|
||||||
|
function unlockThreatTier(tierId) {
|
||||||
|
const tierDef = DIFFICULTY_TIERS[tierId];
|
||||||
|
if (!tierDef) return;
|
||||||
|
if (G.unlockedTiers.includes(tierId)) {
|
||||||
|
// Already unlocked — just switch
|
||||||
|
setThreatTier(tierId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (spendableCredits() < tierDef.unlockCost) {
|
||||||
|
addLog(`Need ${tierDef.unlockCost}¢ to unlock ${tierDef.name}.`, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
G.credits -= tierDef.unlockCost;
|
||||||
|
G.unlockedTiers.push(tierId);
|
||||||
|
setThreatTier(tierId);
|
||||||
|
addLog(`THREAT LEVEL: ${tierDef.name} unlocked and activated.`, 'win');
|
||||||
|
updateHUD();
|
||||||
|
renderShop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setThreatTier(tierId) {
|
||||||
|
if (!G.unlockedTiers.includes(tierId)) return;
|
||||||
|
G.difficultyTier = tierId;
|
||||||
|
const tierDef = DIFFICULTY_TIERS[tierId];
|
||||||
|
if (tierDef && tierId > 0) {
|
||||||
|
addLog(`THREAT LEVEL: ${tierDef.name} — enemies at ×${tierDef.hpMult} HP, ×${tierDef.rewardMult} rewards.`, 'info');
|
||||||
|
} else {
|
||||||
|
addLog('THREAT LEVEL: NORMAL — standard difficulty.', 'info');
|
||||||
|
}
|
||||||
|
updateHUD();
|
||||||
|
renderShop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PRESTIGE ─────────────────────────────────────────────────
|
||||||
|
function prestigeCost() {
|
||||||
|
const base = [5000, 25000, 60000, 120000, 200000];
|
||||||
|
const lvl = G.prestigeLevel || 0;
|
||||||
|
return base[lvl] ?? (200000 + (lvl - 4) * 100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPermanentBonuses() {
|
||||||
|
const b = G.permanentBonuses || {};
|
||||||
|
if (b.maxHp) { G.tower.maxHp += Math.floor(b.maxHp); G.tower.hp = Math.min(G.tower.hp, G.tower.maxHp); }
|
||||||
|
if (b.armor) { G.tower.armor += Math.floor(b.armor); }
|
||||||
|
if (b.range) { G.tower.range += Math.floor(b.range); }
|
||||||
|
if (b.aimSpeed) { G.tower.aimSpeed += b.aimSpeed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function prestige() {
|
||||||
|
const cost = prestigeCost();
|
||||||
|
if (G.credits < cost) {
|
||||||
|
addLog(`Need ${cost}¢ to prestige.`, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRACTION = 0.25;
|
||||||
|
const newBonuses = { ...(G.permanentBonuses || {}) };
|
||||||
|
|
||||||
|
// Convert all owned tower upgrades into fractional permanent bonuses
|
||||||
|
for (const upg of TOWER_UPGRADE_TREE) {
|
||||||
|
if (!G.towerUpgradesBought.includes(upg.id) || upg.repeatable) continue;
|
||||||
|
const e = upg.effect;
|
||||||
|
if (e.maxHp) newBonuses.maxHp = (newBonuses.maxHp || 0) + e.maxHp * FRACTION;
|
||||||
|
if (e.armor) newBonuses.armor = (newBonuses.armor || 0) + e.armor * FRACTION;
|
||||||
|
if (e.aimSpeed) newBonuses.aimSpeed = (newBonuses.aimSpeed || 0) + e.aimSpeed * FRACTION;
|
||||||
|
if (e.range) newBonuses.range = (newBonuses.range || 0) + e.range * FRACTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevTiers = [...G.unlockedTiers];
|
||||||
|
const newPrestige = (G.prestigeLevel || 0) + 1;
|
||||||
|
|
||||||
|
G = makeGameState();
|
||||||
|
G.permanentBonuses = newBonuses;
|
||||||
|
G.unlockedTiers = prevTiers;
|
||||||
|
G.difficultyTier = prevTiers.includes(0) ? 0 : prevTiers[0];
|
||||||
|
G.prestigeLevel = newPrestige;
|
||||||
|
applyPermanentBonuses();
|
||||||
|
|
||||||
|
setPaused(false);
|
||||||
|
_sidePanelScrollY = 0;
|
||||||
|
_logScrollY = 0;
|
||||||
|
updateHUD();
|
||||||
|
renderShop();
|
||||||
|
|
||||||
|
addLog(`PRESTIGE ${newPrestige} — permanent bonuses banked. New run starts at 150¢.`, 'win');
|
||||||
|
if (newBonuses.maxHp) addLog(` Hull: +${Math.floor(newBonuses.maxHp)} HP permanent`, 'win');
|
||||||
|
if (newBonuses.armor) addLog(` Plating: +${Math.floor(newBonuses.armor)} armor permanent`, 'win');
|
||||||
|
if (newBonuses.range) addLog(` Scanner: +${Math.floor(newBonuses.range)} range permanent`, 'win');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── INIT ──────────────────────────────────────────────────────
|
// ── INIT ──────────────────────────────────────────────────────
|
||||||
@@ -229,7 +352,6 @@ function init() {
|
|||||||
updateHUD();
|
updateHUD();
|
||||||
initInput();
|
initInput();
|
||||||
addLog('SIEGE PROTOCOL initialized.', 'info');
|
addLog('SIEGE PROTOCOL initialized.', 'info');
|
||||||
addLog('[1–0] deploy [scroll] qty [Space] shop [Esc/P] pause [I] inventory', 'info');
|
|
||||||
gameLoop();
|
gameLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user