← Volver a código

Space Invaders

Explora el código (HTML / CSS / JS) del juego

HTML

  1. <!doctype html>
  2. <html lang="es">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>Space Invaders</title>
  7. <link rel="stylesheet" href="css/invasion.css">
  8. </head>
  9. <body>
  10. <!-- Estructura del juego (canvas, HUD, botones…) -->
  11. <script src="js/invasion.js"></script>
  12. </body>
  13. </html>

CSS

  1. * {
  2. box-sizing:border-box;
  3. margin:0;
  4. padding:0
  5. }
  6. :root {
  7. --bg:#06050c;
  8. --purple:#a55cff;
  9. --yellow:#ffd719;
  10. --green:#63ff79;
  11. --cyan:#23dfdf;
  12. --text:#fff;
  13. --muted:rgba(255,255,255,.62)
  14. }
  15. html,body {
  16. width:100%;
  17. min-height:100%;
  18. overflow-x:hidden
  19. }
  20. body {
  21. min-height:100svh;
  22. color:var(--text);
  23. background-color:var(--bg);
  24. background-position:center;
  25. background-size:cover;
  26. background-attachment:fixed;
  27. font-family:"Segoe UI",system-ui,-apple-system,Arial,sans-serif
  28. }
  29. body::before {
  30. content:"";
  31. position:fixed;
  32. inset:0;
  33. z-index:-1;
  34. pointer-events:none;
  35. background:radial-gradient(circle at 50% 34%,rgba(25,18,54,.16),rgba(4,3,10,.7) 82%),rgba(3,2,8,.35)
  36. }
  37. button,a {
  38. -webkit-tap-highlight-color:transparent
  39. }
  40. button {
  41. font:inherit
  42. }
  43. .arcade-nav {
  44. position:relative;
  45. z-index:20;
  46. display:flex;
  47. align-items:center;
  48. justify-content:space-between;
  49. min-height:78px;
  50. padding:14px max(30px,7vw);
  51. border-bottom:1px solid rgba(255,255,255,.08);
  52. background:rgba(4,3,11,.38);
  53. backdrop-filter:blur(8px)
  54. }
  55. .arcade-brand {
  56. display:inline-flex;
  57. align-items:center;
  58. gap:12px;
  59. color:#fff;
  60. text-decoration:none;
  61. font-size:20px;
  62. font-weight:900;
  63. letter-spacing:.035em;
  64. white-space:nowrap
  65. }
  66. .brand-code {
  67. color:var(--purple);
  68. font-size:28px;
  69. letter-spacing:-.12em
  70. }
  71. .arcade-nav-links {
  72. display:flex;
  73. align-items:center;
  74. gap:3px;
  75. padding:6px;
  76. border:1px solid rgba(255,255,255,.11);
  77. border-radius:999px;
  78. background:rgba(15,12,29,.78)
  79. }
  80. .arcade-nav-links a {
  81. display:inline-flex;
  82. align-items:center;
  83. gap:8px;
  84. padding:10px 15px;
  85. color:rgba(255,255,255,.78);
  86. border-radius:999px;
  87. text-decoration:none;
  88. font-size:12px;
  89. font-weight:800;
  90. letter-spacing:.04em;
  91. text-transform:uppercase;
  92. transition:.2s ease
  93. }
  94. .arcade-nav-links a:hover {
  95. color:#fff;
  96. background:rgba(123,92,255,.18);
  97. transform:translateY(-1px)
  98. }
  99. .arcade-nav-links i {
  100. width:17px;
  101. color:var(--purple);
  102. text-align:center
  103. }
  104. .invasion-page {
  105. width:min(1400px,94vw);
  106. margin:0 auto;
  107. padding-bottom:26px
  108. }
  109. .game-toolbar {
  110. display:flex;
  111. align-items:center;
  112. justify-content:space-between;
  113. min-height:56px;
  114. padding:0 4px
  115. }
  116. .toolbar-link {
  117. color:var(--purple);
  118. text-decoration:none;
  119. font-size:13px;
  120. font-weight:850;
  121. letter-spacing:.06em
  122. }
  123. .toolbar-button {
  124. border:0;
  125. background:none;
  126. cursor:pointer
  127. }
  128. .toolbar-link:hover {
  129. color:#d3a7ff
  130. }
  131. .arcade-shell {
  132. display:grid;
  133. grid-template-columns:minmax(0,1.55fr) minmax(330px,.95fr);
  134. min-height:calc(100svh - 160px);
  135. overflow:hidden;
  136. border:1px solid rgba(181,108,255,.48);
  137. border-radius:28px;
  138. background:linear-gradient(135deg,rgba(11,8,25,.92),rgba(5,4,14,.9));
  139. box-shadow:0 35px 100px rgba(0,0,0,.62),inset 0 1px rgba(255,255,255,.04);
  140. backdrop-filter:blur(9px)
  141. }
  142. .board-column {
  143. display:flex;
  144. min-width:0;
  145. flex-direction:column;
  146. padding:18px 26px 20px;
  147. border-right:1px solid rgba(255,255,255,.08)
  148. }
  149. .invasion-logo {
  150. display:flex;
  151. align-items:center;
  152. justify-content:center;
  153. gap:15px;
  154. min-height:70px;
  155. margin:0 auto 7px;
  156. color:#65ff72;
  157. font-family:"Courier New",ui-monospace,monospace;
  158. font-size:clamp(29px,3.2vw,48px);
  159. font-weight:1000;
  160. line-height:1;
  161. letter-spacing:.035em;
  162. text-shadow:2px 0 #17d8d8,0 3px #167a82,0 0 15px rgba(65,255,123,.36)
  163. }
  164. .invader-mark {
  165. width:clamp(25px,2.8vw,40px);
  166. height:auto;
  167. fill:#65ff72;
  168. filter:drop-shadow(2px 2px 0 #138c9b) drop-shadow(0 0 7px rgba(66,255,124,.5));
  169. shape-rendering:crispEdges
  170. }
  171. .game-frame {
  172. display:flex;
  173. min-height:0;
  174. flex:1;
  175. flex-direction:column;
  176. gap:8px
  177. }
  178. .game-bezel {
  179. position:relative;
  180. width:100%;
  181. min-height:420px;
  182. flex:1;
  183. overflow:hidden;
  184. border:2px solid rgba(18,104,255,.9);
  185. border-radius:16px;
  186. background:url("../sources/img/fondoSpace2.jpg") center/cover no-repeat;
  187. box-shadow:0 0 28px rgba(32,70,255,.22),inset 0 0 28px rgba(0,0,0,.7)
  188. }
  189. .game-bezel::after {
  190. content:"";
  191. position:absolute;
  192. inset:0;
  193. border-radius:inherit;
  194. background:linear-gradient(180deg,rgba(0,0,0,.15),rgba(0,0,0,.30));
  195. pointer-events:none
  196. }
  197. #gameCanvas {
  198. position:relative;
  199. z-index:1;
  200. display:block;
  201. width:100%;
  202. height:100%;
  203. min-height:420px;
  204. border-radius:14px;
  205. background:transparent;
  206. touch-action:none
  207. }
  208. .game-hint {
  209. position:absolute;
  210. inset:0;
  211. z-index:2;
  212. display:flex;
  213. align-items:center;
  214. justify-content:center;
  215. flex-direction:column;
  216. gap:12px;
  217. padding:20px;
  218. border-radius:14px;
  219. color:#fff;
  220. text-align:center;
  221. pointer-events:none;
  222. opacity:1;
  223. transition:opacity .25s ease;
  224. backdrop-filter:blur(6px)
  225. }
  226. .game-hint.hidden {
  227. opacity:0
  228. }
  229. .hint-title {
  230. font-size:26px;
  231. font-weight:800
  232. }
  233. .hint-sub {
  234. font-size:13px;
  235. line-height:1.5;
  236. opacity:.87
  237. }
  238. .game-footnote {
  239. padding:2px 5px;
  240. color:rgba(255,255,255,.6);
  241. font-size:11px
  242. }
  243. .control-column {
  244. display:flex;
  245. min-width:0;
  246. flex-direction:column;
  247. padding:24px 28px 22px
  248. }
  249. .desktop-stats,.mobile-stats {
  250. display:grid;
  251. grid-template-columns:repeat(2,1fr);
  252. gap:12px
  253. }
  254. .mobile-stats {
  255. display:none
  256. }
  257. .stat-card {
  258. display:flex;
  259. align-items:center;
  260. justify-content:center;
  261. gap:9px;
  262. min-width:0;
  263. padding:12px 9px;
  264. border:1px solid rgba(255,255,255,.14);
  265. border-radius:16px;
  266. background:rgba(255,255,255,.035);
  267. box-shadow:inset 0 1px rgba(255,255,255,.04);
  268. font-size:11px;
  269. font-weight:700;
  270. letter-spacing:.04em
  271. }
  272. .stat-card span:last-child {
  273. display:flex;
  274. min-width:0;
  275. flex-direction:column;
  276. gap:2px
  277. }
  278. .stat-card b {
  279. color:var(--yellow);
  280. font-size:19px;
  281. line-height:1
  282. }
  283. .stat-icon {
  284. color:var(--yellow);
  285. font-size:22px
  286. }
  287. .stat-icon.green {
  288. color:var(--green)
  289. }
  290. .controls-block {
  291. display:grid;
  292. grid-template-columns:1fr 175px;
  293. align-items:center;
  294. gap:18px;
  295. margin:30px 0 26px
  296. }
  297. .control-copy h2,.score-preview h2 {
  298. margin-bottom:17px;
  299. color:var(--purple);
  300. font-size:15px;
  301. font-weight:900;
  302. letter-spacing:.045em
  303. }
  304. .control-copy p {
  305. margin:10px 0;
  306. color:rgba(255,255,255,.7);
  307. font-size:12px;
  308. line-height:1.45
  309. }
  310. .control-copy strong {
  311. color:var(--green)
  312. }
  313. .target-reticle {
  314. position:relative;
  315. width:150px;
  316. aspect-ratio:1;
  317. margin:auto;
  318. border:2px solid var(--green);
  319. border-radius:50%;
  320. box-shadow:0 0 18px rgba(48,255,132,.22),inset 0 0 16px rgba(48,255,132,.1)
  321. }
  322. .target-reticle::before,.target-reticle::after {
  323. content:"";
  324. position:absolute;
  325. background:var(--cyan);
  326. box-shadow:0 0 7px var(--cyan)
  327. }
  328. .target-reticle::before {
  329. top:-15px;
  330. bottom:-15px;
  331. left:50%;
  332. width:2px;
  333. transform:translateX(-50%)
  334. }
  335. .target-reticle::after {
  336. left:-15px;
  337. right:-15px;
  338. top:50%;
  339. height:2px;
  340. transform:translateY(-50%)
  341. }
  342. .target-reticle span {
  343. position:absolute;
  344. inset:30%;
  345. border:1px solid rgba(59,255,143,.75);
  346. border-radius:50%
  347. }
  348. .target-reticle span::after {
  349. content:"";
  350. position:absolute;
  351. inset:36%;
  352. border:2px solid var(--cyan);
  353. border-radius:50%;
  354. background:rgba(35,223,223,.25)
  355. }
  356. .action-row {
  357. display:grid;
  358. grid-template-columns:1fr;
  359. gap:12px
  360. }
  361. .arcade-action {
  362. min-height:48px;
  363. cursor:pointer;
  364. border:1px solid rgba(181,108,255,.45);
  365. border-radius:13px;
  366. color:var(--purple);
  367. background:rgba(123,92,255,.08);
  368. font-size:12px;
  369. font-weight:900;
  370. letter-spacing:.035em;
  371. transition:.2s ease
  372. }
  373. .arcade-action:hover {
  374. color:#fff;
  375. background:rgba(123,92,255,.19)
  376. }
  377. .arcade-action:active {
  378. transform:scale(.98)
  379. }
  380. .score-preview {
  381. margin-top:25px
  382. }
  383. .ranking-preview {
  384. overflow:hidden;
  385. list-style:none;
  386. border:1px solid rgba(255,255,255,.13);
  387. border-radius:13px;
  388. background:rgba(255,255,255,.025)
  389. }
  390. .ranking-preview li {
  391. display:grid;
  392. grid-template-columns:30px 1fr auto;
  393. gap:8px;
  394. padding:11px 14px;
  395. border-bottom:1px solid rgba(255,255,255,.1);
  396. font-size:13px
  397. }
  398. .ranking-preview li:last-child {
  399. border-bottom:0
  400. }
  401. .rank-position {
  402. color:var(--purple);
  403. font-weight:900
  404. }
  405. .ranking-preview li:first-child .rank-position {
  406. color:var(--yellow)
  407. }
  408. .rank-name {
  409. overflow:hidden;
  410. text-overflow:ellipsis;
  411. white-space:nowrap
  412. }
  413. .rank-score {
  414. color:var(--yellow);
  415. font-weight:900
  416. }
  417. .ranking-loading,.ranking-empty {
  418. display:block!important;
  419. color:var(--muted);
  420. text-align:center
  421. }
  422. .btn-start-invasion {
  423. display:flex;
  424. align-items:center;
  425. justify-content:center;
  426. gap:15px;
  427. width:100%;
  428. min-height:68px;
  429. margin-top:auto;
  430. padding:12px 20px;
  431. cursor:pointer;
  432. border:2px solid var(--cyan);
  433. border-radius:12px;
  434. color:var(--green);
  435. background:linear-gradient(180deg,rgba(10,42,45,.9),rgba(3,18,24,.96));
  436. box-shadow:0 0 14px rgba(35,223,223,.28),inset 0 0 22px rgba(35,223,223,.08);
  437. font-family:"Courier New",ui-monospace,monospace;
  438. font-size:25px;
  439. font-weight:1000;
  440. letter-spacing:.075em;
  441. text-shadow:2px 2px 0 #087b83,0 0 9px rgba(99,255,121,.55);
  442. transition:transform .15s ease,filter .2s ease,box-shadow .2s ease
  443. }
  444. .btn-start-invasion svg {
  445. width:31px;
  446. fill:var(--green);
  447. filter:drop-shadow(2px 2px 0 #087b83)
  448. }
  449. .btn-start-invasion:hover {
  450. filter:brightness(1.16);
  451. box-shadow:0 0 24px rgba(35,223,223,.43),inset 0 0 25px rgba(35,223,223,.12)
  452. }
  453. .btn-start-invasion:active {
  454. transform:scale(.985)
  455. }
  456. @media(max-width:1050px) {
  457. .invasion-page {
  458. width:min(96vw,850px)
  459. }
  460. .arcade-shell {
  461. grid-template-columns:1fr;
  462. overflow:visible
  463. }
  464. .board-column {
  465. border-right:0;
  466. border-bottom:1px solid rgba(255,255,255,.08)
  467. }
  468. .game-bezel,#gameCanvas {
  469. height:min(72vw,650px);
  470. min-height:520px
  471. }
  472. .control-column {
  473. padding:22px 30px 28px
  474. }
  475. .controls-block {
  476. max-width:560px;
  477. width:100%;
  478. margin:24px auto 20px
  479. }
  480. }
  481. @media(max-width:700px) {
  482. body {
  483. background-attachment:scroll
  484. }
  485. .arcade-nav {
  486. min-height:76px;
  487. padding:12px 16px
  488. }
  489. .arcade-brand {
  490. gap:8px;
  491. font-size:15px
  492. }
  493. .brand-code {
  494. font-size:22px
  495. }
  496. .arcade-nav-links {
  497. gap:0;
  498. padding:5px
  499. }
  500. .arcade-nav-links a {
  501. padding:9px 10px
  502. }
  503. .arcade-nav-links a span {
  504. display:none
  505. }
  506. .invasion-page {
  507. width:100%;
  508. padding-bottom:0
  509. }
  510. .game-toolbar {
  511. min-height:58px;
  512. padding:0 20px
  513. }
  514. .arcade-shell {
  515. min-height:0;
  516. border-right:0;
  517. border-left:0;
  518. border-radius:0;
  519. background:rgba(7,5,17,.83)
  520. }
  521. .board-column {
  522. padding:18px 12px 16px
  523. }
  524. .invasion-logo {
  525. min-height:52px;
  526. gap:8px;
  527. margin-bottom:10px;
  528. font-size:clamp(22px,7vw,34px)
  529. }
  530. .invader-mark {
  531. width:24px
  532. }
  533. .desktop-stats {
  534. display:none
  535. }
  536. .mobile-stats {
  537. display:grid;
  538. gap:8px;
  539. margin-bottom:16px
  540. }
  541. .stat-card {
  542. padding:10px 6px;
  543. border-radius:13px;
  544. font-size:9px
  545. }
  546. .stat-card b {
  547. font-size:15px
  548. }
  549. .stat-icon {
  550. font-size:17px
  551. }
  552. .game-bezel,#gameCanvas {
  553. height:82vh;
  554. min-height:82vh
  555. }
  556. .game-bezel {
  557. border-radius:13px;
  558. background-image:url("../sources/img/invasionMovil.png")
  559. }
  560. .control-column {
  561. padding:22px 16px 34px
  562. }
  563. .controls-block {
  564. grid-template-columns:1fr 135px;
  565. width:100%;
  566. margin:0 0 22px
  567. }
  568. .target-reticle {
  569. width:112px
  570. }
  571. .action-row,.score-preview {
  572. width:100%
  573. }
  574. .score-preview {
  575. margin-top:22px
  576. }
  577. }
  578. @media(max-width:390px) {
  579. .arcade-nav {
  580. padding-inline:11px
  581. }
  582. .arcade-brand {
  583. font-size:14px
  584. }
  585. .arcade-nav-links a {
  586. padding:8px
  587. }
  588. .game-toolbar {
  589. padding-inline:15px
  590. }
  591. .board-column {
  592. padding-inline:9px
  593. }
  594. .invasion-logo {
  595. font-size:21px
  596. }
  597. .invader-mark {
  598. width:20px
  599. }
  600. .controls-block {
  601. grid-template-columns:1fr 100px
  602. }
  603. .target-reticle {
  604. width:88px
  605. }
  606. .arcade-action {
  607. font-size:10px
  608. }
  609. }
  610. @media(max-height:800px) and (min-width:1051px) {
  611. .arcade-nav {
  612. min-height:68px;
  613. padding-block:9px
  614. }
  615. .game-toolbar {
  616. min-height:46px
  617. }
  618. .invasion-page {
  619. width:min(1280px,93vw)
  620. }
  621. .arcade-shell {
  622. min-height:calc(100svh - 128px)
  623. }
  624. .board-column {
  625. padding-top:10px
  626. }
  627. .invasion-logo {
  628. min-height:50px;
  629. font-size:34px
  630. }
  631. .control-column {
  632. padding-block:18px
  633. }
  634. .controls-block {
  635. margin:18px 0 14px
  636. }
  637. .target-reticle {
  638. width:120px
  639. }
  640. .score-preview {
  641. margin-top:17px
  642. }
  643. }

