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