THE GAME
by SolarHero753293 lines123.0 KB
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>THE GAME</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; user-select:none; -webkit-user-select:none; }
html, body { width:100%; height:100%; overflow:hidden; background:#05050a; font-family:'Press Start 2P', 'Courier New', monospace; touch-action:none; -webkit-font-smoothing:none; font-smooth:never; text-rendering:optimizeSpeed; }
#game { display:block; width:100%; height:100%; background:#05050a; position:relative; }
#shaderCanvas { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:1; image-rendering:pixelated; image-rendering:crisp-edges; }
#hud { position:absolute; top:0; left:0; width:100%; padding:6px 10px; display:flex; flex-direction:column; gap:3px; pointer-events:none; z-index:5; opacity:0.8; }
.barRow { display:flex; align-items:center; gap:4px; width:100%; }
.barLabel { font-size:7px; font-weight:400; color:#cfd6ff; min-width:22px; text-shadow:2px 2px 0 #000; }
.barBg { flex:1; max-width:200px; height:9px; background:#0a0a12; border:2px solid #cfd6ff; border-radius:0; overflow:hidden; position:relative; box-shadow:2px 2px 0 rgba(0,0,0,0.6); image-rendering:pixelated; }
.barFill { height:100%; width:0%; transition:width .12s steps(10); border-radius:0; }
.barText { position:absolute; right:2px; top:50%; transform:translateY(-50%); font-size:6px; font-weight:400; color:#fff; text-shadow:1px 1px 0 #000; pointer-events:none; }
#hpFill { background:#ff3b5c; }
#xpFill { background:#2ecc71; }
#shieldFill { background:#00e5ff; }
#shieldRow { display:none; }
#topRight { position:absolute; top:6px; right:10px; pointer-events:none; z-index:5; text-align:right; }
#eraName { font-size:10px; font-weight:400; letter-spacing:0px; opacity:0.9; color:#e8e8ff; text-shadow:2px 2px 0 #000; }
#stageTimerText { font-size:8px; font-weight:400; opacity:0.7; color:#cfd6ff; text-shadow:2px 2px 0 #000; margin-top:4px; }
#killCounter { font-size:7px; font-weight:400; opacity:0.6; color:#cfd6ff; text-shadow:2px 2px 0 #000; margin-top:4px; }
#weaponCooldowns { position:absolute; right:10px; top:50%; transform:translateY(-50%); display:flex; flex-direction:column; gap:3px; pointer-events:none; z-index:5; align-items:flex-end; }
#weaponCooldowns .weaponLabel { font-size:6px; font-weight:400; color:rgba(255,255,255,0.4); text-transform:uppercase; letter-spacing:1px; margin-bottom:2px; }
.weaponCD { font-size:7px; font-weight:400; font-family:'Press Start 2P','Courier New',monospace; background:#0a0a12; padding:4px 8px; border-radius:0; border:2px solid rgba(255,255,255,0.15); min-width:60px; text-align:right; display:flex; align-items:center; justify-content:flex-end; gap:6px; box-shadow:2px 2px 0 rgba(0,0,0,0.6); }
.weaponCD.ready { color:#00ff88; border-color:#00ff88; opacity:0.5; }
.weaponCD.ready .cd-icon { color:#00ff88; }
.weaponCD.active { color:#ff6b6b; border-color:#ff6b6b; opacity:0.5; }
.weaponCD.active .cd-icon { color:#ff6b6b; }
.weaponCD .cd-icon { font-size:10px; }
.weaponCD .cd-time { font-size:7px; min-width:30px; text-align:right; }
#banner { position:absolute; bottom:78px; left:50%; transform:translate(-50%,14px) scale(0.92); color:#fff; font-size:min(3.2vw,15px); font-weight:400; text-align:center; text-shadow:3px 3px 0 #000; opacity:0; pointer-events:none; z-index:6; transition:opacity .35s, transform .35s; letter-spacing:1px; }
#banner.show { opacity:1; transform:translate(-50%,0) scale(1); }
#bannerSub { position:absolute; bottom:60px; left:50%; transform:translate(-50%,14px); color:#cfd6ff; font-size:7px; font-weight:400; opacity:0; pointer-events:none; z-index:6; transition:opacity .35s, transform .35s; text-shadow:2px 2px 0 #000; }
#bannerSub.show { opacity:1; transform:translate(-50%,0); }
#bannerBg { position:absolute; left:0; right:0; bottom:0; height:150px; background:rgba(0,0,0,0.55); opacity:0; pointer-events:none; z-index:5; transition:opacity .35s; }
#bannerBg.show { opacity:1; }
.overlay { position:absolute; inset:0; background:rgba(3,3,10,0.92); display:flex; align-items:center; justify-content:center; flex-direction:column; z-index:20; text-align:center; padding:20px; image-rendering:pixelated; }
#startOverlay { background:transparent; }
.hidden { display:none !important; }
.title { font-size:min(4vw,22px); font-weight:400; letter-spacing:1px; color:#fff; margin-bottom:10px; text-shadow:3px 3px 0 #000; line-height:1.6; }
.subtitle { color:#b8bfe8; font-size:9px; font-weight:400; margin-bottom:16px; max-width:380px; line-height:1.8; text-shadow:2px 2px 0 #000; }
.btn { pointer-events:auto; cursor:pointer; background:#2d6bff; border:none; color:#fff; font-weight:400; font-size:10px; font-family:'Press Start 2P','Courier New',monospace; padding:12px 20px; border-radius:0; box-shadow:4px 4px 0 rgba(0,0,0,0.7), 0 0 10px rgba(45,107,255,0.35); transition:transform .1s, box-shadow .1s; margin:5px; letter-spacing:0.5px; image-rendering:pixelated; animation:btnGlow 2.6s ease-in-out infinite; }
.btn:hover { box-shadow:5px 5px 0 rgba(0,0,0,0.7), 0 0 16px rgba(45,107,255,0.6); }
.btn:active { transform:translate(3px,3px); box-shadow:1px 1px 0 rgba(0,0,0,0.7); animation:none; }
.btn-danger { background:linear-gradient(135deg, #ff6b82, #d81e3f); border-color:#ffb3bf; box-shadow:4px 4px 0 rgba(0,0,0,0.7), 0 0 10px rgba(255,59,92,0.35); animation:btnGlowDanger 2.6s ease-in-out infinite; }
.btn-danger:hover { box-shadow:5px 5px 0 rgba(0,0,0,0.7), 0 0 16px rgba(255,59,92,0.6); }
@keyframes btnGlow {
0%, 100% { box-shadow:4px 4px 0 rgba(0,0,0,0.7), 0 0 8px rgba(45,107,255,0.3); }
50% { box-shadow:4px 4px 0 rgba(0,0,0,0.7), 0 0 18px rgba(45,107,255,0.65); }
}
@keyframes btnGlowDanger {
0%, 100% { box-shadow:4px 4px 0 rgba(0,0,0,0.7), 0 0 8px rgba(255,59,92,0.3); }
50% { box-shadow:4px 4px 0 rgba(0,0,0,0.7), 0 0 18px rgba(255,59,92,0.65); }
}
#statsBox { color:#e8e8ff; font-size:9px; font-weight:400; margin-bottom:16px; line-height:2.4; }
#statsBox b { color:#ffd166; }
#cards { display:flex; gap:10px; flex-wrap:wrap; justify-content:center; max-width:600px; align-items:flex-start; }
.cardWrap { display:flex; flex-direction:column; align-items:center; width:132px; }
.card { pointer-events:auto; cursor:pointer; width:132px; background:#0f0f1a; border:3px solid rgba(124,92,255,0.5); border-radius:0; padding:12px 6px; color:#fff; transition:transform .1s; box-shadow:4px 4px 0 rgba(0,0,0,0.6); position:relative; }
.card:hover { transform:translate(-2px,-2px); box-shadow:6px 6px 0 rgba(0,0,0,0.6); }
.card:active { transform:translate(2px,2px); box-shadow:2px 2px 0 rgba(0,0,0,0.6); }
.card .cIcon { font-size:20px; margin-bottom:4px; }
.card .cName { font-weight:400; font-size:8px; margin-bottom:6px; color:#fff; line-height:1.4; }
.card .cDesc { font-size:6.5px; font-weight:400; color:#cfd6ff; line-height:1.6; }
.cRarity { font-size:6px; font-weight:400; padding:3px 5px; border-radius:0; display:inline-block; margin-top:6px; border:1px solid currentColor; }
.rarity-common { background:rgba(46,204,113,0.18); color:#2ecc71; }
.rarity-uncommon { background:rgba(77,208,255,0.2); color:#4dd0ff; }
.rarity-rare { background:rgba(77,208,255,0.2); color:#4dd0ff; }
.rarity-epic { background:rgba(155,89,182,0.3); color:#9b59b6; }
.rarity-legendary { background:rgba(255,215,0,0.2); color:#ffd700; }
.rarity-mythic { background:rgba(255,30,30,0.22); color:#ff2b2b; }
.rarity-new { background:rgba(255,255,255,0.18); color:#ffffff; }
.cTag { position:absolute; top:6px; right:6px; font-size:5.5px; font-weight:400; padding:3px 5px; border-radius:0; display:inline-block; margin-top:0; border:1px solid currentColor; }
.tag-pasiva, .tag-arma, .tag-gadget { background:rgba(170,170,170,0.15); color:#aaa; }
#weaponSelect { display:flex; gap:10px; flex-wrap:wrap; justify-content:center; margin:10px 0; }
.weapon-option { cursor:pointer; padding:14px 18px; border-radius:0; border:3px solid rgba(255,255,255,0.2); background:#0f0f1a; color:#fff; transition:all .1s; min-width:100px; box-shadow:3px 3px 0 rgba(0,0,0,0.6); }
.weapon-option:hover { border-color:rgba(77,208,255,0.6); transform:translate(-1px,-1px); box-shadow:4px 4px 0 rgba(0,0,0,0.6); }
.weapon-option.selected { border-color:#4dd0ff; background:rgba(77,208,255,0.15); box-shadow:3px 3px 0 #4dd0ff; }
.weapon-option .wo-icon { font-size:22px; display:block; margin-bottom:8px; }
.weapon-option .wo-name { font-size:8px; font-weight:400; color:#ffd166; margin-bottom:6px; display:block; }
.weapon-option .wo-desc { font-size:6px; font-weight:400; color:#cfd6ff; line-height:1.6; display:block; }
#joyBase { position:absolute; width:80px; height:80px; border-radius:0; background:rgba(255,255,255,0.04); border:3px solid rgba(255,255,255,0.25); display:none; z-index:8; }
#joyStick { position:absolute; width:32px; height:32px; border-radius:0; background:rgba(255,255,255,0.6); border:2px solid rgba(255,255,255,0.3); display:none; z-index:9; }
#flash { position:absolute; inset:0; background:#fff; opacity:0; pointer-events:none; z-index:15; }
#bossFlash { position:absolute; inset:0; background:rgba(77,208,255,0.08); opacity:0; pointer-events:none; z-index:14; transition:opacity 0.3s; }
#damageFlash { position:absolute; inset:0; background:radial-gradient(ellipse at center, rgba(255,0,0,0) 45%, rgba(200,0,20,0.4) 100%); opacity:0; pointer-events:none; z-index:13; transition:opacity 0.35s ease-out; }
#dimOverlay { position:absolute; inset:0; background:rgba(0,0,0,0.85); opacity:0; pointer-events:none; z-index:12; transition:opacity 0.3s; }
#gadgetBtn { position:absolute; bottom:16px; right:16px; width:46px; height:46px; border-radius:0; border:3px solid #888; color:#fff; font-size:14px; display:none; align-items:center; justify-content:center; z-index:10; pointer-events:auto; cursor:pointer; flex-direction:column; gap:1px; background:rgba(255,107,53,0.9); box-shadow:3px 3px 0 rgba(0,0,0,0.6); }
#gadgetBtn .uses { font-size:7px; font-weight:400; opacity:0.9; }
#gadgetBtn:active { transform:translate(2px,2px); box-shadow:1px 1px 0 rgba(0,0,0,0.6); }
#gadgetCooldown { position:absolute; bottom:68px; right:16px; font-size:8px; font-weight:400; color:#fff; font-family:'Press Start 2P','Courier New',monospace; background:#0a0a12; padding:4px 6px; border-radius:0; display:none; z-index:10; border:2px solid rgba(255,255,255,0.2); box-shadow:2px 2px 0 rgba(0,0,0,0.6); }
#pauseBtn { position:absolute; left:8px; top:50%; transform:translateY(-50%); width:32px; height:32px; border-radius:0; background:#141423; border:3px solid #ffffff; color:#ffffff; font-size:12px; font-weight:400; font-family:'Press Start 2P','Courier New',monospace; display:flex; align-items:center; justify-content:center; z-index:11; pointer-events:auto; cursor:pointer; box-shadow:3px 3px 0 rgba(0,0,0,0.6); transition:transform .1s; }
#pauseBtn:active { transform:translateY(-50%) translate(2px,2px); box-shadow:1px 1px 0 rgba(0,0,0,0.6); }
#debugLevelBtn { position:absolute; left:8px; bottom:16px; padding:8px 10px; border-radius:0; background:#141423; border:3px solid #ffd166; color:#ffd166; font-size:7px; font-weight:400; font-family:'Press Start 2P','Courier New',monospace; display:none; align-items:center; justify-content:center; z-index:11; pointer-events:auto; cursor:pointer; box-shadow:3px 3px 0 rgba(0,0,0,0.6); transition:transform .1s; }
#debugLevelBtn:active { transform:translate(2px,2px); box-shadow:1px 1px 0 rgba(0,0,0,0.6); }
#pauseOverlay { position:absolute; inset:0; background:rgba(3,3,10,0.9); display:none; align-items:center; justify-content:center; flex-direction:column; z-index:16; text-align:center; padding:20px; }
#pauseOverlay.show { display:flex; }
#pauseOverlay .title { font-size:min(5vw,24px); margin-bottom:12px; color:#fff; }
#pauseOverlay .stats-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px 22px; color:#e8e8ff; font-size:8px; font-weight:400; margin:12px 0; text-align:left; background:#0f0f1a; border:3px solid rgba(124,92,255,0.4); border-radius:0; padding:16px 18px; line-height:1.6; box-shadow:4px 4px 0 rgba(0,0,0,0.6); }
#pauseOverlay .stats-grid b { color:#ffd166; }
#pauseOverlay .btn { margin:4px; }
.menu-title { font-size:min(6vw,32px); font-weight:400; color:#fff; letter-spacing:2px; margin-bottom:12px; line-height:1.6; }
.menu-sub { color:#999; font-size:9px; font-weight:400; margin-bottom:26px; text-shadow:2px 2px 0 #000; }
.wave-letter { display:inline-block; text-shadow:4px 4px 0 #000; animation:wave 1.5s ease-in-out infinite; animation-delay:calc(var(--i, 0) * 0.08s); }
.wave-letter.rainbow { color:#fff; animation:wave 1.5s ease-in-out infinite, letterColor 3s linear infinite; animation-delay:calc(var(--i, 0) * 0.08s); }
.wave-letter.gold { color:#ffd700; text-shadow:4px 4px 0 #000, 0 0 14px rgba(255,215,0,0.9); animation:waveSmall 1.5s ease-in-out infinite, goldShine 1.6s ease-in-out infinite; animation-delay:calc(var(--i, 0) * 0.08s); }
.wave-letter.mythic { color:#ff2b2b; text-shadow:4px 4px 0 #000, 0 0 18px rgba(255,20,20,0.95); animation:waveSmall 1.5s ease-in-out infinite, mythicShine 1.2s ease-in-out infinite; animation-delay:calc(var(--i, 0) * 0.08s); }
.wave-letter.mythicHalf { color:#ff2b2b; text-shadow:4px 4px 0 #000, 0 0 18px rgba(255,20,20,0.95); animation:waveTiny 1.5s ease-in-out infinite, mythicShine 1.2s ease-in-out infinite; animation-delay:calc(var(--i, 0) * 0.08s); }
.wave-letter.goldHalf { color:#ffd700; text-shadow:4px 4px 0 #000, 0 0 14px rgba(255,215,0,0.9); animation:waveTiny 1.5s ease-in-out infinite, goldShine 1.6s ease-in-out infinite; animation-delay:calc(var(--i, 0) * 0.08s); }
@keyframes goldShine {
0%, 100% { filter:brightness(1); }
50% { filter:brightness(1.7); }
}
@keyframes mythicShine {
0%, 100% { filter:brightness(1); }
50% { filter:brightness(1.8); }
}
.era-glow-red { animation: redShine 1.4s ease-in-out infinite; text-shadow:2px 2px 0 #000, 0 0 12px rgba(255,20,20,0.9) !important; }
@keyframes redShine {
0%, 100% { filter:brightness(1); }
50% { filter:brightness(1.8); }
}
@keyframes letterColor {
0% { color:#ff3b3b; }
14% { color:#ff9d3b; }
28% { color:#f7ff3b; }
42% { color:#3bff6e; }
57% { color:#3bd0ff; }
71% { color:#9d3bff; }
85% { color:#ff3bd0; }
100% { color:#ff3b3b; }
}
@keyframes wave {
0%, 100% { transform:translateY(0px); }
50% { transform:translateY(-6px); }
}
@keyframes waveSmall {
0%, 100% { transform:translateY(0px); }
50% { transform:translateY(-3px); }
}
@keyframes waveTiny {
0%, 100% { transform:translateY(0px); }
50% { transform:translateY(-1.5px); }
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<canvas id="shaderCanvas"></canvas>
<div id="flash"></div>
<div id="bossFlash"></div>
<div id="damageFlash"></div>
<div id="dimOverlay"></div>
<div id="hud">
<div class="barRow"><span class="barLabel">HP</span><div class="barBg"><div id="hpFill" class="barFill"></div><span class="barText" id="hpText">100/100</span></div></div>
<div class="barRow" id="shieldRow"><span class="barLabel">SH</span><div class="barBg"><div id="shieldFill" class="barFill"></div><span class="barText" id="shieldText">0/0</span></div></div>
<div class="barRow"><span class="barLabel">XP</span><div class="barBg"><div id="xpFill" class="barFill"></div><span class="barText" id="xpText">0/12</span></div></div>
<div id="lvlTag" style="color:#ffd166; font-size:8px; font-weight:400; text-shadow:2px 2px 0 #000; margin-top:4px;">Nv.1</div>
</div>
<div id="topRight">
<div id="eraName">--</div>
<div id="stageTimerText">00:00</div>
<div id="killCounter">Kills: 0</div>
</div>
<div id="weaponCooldowns"></div>
<div id="bannerBg"></div>
<div id="banner"></div>
<div id="bannerSub"></div>
<div id="joyBase"></div>
<div id="joyStick"></div>
<div id="gadgetBtn"><span>◈</span><span class="uses">3</span></div>
<div id="gadgetCooldown"></div>
<div id="pauseBtn">||</div>
<div id="debugLevelBtn">+NIVEL</div>
<!-- MENU -->
<div id="startOverlay" class="overlay">
<div class="menu-title" id="menuTitle">THE GAME</div>
<div class="menu-sub">Sobrevive a las hordas infinitas</div>
<div id="bestScoreText" style="font-size:8px; color:#ffd166; margin-bottom:16px; text-shadow:2px 2px 0 #000;"></div>
<button class="btn" id="startBtn" style="margin-bottom:6px;">INICIAR</button>
<button class="btn" id="infoBtn" style="background:#6b7280;">INFORMACIÓN</button>
<div id="creditsText" style="margin-top:18px; font-size:7px; color:#777; text-shadow:2px 2px 0 #000; letter-spacing:0.5px;">v1.0.0 · Hecho por Holy</div>
</div>
<!-- INFO -->
<div id="infoOverlay" class="overlay hidden">
<div class="title" style="font-size:min(5vw,24px); color:#fff; margin-bottom:14px;">GUÍA RÁPIDA</div>
<div class="subtitle" style="text-align:left; max-width:440px; max-height:56vh; overflow-y:auto; pointer-events:auto; touch-action:pan-y; padding:4px 10px 4px 0; line-height:2.1;">
<b style="color:#ffd166;">OBJETIVO</b><br>
Sobrevive el mayor tiempo posible mientras las hordas de enemigos se vuelven más fuertes con cada etapa.<br><br>
<b style="color:#ffd166;">CONTROLES</b><br>
Muévete con el joystick táctil o con WASD / flechas. Tus armas disparan solas contra el enemigo más cercano dentro de tu rango de acción (el círculo punteado a tu alrededor).<br><br>
<b style="color:#ffd166;">NIVELES Y MEJORAS</b><br>
Recoge los cristales verdes de XP para subir de nivel. Al subir, elige una de tres mejoras: pasivas, armas o gadgets.<br><br>
<b style="color:#ffd166;">RAREZA</b><br>
Cada mejora tiene una rareza (Común, Raro, Épico, Legendario o Mítico). Mientras más alta la rareza, más fuerte es el efecto de esa mejora.<br><br>
<b style="color:#ffd166;">COFRES</b><br>
Cada 3 etapas aparece un cofre dorado en el mapa. Ábrelo para obtener una mejora extra sin gastar XP. Una línea punteada te guía hasta él.<br><br>
<b style="color:#ffd166;">GADGETS</b><br>
Habilidades especiales con usos limitados y tiempo de recarga. Actívalas con el botón ◈ en la esquina inferior derecha. Una vez que obtienes un gadget, solo podrás encontrar mejoras que le den más usos a ese mismo gadget.<br><br>
<b style="color:#ffd166;">COMBO</b><br>
Eliminar enemigos seguidos aumenta tu racha de combo, que sube tu cadencia de disparo en los niveles más altos.<br><br>
<b style="color:#ffd166;">ÉLITES</b><br>
Desde la etapa 2 pueden aparecer enemigos élite (con brillo dorado y más grandes). Son más fuertes, pero al derrotarlos sueltan una recompensa mucho mayor y mejoran tu próxima mejora.<br><br>
<b style="color:#ffd166;">ETAPAS</b><br>
Cada etapa trae enemigos nuevos y más difíciles. Sobrevive todo el tiempo que puedas.<br><br>
<b style="color:#ffd166;">GUÍA DE RAREZAS</b><br>
<span style="color:#2ecc71;">COMÚN</span> — efecto base (x1). La mejora más frecuente.<br>
<span style="color:#4dd0ff;">RARO</span> — x1.25 de fuerza sobre el valor base.<br>
<span style="color:#9b59b6;">ÉPICO</span> — x1.5 de fuerza. Menos frecuente.<br>
<span style="color:#ffd700;">LEGENDARIO</span> — x2 de fuerza. Muy poco común.<br>
<span style="color:#ff2b2b;">MÍTICO</span> — x2.75 de fuerza. Extremadamente raro.<br><br>
La estadística <b>Suerte</b> inclina la probabilidad hacia rarezas más altas. Además, el juego lleva un contador interno: si pasas muchas mejoras seguidas sin obtener Épico o superior, las probabilidades de conseguir una rareza alta aumentan (sistema de "piedad"), así nunca te quedarás demasiado tiempo sin una mejora fuerte.
</div>
<button class="btn" id="closeInfoBtn" style="background:#6b7280; margin-top:14px;">VOLVER</button>
</div>
<!-- WEAPON SELECT -->
<div id="weaponSelectOverlay" class="overlay hidden">
<div class="title" id="weaponSelectTitle" style="font-size:min(5vw,28px); color:#fff;">ELIGE TU ARMA</div>
<div class="subtitle" style="margin-bottom:8px;">Selecciona un arma inicial</div>
<div id="weaponSelect"></div>
<button class="btn" id="confirmWeaponBtn" style="margin-top:8px;">COMENZAR</button>
</div>
<!-- LEVEL UP -->
<div id="levelUpOverlay" class="overlay hidden">
<div class="title" id="levelUpTitle" style="font-size:min(5vw,28px); color:#fff;">SUBISTE AL NIVEL 1</div>
<div class="subtitle" style="margin-bottom:8px;">Elige una mejora</div>
<div id="cards"></div>
</div>
<!-- PAUSA -->
<div id="pauseOverlay">
<div class="title" style="color:#fff;">PAUSA</div>
<div class="stats-grid" id="pauseStats"></div>
<div style="display:flex; gap:6px; flex-wrap:wrap; justify-content:center;">
<button class="btn" id="resumeBtn">REANUDAR</button>
<button class="btn btn-danger" id="restartPauseBtn">SALIR</button>
</div>
</div>
<!-- GAME OVER -->
<div id="gameOverOverlay" class="overlay hidden">
<div class="title" id="gameOverTitle" style="font-size:min(6vw,30px); color:#fff;">FIN DEL VIAJE</div>
<div id="statsBox"></div>
<button class="btn" id="restartBtn">SALIR</button>
</div>
<script>
// ============================================================
// THE GAME - VERSION COMPLETA CON SHADER 5%
// ============================================================
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const shaderCanvas = document.getElementById('shaderCanvas');
const shaderCtx = shaderCanvas.getContext('2d');
let W, H, DPR;
let rangeRotation = 0;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = W * DPR;
canvas.height = H * DPR;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
shaderCanvas.width = W;
shaderCanvas.height = H;
shaderCanvas.style.width = W + 'px';
shaderCanvas.style.height = H + 'px';
shaderCtx.imageSmoothingEnabled = false;
}
window.addEventListener('resize', resize);
resize();
// ---------- SHADER PIXELADO 5% ----------
function applyPixelShader() {
const pixelSize = Math.max(2, Math.floor(Math.min(W, H) * 0.005));
if (pixelSize < 2) return;
shaderCtx.drawImage(canvas, 0, 0, W, H);
const imageData = shaderCtx.getImageData(0, 0, W, H);
const data = imageData.data;
for (let y = 0; y < H; y += pixelSize) {
for (let x = 0; x < W; x += pixelSize) {
let r = 0, g = 0, b = 0, a = 0, c = 0;
const endX = Math.min(x + pixelSize, W);
const endY = Math.min(y + pixelSize, H);
for (let dy = y; dy < endY; dy++) {
for (let dx = x; dx < endX; dx++) {
const idx = (dy * W + dx) * 4;
r += data[idx];
g += data[idx + 1];
b += data[idx + 2];
a += data[idx + 3];
c++;
}
}
if (c > 0) {
r = Math.floor(r / c);
g = Math.floor(g / c);
b = Math.floor(b / c);
a = Math.floor(a / c);
for (let dy = y; dy < endY; dy++) {
for (let dx = x; dx < endX; dx++) {
const idx = (dy * W + dx) * 4;
data[idx] = r;
data[idx + 1] = g;
data[idx + 2] = b;
data[idx + 3] = a;
}
}
}
}
}
shaderCtx.putImageData(imageData, 0, 0);
}
// ---------- CONFIG ----------
const CFG = {
WORLD_SIZE: 4000,
SPAWN_INTERVAL: 0.9,
MAX_ENEMIES: 35,
WAVE_DURATION: 40,
XP_PER_LEVEL: 12,
XP_MULT: 1.3,
PLAYER_SPEED: 150,
PLAYER_RANGE: 80,
PLAYER_DAMAGE: 10,
FIRE_RATE: 0.4,
CONTACT_DAMAGE_BASE: 5,
CONTACT_DAMAGE_SCALING: 0.5,
CONTACT_DAMAGE_MAX: 25,
CONTACT_ENEMY_COOLDOWN: 0.3,
CONTACT_KNOCKBACK: 10,
CONTACT_TANK_KNOCKBACK: 5,
CONTACT_BOSS_KNOCKBACK: 3,
SHIELD_REGEN: 2,
SHIELD_BASE: 25,
MINIMAP_SIZE: 100,
LASER_INTERVAL: 0.5,
MISSILE_INTERVAL: 1.2,
WAVE_INTERVAL: 1.2,
SHOTGUN_INTERVAL: 1.0,
BULLET_SPEED: 288,
SHOTGUN_BULLET_SPEED: 220,
ENEMY_SPEED_BASE: 50,
XP_COLLECT_RADIUS: 40,
XP_GEM_LIFETIME: 10,
COMBO_TIMEOUT: 3.0,
CHEST_SPAWN_INTERVAL: 3,
BANNER_DURATION: 4
};
// ---------- ESTADO ----------
let state = 'menu';
let elapsed = 0, kills = 0, waveCount = 1, loopCount = 0, stageIndex = 0, stageTimer = 0;
let bgTime = 0;
let spawnTimer = 0, isPaused = false, pauseTimer = 0;
let infoOpen = false;
let shakeTimer = 0, shakeIntensity = 0, zoomLevel = 1, zoomTarget = 1;
let comboCount = 0, comboTimer = 0, frenzyActive = false;
let recordScore = 0;
let recordStats = null; // mejor partida guardada de forma persistente (localStorage)
let damageFlashTimer = 0;
let selectedWeapon = null;
let chests = [];
let chestSpawnCounter = 0;
let dashActive = false;
let dashTimer = 0;
let bannerPause = false;
let bannerPauseTimer = 0;
let pityCounter = 0;
let stageTypeInterval = null;
let displayedStageColor = { r: 255, g: 255, b: 255 };
// ---------- ESTADÍSTICAS PERSISTENTES (récord entre partidas) ----------
function loadRecord() {
try {
const raw = localStorage.getItem('thegame_record');
if (raw) {
const data = JSON.parse(raw);
if (data && typeof data.score === 'number') {
recordScore = data.score;
recordStats = data;
}
}
} catch (e) { /* localStorage no disponible: el récord solo dura esta sesión */ }
}
function saveRecord(data) {
recordStats = data;
try {
localStorage.setItem('thegame_record', JSON.stringify(data));
} catch (e) { /* localStorage no disponible */ }
}
function updateBestScoreDisplay() {
const el = document.getElementById('bestScoreText');
if (!el) return;
el.textContent = recordStats ? ('Mejor puntuación: ' + recordStats.kills + ' kills, Nv.' + recordStats.level + ', ' + recordStats.stage) : 'Aún no tienes un récord';
}
loadRecord();
let enemies = [], bullets = [], enemyBullets = [], particles = [], gems = [], floaters = [];
let trails = [], orbiters = [], orbiterBullets = [], playerTurrets = [];
let slowZones = [];
let xpGems = [];
let dimOverlayActive = false;
let dimOverlayTimer = 0;
// ---------- JUGADOR ----------
const p = {
x: CFG.WORLD_SIZE/2, y: CFG.WORLD_SIZE/2, r: 14,
speed: CFG.PLAYER_SPEED,
hp: 100, maxHp: 100,
shield: 0, maxShield: 0, shieldRegen: 0,
damage: CFG.PLAYER_DAMAGE,
fireRate: CFG.FIRE_RATE,
atkTimer: 0,
range: CFG.PLAYER_RANGE,
xp: 0, level: 1, xpToNext: CFG.XP_PER_LEVEL,
multishot: 0,
regen: 0, invuln: 0, magnet: CFG.XP_COLLECT_RADIUS,
hasShield: false,
hasPistol: false,
hasWave: false,
hasOrbiters: false,
hasLaser: false,
hasMissiles: false,
hasShotgun: false,
waveDamage: 18, waveCooldown: 0, waveInterval: CFG.WAVE_INTERVAL,
orbiterCount: 0, orbiterDamage: 5, orbiterFireRate: 0.7, orbiterTimer: 0,
laserDamage: 8, laserTimer: 0, laserCooldown: CFG.LASER_INTERVAL,
laserBounceChance: 0, laserPierce: 0,
missileDamage: 25, missileCooldown: 0, missileInterval: CFG.MISSILE_INTERVAL, missileExplosionRadius: 60,
shotgunDamage: 12, shotgunCooldown: 0, shotgunInterval: CFG.SHOTGUN_INTERVAL, shotgunPelletCount: 5,
pistolPierce: 0,
waveRangeBonus: 0,
gadget: null, gadgetUses: 0, gadgetCooldown: 0, gadgetMaxCooldown: 15,
turretDamage: 18,
slowTimer: 0, slowAmount: 0,
messages: [],
contactCooldown: 0,
waveMessages: [],
weaponReady: {
pistol: true,
wave: true,
laser: true,
missile: true,
orbiter: true,
shotgun: true
},
luck: 0,
thorns: 0,
lifeSteal: 0,
dashDamage: 0,
xpMult: 1
};
// ---------- CAMARA ----------
const camera = {
x: 0, y: 0,
targetX: 0, targetY: 0,
lerpFactor: 0.08
};
// ---------- INPUT ----------
const keys = {};
window.addEventListener('keydown', e => {
keys[e.key.toLowerCase()] = true;
if (e.key === 'Escape') togglePause();
});
window.addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
let joyActive = false, joyDX = 0, joyDY = 0, joyTouchId = null;
const joyBase = document.getElementById('joyBase'), joyStick = document.getElementById('joyStick');
canvas.addEventListener('touchstart', e => {
if (state !== 'playing' || isPaused || infoOpen || bannerPause) return;
const t = e.changedTouches[0];
joyTouchId = t.identifier;
const rect = canvas.getBoundingClientRect();
const cx = t.clientX - rect.left, cy = t.clientY - rect.top;
joyBase.style.display = 'block';
joyStick.style.display = 'block';
joyBase.style.left = (cx - 40) + 'px';
joyBase.style.top = (cy - 40) + 'px';
joyStick.style.left = (cx - 17) + 'px';
joyStick.style.top = (cy - 17) + 'px';
joyActive = true;
e.preventDefault();
}, { passive: false });
canvas.addEventListener('touchmove', e => {
if (!joyActive || isPaused || infoOpen || bannerPause) return;
for (const t of e.changedTouches) {
if (t.identifier === joyTouchId) {
const rect = canvas.getBoundingClientRect();
const bx = parseFloat(joyBase.style.left) + 40;
const by = parseFloat(joyBase.style.top) + 40;
let dx = t.clientX - rect.left - bx;
let dy = t.clientY - rect.top - by;
const maxD = 35;
const d = Math.hypot(dx, dy);
if (d > maxD) { dx = dx / d * maxD; dy = dy / d * maxD; }
joyDX = dx / maxD;
joyDY = dy / maxD;
joyStick.style.left = (bx + dx - 17) + 'px';
joyStick.style.top = (by + dy - 17) + 'px';
}
}
e.preventDefault();
}, { passive: false });
function endTouch(e) {
for (const t of e.changedTouches) {
if (t.identifier === joyTouchId) {
joyActive = false; joyDX = 0; joyDY = 0;
joyBase.style.display = 'none';
joyStick.style.display = 'none';
}
}
}
canvas.addEventListener('touchend', endTouch, { passive: false });
canvas.addEventListener('touchcancel', endTouch, { passive: false });
// ---------- UTILIDADES ----------
function rand(a, b) { return a + Math.random() * (b - a); }
function dist(x1, y1, x2, y2) { return Math.hypot(x2 - x1, y2 - y1); }
function dist2(x1, y1, x2, y2) { const dx = x1 - x2, dy = y1 - y2; return dx * dx + dy * dy; }
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function lerp(a, b, t) { return a + (b - a) * t; }
function wrapWaveLetters(text, effect) {
// effect: true/'rainbow' -> multicolor animated, 'gold' -> shiny golden, 'mythic' -> shiny pink/mythic,
// 'goldHalf'/'mythicHalf' -> same shine but 50% smaller wave amplitude, otherwise plain wave
let cls = '';
if (effect === true || effect === 'rainbow') cls = ' rainbow';
else if (effect === 'gold') cls = ' gold';
else if (effect === 'mythic') cls = ' mythic';
else if (effect === 'goldHalf') cls = ' goldHalf';
else if (effect === 'mythicHalf') cls = ' mythicHalf';
return text.split('').map((ch, i) => {
const safeCh = ch === ' ' ? ' ' : ch;
return '<span class="wave-letter' + cls + '" style="--i:' + i + '">' + safeCh + '</span>';
}).join('');
}
function stage() {
const s = [
{ accent: '#b8e8a0', bg1: '#0f140c', bg2: '#1a2414' },
{ accent: '#e8c8a0', bg1: '#14100c', bg2: '#241c14' },
{ accent: '#a0d8e8', bg1: '#0c1214', bg2: '#142024' },
{ accent: '#e8a0e8', bg1: '#140c14', bg2: '#241424' },
{ accent: '#e8c86b', bg1: '#140f08', bg2: '#241a0d' },
{ accent: '#6bd0ff', bg1: '#08131a', bg2: '#0d2230' },
{ accent: '#ff6bd6', bg1: '#0c0818', bg2: '#170f2e' },
{ accent: '#ff6b6b', bg1: '#160505', bg2: '#280a0a' },
];
const theme = s[stageIndex % s.length];
return { name: 'ETAPA ' + (stageIndex + 1), accent: theme.accent, bg1: theme.bg1, bg2: theme.bg2 };
}
function difficulty() {
return 1 + elapsed / 45 + loopCount * 0.6 + (stageIndex / 8) * 0.3;
}
function addFloater(x, y, text, color) { floaters.push({ x, y, text, color, life: 0.8, t: 0 }); }
// position: 'above' (por defecto, usado por el combo) o 'below' (mensajes de acción de gadgets)
function addWaveMessage(title, subtitle, color, position) {
if (title.includes('RACHA') || title.includes('COMBO') || title.includes('HÍPER')) {
for (let i = p.waveMessages.length - 1; i >= 0; i--) {
const msg = p.waveMessages[i].title;
if (msg.includes('RACHA') || msg.includes('COMBO') || msg.includes('HÍPER')) {
p.waveMessages.splice(i, 1);
}
}
}
p.waveMessages.push({ title, subtitle, color, life: 3.6, t: 0, position: position || 'above' });
}
function screenShake(intensity, duration) { shakeIntensity = Math.max(shakeIntensity, intensity);
shakeTimer = Math.max(shakeTimer, duration); }
function showDimOverlay(duration) {
dimOverlayActive = true;
dimOverlayTimer = duration || 0.5;
document.getElementById('dimOverlay').style.opacity = '1';
}
function hideDimOverlay() {
dimOverlayActive = false;
document.getElementById('dimOverlay').style.opacity = '0';
}
// ---------- COLORES POR TIPO DE ENEMIGO ----------
// Paleta con tonos bien diferenciados entre sí para que cada tipo se reconozca a simple vista.
const ENEMY_COLORS = {
normal: '#e74c3c', // rojo
tank: '#8e44ad', // morado (cuadrado)
pentagon: '#3498db', // azul
hexagon: '#9b59b6', // púrpura
turret: '#2ecc71' // verde
};
const ELITE_COLOR = '#ffd700'; // dorado para la skin de los enemigos élite
function enemyColor(e) { if (e && e.elite) return ELITE_COLOR; return ENEMY_COLORS[e && e.type] || ENEMY_COLORS.normal; }
// ---------- COMBATE CENTRALIZADO ----------
// Toda fuente de daño a enemigos (balas, onda, laser, orbitadores, contacto,
// espinas, gadgets) pasa por damageEnemy para que el combo, el conteo de
// muertes y el robo de vida funcionen igual sin importar el arma usada.
function damageEnemy(e, dmg, hitFlash) {
if (!e || dmg <= 0) return;
e.hp -= dmg;
e.hit = hitFlash !== undefined ? hitFlash : 0.15;
if (p.lifeSteal) {
const heal = dmg * p.lifeSteal;
if (heal > 0) p.hp = Math.min(p.maxHp, p.hp + heal);
}
if (e.hp <= 0) killEnemy(e);
}
function killEnemy(e) {
const idx = enemies.indexOf(e);
if (idx === -1) return;
const xpValue = (e.xp || 2) * (e.elite ? 3 : 1);
spawnXpGem(e.x, e.y, xpValue, e.elite);
// Partículas de eliminación: siempre salen, sin importar el arma que dio el golpe final.
const color = enemyColor(e);
const count = e.type === 'tank' || e.type === 'pentagon' || e.type === 'hexagon' ? 12 : 9;
for (let i = 0; i < count; i++) {
const ang = rand(0, Math.PI * 2);
const spd = rand(30, 110);
particles.push({
x: e.x,
y: e.y,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd - 20,
life: rand(0.2, 0.5),
color: color,
r: rand(2, 4)
});
}
// Recompensa extra al matar un élite: más XP, partículas doradas y garantiza
// que tu próxima mejora sea de rareza alta (empuja el contador de piedad).
if (e.elite) {
for (let i = 0; i < 22; i++) {
const ang = rand(0, Math.PI * 2);
const spd = rand(60, 180);
particles.push({
x: e.x,
y: e.y,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd - 20,
life: rand(0.3, 0.7),
color: '#ffd700',
r: rand(2, 5)
});
}
addFloater(e.x, e.y - 20, '¡ÉLITE!', '#ffd700');
pityCounter = Math.max(pityCounter, 8);
screenShake(5, 0.15);
}
enemies.splice(idx, 1);
kills++;
document.getElementById('killCounter').textContent = 'Kills: ' + kills;
updateCombo();
}
// ---------- TRAIL ----------
let trailList = [];
function addTrail(x1, y1, x2, y2, color) { trailList.push({ x1, y1, x2, y2, color, life: 1.5, maxLife: 1.5 }); }
function drawTrails() {
for (const t of trailList) {
const alpha = (t.life / t.maxLife) * 0.2;
ctx.globalAlpha = alpha;
ctx.strokeStyle = t.color;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(t.x1, t.y1);
ctx.lineTo(t.x2, t.y2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// ---------- RAREZAS ----------
// Sistema de rareza estilo roguelike: pesos ponderados, la Suerte inclina la
// balanza hacia rarezas altas, y un contador de "piedad" garantiza que no
// pases demasiadas mejoras seguidas sin conseguir algo poderoso.
const RARITY_TABLE = {
common: { name: 'COMÚN', class: 'rarity-common', color: '#2ecc71' },
rare: { name: 'RARO', class: 'rarity-uncommon', color: '#4dd0ff' },
epic: { name: 'ÉPICO', class: 'rarity-epic', color: '#9b59b6' },
legendary: { name: 'LEGENDARIO', class: 'rarity-legendary', color: '#ffd700' },
mythic: { name: 'MÍTICO', class: 'rarity-mythic', color: '#ff2b2b' }
};
function getRarity() {
const luckBonus = p.luck || 0;
let weights = { common: 55, rare: 27, epic: 13, legendary: 4.5, mythic: 0.5 };
const shift = luckBonus * 30;
weights.common = Math.max(5, weights.common - shift);
weights.rare += shift * 0.4;
weights.epic += shift * 0.35;
weights.legendary += shift * 0.2;
weights.mythic += shift * 0.05;
// Piedad: si pasan muchas mejoras seguidas sin Épica o superior, suben las probabilidades altas
if (pityCounter >= 8) {
weights.epic += 25;
weights.legendary += 12;
weights.mythic += 3;
} else if (pityCounter >= 4) {
weights.epic += 10;
weights.legendary += 4;
}
const total = weights.common + weights.rare + weights.epic + weights.legendary + weights.mythic;
let roll = Math.random() * total;
let picked = 'common';
if (roll < weights.common) { picked = 'common'; }
else if ((roll -= weights.common) < weights.rare) { picked = 'rare'; }
else if ((roll -= weights.rare) < weights.epic) { picked = 'epic'; }
else if ((roll -= weights.epic) < weights.legendary) { picked = 'legendary'; }
else { picked = 'mythic'; }
if (picked === 'epic' || picked === 'legendary' || picked === 'mythic') pityCounter = 0;
else pityCounter++;
return RARITY_TABLE[picked];
}
const RARITY_MULT = { 'COMÚN': 1, 'RARO': 1.25, 'ÉPICO': 1.5, 'LEGENDARIO': 2, 'MÍTICO': 2.75 };
function rarityMult(rarity) { return rarity ? (RARITY_MULT[rarity.name] || 1) : 1; }
// ---------- VISIBILIDAD DE UI SEGÚN ESTADO ----------
function setMenuUI(isMenu) {
const disp = isMenu ? 'none' : '';
document.getElementById('hud').style.display = disp;
document.getElementById('topRight').style.display = disp;
document.getElementById('weaponCooldowns').style.display = disp;
document.getElementById('pauseBtn').style.display = disp;
document.getElementById('debugLevelBtn').style.display = isMenu ? 'none' : 'flex';
if (isMenu) {
document.getElementById('gadgetBtn').style.display = 'none';
document.getElementById('gadgetCooldown').style.display = 'none';
}
}
// ---------- RESET ----------
function resetGame() {
elapsed = 0; kills = 0; waveCount = 1; loopCount = 0; stageIndex = 0; stageTimer = 0;
spawnTimer = 0; isPaused = false; infoOpen = false; pauseTimer = 0;
shakeTimer = 0; shakeIntensity = 0; zoomLevel = 1; zoomTarget = 1;
comboCount = 0; comboTimer = 0; frenzyActive = false; damageFlashTimer = 0;
chests = []; chestSpawnCounter = 0;
dashActive = false; dashTimer = 0;
bannerPause = false; bannerPauseTimer = 0;
pityCounter = 0;
displayedStageColor = { r: 255, g: 255, b: 255 };
enemies = []; bullets = []; enemyBullets = []; particles = []; gems = []; floaters = [];
trailList = []; orbiters = []; orbiterBullets = []; playerTurrets = [];
slowZones = []; xpGems = [];
p.messages = [];
p.waveMessages = [];
dimOverlayActive = false;
document.getElementById('dimOverlay').style.opacity = '0';
p.x = CFG.WORLD_SIZE / 2;
p.y = CFG.WORLD_SIZE / 2;
p.hp = 100; p.maxHp = 100;
p.shield = 0; p.maxShield = 0; p.shieldRegen = 0;
p.damage = CFG.PLAYER_DAMAGE;
p.fireRate = CFG.FIRE_RATE;
p.atkTimer = 0;
p.range = CFG.PLAYER_RANGE;
p.xp = 0; p.level = 1; p.xpToNext = CFG.XP_PER_LEVEL;
p.multishot = 0;
p.regen = 0; p.invuln = 0; p.magnet = CFG.XP_COLLECT_RADIUS;
p.hasShield = false;
p.hasPistol = false;
p.hasWave = false;
p.hasOrbiters = false;
p.hasLaser = false;
p.hasMissiles = false;
p.hasShotgun = false;
p.waveDamage = 18; p.waveCooldown = 0; p.waveInterval = CFG.WAVE_INTERVAL;
p.orbiterCount = 0; p.orbiterDamage = 5; p.orbiterFireRate = 0.7; p.orbiterTimer = 0;
p.laserDamage = 8; p.laserTimer = 0; p.laserCooldown = CFG.LASER_INTERVAL;
p.laserBounceChance = 0; p.laserPierce = 0;
p.missileDamage = 25; p.missileCooldown = 0; p.missileInterval = CFG.MISSILE_INTERVAL; p.missileExplosionRadius = 60;
p.shotgunDamage = 12; p.shotgunCooldown = 0; p.shotgunInterval = CFG.SHOTGUN_INTERVAL; p.shotgunPelletCount = 5;
p.pistolPierce = 0;
p.waveRangeBonus = 0;
p.gadget = null; p.gadgetUses = 0; p.gadgetCooldown = 0; p.gadgetMaxCooldown = 15;
p.turretDamage = 18;
p.slowTimer = 0; p.slowAmount = 0;
p.contactCooldown = 0;
p.dashDamage = 0;
p.weaponReady = {
pistol: true,
wave: true,
laser: true,
missile: true,
orbiter: true,
shotgun: true
};
p.luck = 0;
p.thorns = 0;
p.lifeSteal = 0;
p.xpMult = 1;
camera.x = p.x - W / 2;
camera.y = p.y - H / 2;
camera.targetX = camera.x;
camera.targetY = camera.y;
document.getElementById('gadgetBtn').style.display = 'none';
document.getElementById('gadgetCooldown').style.display = 'none';
document.getElementById('shieldRow').style.display = 'none';
document.getElementById('hpText').textContent = '100/100';
document.getElementById('shieldText').textContent = '0/0';
document.getElementById('xpText').textContent = '0/' + p.xpToNext;
document.getElementById('lvlTag').textContent = 'Nv.' + p.level;
document.getElementById('killCounter').textContent = 'Kills: 0';
document.getElementById('weaponCooldowns').innerHTML = '';
hideDimOverlay();
}
// ---------- SPAWN ENEMIGOS ----------
function spawnEnemy() {
if (isPaused || enemies.length > CFG.MAX_ENEMIES || bannerPause) return;
const side = Math.floor(rand(0, 4));
let x, y, m = 30;
if (side === 0) { x = rand(0, CFG.WORLD_SIZE);
y = -m; } else if (side === 1) { x = CFG.WORLD_SIZE + m;
y = rand(0, CFG.WORLD_SIZE); } else if (side === 2) { x = rand(0, CFG.WORLD_SIZE);
y = CFG.WORLD_SIZE + m; } else { x = -m;
y = rand(0, CFG.WORLD_SIZE); }
const dm = difficulty();
const currentStage = stageIndex;
let enemyType = 'normal';
const randVal = Math.random();
if (currentStage >= 2 && randVal < 0.15) {
enemyType = 'tank';
} else if (currentStage >= 4 && randVal < 0.25) {
enemyType = 'pentagon';
} else if (currentStage >= 6 && randVal < 0.20) {
enemyType = 'hexagon';
} else if (currentStage >= 3 && randVal < 0.08) {
enemyType = 'turret';
}
// Élites: aparecen desde la etapa 2, con probabilidad creciente. Más grandes,
// más fuertes, y sueltan una recompensa mucho mayor al morir (más roguelike).
const eliteChance = currentStage >= 1 ? Math.min(0.22, 0.04 + currentStage * 0.012) : 0;
const isElite = Math.random() < eliteChance;
const eliteHpMult = isElite ? 2.4 : 1;
const eliteDmgMult = isElite ? 1.4 : 1;
const eliteSizeMult = isElite ? 1.3 : 1;
let obj = null;
if (enemyType === 'turret') {
obj = { x, y, r: 16, hp: 30 * dm, maxHp: 30 * dm, speed: 0, dmg: 5, type: 'turret', xp: 4, hit: 0, shootTimer: 0, shootInterval: 2.5, rot: 0, contactCooldown: 0 };
} else if (enemyType === 'tank') {
obj = { x, y, r: 18, hp: 60 * dm, maxHp: 60 * dm, speed: 30 * (1 + Math.min(dm * 0.04, 0.5)), dmg: 9, type: 'tank', xp: 6, hit: 0, rot: 0, contactCooldown: 0 };
} else if (enemyType === 'pentagon') {
obj = {
x, y, r: 22,
hp: 50 * dm,
maxHp: 50 * dm,
speed: 40 * (1 + Math.min(dm * 0.03, 0.4)),
dmg: 8,
type: 'pentagon',
xp: 10,
hit: 0,
rot: 0,
contactCooldown: 0,
shootTimer: 0,
shootInterval: 2.0
};
} else if (enemyType === 'hexagon') {
obj = {
x, y, r: 20,
hp: 40 * dm,
maxHp: 40 * dm,
speed: 35 * (1 + Math.min(dm * 0.03, 0.3)),
dmg: 6,
type: 'hexagon',
xp: 12,
hit: 0,
rot: 0,
contactCooldown: 0,
shootTimer: 0,
shootInterval: 2.8,
slowAmount: 0.3,
slowDuration: 2.0
};
} else {
const baseSpeed = (CFG.ENEMY_SPEED_BASE + (stageIndex * 2)) * (1 + Math.min(dm * 0.04, 0.3));
obj = {
x, y, r: 12,
hp: 14 * dm,
maxHp: 14 * dm,
speed: baseSpeed,
dmg: 5,
type: 'normal',
xp: 2,
hit: 0,
rot: 0,
contactCooldown: 0
};
}
if (isElite) {
obj.hp *= eliteHpMult;
obj.maxHp *= eliteHpMult;
obj.dmg *= eliteDmgMult;
obj.r *= eliteSizeMult;
obj.elite = true;
}
enemies.push(obj);
}
// ---------- XP GEM SYSTEM ----------
function spawnXpGem(x, y, value, golden) {
xpGems.push({ x, y, value, r: golden ? 8 : 6, t: 0, life: CFG.XP_GEM_LIFETIME, alpha: 1, golden: !!golden });
}
function collectXpGems() {
for (let i = xpGems.length - 1; i >= 0; i--) {
const g = xpGems[i];
g.t += 1/60;
g.life -= 1/60;
// Desvanecido: aparecen con un fade-in suave y desaparecen con un fade-out suave.
const fadeIn = Math.min(1, g.t / 0.35);
const fadeOut = g.life < 2 ? Math.max(0, g.life / 2) : 1;
g.alpha = Math.min(fadeIn, fadeOut);
if (g.life <= 0) {
xpGems.splice(i, 1);
continue;
}
const d = dist(p.x, p.y, g.x, g.y);
if (d < p.magnet) {
const pull = 1 - d / p.magnet;
g.x += (p.x - g.x) * pull * 6 * (1/60);
g.y += (p.y - g.y) * pull * 6 * (1/60);
}
if (d < p.r + g.r + 4) {
gainXp(g.value);
xpGems.splice(i, 1);
}
}
}
function gainXp(v) {
p.xp += v * (p.xpMult || 1);
while (p.xp >= p.xpToNext) {
p.xp -= p.xpToNext;
p.level++;
p.xpToNext = Math.floor(p.xpToNext * CFG.XP_MULT + 4);
screenShake(2, 0.15);
document.getElementById('lvlTag').textContent = 'Nv.' + p.level;
showLevelUp(false);
}
document.getElementById('xpText').textContent = Math.floor(p.xp) + '/' + p.xpToNext;
}
// ---------- MEJORAS ----------
function getUpgradePool(forceWeapon) {
const pool = [];
if (forceWeapon) {
const allWeapons = [
{ id: 'pistol', icon: '●', name: 'Pistola', desc: 'Disparo básico automático', apply: () => { p.hasPistol = true; }, type: 'arma' },
{ id: 'wave', icon: '◆', name: 'Onda', desc: 'Daño en área cada 1.2s', apply: () => { p.hasWave = true; }, type: 'arma' },
{ id: 'orbiter', icon: '▲', name: 'Orbitador', desc: 'Orbita y dispara al enemigo', apply: () => { p.hasOrbiters = true;
p.orbiterCount = 1; }, type: 'arma' },
{ id: 'laser', icon: '⬡', name: 'Láser', desc: 'Rayo continuo de energía', apply: () => { p.hasLaser = true; }, type: 'arma' },
{ id: 'missiles', icon: '⬢', name: 'Misil', desc: 'Explosión en área al impactar', apply: () => { p.hasMissiles = true; }, type: 'arma' },
{ id: 'shotgun', icon: '⬣', name: 'Escopeta', desc: 'Dispara múltiples proyectiles', apply: () => { p.hasShotgun = true; }, type: 'arma' }
];
const available = allWeapons.filter(w => {
if (w.id === 'pistol') return !p.hasPistol;
if (w.id === 'wave') return !p.hasWave;
if (w.id === 'orbiter') return !p.hasOrbiters;
if (w.id === 'laser') return !p.hasLaser;
if (w.id === 'missiles') return !p.hasMissiles;
if (w.id === 'shotgun') return !p.hasShotgun;
return true;
});
const shuffled = available.sort(() => Math.random() - 0.5);
return shuffled.slice(0, Math.min(3, shuffled.length));
}
const rarity = getRarity();
const mult = rarityMult(rarity);
// Pasivas
pool.push({ id: 'speed', icon: '○', name: 'Velocidad', desc: `+${Math.round(20 * mult)}% velocidad`, apply: () => { p.speed *= 1 + 0.2 * mult; }, type: 'pasiva', rarity: rarity });
pool.push({ id: 'hp', icon: '○', name: 'Vitalidad', desc: `+${Math.round(30 * mult)} vida máxima`, apply: () => { const amt = Math.round(30 * mult);
p.maxHp += amt;
p.hp = Math.min(p.hp + amt, p.maxHp); }, type: 'pasiva', rarity: rarity });
pool.push({ id: 'regen', icon: '○', name: 'Regeneración', desc: `+${(1.5 * mult).toFixed(1)} vida/s`, apply: () => { p.regen += 1.5 * mult; }, type: 'pasiva', rarity: rarity });
pool.push({ id: 'magnet', icon: '○', name: 'Imán', desc: `+${Math.round(60 * mult)}% radio recogida`, apply: () => { p.magnet *= 1 + 0.6 * mult; }, type: 'pasiva', rarity: rarity });
pool.push({ id: 'thorns', icon: '○', name: 'Espinas', desc: `Devuelve ${Math.round(15 * mult)}% daño recibido`, apply: () => { p.thorns = (p.thorns || 0) + 0.15 * mult; }, type: 'pasiva', rarity: rarity });
pool.push({ id: 'lifeSteal', icon: '○', name: 'Robo de Vida', desc: `Cura ${Math.round(10 * mult)}% del daño infligido`, apply: () => { p.lifeSteal = (p.lifeSteal || 0) + 0.10 * mult; }, type: 'pasiva', rarity: rarity });
pool.push({ id: 'luck', icon: '○', name: 'Suerte', desc: `+${Math.round(10 * mult)}% mejores mejoras`, apply: () => { p.luck = Math.min((p.luck || 0) + 0.10 * mult, 0.50); }, type: 'pasiva', rarity: rarity });
pool.push({ id: 'xpBoost', icon: '○', name: 'Sabiduría', desc: `+${Math.round(20 * mult)}% experiencia obtenida`, apply: () => { p.xpMult = (p.xpMult || 1) + 0.20 * mult; }, type: 'pasiva', rarity: rarity });
if (!p.hasShield) {
pool.push({ id: 'shield', icon: '○', name: 'Escudo', desc: `+${Math.round(CFG.SHIELD_BASE * mult)} escudo, ${(CFG.SHIELD_REGEN * mult).toFixed(1)}/s regen`, apply: () => { p.hasShield = true;
const sh = Math.round(CFG.SHIELD_BASE * mult);
p.maxShield = sh;
p.shield = sh;
p.shieldRegen = CFG.SHIELD_REGEN * mult;
document.getElementById('shieldRow').style.display = 'flex'; }, type: 'pasiva', rarity: rarity });
} else {
pool.push({ id: 'shieldUp', icon: '○', name: 'Escudo+', desc: `+${Math.round(20 * mult)} escudo, +${(1.5 * mult).toFixed(1)} regen`, apply: () => { const amt = Math.round(20 * mult);
p.maxShield += amt;
p.shield = Math.min(p.shield + amt, p.maxShield);
p.shieldRegen += 1.5 * mult; }, type: 'pasiva', rarity: rarity });
}
pool.push({ id: 'rangeUp', icon: '◎', name: 'Rango', desc: `+${Math.round(20 * mult)}% rango de ataque`, apply: () => { p.range *= 1 + 0.2 * mult; }, type: 'pasiva', rarity: rarity });
// Armas
if (!p.hasPistol) {
pool.push({ id: 'pistol', icon: '●', name: 'Pistola', desc: 'Disparo básico automático', apply: () => { p.hasPistol = true; }, type: 'arma', newUnlock: true });
} else {
pool.push({ id: 'dmg', icon: '●', name: 'Daño Pistola', desc: `+${Math.round(30 * mult)}% daño`, apply: () => { p.damage *= 1 + 0.3 * mult; }, type: 'arma', rarity: rarity });
pool.push({ id: 'rate', icon: '●', name: 'Cadencia Pistola', desc: `-${Math.round(20 * mult)}% tiempo`, apply: () => { p.fireRate *= Math.max(0.15, 1 - 0.2 * mult); }, type: 'arma', rarity: rarity });
pool.push({ id: 'multi', icon: '●', name: 'Multidisparo', desc: `+${Math.max(1, Math.round(mult))} proyectil(es)`, apply: () => { p.multishot += Math.max(1, Math.round(mult)); }, type: 'arma', rarity: rarity });
pool.push({ id: 'pistolPierce', icon: '●', name: 'Perforante', desc: `Las balas atraviesan +${Math.max(1, Math.round(mult))} enemigo(s)`, apply: () => { p.pistolPierce = (p.pistolPierce || 0) + Math.max(1, Math.round(mult)); }, type: 'arma', rarity: rarity });
}
if (!p.hasWave) {
pool.push({ id: 'wave', icon: '◆', name: 'Onda', desc: 'Daño en área cada 1.2s', apply: () => { p.hasWave = true; }, type: 'arma', newUnlock: true });
} else {
pool.push({ id: 'waveDmg', icon: '◆', name: 'Daño Onda', desc: `+${Math.round(40 * mult)}% daño`, apply: () => { p.waveDamage *= 1 + 0.4 * mult; }, type: 'arma', rarity: rarity });
pool.push({ id: 'waveRate', icon: '◆', name: 'Cadencia Onda', desc: `-${Math.round(20 * mult)}% tiempo`, apply: () => { p.waveInterval *= Math.max(0.15, 1 - 0.2 * mult); }, type: 'arma', rarity: rarity });
}
if (!p.hasOrbiters) {
pool.push({ id: 'orbiter', icon: '▲', name: 'Orbitador', desc: '1 orbitador orbital', apply: () => { p.hasOrbiters = true;
p.orbiterCount = 1; }, type: 'arma', newUnlock: true });
} else if (p.orbiterCount < 5) {
pool.push({ id: 'orbiterMore', icon: '▲', name: `+${Math.max(1, Math.round(mult))} Orbitador(es)`, desc: 'Añade orbitador(es)', apply: () => { p.orbiterCount += Math.max(1, Math.round(mult)); }, type: 'arma', rarity: rarity });
}
if (p.hasOrbiters) {
pool.push({ id: 'orbiterDmg', icon: '▲', name: 'Daño Orbitador', desc: `+${Math.round(50 * mult)}% daño`, apply: () => { p.orbiterDamage *= 1 + 0.5 * mult; }, type: 'arma', rarity: rarity });
pool.push({ id: 'orbiterRate', icon: '▲', name: 'Cadencia Orbitador', desc: `-${Math.round(25 * mult)}% tiempo`, apply: () => { p.orbiterFireRate *= Math.max(0.15, 1 - 0.25 * mult); }, type: 'arma', rarity: rarity });
}
if (!p.hasLaser) {
pool.push({ id: 'laser', icon: '⬡', name: 'Láser', desc: 'Rayo continuo de energía', apply: () => { p.hasLaser = true; }, type: 'arma', newUnlock: true });
}
if (p.hasLaser) {
pool.push({ id: 'laserDmg', icon: '⬡', name: 'Daño Láser', desc: `+${Math.round(40 * mult)}% daño`, apply: () => { p.laserDamage *= 1 + 0.4 * mult; }, type: 'arma', rarity: rarity });
pool.push({ id: 'laserBounce', icon: '⬡', name: 'Rebote Láser', desc: `${Math.round(25 * mult)}% rebote a otro enemigo`, apply: () => { p.laserBounceChance = Math.min(p.laserBounceChance + 0.25 * mult, 0.85); }, type: 'arma', rarity: rarity });
pool.push({ id: 'laserRate', icon: '⬡', name: 'Cadencia Láser', desc: `-${Math.round(20 * mult)}% tiempo`, apply: () => { p.laserCooldown *= Math.max(0.15, 1 - 0.2 * mult); }, type: 'arma', rarity: rarity });
}
if (!p.hasMissiles) {
pool.push({ id: 'missiles', icon: '⬢', name: 'Misil', desc: 'Explosión en área al impactar', apply: () => { p.hasMissiles = true; }, type: 'arma', newUnlock: true });
}
if (p.hasMissiles) {
pool.push({ id: 'missileDmg', icon: '⬢', name: 'Daño Misil', desc: `+${Math.round(40 * mult)}% daño`, apply: () => { p.missileDamage *= 1 + 0.4 * mult; }, type: 'arma', rarity: rarity });
pool.push({ id: 'missileRate', icon: '⬢', name: 'Cadencia Misil', desc: `-${Math.round(25 * mult)}% tiempo`, apply: () => { p.missileInterval *= Math.max(0.15, 1 - 0.25 * mult); }, type: 'arma', rarity: rarity });
pool.push({ id: 'missileRadius', icon: '⬢', name: 'Radio Misil', desc: `+${Math.round(30 * mult)}% radio de explosión`, apply: () => { p.missileExplosionRadius *= 1 + 0.3 * mult; }, type: 'arma', rarity: rarity });
}
if (!p.hasShotgun) {
pool.push({ id: 'shotgun', icon: '⬣', name: 'Escopeta', desc: 'Dispara 5 proyectiles', apply: () => { p.hasShotgun = true; }, type: 'arma', newUnlock: true });
}
if (p.hasShotgun) {
pool.push({ id: 'shotgunDmg', icon: '⬣', name: 'Daño Escopeta', desc: `+${Math.round(30 * mult)}% daño`, apply: () => { p.shotgunDamage *= 1 + 0.3 * mult; }, type: 'arma', rarity: rarity });
pool.push({ id: 'shotgunPellets', icon: '⬣', name: 'Más Proyectiles', desc: `+${Math.max(2, Math.round(2 * mult))} proyectiles`, apply: () => { p.shotgunPelletCount += Math.max(2, Math.round(2 * mult)); }, type: 'arma', rarity: rarity });
pool.push({ id: 'shotgunRate', icon: '⬣', name: 'Cadencia Escopeta', desc: `-${Math.round(20 * mult)}% tiempo`, apply: () => { p.shotgunInterval *= Math.max(0.15, 1 - 0.2 * mult); }, type: 'arma', rarity: rarity });
}
// Gadgets - solo se ofrece un gadget nuevo si todavía no tienes ninguno.
// Si ya tienes un gadget, solo pueden salir mejoras de ESE mismo gadget (más usos).
if (!p.gadget) {
const gRarity = getRarity();
const gMult = rarityMult(gRarity);
const uses = Math.max(3, Math.round(3 * gMult));
pool.push({ id: 'turretGadget', icon: '◈', name: 'Torreta', desc: `${uses} usos, 15s cd`, apply: () => {
p.gadget = 'turret';
p.gadgetUses = uses;
p.gadgetCooldown = 0;
p.gadgetMaxCooldown = 15;
document.getElementById('gadgetBtn').style.display = 'flex';
document.getElementById('gadgetBtn').style.background = 'rgba(255,107,53,0.8)';
document.getElementById('gadgetBtn').innerHTML = '<span>◈</span><span class="uses">' + p.gadgetUses + '</span>';
}, type: 'gadget', rarity: gRarity });
} else if (p.gadget === 'turret') {
const gRarity = getRarity();
const gMult = rarityMult(gRarity);
const uses = Math.max(3, Math.round(3 * gMult));
pool.push({ id: 'turretGadgetUp', icon: '◈', name: 'Torreta +usos', desc: `+${uses} usos`, apply: () => {
p.gadgetUses += uses;
document.getElementById('gadgetBtn').querySelector('.uses').textContent = p.gadgetUses;
}, type: 'gadget', rarity: gRarity });
pool.push({ id: 'turretDmg', icon: '◈', name: 'Daño Torreta', desc: `+${Math.round(40 * mult)}% daño`, apply: () => { p.turretDamage *= 1 + 0.4 * mult; }, type: 'gadget', rarity: rarity });
}
if (!p.gadget) {
const gRarity = getRarity();
const gMult = rarityMult(gRarity);
const uses = Math.max(2, Math.round(2 * gMult));
pool.push({ id: 'invisGadget', icon: '◉', name: 'Invulnerabilidad', desc: `${uses} usos, 20s cd`, apply: () => {
p.gadget = 'invis';
p.gadgetUses = uses;
p.gadgetCooldown = 0;
p.gadgetMaxCooldown = 20;
document.getElementById('gadgetBtn').style.display = 'flex';
document.getElementById('gadgetBtn').style.background = 'rgba(77,208,255,0.8)';
document.getElementById('gadgetBtn').innerHTML = '<span>◉</span><span class="uses">' + p.gadgetUses + '</span>';
}, type: 'gadget', rarity: gRarity });
} else if (p.gadget === 'invis') {
const gRarity = getRarity();
const gMult = rarityMult(gRarity);
const uses = Math.max(2, Math.round(2 * gMult));
pool.push({ id: 'invisGadgetUp', icon: '◉', name: 'Invuln. +usos', desc: `+${uses} usos`, apply: () => {
p.gadgetUses += uses;
document.getElementById('gadgetBtn').querySelector('.uses').textContent = p.gadgetUses;
}, type: 'gadget', rarity: gRarity });
}
if (!p.gadget) {
const gRarity = getRarity();
const gMult = rarityMult(gRarity);
const uses = Math.max(1, Math.round(1 * gMult));
pool.push({ id: 'bombGadget', icon: '◎', name: 'Bomba', desc: `${uses} uso(s), 30s cd`, apply: () => {
p.gadget = 'bomb';
p.gadgetUses = uses;
p.gadgetCooldown = 0;
p.gadgetMaxCooldown = 30;
document.getElementById('gadgetBtn').style.display = 'flex';
document.getElementById('gadgetBtn').style.background = 'rgba(255,59,92,0.8)';
document.getElementById('gadgetBtn').innerHTML = '<span>◎</span><span class="uses">' + p.gadgetUses + '</span>';
}, type: 'gadget', rarity: gRarity });
} else if (p.gadget === 'bomb') {
const gRarity = getRarity();
const gMult = rarityMult(gRarity);
const uses = Math.max(1, Math.round(1 * gMult));
pool.push({ id: 'bombGadgetUp', icon: '◎', name: 'Bomba +usos', desc: `+${uses} uso(s)`, apply: () => {
p.gadgetUses += uses;
document.getElementById('gadgetBtn').querySelector('.uses').textContent = p.gadgetUses;
}, type: 'gadget', rarity: gRarity });
}
if (!p.gadget) {
const gRarity = getRarity();
const gMult = rarityMult(gRarity);
const uses = Math.max(3, Math.round(3 * gMult));
pool.push({ id: 'dashGadget', icon: '◐', name: 'Dash', desc: `${uses} usos, 12s cd (invulnerable 2s)`, apply: () => {
p.gadget = 'dash';
p.gadgetUses = uses;
p.gadgetCooldown = 0;
p.gadgetMaxCooldown = 12;
document.getElementById('gadgetBtn').style.display = 'flex';
document.getElementById('gadgetBtn').style.background = 'rgba(255,209,102,0.8)';
document.getElementById('gadgetBtn').innerHTML = '<span>◐</span><span class="uses">' + p.gadgetUses + '</span>';
}, type: 'gadget', rarity: gRarity });
} else if (p.gadget === 'dash') {
const gRarity = getRarity();
const gMult = rarityMult(gRarity);
const uses = Math.max(3, Math.round(3 * gMult));
pool.push({ id: 'dashGadgetUp', icon: '◐', name: 'Dash +usos', desc: `+${uses} usos`, apply: () => {
p.gadgetUses += uses;
document.getElementById('gadgetBtn').querySelector('.uses').textContent = p.gadgetUses;
}, type: 'gadget', rarity: gRarity });
pool.push({ id: 'dashDmg', icon: '◐', name: 'Dash Daño', desc: 'El dash hace más daño', apply: () => { p.dashDamage = (p.dashDamage || 0) + 15 * mult; }, type: 'gadget', rarity: rarity });
}
const shuffled = pool.sort(() => Math.random() - 0.5);
return shuffled.slice(0, Math.min(3, shuffled.length));
}
const levelUpOverlay = document.getElementById('levelUpOverlay');
const cardsDiv = document.getElementById('cards');
function showLevelUp(forceWeapon, customTitle, titleEffect) {
state = 'levelup';
const titleText = customTitle || ('SUBISTE AL NIVEL ' + p.level);
document.getElementById('levelUpTitle').innerHTML = wrapWaveLetters(titleText, titleEffect || 'mythic');
const opts = getUpgradePool(forceWeapon);
cardsDiv.innerHTML = '';
opts.forEach(o => {
const wrap = document.createElement('div');
wrap.className = 'cardWrap';
const c = document.createElement('div');
c.className = 'card';
const isNew = !!o.newUnlock;
const rarityColor = isNew ? '#ffffff' : (o.rarity ? o.rarity.color : '#2ecc71');
c.style.borderColor = rarityColor;
c.style.boxShadow = `0 4px 14px rgba(0,0,0,0.3), 0 0 20px ${rarityColor}15`;
const rarityClass = isNew ? 'rarity-new' : (o.rarity ? o.rarity.class : 'rarity-common');
const rarityName = isNew ? '¡NUEVO!' : (o.rarity ? o.rarity.name : 'COMÚN');
const isLegendary = !isNew && o.rarity && o.rarity.name === 'LEGENDARIO';
const isMythic = !isNew && o.rarity && o.rarity.name === 'MÍTICO';
const nameHTML = isMythic ? wrapWaveLetters(o.name, 'mythicHalf') : (isLegendary ? wrapWaveLetters(o.name, 'goldHalf') : o.name);
const nameStyle = isLegendary ? ' style="color:#ffd700;"' : (isMythic ? ' style="color:#ff2b2b;"' : '');
c.innerHTML = `
<div class="cTag tag-${o.type}">${o.type.toUpperCase()}</div>
<div class="cIcon">${o.icon}</div>
<div class="cName"${nameStyle}>${nameHTML}</div>
<div class="cDesc">${o.desc}</div>
<div class="cRarity ${rarityClass}">${rarityName}</div>
`;
wrap.appendChild(c);
wrap.addEventListener('click', () => {
o.apply();
levelUpOverlay.classList.add('hidden');
state = 'playing';
hideDimOverlay();
});
cardsDiv.appendChild(wrap);
});
levelUpOverlay.classList.remove('hidden');
showDimOverlay(0.5);
}
// ---------- BANNER ----------
const bannerEl = document.getElementById('banner'),
bannerSubEl = document.getElementById('bannerSub'),
bannerBgEl = document.getElementById('bannerBg');
const stageSubMessages = [
'La realidad se fractura...',
'Algo se aproxima en la oscuridad...',
'El aire se vuelve más denso...',
'Los enemigos evolucionan...',
'Una nueva amenaza emerge...',
'El horizonte cambia...',
'Prepárate para lo que viene...',
'El pulso del mundo se acelera...',
'Las sombras se multiplican...',
'Un escalofrío recorre el aire...',
'No hay vuelta atrás...',
'El caos se intensifica...'
];
function announceStage() {
const s = stage();
bannerEl.innerHTML = wrapWaveLetters(s.name, false);
bannerEl.style.color = '#ffffff';
bannerSubEl.textContent = stageSubMessages[Math.floor(Math.random() * stageSubMessages.length)];
bannerEl.classList.add('show');
bannerSubEl.classList.add('show');
bannerBgEl.classList.add('show');
setTimeout(() => {
bannerEl.classList.remove('show');
bannerSubEl.classList.remove('show');
bannerBgEl.classList.remove('show');
}, CFG.BANNER_DURATION * 1000);
}
// ---------- GADGET ----------
function deployGadget() {
if (!p.gadget || p.gadgetUses <= 0 || p.gadgetCooldown > 0) return;
p.gadgetUses--;
p.gadgetCooldown = p.gadgetMaxCooldown;
if (p.gadget === 'turret') {
let target = null,
minD = Infinity;
for (const e of enemies) {
const d = dist(p.x, p.y, e.x, e.y);
if (d < minD) { minD = d;
target = e; }
}
playerTurrets.push({ x: p.x, y: p.y, life: 6.0, maxLife: 6.0, shootTimer: 0, damage: p.turretDamage, r: 16, target: target });
addWaveMessage('TORRETA DESPLEGADA', '', '#ff6b35', 'below');
screenShake(3, 0.1);
}
if (p.gadget === 'invis') {
p.invuln = 3.0;
addWaveMessage('¡INVULNERABLE!', '', '#4dd0ff', 'below');
screenShake(4, 0.15);
}
if (p.gadget === 'bomb') {
for (const e of enemies) {
const d = dist(p.x, p.y, e.x, e.y);
if (d < 200) {
damageEnemy(e, 80, 0.3);
}
}
for (let i = 0; i < 50; i++) {
const ang = rand(0, Math.PI * 2);
const r = rand(20, 200);
particles.push({
x: p.x + Math.cos(ang) * r,
y: p.y + Math.sin(ang) * r,
vx: Math.cos(ang) * rand(50, 250),
vy: Math.sin(ang) * rand(50, 250) - 20,
life: rand(0.3, 0.8),
color: ['#ff6b6b', '#ff8a5c', '#ffd700'][Math.floor(rand(0, 3))],
r: rand(3, 8)
});
}
for (let i = 0; i < 20; i++) {
const ang = (i / 20) * Math.PI * 2;
particles.push({
x: p.x + Math.cos(ang) * 80,
y: p.y + Math.sin(ang) * 80,
vx: Math.cos(ang) * rand(20, 50),
vy: Math.sin(ang) * rand(20, 50) - 10,
life: rand(0.2, 0.5),
color: 'rgba(255,107,53,0.5)',
r: rand(3, 7)
});
}
screenShake(12, 0.4);
addWaveMessage('¡BOOM!', '', '#ff6b6b', 'below');
}
if (p.gadget === 'dash') {
dashActive = true;
dashTimer = 2.0;
p.invuln = 2.0;
const speed = 500;
const ang = Math.atan2(joyDY, joyDX);
if (joyDX !== 0 || joyDY !== 0) {
p.x += Math.cos(ang) * speed * 0.15;
p.y += Math.sin(ang) * speed * 0.15;
} else {
p.x += 120;
}
for (let i = 0; i < 30; i++) {
particles.push({
x: p.x + rand(-30, 30),
y: p.y + rand(-30, 30),
vx: rand(-80, 80),
vy: rand(-80, 80),
life: rand(0.2, 0.6),
color: '#ffd166',
r: rand(2, 5)
});
}
if (p.dashDamage) {
for (const e of enemies) {
if (dist(p.x, p.y, e.x, e.y) < 60) {
damageEnemy(e, p.dashDamage, 0.2);
}
}
}
screenShake(5, 0.1);
addWaveMessage('¡DASH!', '', '#ffd166', 'below');
}
document.getElementById('gadgetBtn').querySelector('.uses').textContent = p.gadgetUses;
if (p.gadgetUses <= 0) document.getElementById('gadgetBtn').style.display = 'none';
updateGadgetCooldown();
}
function updateGadgetCooldown() {
const cdEl = document.getElementById('gadgetCooldown');
if (p.gadget && p.gadgetCooldown > 0 && p.gadgetUses > 0) {
cdEl.style.display = 'block';
cdEl.textContent = Math.ceil(p.gadgetCooldown) + 's';
} else {
cdEl.style.display = 'none';
}
}
document.getElementById('gadgetBtn').addEventListener('click', deployGadget);
document.getElementById('gadgetBtn').addEventListener('touchend', (e) => { e.preventDefault();
deployGadget(); });
// ---------- DEBUG: SUBIR DE NIVEL (solo para testeo) ----------
document.getElementById('debugLevelBtn').addEventListener('click', () => {
if (state !== 'playing') return;
gainXp(p.xpToNext);
});
document.getElementById('debugLevelBtn').addEventListener('touchend', (e) => {
e.preventDefault();
if (state !== 'playing') return;
gainXp(p.xpToNext);
});
// ---------- PAUSA ----------
function togglePause() {
if (state !== 'playing') return;
infoOpen = !infoOpen;
isPaused = infoOpen;
document.getElementById('pauseOverlay').classList.toggle('show', infoOpen);
if (infoOpen) {
showDimOverlay(0.5);
updatePauseStats();
} else {
hideDimOverlay();
}
}
function updatePauseStats() {
const weapons = [];
if (p.hasPistol) weapons.push('● Pistola');
if (p.hasWave) weapons.push('◆ Onda');
if (p.hasOrbiters) weapons.push('▲ Orbitadores (' + p.orbiterCount + ')');
if (p.hasLaser) weapons.push('⬡ Láser');
if (p.hasMissiles) weapons.push('⬢ Misiles');
if (p.hasShotgun) weapons.push('⬣ Escopeta');
document.getElementById('pauseStats').innerHTML = `
<div><b>Vida:</b> ${Math.floor(p.hp)}/${p.maxHp}</div>
<div><b>Escudo:</b> ${Math.floor(p.shield)}/${p.maxShield}</div>
<div><b>Nivel:</b> ${p.level}</div>
<div><b>Tiempo:</b> ${Math.floor(elapsed/60)}:${String(Math.floor(elapsed%60)).padStart(2,'0')}</div>
<div><b>Enemigos eliminados:</b> ${kills}</div>
<div><b>Daño:</b> ${Math.round(p.damage)}</div>
<div><b>Velocidad:</b> ${Math.round(p.speed)}</div>
<div><b>Rango:</b> ${Math.round(p.range)}</div>
<div><b>Armas:</b> ${weapons.join(', ') || 'Ninguna'}</div>
`;
}
document.getElementById('resumeBtn').addEventListener('click', togglePause);
document.getElementById('pauseBtn').addEventListener('click', togglePause);
let restartConfirmArmed = false;
let restartConfirmTimeout = null;
document.getElementById('restartPauseBtn').addEventListener('click', () => {
const btn = document.getElementById('restartPauseBtn');
if (!restartConfirmArmed) {
restartConfirmArmed = true;
btn.textContent = '¿SEGURO?';
clearTimeout(restartConfirmTimeout);
restartConfirmTimeout = setTimeout(() => {
restartConfirmArmed = false;
btn.textContent = 'SALIR';
}, 3000);
return;
}
clearTimeout(restartConfirmTimeout);
restartConfirmArmed = false;
btn.textContent = 'SALIR';
document.getElementById('pauseOverlay').classList.remove('show');
infoOpen = false;
isPaused = false;
document.getElementById('gameOverOverlay').classList.add('hidden');
document.querySelectorAll('.weapon-option').forEach(el => el.classList.remove('selected'));
selectedWeapon = null;
state = 'menu';
setMenuUI(true);
updateBestScoreDisplay();
document.getElementById('startOverlay').classList.remove('hidden');
});
// ---------- COFRES ----------
function spawnChest() {
const x = rand(50, CFG.WORLD_SIZE - 50);
const y = rand(50, CFG.WORLD_SIZE - 50);
chests.push({ x, y, r: 14, opened: false, lineLife: 10, lineMaxLife: 10 });
}
function openChest(chest) {
if (chest.opened) return;
chest.opened = true;
for (let i = 0; i < 30; i++) {
const ang = rand(0, Math.PI * 2);
const spd = rand(50, 200);
particles.push({
x: chest.x,
y: chest.y,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd - 20,
life: rand(0.3, 0.8),
color: '#ffd700',
r: rand(2, 6)
});
}
screenShake(3, 0.15);
chest.lineLife = 0;
showLevelUp(false, '¡ABRISTE UN COFRE!', 'gold');
}
// ---------- DAÑO POR CONTACTO ----------
function handleContactDamage(enemy) {
if (enemy.contactCooldown > 0) return;
if (enemy.type === 'turret') return;
let contactDmg = CFG.CONTACT_DAMAGE_BASE + (p.level * CFG.CONTACT_DAMAGE_SCALING);
contactDmg = Math.min(contactDmg, CFG.CONTACT_DAMAGE_MAX);
if (contactDmg > 0) {
enemy.contactCooldown = CFG.CONTACT_ENEMY_COOLDOWN;
const angle = Math.atan2(enemy.y - p.y, enemy.x - p.x);
let knockback = CFG.CONTACT_KNOCKBACK;
if (enemy.type === 'tank') knockback = CFG.CONTACT_TANK_KNOCKBACK;
if (enemy.type === 'pentagon' || enemy.type === 'hexagon') knockback = CFG.CONTACT_BOSS_KNOCKBACK;
enemy.x += Math.cos(angle) * knockback;
enemy.y += Math.sin(angle) * knockback;
damageEnemy(enemy, contactDmg, 0.25);
}
}
// ---------- SISTEMA DE COMBO ----------
function updateCombo() {
comboCount++;
comboTimer = CFG.COMBO_TIMEOUT;
let title = '';
let subtitle = 'x' + comboCount;
let color = '#ffd166';
let shakeAmt = 1;
let burstColor = '#ffd166';
if (comboCount >= 2 && comboCount < 5) {
const t = Math.min(1, (comboCount - 2) / 3);
const r = 255;
const g = Math.floor(lerp(200, 255, t));
const b = Math.floor(lerp(50, 0, t));
color = `rgb(${r},${g},${b})`;
title = '¡RACHA!';
shakeAmt = 1.5;
burstColor = color;
} else if (comboCount >= 5 && comboCount < 10) {
const t = Math.min(1, (comboCount - 5) / 5);
const r = 255;
const g = Math.floor(lerp(255, 200, t));
const b = Math.floor(lerp(0, 0, t));
color = `rgb(${r},${g},${b})`;
title = '¡BUEN COMBO!';
shakeAmt = 2.5;
burstColor = color;
} else if (comboCount >= 10 && comboCount < 20) {
const t = Math.min(1, (comboCount - 10) / 10);
const r = 255;
const g = Math.floor(lerp(255, 100, t));
const b = Math.floor(lerp(0, 0, t));
color = `rgb(${r},${g},${b})`;
title = '¡SUPER COMBO!';
shakeAmt = 4;
burstColor = color;
} else if (comboCount >= 20 && comboCount < 35) {
const t = Math.min(1, (comboCount - 20) / 15);
const r = Math.floor(lerp(255, 255, t));
const g = Math.floor(lerp(100, 50, t));
const b = Math.floor(lerp(0, 255, t));
color = `rgb(${r},${g},${b})`;
title = '¡MEGA COMBO!';
shakeAmt = 6;
burstColor = color;
} else if (comboCount >= 35 && comboCount < 50) {
const t = Math.min(1, (comboCount - 35) / 15);
const r = Math.floor(lerp(255, 150, t));
const g = Math.floor(lerp(50, 0, t));
const b = Math.floor(lerp(255, 255, t));
color = `rgb(${r},${g},${b})`;
title = '¡ULTRA COMBO!';
shakeAmt = 8;
burstColor = color;
} else if (comboCount >= 50) {
const hue = (comboCount * 2) % 360;
color = `hsl(${hue}, 100%, 60%)`;
title = '¡HÍPER COMBO!';
frenzyActive = true;
shakeAmt = 10;
burstColor = color;
}
if (comboCount >= 2) {
addWaveMessage(title, subtitle, color);
screenShake(shakeAmt, 0.12);
const burstCount = Math.min(6 + comboCount, 30);
for (let i = 0; i < burstCount; i++) {
const ang = rand(0, Math.PI * 2);
const spd = rand(40, 140);
particles.push({
x: p.x,
y: p.y,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd - 20,
life: rand(0.25, 0.6),
color: burstColor,
r: rand(2, 5)
});
}
}
}
// ---------- UPDATE WEAPON COOLDOWNS UI ----------
function updateWeaponCDs() {
const container = document.getElementById('weaponCooldowns');
let html = '<div class="weaponLabel">ARMAS</div>';
const weaponIcons = {
pistol: '●',
wave: '◆',
laser: '⬡',
missile: '⬢',
orbiter: '▲',
shotgun: '⬣'
};
const weapons = [
{ id: 'pistol', active: p.hasPistol, timer: p.atkTimer, interval: p.fireRate, ready: p.weaponReady.pistol },
{ id: 'wave', active: p.hasWave, timer: p.waveCooldown, interval: p.waveInterval, ready: p.weaponReady.wave },
{ id: 'laser', active: p.hasLaser, timer: p.laserTimer, interval: p.laserCooldown, ready: p.weaponReady.laser },
{ id: 'missile', active: p.hasMissiles, timer: p.missileCooldown, interval: p.missileInterval, ready: p.weaponReady.missile },
{ id: 'orbiter', active: p.hasOrbiters && p.orbiterCount > 0, timer: p.orbiterTimer, interval: p.orbiterFireRate, ready: p.weaponReady.orbiter },
{ id: 'shotgun', active: p.hasShotgun, timer: p.shotgunCooldown, interval: p.shotgunInterval, ready: p.weaponReady.shotgun }
];
let hasWeapons = false;
for (const w of weapons) {
if (!w.active) continue;
hasWeapons = true;
const icon = weaponIcons[w.id] || '●';
if (w.ready) {
html += `<div class="weaponCD ready"><span class="cd-icon">${icon}</span> <span class="cd-time">CARGADA</span></div>`;
} else {
const remaining = Math.max(0, w.timer);
html += `<div class="weaponCD active"><span class="cd-icon">${icon}</span> <span class="cd-time">${remaining.toFixed(1)}s</span></div>`;
}
}
if (!hasWeapons) html = '';
container.innerHTML = html;
}
// ---------- UPDATE ----------
function update(dt) {
elapsed += dt;
// Banner pause
if (bannerPause) {
bannerPauseTimer -= dt;
if (bannerPauseTimer <= 0) {
bannerPause = false;
isPaused = false;
}
return;
}
stageTimer += dt;
comboTimer -= dt;
if (comboTimer <= 0 && comboCount > 0) {
comboCount = 0;
frenzyActive = false;
// Al terminar la racha, se desvanece el mensaje suavemente en vez de cortarse de golpe.
for (const msg of p.waveMessages) {
if (msg.title.includes('RACHA') || msg.title.includes('COMBO') || msg.title.includes('HÍPER')) {
msg.life = Math.min(msg.life, msg.t + 1.4);
}
}
}
if (dashActive) {
dashTimer -= dt;
if (dashTimer <= 0) {
dashActive = false;
}
}
updateWeaponCDs();
updateGadgetCooldown();
// Etapas infinitas
if (stageTimer >= CFG.WAVE_DURATION) {
stageTimer = 0;
stageIndex++;
waveCount++;
chestSpawnCounter++;
if (chestSpawnCounter >= CFG.CHEST_SPAWN_INTERVAL) {
chestSpawnCounter = 0;
spawnChest();
}
announceStage();
}
// Movimiento
let mx = 0,
my = 0;
if (keys['a'] || keys['arrowleft']) mx -= 1;
if (keys['d'] || keys['arrowright']) mx += 1;
if (keys['w'] || keys['arrowup']) my -= 1;
if (keys['s'] || keys['arrowdown']) my += 1;
if (joyActive) { mx += joyDX;
my += joyDY; }
const mlen = Math.hypot(mx, my);
if (mlen > 0) { mx /= Math.max(mlen, 1);
my /= Math.max(mlen, 1); }
let speed = p.speed;
for (const zone of slowZones) {
if (zone.life > 0 && zone.alpha > 0.1) {
const d = dist(p.x, p.y, zone.x, zone.y);
if (d < zone.radius) {
speed *= 0.5;
break;
}
}
}
if (p.slowTimer > 0) { speed *= (1 - p.slowAmount);
p.slowTimer -= dt; }
if (isPaused) speed = 0;
p.x = clamp(p.x + mx * speed * dt, p.r, CFG.WORLD_SIZE - p.r);
p.y = clamp(p.y + my * speed * dt, p.r, CFG.WORLD_SIZE - p.r);
if (dashActive) {
p.invuln = Math.max(p.invuln, dashTimer);
}
if (p.invuln > 0) p.invuln -= dt;
if (p.contactCooldown > 0) p.contactCooldown -= dt;
if (p.regen > 0) p.hp = Math.min(p.maxHp, p.hp + p.regen * dt);
if (p.shield < p.maxShield && p.shieldRegen > 0) {
p.shield = Math.min(p.maxShield, p.shield + p.shieldRegen * dt);
}
if (p.gadgetCooldown > 0) p.gadgetCooldown -= dt;
for (let i = p.messages.length - 1; i >= 0; i--) {
p.messages[i].t += dt;
if (p.messages[i].t >= p.messages[i].life) p.messages.splice(i, 1);
}
for (let i = p.waveMessages.length - 1; i >= 0; i--) {
p.waveMessages[i].t += dt;
if (p.waveMessages[i].t >= p.waveMessages[i].life) p.waveMessages.splice(i, 1);
}
if (damageFlashTimer > 0) damageFlashTimer -= dt;
document.getElementById('damageFlash').style.opacity = damageFlashTimer > 0 ? '0.7' : '0';
if (dimOverlayActive && dimOverlayTimer > 0) {
dimOverlayTimer -= dt;
if (dimOverlayTimer <= 0) {
hideDimOverlay();
}
}
// Rotación del rango
rangeRotation += dt * 0.3;
// Cofres
for (const chest of chests) {
if (!chest.opened && chest.lineLife > 0) chest.lineLife -= dt;
if (!chest.opened && dist(p.x, p.y, chest.x, chest.y) < p.r + chest.r + 10) {
openChest(chest);
}
}
for (let i = slowZones.length - 1; i >= 0; i--) {
const z = slowZones[i];
if (z.warning) {
z.warningTimer -= dt;
if (z.warningTimer <= 0) {
z.warning = false;
z.life = 3.0;
z.maxLife = 3.0;
z.alpha = 1;
}
} else {
z.life -= dt;
if (z.life > 1.0) {
z.alpha = 1;
} else if (z.life > 0) {
z.alpha = z.life;
} else {
z.alpha = 0;
slowZones.splice(i, 1);
continue;
}
}
}
let nearestEnemy = null;
let nearestDist = Infinity;
for (const e of enemies) {
const d = dist2(p.x, p.y, e.x, e.y);
if (d < nearestDist && d < p.range * p.range) {
nearestDist = d;
nearestEnemy = e;
}
}
const fireRateMult = frenzyActive ? 0.85 : 1.0;
// --- PISTOLA ---
if (p.hasPistol) {
p.atkTimer -= dt;
if (p.weaponReady.pistol && nearestEnemy) {
p.weaponReady.pistol = false;
p.atkTimer = p.fireRate * fireRateMult;
const shots = 1 + p.multishot;
const baseAng = Math.atan2(nearestEnemy.y - p.y, nearestEnemy.x - p.x);
for (let i = 0; i < shots; i++) {
const off = (i - (shots - 1) / 2) * 0.15;
const ang = baseAng + off;
let dmg = p.damage;
bullets.push({
x: p.x,
y: p.y,
vx: Math.cos(ang) * CFG.BULLET_SPEED,
vy: Math.sin(ang) * CFG.BULLET_SPEED,
dmg: dmg,
life: 1.2,
r: 4,
type: 'normal',
pierce: p.pistolPierce || 0,
hitList: []
});
}
}
if (p.atkTimer <= 0) {
p.weaponReady.pistol = true;
}
}
// --- ESCOPETA ---
if (p.hasShotgun) {
p.shotgunCooldown -= dt;
if (p.weaponReady.shotgun && nearestEnemy) {
p.weaponReady.shotgun = false;
p.shotgunCooldown = p.shotgunInterval * fireRateMult;
const baseAng = Math.atan2(nearestEnemy.y - p.y, nearestEnemy.x - p.x);
const pellets = p.shotgunPelletCount || 5;
for (let i = 0; i < pellets; i++) {
const spread = (i - (pellets - 1) / 2) * 0.15;
const ang = baseAng + spread + rand(-0.04, 0.04);
let dmg = p.shotgunDamage;
bullets.push({
x: p.x,
y: p.y,
vx: Math.cos(ang) * CFG.SHOTGUN_BULLET_SPEED,
vy: Math.sin(ang) * CFG.SHOTGUN_BULLET_SPEED,
dmg: dmg,
life: 0.7,
r: 3,
type: 'normal',
pierce: 0,
hitList: []
});
}
screenShake(2, 0.05);
}
if (p.shotgunCooldown <= 0) {
p.weaponReady.shotgun = true;
}
}
// --- LÁSER ---
if (p.hasLaser) {
p.laserTimer -= dt;
if (p.weaponReady.laser && nearestEnemy) {
p.weaponReady.laser = false;
p.laserTimer = p.laserCooldown * fireRateMult;
const ang = Math.atan2(nearestEnemy.y - p.y, nearestEnemy.x - p.x);
let dmg = p.laserDamage;
damageEnemy(nearestEnemy, dmg, 0.15);
if (Math.random() < p.laserBounceChance && enemies.includes(nearestEnemy)) {
let secondTarget = null;
let secondDist = Infinity;
for (const e of enemies) {
if (e === nearestEnemy) continue;
const d = dist2(nearestEnemy.x, nearestEnemy.y, e.x, e.y);
if (d < secondDist && d < p.range * p.range) {
secondDist = d;
secondTarget = e;
}
}
if (secondTarget) {
damageEnemy(secondTarget, dmg * 0.6, 0.15);
for (let i = 0; i < 5; i++) {
particles.push({
x: (nearestEnemy.x + secondTarget.x) / 2 + rand(-10, 10),
y: (nearestEnemy.y + secondTarget.y) / 2 + rand(-10, 10),
vx: rand(-20, 20),
vy: rand(-20, 20),
life: 0.2,
color: '#00ff88',
r: rand(2, 4)
});
}
}
}
for (let i = 0; i < 15; i++) {
const dist2_ = rand(10, p.range * 1.5);
particles.push({
x: p.x + Math.cos(ang) * dist2_,
y: p.y + Math.sin(ang) * dist2_,
vx: rand(-10, 10),
vy: rand(-10, 10),
life: 0.15,
color: '#00ff88',
r: rand(1, 3)
});
}
}
if (p.laserTimer <= 0) {
p.weaponReady.laser = true;
}
}
// --- MISIL ---
if (p.hasMissiles) {
p.missileCooldown -= dt;
if (p.weaponReady.missile && nearestEnemy) {
p.weaponReady.missile = false;
p.missileCooldown = p.missileInterval * fireRateMult;
const ang = Math.atan2(nearestEnemy.y - p.y, nearestEnemy.x - p.x);
let dmg = p.missileDamage;
bullets.push({
x: p.x,
y: p.y,
vx: Math.cos(ang) * 160,
vy: Math.sin(ang) * 160,
dmg: dmg,
life: 2.0,
r: 12,
type: 'missile',
explosionRadius: p.missileExplosionRadius,
target: nearestEnemy
});
for (let i = 0; i < 5; i++) {
particles.push({
x: p.x + rand(-10, 10),
y: p.y + rand(-10, 10),
vx: rand(-30, 30),
vy: rand(-30, 30),
life: 0.3,
color: '#ff6b35',
r: rand(2, 4)
});
}
}
if (p.missileCooldown <= 0) {
p.weaponReady.missile = true;
}
}
// --- ONDA ---
if (p.hasWave) {
p.waveCooldown -= dt;
if (p.weaponReady.wave) {
let hit = false;
const dmgBonus = p.waveDamage;
for (const e of enemies) {
if (dist(p.x, p.y, e.x, e.y) < p.r + e.r + p.range) {
hit = true;
damageEnemy(e, dmgBonus, 0.2);
}
}
if (hit) {
p.weaponReady.wave = false;
p.waveCooldown = p.waveInterval * fireRateMult;
for (let i = 0; i < 20; i++) {
const ang = rand(0, Math.PI * 2);
const r = rand(5, p.range);
particles.push({
x: p.x + Math.cos(ang) * r,
y: p.y + Math.sin(ang) * r,
vx: Math.cos(ang) * rand(10, 50),
vy: Math.sin(ang) * rand(10, 50) - 10,
life: rand(0.2, 0.4),
color: '#ffd700',
r: rand(1, 3)
});
}
for (let i = 0; i < 30; i++) {
const ang = (i / 30) * Math.PI * 2;
particles.push({
x: p.x + Math.cos(ang) * p.range,
y: p.y + Math.sin(ang) * p.range,
vx: Math.cos(ang) * rand(20, 60),
vy: Math.sin(ang) * rand(20, 60) - 10,
life: rand(0.2, 0.5),
color: 'rgba(255,215,0,0.6)',
r: rand(1, 2)
});
}
}
}
if (p.waveCooldown <= 0) {
p.weaponReady.wave = true;
}
}
// --- ORBITADORES ---
if (p.hasOrbiters && p.orbiterCount > 0) {
p.orbiterTimer -= dt;
while (orbiters.length < p.orbiterCount) {
const idx = orbiters.length;
orbiters.push({ angle: (idx / p.orbiterCount) * Math.PI * 2, dist: 38, speed: 2.5 });
}
for (const orb of orbiters) {
orb.angle += orb.speed * dt;
const ox = p.x + Math.cos(orb.angle) * orb.dist;
const oy = p.y + Math.sin(orb.angle) * orb.dist;
if (p.weaponReady.orbiter) {
let nearest = null,
nd = Infinity;
for (const e of enemies) {
const d = dist2(ox, oy, e.x, e.y);
if (d < nd && d < p.range * p.range) { nd = d;
nearest = e; }
}
if (nearest) {
p.weaponReady.orbiter = false;
p.orbiterTimer = p.orbiterFireRate * fireRateMult;
const ang = Math.atan2(nearest.y - oy, nearest.x - ox);
let dmg = p.orbiterDamage;
orbiterBullets.push({
x: ox,
y: oy,
vx: Math.cos(ang) * 260,
vy: Math.sin(ang) * 260,
dmg: dmg,
life: 1.3,
r: 3
});
}
}
}
if (p.orbiterTimer <= 0) {
p.weaponReady.orbiter = true;
}
}
// Spawn enemigos
if (!isPaused && !bannerPause) {
spawnTimer -= dt;
if (spawnTimer <= 0) {
const spawnRate = Math.max(0.25, CFG.SPAWN_INTERVAL - difficulty() * 0.06);
spawnTimer = spawnRate;
const count = Math.min(1 + Math.floor(difficulty() * 0.25), 4);
for (let i = 0; i < count; i++) spawnEnemy();
}
}
// Actualizar enemigos
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
if (e.contactCooldown > 0) e.contactCooldown -= dt;
if (e.type === 'pentagon' || e.type === 'hexagon') {
e.shootTimer -= dt;
// Solo disparan si el jugador está lo bastante cerca para que la bala pueda llegar.
const atkRange = e.type === 'hexagon' ? 260 : 280;
if (e.shootTimer <= 0 && dist(e.x, e.y, p.x, p.y) < atkRange) {
e.shootTimer = e.shootInterval;
const ang = Math.atan2(p.y - e.y, p.x - e.x);
const spread = e.type === 'hexagon' ? 0.15 : 0.1;
for (let i2 = -1; i2 <= 1; i2++) {
const s = i2 * spread;
enemyBullets.push({
x: e.x,
y: e.y,
vx: Math.cos(ang + s) * 100,
vy: Math.sin(ang + s) * 100,
dmg: e.dmg,
life: 3.0,
r: e.type === 'hexagon' ? 6 : 5,
color: e.type === 'hexagon' ? ENEMY_COLORS.hexagon : ENEMY_COLORS.pentagon,
slowAmount: e.type === 'hexagon' ? e.slowAmount : 0,
slowDuration: e.type === 'hexagon' ? e.slowDuration : 0
});
}
}
}
if (e.type === 'turret') {
e.shootTimer -= dt;
const atkRange = 200;
if (e.shootTimer <= 0 && dist(e.x, e.y, p.x, p.y) < atkRange) {
e.shootTimer = e.shootInterval;
const ang = Math.atan2(p.y - e.y, p.x - e.x);
enemyBullets.push({
x: e.x,
y: e.y,
vx: Math.cos(ang) * 90,
vy: Math.sin(ang) * 90,
dmg: e.dmg,
life: 2.5,
r: 4,
color: '#2ecc71'
});
}
continue;
}
if (e.type !== 'turret') {
const ang = Math.atan2(p.y - e.y, p.x - e.x);
const d = Math.hypot(p.x - e.x, p.y - e.y) || 1;
const spd = e.speed;
e.x += (p.x - e.x) / d * spd * dt;
e.y += (p.y - e.y) / d * spd * dt;
}
// Rotación visual independiente del movimiento: los enemigos giran
// constantemente sobre sí mismos en vez de "mirar" hacia el jugador.
e.rot = (e.rot || 0) + dt * 2.2;
if (!e._trailPos) e._trailPos = { x: e.x, y: e.y };
if (!e._trailTimer) e._trailTimer = 0;
e._trailTimer += dt;
if (e._trailTimer > 0.08) {
e._trailTimer = 0;
addTrail(e._trailPos.x, e._trailPos.y, e.x, e.y, enemyColor(e));
e._trailPos = { x: e.x, y: e.y };
}
if (e.hit > 0) e.hit -= dt;
if (e.type === 'turret') continue;
if (dist(e.x, e.y, p.x, p.y) < e.r + p.r && p.invuln <= 0) {
let remaining = e.dmg;
if (p.shield > 0) {
const absorbed = Math.min(p.shield, remaining);
p.shield -= absorbed;
remaining -= absorbed;
}
if (remaining > 0) {
p.hp -= remaining;
damageFlashTimer = 0.2;
addFloater(p.x, p.y - 10, '-' + Math.round(remaining), '#ff3b5c');
if (p.thorns) {
const thornDmg = remaining * p.thorns;
addFloater(e.x, e.y - 10, '-' + Math.round(thornDmg), '#ff6b6b');
damageEnemy(e, thornDmg, 0.15);
}
}
p.invuln = 0.5;
p.contactCooldown = 0.5;
screenShake(6, 0.15);
if (enemies.includes(e)) handleContactDamage(e);
if (p.hp <= 0) { p.hp = 0;
triggerGameOver(); return; }
}
}
// Balas enemigas
for (let i = enemyBullets.length - 1; i >= 0; i--) {
const b = enemyBullets[i];
b.x += b.vx * dt;
b.y += b.vy * dt;
b.life -= dt;
if (b.life <= 0 || b.x < -20 || b.x > CFG.WORLD_SIZE + 20 || b.y < -20 || b.y > CFG.WORLD_SIZE + 20) {
enemyBullets.splice(i, 1);
continue;
}
if (dist(b.x, b.y, p.x, p.y) < b.r + p.r && p.invuln <= 0) {
let remaining = b.dmg;
if (p.shield > 0) {
const absorbed = Math.min(p.shield, remaining);
p.shield -= absorbed;
remaining -= absorbed;
}
if (remaining > 0) {
p.hp -= remaining;
if (b.slowAmount && b.slowDuration) {
p.slowTimer = b.slowDuration;
p.slowAmount = b.slowAmount;
addFloater(p.x, p.y - 10, 'Ralentizado!', '#9b59b6');
}
}
damageFlashTimer = 0.15;
enemyBullets.splice(i, 1);
if (p.hp <= 0) { p.hp = 0;
triggerGameOver(); return; }
}
}
// Balas del jugador
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.x += b.vx * dt;
b.y += b.vy * dt;
b.life -= dt;
if (b.life <= 0 || b.x < -40 || b.x > CFG.WORLD_SIZE + 40 || b.y < -40 || b.y > CFG.WORLD_SIZE + 40) {
bullets.splice(i, 1);
continue;
}
let hit = false;
for (const e of enemies) {
if (b.hitList && b.hitList.includes(e)) continue;
if (dist2(b.x, b.y, e.x, e.y) < (b.r + e.r) * (b.r + e.r)) {
if (b.type === 'missile') {
for (const e2 of enemies) {
if (e2 !== e && dist(b.x, b.y, e2.x, e2.y) < b.explosionRadius) {
damageEnemy(e2, b.dmg * 0.5, 0.15);
}
}
for (let p2 = 0; p2 < 30; p2++) {
const ang = rand(0, Math.PI * 2);
const spd = rand(50, 250);
particles.push({
x: b.x,
y: b.y,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd - 20,
life: rand(0.2, 0.7),
color: ['#ff6b35', '#ffd700', '#ff3b5c'][Math.floor(rand(0, 3))],
r: rand(2, 7)
});
}
for (let p2 = 0; p2 < 20; p2++) {
const ang = (p2 / 20) * Math.PI * 2;
particles.push({
x: b.x + Math.cos(ang) * 40,
y: b.y + Math.sin(ang) * 40,
vx: Math.cos(ang) * rand(10, 30),
vy: Math.sin(ang) * rand(10, 30) - 10,
life: rand(0.2, 0.5),
color: 'rgba(255,107,53,0.5)',
r: rand(2, 5)
});
}
screenShake(10, 0.3);
}
damageEnemy(e, b.dmg, 0.15);
if (b.pierce && b.pierce > 0) {
b.hitList = b.hitList || [];
b.hitList.push(e);
b.pierce--;
} else {
hit = true;
}
break;
}
}
if (hit) bullets.splice(i, 1);
}
// Balas de orbitadores
for (let i = orbiterBullets.length - 1; i >= 0; i--) {
const b = orbiterBullets[i];
b.x += b.vx * dt;
b.y += b.vy * dt;
b.life -= dt;
if (b.life <= 0 || b.x < -20 || b.x > CFG.WORLD_SIZE + 20 || b.y < -20 || b.y > CFG.WORLD_SIZE + 20) {
orbiterBullets.splice(i, 1);
continue;
}
let hit = false;
for (const e of enemies) {
if (dist2(b.x, b.y, e.x, e.y) < (b.r + e.r) * (b.r + e.r)) {
damageEnemy(e, b.dmg, 0.15);
hit = true;
break;
}
}
if (hit) orbiterBullets.splice(i, 1);
}
// Torretas del jugador
for (let i = playerTurrets.length - 1; i >= 0; i--) {
const t = playerTurrets[i];
t.life -= dt;
t.shootTimer -= dt;
if (t.life <= 0) { playerTurrets.splice(i, 1); continue; }
if (t.shootTimer <= 0) {
t.shootTimer = 0.2;
let target = t.target;
if (!target || !enemies.includes(target) || target.hp <= 0) {
let minD = Infinity;
for (const e of enemies) {
const d = dist(t.x, t.y, e.x, e.y);
if (d < minD && d < 300) { minD = d;
target = e; }
}
t.target = target;
}
if (target && enemies.includes(target) && target.hp > 0) {
const ang = Math.atan2(target.y - t.y, target.x - t.x);
for (let j = -1; j <= 1; j++) {
const off = j * 0.12;
let dmg = t.damage;
bullets.push({
x: t.x,
y: t.y,
vx: Math.cos(ang + off) * 340,
vy: Math.sin(ang + off) * 340,
dmg: dmg,
life: 0.7,
r: 3,
type: 'normal',
pierce: 0,
hitList: []
});
}
}
}
}
collectXpGems();
// Partículas
for (let i = particles.length - 1; i >= 0; i--) {
const pt = particles[i];
pt.x += pt.vx * dt;
pt.y += pt.vy * dt;
pt.vx *= 0.95;
pt.vy *= 0.95;
pt.life -= dt;
if (pt.life <= 0) particles.splice(i, 1);
}
// Floaters
for (let i = floaters.length - 1; i >= 0; i--) {
const f = floaters[i];
f.t += dt;
f.y -= 20 * dt;
if (f.t >= f.life) floaters.splice(i, 1);
}
// Trails
for (let i = trailList.length - 1; i >= 0; i--) {
trailList[i].life -= dt;
if (trailList[i].life <= 0) trailList.splice(i, 1);
}
if (shakeTimer > 0) shakeTimer -= dt;
if (shakeTimer <= 0) shakeIntensity = 0;
zoomLevel += (zoomTarget - zoomLevel) * 0.05;
camera.targetX = p.x - W / 2;
camera.targetY = p.y - H / 2;
camera.x += (camera.targetX - camera.x) * camera.lerpFactor;
camera.y += (camera.targetY - camera.y) * camera.lerpFactor;
camera.x = clamp(camera.x, 0, CFG.WORLD_SIZE - W);
camera.y = clamp(camera.y, 0, CFG.WORLD_SIZE - H);
// HUD
document.getElementById('hpFill').style.width = Math.max(0, p.hp / p.maxHp * 100) + '%';
document.getElementById('hpText').textContent = Math.floor(p.hp) + '/' + p.maxHp;
if (p.hasShield) {
document.getElementById('shieldFill').style.width = Math.max(0, p.shield / p.maxShield * 100) + '%';
document.getElementById('shieldText').textContent = Math.floor(p.shield) + '/' + p.maxShield;
}
document.getElementById('xpFill').style.width = Math.max(0, p.xp / p.xpToNext * 100) + '%';
const eraEl = document.getElementById('eraName');
eraEl.textContent = stage().name;
// Transición de color gradual y suave hacia el rojo a medida que avanzan las etapas.
const eraT = Math.min(1, stageIndex / 9); // 0 en etapa 1, 1 en etapa 10+
const targetG = Math.round(255 * (1 - eraT));
displayedStageColor.g += (targetG - displayedStageColor.g) * 0.03;
const smoothG = Math.round(displayedStageColor.g);
eraEl.style.color = `rgb(255,${smoothG},${smoothG})`;
eraEl.classList.toggle('era-glow-red', stageIndex >= 9);
const remaining = Math.max(0, CFG.WAVE_DURATION - stageTimer);
const mins = Math.floor(remaining / 60);
const secs = Math.floor(remaining % 60);
document.getElementById('stageTimerText').textContent =
String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
if (p.gadget && p.gadgetUses > 0) {
document.getElementById('gadgetBtn').style.display = 'flex';
document.getElementById('gadgetBtn').style.opacity = p.gadgetCooldown <= 0 ? '1' : '0.4';
}
}
// ---------- MINIMAPA CIRCULAR ----------
function drawMinimap() {
const size = CFG.MINIMAP_SIZE;
const cx = 12 + size / 2;
const cy = 62 + size / 2;
const radius = size / 2;
ctx.save();
ctx.globalAlpha = 0.85;
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = 20;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.shadowBlur = 0;
const scale = (radius - 4) / CFG.WORLD_SIZE;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, radius - 3, 0, Math.PI * 2);
ctx.clip();
for (const chest of chests) {
if (!chest.opened) {
const cx2 = cx + (chest.x - CFG.WORLD_SIZE/2) * scale;
const cy2 = cy + (chest.y - CFG.WORLD_SIZE/2) * scale;
ctx.fillStyle = '#ffd700';
ctx.beginPath();
ctx.arc(cx2, cy2, 3, 0, Math.PI * 2);
ctx.fill();
}
}
for (const z of slowZones) {
if (z.alpha > 0.1) {
const zx = cx + (z.x - CFG.WORLD_SIZE/2) * scale;
const zy = cy + (z.y - CFG.WORLD_SIZE/2) * scale;
const zr = z.radius * scale;
ctx.fillStyle = `rgba(155,89,182,${z.alpha * 0.3})`;
ctx.beginPath();
ctx.arc(zx, zy, zr, 0, Math.PI * 2);
ctx.fill();
}
}
for (const e of enemies) {
const ex = cx + (e.x - CFG.WORLD_SIZE/2) * scale;
const ey = cy + (e.y - CFG.WORLD_SIZE/2) * scale;
ctx.save();
ctx.translate(ex, ey);
const s = Math.max(2, 4 * scale);
const miniColor = e.elite ? ELITE_COLOR : null;
if (e.type === 'hexagon') {
ctx.fillStyle = miniColor || ENEMY_COLORS.hexagon;
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const ang = (i / 6) * Math.PI * 2 - Math.PI / 2 + elapsed * 0.3;
const r = s * 1.4;
if (i === 0) ctx.moveTo(Math.cos(ang) * r, Math.sin(ang) * r);
else ctx.lineTo(Math.cos(ang) * r, Math.sin(ang) * r);
}
ctx.closePath();
ctx.fill();
} else if (e.type === 'pentagon') {
ctx.fillStyle = miniColor || ENEMY_COLORS.pentagon;
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const ang = (i / 5) * Math.PI * 2 - Math.PI / 2 + elapsed * 0.3;
const r = s * 1.3;
if (i === 0) ctx.moveTo(Math.cos(ang) * r, Math.sin(ang) * r);
else ctx.lineTo(Math.cos(ang) * r, Math.sin(ang) * r);
}
ctx.closePath();
ctx.fill();
} else if (e.type === 'tank') {
ctx.fillStyle = miniColor || ENEMY_COLORS.tank;
ctx.fillRect(-s, -s, s * 2, s * 2);
} else if (e.type === 'turret') {
ctx.fillStyle = miniColor || ENEMY_COLORS.turret;
ctx.beginPath();
ctx.arc(0, 0, s, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillStyle = miniColor || ENEMY_COLORS.normal;
ctx.beginPath();
for (let i = 0; i < 3; i++) {
const a = (i / 3) * Math.PI * 2 - Math.PI / 2 + elapsed * 0.3;
const r = s * 1.2;
if (i === 0) ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r);
else ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
}
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
for (const g of xpGems) {
// El minimapa no aplica el desvanecido por expiración, solo el mundo lo hace.
ctx.fillStyle = g.golden ? 'rgba(255,215,0,0.6)' : 'rgba(46,204,113,0.5)';
ctx.beginPath();
ctx.arc(cx + (g.x - CFG.WORLD_SIZE/2) * scale, cy + (g.y - CFG.WORLD_SIZE/2) * scale, 2, 0, Math.PI * 2);
ctx.fill();
}
const px = cx + (p.x - CFG.WORLD_SIZE/2) * scale;
const py = cy + (p.y - CFG.WORLD_SIZE/2) * scale;
ctx.shadowColor = '#ffffff';
ctx.shadowBlur = 8;
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(px, py, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
const vx = cx + (camera.x - CFG.WORLD_SIZE/2) * scale;
const vy = cy + (camera.y - CFG.WORLD_SIZE/2) * scale;
const vw = W * scale;
const vh = H * scale;
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
ctx.strokeRect(vx, vy, vw, vh);
ctx.restore();
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
}
// ---------- RENDER ----------
function render() {
const s = stage();
let ox = 0,
oy = 0;
if (shakeIntensity > 0) { ox = rand(-shakeIntensity, shakeIntensity);
oy = rand(-shakeIntensity, shakeIntensity); }
ctx.save();
const cx = W / 2,
cy = H / 2;
ctx.translate(cx, cy);
ctx.scale(zoomLevel, zoomLevel);
ctx.translate(-cx, -cy);
ctx.translate(-camera.x + ox, -camera.y + oy);
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, Math.max(W, H) * 0.75);
grad.addColorStop(0, s.bg2);
grad.addColorStop(1, s.bg1);
ctx.fillStyle = grad;
ctx.fillRect(-20, -20, CFG.WORLD_SIZE + 40, CFG.WORLD_SIZE + 40);
ctx.strokeStyle = 'rgba(255,255,255,0.015)';
ctx.lineWidth = 1;
const gap = 60;
for (let x = (-bgTime * 15) % gap; x < CFG.WORLD_SIZE; x += gap) { ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, CFG.WORLD_SIZE);
ctx.stroke(); }
for (let y = 0; y < CFG.WORLD_SIZE; y += gap) { ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(CFG.WORLD_SIZE, y);
ctx.stroke(); }
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 4;
ctx.strokeRect(0, 0, CFG.WORLD_SIZE, CFG.WORLD_SIZE);
if (state !== 'menu') {
for (const chest of chests) {
if (!chest.opened) {
ctx.save();
ctx.translate(chest.x, chest.y);
const pulse = 1 + Math.sin(elapsed * 2) * 0.05;
const r = chest.r * pulse;
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fillStyle = '#ffd700';
ctx.fill();
ctx.strokeStyle = '#ff8c00';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = 'rgba(255,215,0,0.3)';
ctx.beginPath();
ctx.arc(0, 0, r * 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
for (const chest of chests) {
if (!chest.opened && chest.lineLife > 0) {
const fadeWindow = 1.5;
const lineAlpha = (chest.lineLife > fadeWindow ? 1 : chest.lineLife / fadeWindow) * 0.55;
ctx.save();
ctx.globalAlpha = lineAlpha;
ctx.strokeStyle = '#ffd700';
ctx.lineWidth = 2.5;
ctx.setLineDash([12, 9]);
ctx.lineDashOffset = -elapsed * 70;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(chest.x, chest.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
}
for (const z of slowZones) {
if (z.alpha > 0.01) {
ctx.save();
ctx.globalAlpha = z.alpha * 0.3;
if (z.warning) {
const pulse = 0.5 + 0.5 * Math.sin(elapsed * 8);
ctx.globalAlpha = pulse * 0.5;
ctx.strokeStyle = '#ff6b6b';
ctx.lineWidth = 2;
ctx.setLineDash([8, 8]);
ctx.beginPath();
ctx.arc(z.x, z.y, z.radius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = z.alpha * 0.3;
}
ctx.fillStyle = 'rgba(155,89,182,0.15)';
ctx.beginPath();
ctx.arc(z.x, z.y, z.radius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = `rgba(155,89,182,${z.alpha * 0.2})`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(z.x, z.y, z.radius, 0, Math.PI * 2);
ctx.stroke();
if (!z.warning) {
for (let i = 0; i < 5; i++) {
const ang = rand(0, Math.PI * 2);
const r = rand(10, z.radius);
particles.push({
x: z.x + Math.cos(ang) * r,
y: z.y + Math.sin(ang) * r,
vx: rand(-10, 10),
vy: rand(-10, 10),
life: rand(0.2, 0.5),
color: 'rgba(155,89,182,0.3)',
r: rand(1, 2)
});
}
}
ctx.globalAlpha = 1;
ctx.restore();
}
}
drawTrails();
for (const g of xpGems) {
const alpha = g.alpha !== undefined ? g.alpha : 1;
ctx.globalAlpha = alpha;
ctx.save();
ctx.translate(g.x, g.y);
ctx.rotate(g.t * 3);
const gemColor = g.golden ? '#ffd700' : '#2ecc71';
ctx.fillStyle = gemColor;
ctx.shadowColor = gemColor;
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.moveTo(0, -g.r);
ctx.lineTo(g.r, 0);
ctx.lineTo(0, g.r);
ctx.lineTo(-g.r, 0);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = g.golden ? 'rgba(255,215,0,0.2)' : 'rgba(46,204,113,0.2)';
ctx.beginPath();
ctx.arc(0, 0, g.r * 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.globalAlpha = 1;
}
// Rango de acción — siempre del mismo color que el personaje (blanco)
ctx.save();
const rangeGrad = ctx.createRadialGradient(p.x, p.y, p.range * 0.6, p.x, p.y, p.range);
rangeGrad.addColorStop(0, 'rgba(255,255,255,0.02)');
rangeGrad.addColorStop(1, 'rgba(255,255,255,0.06)');
ctx.beginPath();
ctx.arc(p.x, p.y, p.range, 0, Math.PI * 2);
ctx.fillStyle = rangeGrad;
ctx.fill();
// Borde punteado grueso y giratorio
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.lineWidth = 3.5;
ctx.setLineDash([14, 10]);
ctx.lineDashOffset = -rangeRotation * 60;
ctx.beginPath();
ctx.arc(p.x, p.y, p.range, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
for (const pt of particles) {
ctx.globalAlpha = Math.max(0, pt.life / 0.6);
ctx.fillStyle = pt.color;
ctx.shadowColor = pt.color;
ctx.shadowBlur = 4;
ctx.beginPath();
ctx.arc(pt.x, pt.y, pt.r, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
for (const e of enemies) {
ctx.save();
ctx.translate(e.x, e.y);
ctx.rotate(e.rot || 0);
if (e.elite) {
// Anillo dorado pulsante para identificar enemigos élite a simple vista.
const pulse = 1 + Math.sin(elapsed * 6) * 0.12;
ctx.save();
ctx.rotate(-(e.rot || 0));
ctx.strokeStyle = '#ffd700';
ctx.lineWidth = 2;
ctx.shadowColor = '#ffd700';
ctx.shadowBlur = 16;
ctx.beginPath();
ctx.arc(0, 0, (e.r + 6) * pulse, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.restore();
}
const glowColor = enemyColor(e);
ctx.shadowColor = glowColor;
ctx.shadowBlur = e.elite ? 20 : 12;
if (e.type === 'hexagon') {
const color = e.hit > 0 ? '#ffffff' : (e.elite ? ELITE_COLOR : '#9b59b6');
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const ang = (i / 6) * Math.PI * 2 - Math.PI / 2;
const r = e.r * 1.2;
if (i === 0) ctx.moveTo(Math.cos(ang) * r, Math.sin(ang) * r);
else ctx.lineTo(Math.cos(ang) * r, Math.sin(ang) * r);
}
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = e.elite ? '#b8860b' : '#8e44ad';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, e.r * 0.5, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
ctx.stroke();
} else if (e.type === 'pentagon') {
const color = e.hit > 0 ? '#ffffff' : (e.elite ? ELITE_COLOR : ENEMY_COLORS.pentagon);
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const ang = (i / 5) * Math.PI * 2 - Math.PI / 2;
const r = e.r * 1.2;
if (i === 0) ctx.moveTo(Math.cos(ang) * r, Math.sin(ang) * r);
else ctx.lineTo(Math.cos(ang) * r, Math.sin(ang) * r);
}
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = e.elite ? '#b8860b' : '#2170a8';
ctx.lineWidth = 2;
ctx.stroke();
} else if (e.type === 'tank') {
const size = e.r * 1.4;
ctx.fillStyle = e.hit > 0 ? '#ffffff' : (e.elite ? ELITE_COLOR : ENEMY_COLORS.tank);
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.shadowBlur = 0;
ctx.strokeStyle = e.elite ? '#b8860b' : '#5b2c6f';
ctx.lineWidth = 2;
ctx.strokeRect(-size / 2, -size / 2, size, size);
} else if (e.type === 'turret') {
ctx.beginPath();
ctx.arc(0, 0, e.r, 0, Math.PI * 2);
ctx.fillStyle = e.hit > 0 ? '#ffffff' : (e.elite ? ELITE_COLOR : '#2ecc71');
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = e.elite ? '#b8860b' : '#27ae60';
ctx.lineWidth = 2;
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(-e.r * 0.5, 0);
ctx.lineTo(e.r * 0.5, 0);
ctx.moveTo(0, -e.r * 0.5);
ctx.lineTo(0, e.r * 0.5);
ctx.stroke();
} else {
ctx.beginPath();
for (let i = 0; i < 3; i++) {
const a = (i / 3) * Math.PI * 2 - Math.PI / 2;
const r = e.r * 1.2;
if (i === 0) ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r);
else ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
}
ctx.closePath();
ctx.fillStyle = e.hit > 0 ? '#ffffff' : (e.elite ? ELITE_COLOR : '#e74c3c');
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = e.elite ? '#b8860b' : '#c0392b';
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.shadowBlur = 0;
ctx.restore();
if (!e.type || e.hp < e.maxHp) {
ctx.save();
const w = e.r * 2;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(e.x - w / 2, e.y - e.r - 10, w, 3);
const hpPct = Math.max(0, e.hp / e.maxHp);
let color = enemyColor(e);
ctx.fillStyle = color;
ctx.fillRect(e.x - w / 2, e.y - e.r - 10, w * hpPct, 3);
ctx.restore();
}
}
// Las balas se desvanecen gradualmente en su último tramo de vida en vez de desaparecer de golpe.
const BULLET_FADE_WINDOW = 0.3;
for (const b of enemyBullets) {
const fadeAlpha = Math.min(1, Math.max(0, b.life / BULLET_FADE_WINDOW));
ctx.globalAlpha = fadeAlpha;
ctx.fillStyle = b.color || '#2ecc71';
ctx.shadowColor = ctx.fillStyle;
ctx.shadowBlur = 16;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
for (const b of bullets) {
const fadeAlpha = Math.min(1, Math.max(0, b.life / BULLET_FADE_WINDOW));
if (b.type === 'missile') {
ctx.save();
ctx.globalAlpha = fadeAlpha;
ctx.translate(b.x, b.y);
ctx.rotate(Math.atan2(b.vy, b.vx));
ctx.fillStyle = '#ff6b35';
ctx.shadowColor = '#ff6b35';
ctx.shadowBlur = 22;
const size = b.r * 0.8;
ctx.fillRect(-size, -size, size * 2, size * 2);
ctx.shadowBlur = 0;
for (let i = 0; i < 4; i++) {
ctx.globalAlpha = fadeAlpha * 0.3 * (1 - i * 0.2);
const s2 = size * (1 - i * 0.15);
ctx.fillStyle = '#ff8a5c';
ctx.fillRect(-b.vx * 0.02 * i - s2, -b.vy * 0.02 * i - s2, s2 * 2, s2 * 2);
}
ctx.globalAlpha = 1;
ctx.restore();
} else {
ctx.globalAlpha = fadeAlpha;
ctx.fillStyle = s.accent;
ctx.shadowColor = s.accent;
ctx.shadowBlur = 14;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
}
for (const b of orbiterBullets) {
const fadeAlpha = Math.min(1, Math.max(0, b.life / BULLET_FADE_WINDOW));
ctx.globalAlpha = fadeAlpha;
ctx.fillStyle = '#9b59b6';
ctx.shadowColor = '#9b59b6';
ctx.shadowBlur = 12;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
if (p.hasOrbiters) {
for (const orb of orbiters) {
const ox = p.x + Math.cos(orb.angle) * orb.dist;
const oy = p.y + Math.sin(orb.angle) * orb.dist;
ctx.save();
ctx.beginPath();
ctx.arc(ox, oy, 5, 0, Math.PI * 2);
ctx.fillStyle = '#9b59b6';
ctx.fill();
ctx.strokeStyle = '#8e44ad';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
}
}
for (const t of playerTurrets) {
const alpha = t.life / t.maxLife;
ctx.globalAlpha = alpha;
ctx.save();
ctx.translate(t.x, t.y);
ctx.beginPath();
ctx.arc(0, 0, t.r, 0, Math.PI * 2);
ctx.fillStyle = '#ff6b35';
ctx.fill();
ctx.strokeStyle = '#ff9a6b';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('◈', 0, 0);
ctx.restore();
ctx.globalAlpha = 1;
}
ctx.save();
// Invulnerabilidad: en vez de parpadear, el personaje se vuelve 50% transparente.
const inv = p.invuln > 0;
ctx.globalAlpha = inv ? 0.5 : 1;
if (p.shield > 0) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r + 10, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(77,208,255,${0.2 + (p.shield / p.maxShield) * 0.3})`;
ctx.lineWidth = 2;
ctx.stroke();
for (let i = 0; i < 4; i++) {
const ang = elapsed * 1.5 + i * Math.PI / 2;
const r = p.r + 14;
ctx.fillStyle = 'rgba(77,208,255,0.15)';
ctx.beginPath();
ctx.arc(p.x + Math.cos(ang) * r, p.y + Math.sin(ang) * r, 2, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.beginPath();
ctx.arc(p.x, p.y, p.r + 5, 0, Math.PI * 2);
ctx.strokeStyle = s.accent;
ctx.lineWidth = 2;
ctx.globalAlpha = (inv ? 0.15 : 0.3);
ctx.stroke();
ctx.globalAlpha = inv ? 0.5 : 1;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
for (const msg of p.waveMessages) {
const lifeLeft = msg.life - msg.t;
const fadeWindow = 2.0;
// Desvanecido: entra suave y sale suave, sin "saltar" de golpe.
const fadeIn = Math.min(1, msg.t / 0.3);
const fadeOut = lifeLeft >= fadeWindow ? 1 : Math.max(0, lifeLeft / fadeWindow);
const alpha = Math.min(fadeIn, fadeOut);
ctx.globalAlpha = alpha;
const isBelow = msg.position === 'below';
if (isBelow) {
// Mensajes de acción de gadgets: sin efecto de onda, solo texto que se desvanece.
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = msg.color;
ctx.shadowColor = 'rgba(0,0,0,0.85)';
ctx.shadowBlur = 6;
ctx.font = '13px "Press Start 2P", monospace';
ctx.fillText(msg.title, p.x, p.y + p.r + 58);
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
continue;
}
let titleSize = 24;
let subSize = 14;
if (msg.title.includes('HÍPER')) {
titleSize = 29;
subSize = 17;
} else if (msg.title.includes('ULTRA') || msg.title.includes('MEGA')) {
titleSize = 26;
subSize = 15;
}
const pulse = 1 + Math.sin(elapsed * 10 + msg.t * 3) * 0.06;
titleSize *= pulse;
subSize *= pulse;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.shadowColor = 'rgba(0,0,0,0.9)';
ctx.shadowBlur = 15;
const titleChars = msg.title.split('');
const titleStep = titleSize * 0.92;
const titleWidth = titleChars.length * titleStep;
const titleStartX = p.x - titleWidth / 2;
const titleBaseY = p.y - p.r - 50;
ctx.globalAlpha = alpha;
const isHyper = msg.title.includes('HÍPER');
for (let i = 0; i < titleChars.length; i++) {
const waveOffset = Math.sin(elapsed * 4 + i * 0.7 + msg.t * 2) * 5;
const x = titleStartX + i * titleStep + titleSize * 0.3;
const y = titleBaseY - waveOffset;
const charColor = isHyper ? `hsl(${(elapsed * 140 + i * 45) % 360}, 100%, 60%)` : msg.color;
ctx.fillStyle = charColor;
ctx.shadowColor = charColor;
ctx.shadowBlur = 18;
ctx.font = (titleSize + Math.sin(elapsed * 2 + i * 0.5 + msg.t) * 2) + 'px "Press Start 2P", monospace';
ctx.fillText(titleChars[i], x, y);
}
ctx.shadowBlur = 15;
if (msg.subtitle) {
const subChars = msg.subtitle.split('');
const subStep = subSize * 0.85;
const subWidth = subChars.length * subStep;
const subStartX = p.x - subWidth / 2;
const subBaseY = p.y - p.r - 25;
for (let i = 0; i < subChars.length; i++) {
const waveOffset = Math.sin(elapsed * 4 + i * 0.7 + msg.t * 2 + 1) * 3;
const x = subStartX + i * subStep + subSize * 0.3;
const y = subBaseY - waveOffset;
const charColor = isHyper ? `hsl(${(elapsed * 140 + i * 45 + 40) % 360}, 100%, 60%)` : msg.color;
ctx.fillStyle = charColor;
ctx.shadowColor = charColor;
ctx.shadowBlur = 10;
ctx.globalAlpha = alpha * 0.8;
ctx.font = (subSize + Math.sin(elapsed * 2 + i * 0.5 + msg.t + 1) * 1.5) + 'px "Press Start 2P", monospace';
ctx.fillText(subChars[i], x, y);
}
}
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
for (const f of floaters) {
ctx.globalAlpha = Math.max(0, 1 - f.t / f.life);
ctx.fillStyle = f.color;
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 6;
ctx.fillText(f.text, f.x, f.y);
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
} // fin if (state !== 'menu')
ctx.restore();
if (state !== 'menu') drawMinimap();
}
// ---------- GAME LOOP ----------
let lastTime = 0;
function gameLoop(time) {
const dt = Math.min(0.033, (time - lastTime) / 1000);
lastTime = time;
bgTime += dt;
if (state === 'playing') {
if (isPaused || bannerPause) {
render();
applyPixelShader();
requestAnimationFrame(gameLoop);
return;
}
if (!isPaused && !infoOpen) update(dt);
render();
applyPixelShader();
} else if (state === 'levelup' || state === 'menu' || state === 'gameover') {
render();
applyPixelShader();
}
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
// ---------- GAME OVER ----------
function triggerGameOver() {
state = 'gameover';
document.getElementById('gameOverTitle').innerHTML = wrapWaveLetters('FIN DEL VIAJE', 'mythic');
const score = Math.floor(elapsed * 10 + kills * 5 + p.level * 50);
const isNewRecord = score > recordScore;
if (isNewRecord) {
recordScore = score;
saveRecord({ score, kills, level: p.level, wave: waveCount, stage: stage().name, time: elapsed });
}
const mm = Math.floor(elapsed / 60),
ss = Math.floor(elapsed % 60);
document.getElementById('statsBox').innerHTML =
'Tiempo: <b>' + (mm < 10 ? '0' : '') + mm + ':' + (ss < 10 ? '0' : '') + ss + '</b><br>' +
'Nivel: <b>' + p.level + '</b><br>' +
'Enemigos eliminados: <b>' + kills + '</b><br>' +
'Oleadas: <b>' + waveCount + '</b><br>' +
'Etapa: <b>' + stage().name + '</b><br>' +
'Puntuación: <b>' + score + '</b><br>' +
'Récord: <b>' + recordScore + '</b>' +
(isNewRecord ? '<br>¡NUEVO RÉCORD!' : '');
document.getElementById('gameOverOverlay').classList.remove('hidden');
}
// ---------- EVENTOS ----------
function initWeaponSelect() {
const container = document.getElementById('weaponSelect');
container.innerHTML = '';
const allWeapons = [
{ id: 'pistol', icon: '●', name: 'Pistola', desc: 'Disparo básico automático' },
{ id: 'wave', icon: '◆', name: 'Onda', desc: 'Daño en área cada 1.2s' },
{ id: 'orbiter', icon: '▲', name: 'Orbitador', desc: 'Orbita y dispara al enemigo' },
{ id: 'laser', icon: '⬡', name: 'Láser', desc: 'Rayo continuo de energía' },
{ id: 'missiles', icon: '⬢', name: 'Misil', desc: 'Explosión en área al impactar' },
{ id: 'shotgun', icon: '⬣', name: 'Escopeta', desc: 'Dispara múltiples proyectiles' }
];
const shuffled = allWeapons.sort(() => Math.random() - 0.5);
const selected = shuffled.slice(0, 3);
selected.forEach(w => {
const div = document.createElement('div');
div.className = 'weapon-option';
div.innerHTML = `
<span class="wo-icon">${w.icon}</span>
<span class="wo-name">${w.name}</span>
<span class="wo-desc">${w.desc}</span>
`;
div.addEventListener('click', () => {
document.querySelectorAll('.weapon-option').forEach(el => el.classList.remove('selected'));
div.classList.add('selected');
selectedWeapon = w.id;
});
container.appendChild(div);
});
}
// MENU
document.getElementById('startBtn').addEventListener('click', () => {
document.getElementById('startOverlay').classList.add('hidden');
document.getElementById('weaponSelectOverlay').classList.remove('hidden');
initWeaponSelect();
});
document.getElementById('confirmWeaponBtn').addEventListener('click', () => {
if (!selectedWeapon) {
addFloater(W/2, H/2 - 50, '¡Elige un arma!', '#ff6b6b');
return;
}
document.getElementById('weaponSelectOverlay').classList.add('hidden');
resetGame();
if (selectedWeapon === 'pistol') p.hasPistol = true;
else if (selectedWeapon === 'wave') p.hasWave = true;
else if (selectedWeapon === 'orbiter') { p.hasOrbiters = true;
p.orbiterCount = 1; }
else if (selectedWeapon === 'laser') p.hasLaser = true;
else if (selectedWeapon === 'missiles') p.hasMissiles = true;
else if (selectedWeapon === 'shotgun') p.hasShotgun = true;
state = 'playing';
setMenuUI(false);
announceStage();
selectedWeapon = null;
});
// INFO
document.getElementById('infoBtn').addEventListener('click', () => {
document.getElementById('startOverlay').classList.add('hidden');
document.getElementById('infoOverlay').classList.remove('hidden');
});
document.getElementById('closeInfoBtn').addEventListener('click', () => {
document.getElementById('infoOverlay').classList.add('hidden');
document.getElementById('startOverlay').classList.remove('hidden');
});
document.getElementById('restartBtn').addEventListener('click', () => {
document.getElementById('gameOverOverlay').classList.add('hidden');
document.getElementById('startOverlay').classList.remove('hidden');
document.querySelectorAll('.weapon-option').forEach(el => el.classList.remove('selected'));
selectedWeapon = null;
state = 'menu';
setMenuUI(true);
updateBestScoreDisplay();
});
// Inicializar
initWeaponSelect();
document.getElementById('menuTitle').innerHTML = wrapWaveLetters('THE GAME', true);
document.getElementById('weaponSelectTitle').innerHTML = wrapWaveLetters('ELIGE TU ARMA', false);
p.x = CFG.WORLD_SIZE / 2;
p.y = CFG.WORLD_SIZE / 2;
camera.x = p.x - W / 2;
camera.y = p.y - H / 2;
setMenuUI(true);
updateBestScoreDisplay();
render();
applyPixelShader();
console.log('THE GAME - Versión completa con shader 5%');
console.log('Armas: Pistola, Onda, Orbitadores, Láser, Misiles, Escopeta');
console.log('Gadgets: Torreta, Invulnerabilidad, Bomba, Dash');
console.log('Enemigos: Triángulo, Cuadrado, Pentágono, Hexágono');
console.log('Cofres cada 3 etapas - Recompensa de mejora');
console.log('Rango de acción rotativo');
</script>
</body>
</html>
Game Source: THE GAME
Creator: SolarHero75
Libraries: none
Complexity: complex (3293 lines, 123.0 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: the-game-solarhero75" to link back to the original. Then publish at arcadelab.ai/publish.