Initial commit: Siege Protocol

Inverted tower-defense browser game — deploy enemies yourself, tower auto-kills them, pocket credits, upgrade weapons. HTML + Canvas + vanilla JS, no build step.

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