JS

  1. (() => {
  2. const canvas = document.getElementById("gameCanvas");
  3. const ctx = canvas.getContext("2d");
  4. const uiScore = document.getElementById("uiScore");
  5. const uiStage = document.getElementById("uiStage");
  6. const btnPlay = document.getElementById("btnReset");
  7. const hintOverlay = document.getElementById("hintOverlay");
  8. // =========================
  9. // I18N (ES por defecto / EN opcional)
  10. // =========================
  11. const es = {
  12. ready: "Listo",
  13. readyHtml: `Dale a <b>Play</b> para empezar<br>
  14. PC: ratón + click (ráfaga) · Móvil: tap dispara · hold dispara · drag mueve`,
  15. gameOver: "Game Over",
  16. gameOverHtml: `Han llegado al tope.<br><b>Dale a Play</b> para reintentar.`,
  17. };
  18. const en = {
  19. ready: "Ready",
  20. readyHtml: `Press <b>Play</b> to start<br>
  21. PC: mouse + click (burst) · Mobile: tap to shoot · hold to fire · drag to move`,
  22. gameOver: "Game Over",
  23. gameOverHtml: `They reached the top.<br><b>Press Play</b> to try again.`,
  24. };
  25. const T = (window.APP_LOCALE === 'en') ? { ...en } : { ...es };
  26. // =========================
  27. // CONFIG
  28. // =========================
  29. const LOGICAL_W = 540;
  30. const LOGICAL_H = 960;
  31. const FLOOR_Y = LOGICAL_H - 110;
  32. // ✅ DIFICULTAD CONTROLADA (para que Stage 2 no sea infierno)
  33. const BASE_TICK_MS = 700; // inicio stage 1
  34. const STAGE_STEP_MS = 45; // cada stage empieza un pelín más rápido
  35. const MIN_TICK_MS = 240; // NUNCA más rápido que esto
  36. const ALIVE_ACCEL_MS = 280; // cuánto acelera cuando quedan pocos (0..ALIVE_ACCEL_MS)
  37. // ✅ Origen fijo del disparo (centro de la línea tope)
  38. const SHOT_ORIGIN = { x: LOGICAL_W / 2, y: FLOOR_Y - 6 };
  39. const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
  40. // =========================
  41. // BG (sin rutas absolutas): lee background-image del canvas
  42. // =========================
  43. function extractCssUrl(cssBg) {
  44. if (!cssBg || cssBg === "none") return null;
  45. const m = cssBg.match(/url\((['"]?)(.*?)\1\)/i);
  46. return m && m[2] ? m[2] : null;
  47. }
  48. const bgImg = new Image();
  49. const bgUrl = extractCssUrl(getComputedStyle(canvas).backgroundImage);
  50. if (bgUrl) bgImg.src = bgUrl;
  51. // =========================
  52. // RESIZE (usa tamaño real del canvas)
  53. // =========================
  54. function resizeCanvas() {
  55. const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1));
  56. const r = canvas.getBoundingClientRect();
  57. const displayW = Math.max(1, Math.floor(r.width));
  58. const displayH = Math.max(1, Math.floor(r.height));
  59. canvas.width = Math.floor(displayW * dpr);
  60. canvas.height = Math.floor(displayH * dpr);
  61. ctx.setTransform(1, 0, 0, 1, 0, 0);
  62. ctx.scale(canvas.width / LOGICAL_W, canvas.height / LOGICAL_H);
  63. }
  64. function getPointerPos(clientX, clientY) {
  65. const r = canvas.getBoundingClientRect();
  66. const x = (clientX - r.left) / r.width;
  67. const y = (clientY - r.top) / r.height;
  68. return {
  69. x: clamp(x * LOGICAL_W, 0, LOGICAL_W),
  70. y: clamp(y * LOGICAL_H, 0, LOGICAL_H),
  71. };
  72. }
  73. // =========================
  74. // PIXEL ALIENS (sin imágenes)
  75. // =========================
  76. const SPRITES = [
  77. [
  78. "00111100",
  79. "01111110",
  80. "11111111",
  81. "11011011",
  82. "11111111",
  83. "00100100",
  84. "01000010",
  85. "10000001",
  86. ],
  87. [
  88. "00111100",
  89. "11111111",
  90. "10111101",
  91. "11111111",
  92. "01111110",
  93. "00100100",
  94. "01000010",
  95. "01000010",
  96. ],
  97. [
  98. "01000010",
  99. "00100100",
  100. "11111111",
  101. "10111101",
  102. "11111111",
  103. "00100100",
  104. "01000010",
  105. "10000001",
  106. ],
  107. ];
  108. function drawPixelSprite(sprite, x, y, w, h, hue) {
  109. const rows = sprite.length;
  110. const cols = sprite[0].length;
  111. const px = w / cols;
  112. const py = h / rows;
  113. ctx.save();
  114. ctx.shadowBlur = 16;
  115. ctx.shadowColor = `hsla(${hue},95%,65%,.7)`;
  116. for (let r = 0; r < rows; r++) {
  117. const row = sprite[r];
  118. for (let c = 0; c < cols; c++) {
  119. if (row[c] !== "1") continue;
  120. const rx = x + c * px;
  121. const ry = y + r * py;
  122. ctx.fillStyle = `hsla(${hue},95%,62%,.95)`;
  123. ctx.fillRect(rx, ry, px, py);
  124. ctx.fillStyle = "rgba(255,255,255,.08)";
  125. ctx.fillRect(rx + px * 0.18, ry + py * 0.18, px * 0.55, py * 0.55);
  126. }
  127. }
  128. ctx.restore();
  129. }
  130. // =========================
  131. // STATE
  132. // =========================
  133. const state = {
  134. running: false,
  135. gameOver: false,
  136. score: 0,
  137. stage: 1,
  138. cross: { x: LOGICAL_W / 2, y: LOGICAL_H * 0.55 },
  139. // disparo
  140. firing: false,
  141. fireRate: 9, // ráfaga
  142. fireCooldown: 0,
  143. // láser visual (recorta al impacto)
  144. laser: {
  145. t: 0,
  146. dur: 0.09,
  147. x1: SHOT_ORIGIN.x,
  148. y1: SHOT_ORIGIN.y,
  149. x2: SHOT_ORIGIN.x,
  150. y2: SHOT_ORIGIN.y,
  151. hitX: null,
  152. hitY: null,
  153. },
  154. // pointer
  155. pointerDown: false,
  156. pointerId: null,
  157. pointerType: "mouse",
  158. downX: 0,
  159. downY: 0,
  160. downTime: 0,
  161. moved: false,
  162. dragThreshold: 14,
  163. holdToAutofireMs: 140,
  164. bullets: [],
  165. particles: [],
  166. invaders: [],
  167. invCfg: {
  168. cols: 10,
  169. rows: 5,
  170. w: 40,
  171. h: 32,
  172. gapX: 14,
  173. gapY: 16,
  174. offsetY: 110,
  175. },
  176. // spawn progresivo
  177. spawn: {
  178. total: 0,
  179. revealed: 0,
  180. acc: 0,
  181. intervalMs: 70,
  182. },
  183. // movimiento clásico por ticks (sin “acumulaciones” locas)
  184. swarm: {
  185. dir: -1, // empieza derecha->izq
  186. stepX: 10,
  187. stepDown: 22,
  188. borderPad: 18,
  189. tickMs: BASE_TICK_MS,
  190. tickAcc: 0,
  191. },
  192. };
  193. // =========================
  194. // UI / overlay
  195. // =========================
  196. function updateUI() {
  197. if (uiScore) uiScore.textContent = String(state.score);
  198. if (uiStage) uiStage.textContent = String(state.stage);
  199. }
  200. function showOverlay(title, html) {
  201. if (!hintOverlay) return;
  202. const t = hintOverlay.querySelector(".hint-title");
  203. const s = hintOverlay.querySelector(".hint-sub");
  204. if (t) t.textContent = title;
  205. if (s) s.innerHTML = html;
  206. hintOverlay.classList.remove("hidden");
  207. }
  208. function hideOverlay() {
  209. if (!hintOverlay) return;
  210. hintOverlay.classList.add("hidden");
  211. }
  212. // =========================
  213. // SPAWN WAVE
  214. // =========================
  215. function spawnWave() {
  216. state.invaders.length = 0;
  217. const { cols, rows, w, h, gapX, gapY, offsetY } = state.invCfg;
  218. const totalW = cols * w + (cols - 1) * gapX;
  219. const offsetX = Math.round((LOGICAL_W - totalW) / 2);
  220. for (let r = 0; r < rows; r++) {
  221. for (let c = 0; c < cols; c++) {
  222. state.invaders.push({
  223. x: offsetX + c * (w + gapX),
  224. y: offsetY + r * (h + gapY),
  225. w, h,
  226. alive: true,
  227. active: false, // aparece poco a poco
  228. sprite: SPRITES[r % SPRITES.length],
  229. hue: 20 + r * 35,
  230. });
  231. }
  232. }
  233. state.spawn.total = state.invaders.length;
  234. state.spawn.revealed = 0;
  235. state.spawn.acc = 0;
  236. }
  237. // =========================
  238. // GAME FLOW
  239. // =========================
  240. function setStageStartSpeed() {
  241. const start = BASE_TICK_MS - (state.stage - 1) * STAGE_STEP_MS;
  242. state.swarm.tickMs = Math.max(MIN_TICK_MS, start);
  243. state.swarm.tickAcc = 0;
  244. }
  245. function resetGame() {
  246. state.running = false;
  247. state.gameOver = false;
  248. state.score = 0;
  249. state.stage = 1;
  250. state.cross.x = LOGICAL_W / 2;
  251. state.cross.y = LOGICAL_H * 0.55;
  252. state.firing = false;
  253. state.fireCooldown = 0;
  254. state.laser.t = 0;
  255. state.laser.hitX = null;
  256. state.laser.hitY = null;
  257. state.pointerDown = false;
  258. state.pointerId = null;
  259. state.moved = false;
  260. state.bullets.length = 0;
  261. state.particles.length = 0;
  262. state.swarm.dir = -1;
  263. setStageStartSpeed();
  264. spawnWave();
  265. updateUI();
  266. showOverlay(
  267. T.ready,
  268. T.readyHtml
  269. );
  270. }
  271. function startGame() {
  272. state.running = true;
  273. state.gameOver = false;
  274. state.firing = false;
  275. state.fireCooldown = 0;
  276. hideOverlay();
  277. }
  278. function nextStage() {
  279. state.stage++;
  280. state.score += 250;
  281. state.swarm.dir = -1;
  282. setStageStartSpeed();
  283. spawnWave();
  284. updateUI();
  285. }
  286. function doGameOver() {
  287. state.running = false;
  288. state.gameOver = true;
  289. state.firing = false;
  290. showOverlay(T.gameOver, T.gameOverHtml);
  291. if (window.GameScores) { GameScores.submit('invasion', state.score); }
  292. }
  293. // =========================
  294. // FX
  295. // =========================
  296. function boom(x, y, hue) {
  297. for (let i = 0; i < 14; i++) {
  298. const a = Math.random() * Math.PI * 2;
  299. const sp = 60 + Math.random() * 180;
  300. state.particles.push({
  301. x, y,
  302. vx: Math.cos(a) * sp,
  303. vy: Math.sin(a) * sp,
  304. life: 0.35 + Math.random() * 0.25,
  305. t: 0,
  306. hue
  307. });
  308. }
  309. }
  310. // =========================
  311. // SHOOT (B): sale del centro fijo y va hacia la mira
  312. // =========================
  313. function shootOnce() {
  314. if (!state.running || state.gameOver) return;
  315. const dx = state.cross.x - SHOT_ORIGIN.x;
  316. const dy = state.cross.y - SHOT_ORIGIN.y;
  317. const len = Math.max(1, Math.hypot(dx, dy));
  318. const speed = 900;
  319. const vx = (dx / len) * speed;
  320. const vy = (dy / len) * speed;
  321. state.bullets.push({
  322. x: SHOT_ORIGIN.x,
  323. y: SHOT_ORIGIN.y,
  324. vx,
  325. vy,
  326. r: 4.6,
  327. life: 1.2,
  328. });
  329. // láser: del origen a la mira, se recorta al impactar
  330. state.laser.t = state.laser.dur;
  331. state.laser.x1 = SHOT_ORIGIN.x;
  332. state.laser.y1 = SHOT_ORIGIN.y;
  333. state.laser.x2 = state.cross.x;
  334. state.laser.y2 = state.cross.y;
  335. state.laser.hitX = null;
  336. state.laser.hitY = null;
  337. }
  338. // =========================
  339. // INPUT (mouse + táctil fino)
  340. // =========================
  341. function onPointerDown(e) {
  342. canvas.setPointerCapture?.(e.pointerId);
  343. state.pointerDown = true;
  344. state.pointerId = e.pointerId;
  345. state.pointerType = e.pointerType || "mouse";
  346. const p = getPointerPos(e.clientX, e.clientY);
  347. state.cross.x = p.x;
  348. state.cross.y = p.y;
  349. state.downX = p.x;
  350. state.downY = p.y;
  351. state.downTime = performance.now();
  352. state.moved = false;
  353. // PC: mantener click = ráfaga
  354. if (state.pointerType === "mouse" && state.running && !state.gameOver) {
  355. state.firing = true;
  356. state.fireCooldown = 0;
  357. }
  358. e.preventDefault();
  359. }
  360. function onPointerMove(e) {
  361. if (!state.pointerDown || e.pointerId !== state.pointerId) return;
  362. const p = getPointerPos(e.clientX, e.clientY);
  363. const dist = Math.hypot(p.x - state.downX, p.y - state.downY);
  364. if (dist > state.dragThreshold) {
  365. state.moved = true;
  366. if (state.pointerType !== "mouse") state.firing = false; // drag no dispara
  367. }
  368. state.cross.x = p.x;
  369. state.cross.y = p.y;
  370. e.preventDefault();
  371. }
  372. function onPointerUp(e) {
  373. if (e.pointerId !== state.pointerId) return;
  374. const wasMoved = state.moved;
  375. const heldMs = performance.now() - state.downTime;
  376. if (state.pointerType === "mouse") {
  377. state.firing = false;
  378. } else {
  379. // tap corto = 1 tiro
  380. if (state.running && !state.gameOver && !wasMoved && heldMs < state.holdToAutofireMs) {
  381. shootOnce();
  382. }
  383. state.firing = false;
  384. }
  385. state.pointerDown = false;
  386. state.pointerId = null;
  387. e.preventDefault();
  388. }
  389. canvas.addEventListener("contextmenu", (e) => e.preventDefault());
  390. canvas.addEventListener("pointerdown", onPointerDown, { passive: false });
  391. canvas.addEventListener("pointermove", onPointerMove, { passive: false });
  392. canvas.addEventListener("pointerup", onPointerUp, { passive: false });
  393. canvas.addEventListener("pointercancel", onPointerUp, { passive: false });
  394. // =========================
  395. // DIFICULTAD: tick por % vivos
  396. // =========================
  397. function applyAliveBasedSpeed(aliveCount) {
  398. const total = state.spawn.total || 1;
  399. const aliveRatio = aliveCount / total; // 1 -> 0
  400. const accel = (1 - aliveRatio) * ALIVE_ACCEL_MS; // 0..ALIVE_ACCEL_MS
  401. const base = Math.max(MIN_TICK_MS, BASE_TICK_MS - (state.stage - 1) * STAGE_STEP_MS);
  402. state.swarm.tickMs = Math.max(MIN_TICK_MS, base - accel);
  403. }
  404. // =========================
  405. // UPDATE
  406. // =========================
  407. function update(dt, nowMs) {
  408. if (!state.running || state.gameOver) return;
  409. // spawn progresivo
  410. state.spawn.acc += dt * 1000;
  411. while (state.spawn.revealed < state.spawn.total && state.spawn.acc >= state.spawn.intervalMs) {
  412. state.spawn.acc -= state.spawn.intervalMs;
  413. state.invaders[state.spawn.revealed].active = true;
  414. state.spawn.revealed++;
  415. }
  416. // autofire táctil: mantener sin mover
  417. if (state.pointerDown && state.pointerType !== "mouse" && !state.moved) {
  418. const held = nowMs - state.downTime;
  419. if (held >= state.holdToAutofireMs) state.firing = true;
  420. }
  421. // ráfaga
  422. if (state.firing) {
  423. state.fireCooldown -= dt;
  424. if (state.fireCooldown <= 0) {
  425. shootOnce();
  426. state.fireCooldown = 1 / state.fireRate;
  427. }
  428. }
  429. // láser timer
  430. if (state.laser.t > 0) {
  431. state.laser.t -= dt;
  432. if (state.laser.t <= 0) {
  433. state.laser.hitX = null;
  434. state.laser.hitY = null;
  435. }
  436. }
  437. // bullets
  438. for (let i = state.bullets.length - 1; i >= 0; i--) {
  439. const b = state.bullets[i];
  440. b.x += b.vx * dt;
  441. b.y += b.vy * dt;
  442. b.life -= dt;
  443. if (b.life <= 0 || b.y < -120 || b.y > LOGICAL_H + 120 || b.x < -120 || b.x > LOGICAL_W + 120) {
  444. state.bullets.splice(i, 1);
  445. }
  446. }
  447. // particles
  448. for (let i = state.particles.length - 1; i >= 0; i--) {
  449. const p = state.particles[i];
  450. p.t += dt;
  451. p.x += p.vx * dt;
  452. p.y += p.vy * dt;
  453. p.vx *= (1 - 1.2 * dt);
  454. p.vy *= (1 - 1.2 * dt);
  455. if (p.t >= p.life) state.particles.splice(i, 1);
  456. }
  457. // movimiento invaders SOLO cuando ya aparecieron todos
  458. if (state.spawn.revealed >= state.spawn.total) {
  459. state.swarm.tickAcc += dt * 1000;
  460. while (state.swarm.tickAcc >= state.swarm.tickMs) {
  461. state.swarm.tickAcc -= state.swarm.tickMs;
  462. let minX = Infinity, maxX = -Infinity;
  463. let aliveCount = 0;
  464. for (const inv of state.invaders) {
  465. if (!inv.alive || !inv.active) continue;
  466. aliveCount++;
  467. minX = Math.min(minX, inv.x);
  468. maxX = Math.max(maxX, inv.x + inv.w);
  469. }
  470. if (aliveCount === 0) {
  471. nextStage();
  472. return;
  473. }
  474. // ✅ dificultad real: velocidad solo por % vivos (y base por stage)
  475. applyAliveBasedSpeed(aliveCount);
  476. const leftBound = state.swarm.borderPad;
  477. const rightBound = LOGICAL_W - state.swarm.borderPad;
  478. const nextMinX = minX + state.swarm.stepX * state.swarm.dir;
  479. const nextMaxX = maxX + state.swarm.stepX * state.swarm.dir;
  480. const hitLeft = nextMinX <= leftBound;
  481. const hitRight = nextMaxX >= rightBound;
  482. if (hitLeft || hitRight) {
  483. state.swarm.dir *= -1;
  484. for (const inv of state.invaders) {
  485. if (!inv.alive || !inv.active) continue;
  486. inv.y += state.swarm.stepDown;
  487. }
  488. } else {
  489. for (const inv of state.invaders) {
  490. if (!inv.alive || !inv.active) continue;
  491. inv.x += state.swarm.stepX * state.swarm.dir;
  492. }
  493. }
  494. // game over si tocan la línea
  495. for (const inv of state.invaders) {
  496. if (!inv.alive || !inv.active) continue;
  497. if (inv.y + inv.h >= FLOOR_Y) {
  498. doGameOver();
  499. return;
  500. }
  501. }
  502. }
  503. }
  504. // colisiones bala vs invader (impacto: desaparecer y recortar láser)
  505. for (let bi = state.bullets.length - 1; bi >= 0; bi--) {
  506. const b = state.bullets[bi];
  507. let hit = false;
  508. for (const inv of state.invaders) {
  509. if (!inv.alive || !inv.active) continue;
  510. if (
  511. b.x + b.r >= inv.x &&
  512. b.x - b.r <= inv.x + inv.w &&
  513. b.y + b.r >= inv.y &&
  514. b.y - b.r <= inv.y + inv.h
  515. ) {
  516. inv.alive = false;
  517. hit = true;
  518. state.score += 10 + Math.floor(inv.hue / 18);
  519. // recorta láser al impacto
  520. state.laser.hitX = inv.x + inv.w / 2;
  521. state.laser.hitY = inv.y + inv.h / 2;
  522. state.laser.t = Math.min(state.laser.t, 0.06);
  523. boom(inv.x + inv.w / 2, inv.y + inv.h / 2, inv.hue);
  524. break;
  525. }
  526. }
  527. if (hit) {
  528. state.bullets.splice(bi, 1);
  529. updateUI();
  530. }
  531. }
  532. }
  533. // =========================
  534. // DRAW
  535. // =========================
  536. // (Tu versión “solucionado” funciona, la dejo compatible)
  537. function drawBackgroundImage() {
  538. ctx.save();
  539. if (!bgUrl || !bgImg.complete || !bgImg.naturalWidth) {
  540. // fallback simple (sin oscurecer)
  541. ctx.clearRect(0, 0, LOGICAL_W, LOGICAL_H);
  542. ctx.restore();
  543. return;
  544. }
  545. const iw = bgImg.naturalWidth;
  546. const ih = bgImg.naturalHeight;
  547. const scale = Math.max(LOGICAL_W / iw, LOGICAL_H / ih);
  548. const w = iw * scale;
  549. const h = ih * scale;
  550. const x = (LOGICAL_W - w) / 2;
  551. const y = (LOGICAL_H - h) / 2;
  552. ctx.drawImage(bgImg, x, y, w, h);
  553. // 👉 aquí puedes meter tu viñeta/oscurecido si quieres (tú ya lo tienes afinado)
  554. ctx.restore();
  555. }
  556. function drawLaser() {
  557. if (state.laser.t <= 0) return;
  558. const k = state.laser.t / state.laser.dur;
  559. const x1 = state.laser.x1;
  560. const y1 = state.laser.y1;
  561. const x2 = (state.laser.hitX != null) ? state.laser.hitX : state.laser.x2;
  562. const y2 = (state.laser.hitY != null) ? state.laser.hitY : state.laser.y2;
  563. ctx.save();
  564. ctx.globalAlpha = 0.9 * k;
  565. ctx.strokeStyle = "rgba(120,255,90,.95)";
  566. ctx.lineWidth = 5;
  567. ctx.shadowBlur = 26;
  568. ctx.shadowColor = "rgba(120,255,90,.95)";
  569. ctx.beginPath();
  570. ctx.moveTo(x1, y1);
  571. ctx.lineTo(x2, y2);
  572. ctx.stroke();
  573. // núcleo
  574. ctx.globalAlpha = 0.95 * k;
  575. ctx.lineWidth = 2;
  576. ctx.shadowBlur = 0;
  577. ctx.strokeStyle = "rgba(220,255,220,.9)";
  578. ctx.beginPath();
  579. ctx.moveTo(x1, y1);
  580. ctx.lineTo(x2, y2);
  581. ctx.stroke();
  582. ctx.restore();
  583. }
  584. function drawTriCrosshair(nowMs) {
  585. const c = { x: state.cross.x, y: state.cross.y };
  586. const a = { x: 90, y: FLOOR_Y + 70 };
  587. const b = { x: LOGICAL_W - 90, y: FLOOR_Y + 70 };
  588. ctx.save();
  589. const pulse = 0.8 + 0.2 * Math.sin(nowMs * 0.01);
  590. ctx.strokeStyle = "rgba(255,40,60,.85)";
  591. ctx.lineWidth = 3;
  592. ctx.shadowBlur = 18;
  593. ctx.shadowColor = "rgba(255,40,60,.65)";
  594. seg(c, a);
  595. seg(c, b);
  596. node(a, 6);
  597. node(b, 6);
  598. // centro
  599. ctx.shadowBlur = 26;
  600. ctx.shadowColor = "rgba(255,40,60,.9)";
  601. ctx.fillStyle = "rgba(255,40,60,.95)";
  602. ctx.beginPath();
  603. ctx.arc(c.x, c.y, 6.5, 0, Math.PI * 2);
  604. ctx.fill();
  605. // cruz + aro
  606. ctx.shadowBlur = 0;
  607. ctx.strokeStyle = "rgba(255,40,60,.92)";
  608. ctx.lineWidth = 3.2;
  609. ctx.lineCap = "round";
  610. const size = 26;
  611. const gap = 10;
  612. line(c.x - size, c.y, c.x - gap, c.y);
  613. line(c.x + gap, c.y, c.x + size, c.y);
  614. line(c.x, c.y - size, c.x, c.y - gap);
  615. line(c.x, c.y + gap, c.x, c.y + size);
  616. ctx.globalAlpha = 0.85;
  617. ctx.lineWidth = 2.6;
  618. ctx.beginPath();
  619. ctx.arc(c.x, c.y, 24 + 2 * pulse, 0, Math.PI * 2);
  620. ctx.stroke();
  621. ctx.restore();
  622. function seg(p1, p2) {
  623. ctx.beginPath();
  624. ctx.moveTo(p1.x, p1.y);
  625. ctx.lineTo(p2.x, p2.y);
  626. ctx.stroke();
  627. }
  628. function node(p, r) {
  629. ctx.beginPath();
  630. ctx.fillStyle = "rgba(255,40,60,.95)";
  631. ctx.shadowBlur = 18;
  632. ctx.shadowColor = "rgba(255,40,60,.75)";
  633. ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
  634. ctx.fill();
  635. }
  636. function line(x1, y1, x2, y2) {
  637. ctx.beginPath();
  638. ctx.moveTo(x1, y1);
  639. ctx.lineTo(x2, y2);
  640. ctx.stroke();
  641. }
  642. }
  643. function draw(nowMs) {
  644. ctx.clearRect(0, 0, LOGICAL_W, LOGICAL_H);
  645. drawBackgroundImage();
  646. // línea tope
  647. ctx.save();
  648. ctx.globalAlpha = 0.45;
  649. ctx.fillStyle = "rgba(255,255,255,.9)";
  650. ctx.fillRect(0, FLOOR_Y, LOGICAL_W, 2);
  651. ctx.restore();
  652. // invaders
  653. for (const inv of state.invaders) {
  654. if (!inv.alive || !inv.active) continue;
  655. drawPixelSprite(inv.sprite, inv.x, inv.y, inv.w, inv.h, inv.hue);
  656. }
  657. // bullets
  658. for (const b of state.bullets) {
  659. ctx.save();
  660. ctx.beginPath();
  661. ctx.fillStyle = "rgba(124,255,91,.95)";
  662. ctx.shadowBlur = 18;
  663. ctx.shadowColor = "rgba(124,255,91,.85)";
  664. ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
  665. ctx.fill();
  666. ctx.restore();
  667. }
  668. // láser verde
  669. drawLaser();
  670. // partículas
  671. for (const p of state.particles) {
  672. const k = 1 - (p.t / p.life);
  673. ctx.save();
  674. ctx.globalAlpha = 0.9 * k;
  675. ctx.fillStyle = `hsla(${p.hue}, 95%, 65%, 1)`;
  676. ctx.shadowBlur = 18;
  677. ctx.shadowColor = `hsla(${p.hue}, 95%, 65%, .9)`;
  678. ctx.beginPath();
  679. ctx.arc(p.x, p.y, 3.2 * k, 0, Math.PI * 2);
  680. ctx.fill();
  681. ctx.restore();
  682. }
  683. // mira
  684. drawTriCrosshair(nowMs);
  685. }
  686. // =========================
  687. // LOOP
  688. // =========================
  689. let lastT = performance.now();
  690. function tick(now) {
  691. const dt = Math.min(0.033, (now - lastT) / 1000);
  692. lastT = now;
  693. update(dt, now);
  694. draw(now);
  695. requestAnimationFrame(tick);
  696. }
  697. // =========================
  698. // PLAY BUTTON
  699. // =========================
  700. btnPlay?.addEventListener("click", () => {
  701. if (state.gameOver) {
  702. resetGame();
  703. startGame();
  704. return;
  705. }
  706. if (!state.running) {
  707. startGame();
  708. return;
  709. }
  710. });
  711. // =========================
  712. // INIT
  713. // =========================
  714. window.addEventListener("resize", resizeCanvas);
  715. requestAnimationFrame(() => {
  716. resizeCanvas();
  717. resetGame();
  718. requestAnimationFrame(tick);
  719. });
  720. })();