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:
@@ -0,0 +1,5 @@
|
||||
# Local AI tooling — personal, not game code
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
agentdb.db
|
||||
*.db
|
||||
@@ -0,0 +1,104 @@
|
||||
/* ── DEV CONSOLE ── */
|
||||
#dev-console {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 80px; left: 20px;
|
||||
width: 520px;
|
||||
max-height: 340px;
|
||||
z-index: 500;
|
||||
background: rgba(2,6,10,0.97);
|
||||
border: 1px solid #00d4ff;
|
||||
box-shadow: 0 0 24px rgba(0,212,255,0.3);
|
||||
flex-direction: column;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
#dev-console.open { display: flex; }
|
||||
#dev-console-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0,212,255,0.12);
|
||||
border-bottom: 1px solid #00d4ff44;
|
||||
color: #00d4ff;
|
||||
font-size: 11px;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
#dev-console-hint { font-size: 9px; opacity: 0.5; }
|
||||
#dev-output {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
max-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.dev-line { line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
.dev-line.out { color: #b8d8e8; }
|
||||
.dev-line.ok { color: #00ff88; }
|
||||
.dev-line.err { color: #ff3355; }
|
||||
.dev-line.info { color: #00d4ff; }
|
||||
.dev-line.warn { color: #ffd700; }
|
||||
#dev-input-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid #00d4ff22;
|
||||
}
|
||||
#dev-prompt { color: #00d4ff; opacity: 0.7; white-space: nowrap; }
|
||||
#dev-input {
|
||||
flex: 1; background: transparent; border: none; outline: none;
|
||||
color: #fff; font-family: 'Share Tech Mono', monospace; font-size: 12px;
|
||||
caret-color: #00d4ff;
|
||||
}
|
||||
|
||||
/* ── PERFORMANCE OVERLAY ── */
|
||||
#perf-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 74px;
|
||||
left: 16px;
|
||||
z-index: 80;
|
||||
min-width: 140px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #b8d8e8;
|
||||
background: rgba(2, 6, 10, 0.92);
|
||||
border: 1px solid #00d4ff66;
|
||||
box-shadow: 0 0 16px rgba(0, 212, 255, 0.25);
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
#perf-overlay .perf-title {
|
||||
color: #00d4ff;
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#perf-overlay .perf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
#perf-overlay .perf-row span:first-child {
|
||||
color: #6ea0b8;
|
||||
}
|
||||
|
||||
#perf-overlay .perf-row span:last-child {
|
||||
color: #d8ecff;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#perf-overlay {
|
||||
top: 68px;
|
||||
left: 8px;
|
||||
font-size: 10px;
|
||||
min-width: 124px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* ── VARIABLES ── */
|
||||
:root {
|
||||
--bg:#040a10; --panel:#060e16; --border:#122030; --border2:#1a3048;
|
||||
--tower:#00d4ff; --tower-g:rgba(0,212,255,0.35); --health:#00ff88;
|
||||
--danger:#ff3355; --credits:#ffd700; --text:#b8d8e8; --dim:#2a4a60;
|
||||
--dim2:#3a6080; --bought:#1a3020; --locked:#0a1520;
|
||||
--mono:'Share Tech Mono',monospace; --orb:'Orbitron',monospace;
|
||||
}
|
||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{width:100%;height:100%;overflow:hidden;background:#020508;color:var(--text);font-family:var(--mono);font-size:13px;user-select:none}
|
||||
|
||||
/* ── CANVAS: full viewport, behind everything ── */
|
||||
#canvas{position:fixed;top:0;left:0;z-index:0;display:block}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SIEGE PROTOCOL</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&display=swap">
|
||||
<link rel="stylesheet" href="css/main.css?v=20260615r">
|
||||
<link rel="stylesheet" href="css/dev-console.css?v=20260615r">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<canvas id="canvas"></canvas>
|
||||
|
||||
|
||||
<div id="perf-overlay" aria-live="off"></div>
|
||||
|
||||
|
||||
<script defer src="js/utils.js?v=20260615r"></script>
|
||||
<script defer src="js/defs.js?v=20260615r"></script>
|
||||
<script defer src="js/state.js?v=20260615r"></script>
|
||||
<script defer src="js/audio.js?v=20260615r"></script>
|
||||
<script defer src="js/particles.js?v=20260615r"></script>
|
||||
<script defer src="js/elements.js?v=20260615r"></script>
|
||||
<script defer src="js/portals.js?v=20260615r"></script>
|
||||
<script defer src="js/enemies.js?v=20260615r"></script>
|
||||
<script defer src="js/weapons.js?v=20260615r"></script>
|
||||
<script defer src="js/weapon-fire.js?v=20260615r"></script>
|
||||
<script defer src="js/weapon-projectiles.js?v=20260615r"></script>
|
||||
<script defer src="js/upgrades.js?v=20260615r"></script>
|
||||
<script defer src="js/inventory.js?v=20260615r"></script>
|
||||
<script defer src="js/renderer-world.js?v=20260615r"></script>
|
||||
<script defer src="js/renderer-tower.js?v=20260615r"></script>
|
||||
<script defer src="js/renderer-combat.js?v=20260615r"></script>
|
||||
<script defer src="js/renderer-inventory.js?v=20260615r"></script>
|
||||
<script defer src="js/renderer-shop-overlay.js?v=20260615r"></script>
|
||||
<script defer src="js/renderer-shop-sections.js?v=20260615r"></script>
|
||||
<script defer src="js/renderer-hud.js?v=20260615r"></script>
|
||||
<script defer src="js/renderer-sidepanel.js?v=20260615r"></script>
|
||||
<script defer src="js/renderer-overlays.js?v=20260615r"></script>
|
||||
<script defer src="js/renderer.js?v=20260615r"></script>
|
||||
<script defer src="js/shop.js?v=20260615r"></script>
|
||||
<script defer src="js/input.js?v=20260615r"></script>
|
||||
<script defer src="js/main.js?v=20260615r"></script>
|
||||
<script defer src="js/dev-console.js?v=20260615r"></script>
|
||||
|
||||
<!-- ── DEV CONSOLE ── -->
|
||||
<div id="dev-console">
|
||||
<div id="dev-console-header">
|
||||
<span>▸ DEV CONSOLE</span>
|
||||
<span id="dev-console-hint">~ to close</span>
|
||||
</div>
|
||||
<div id="dev-output"></div>
|
||||
<div id="dev-input-row">
|
||||
<span id="dev-prompt">siege></span>
|
||||
<input id="dev-input" type="text" autocomplete="off" spellcheck="false" placeholder="type a command...">
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+230
@@ -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
@@ -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 } },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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.1–5> | 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
@@ -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
@@ -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
@@ -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(); },
|
||||
};
|
||||
|
||||
// 1–0 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
@@ -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
@@ -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('[1–0] 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('[1–0] deploy [scroll] qty [Space] shop [Esc/P] pause [I] inventory', 'info');
|
||||
gameLoop();
|
||||
}
|
||||
|
||||
window.addEventListener('load', init);
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user