← Volver a código

Space Shooter

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 Shooter</title>
  7. <link rel="stylesheet" href="css/space.css">
  8. </head>
  9. <body>
  10. <!-- Estructura del juego (canvas, HUD, botones…) -->
  11. <script src="js/space.js"></script>
  12. </body>
  13. </html>

CSS

  1. :root {
  2. --space-bg: #05040d;
  3. --space-panel: rgba(8, 6, 20, 0.96);
  4. --space-panel-soft: rgba(20, 14, 38, 0.88);
  5. --space-border: rgba(169, 85, 255, 0.48);
  6. --space-purple: #ad55ff;
  7. --space-cyan: #24f6d2;
  8. --space-yellow: #ffd424;
  9. --space-red: #ff536b;
  10. --space-text: #ffffff;
  11. --space-muted: #b8b3c5;
  12. }
  13. * {
  14. box-sizing: border-box;
  15. }
  16. html {
  17. min-height: 100%;
  18. background: var(--space-bg);
  19. }
  20. body {
  21. min-width: 320px;
  22. min-height: 100vh;
  23. margin: 0;
  24. color: var(--space-text);
  25. background-color: var(--space-bg);
  26. background-position: center;
  27. background-attachment: fixed;
  28. background-size: cover;
  29. font-family: Arial, Helvetica, sans-serif;
  30. }
  31. button,
  32. a {
  33. font: inherit;
  34. }
  35. button {
  36. cursor: pointer;
  37. }
  38. .arcade-nav {
  39. position: relative;
  40. z-index: 20;
  41. display: flex;
  42. align-items: center;
  43. justify-content: space-between;
  44. min-height: 78px;
  45. padding: 12px clamp(24px, 6vw, 130px);
  46. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  47. background: rgba(5, 4, 13, 0.92);
  48. backdrop-filter: blur(14px);
  49. }
  50. .arcade-brand {
  51. display: inline-flex;
  52. align-items: center;
  53. gap: 12px;
  54. color: #ffffff;
  55. text-decoration: none;
  56. font-size: clamp(17px, 1.4vw, 23px);
  57. font-weight: 900;
  58. letter-spacing: 0.03em;
  59. }
  60. .brand-code {
  61. color: var(--space-purple);
  62. font-size: 1.55em;
  63. line-height: 1;
  64. }
  65. .arcade-nav-links {
  66. display: flex;
  67. align-items: center;
  68. gap: 7px;
  69. padding: 7px;
  70. border: 1px solid rgba(255, 255, 255, 0.12);
  71. border-radius: 999px;
  72. background: rgba(12, 9, 24, 0.84);
  73. }
  74. .arcade-nav-links a {
  75. display: inline-flex;
  76. align-items: center;
  77. gap: 8px;
  78. padding: 11px 15px;
  79. border-radius: 999px;
  80. color: #d8d4df;
  81. text-decoration: none;
  82. font-size: 13px;
  83. font-weight: 800;
  84. text-transform: uppercase;
  85. transition: color 0.2s ease, background 0.2s ease;
  86. }
  87. .arcade-nav-links a:hover {
  88. color: #ffffff;
  89. background: rgba(173, 85, 255, 0.14);
  90. }
  91. .arcade-nav-links svg {
  92. width: 18px;
  93. height: 18px;
  94. fill: var(--space-purple);
  95. }
  96. .space-page {
  97. width: min(1400px, 94vw);
  98. margin: 0 auto;
  99. padding-bottom: 26px;
  100. }
  101. .game-toolbar {
  102. display: flex;
  103. align-items: center;
  104. justify-content: space-between;
  105. min-height: 56px;
  106. padding: 0 4px;
  107. }
  108. .toolbar-link {
  109. color: var(--space-purple);
  110. text-decoration: none;
  111. font-size: 13px;
  112. font-weight: 850;
  113. letter-spacing: 0.06em;
  114. }
  115. .toolbar-button {
  116. border: 0;
  117. background: none;
  118. cursor: pointer;
  119. }
  120. .toolbar-link:hover {
  121. color: #d69aff;
  122. }
  123. .arcade-shell {
  124. display: grid;
  125. grid-template-columns: minmax(0, 1.65fr) minmax(330px, 0.9fr);
  126. width: 100%;
  127. min-height: min(760px, calc(100vh - 160px));
  128. overflow: hidden;
  129. border: 1px solid var(--space-border);
  130. border-radius: 28px;
  131. background: var(--space-panel);
  132. box-shadow: 0 28px 80px rgba(0, 0, 0, 0.56);
  133. }
  134. .board-column {
  135. display: flex;
  136. min-width: 0;
  137. flex-direction: column;
  138. padding: clamp(22px, 3vh, 34px) clamp(22px, 2.5vw, 42px) 26px;
  139. border-right: 1px solid rgba(255, 255, 255, 0.08);
  140. }
  141. .space-logo {
  142. display: flex;
  143. align-items: center;
  144. justify-content: center;
  145. gap: 17px;
  146. min-height: 52px;
  147. margin: 0 0 20px;
  148. color: var(--space-cyan);
  149. text-align: center;
  150. font-family: "Courier New", monospace;
  151. font-size: clamp(27px, 3vw, 46px);
  152. font-weight: 900;
  153. line-height: 1;
  154. letter-spacing: 0.08em;
  155. text-shadow:
  156. 0 0 5px rgba(36, 246, 210, 0.92),
  157. 0 0 18px rgba(36, 246, 210, 0.45);
  158. }
  159. .space-logo-icon,
  160. .ship-mark {
  161. width: 42px;
  162. height: 34px;
  163. color: var(--space-cyan);
  164. filter: drop-shadow(0 0 7px rgba(36, 246, 210, 0.75));
  165. }
  166. .game-stage {
  167. position: relative;
  168. flex: 1;
  169. min-height: 440px;
  170. overflow: hidden;
  171. border: 2px solid #1265ff;
  172. border-radius: 18px;
  173. background: #05070c;
  174. box-shadow:
  175. 0 0 26px rgba(18, 101, 255, 0.22),
  176. inset 0 0 28px rgba(0, 0, 0, 0.8);
  177. }
  178. #game {
  179. display: block;
  180. width: 100%;
  181. height: 100%;
  182. background: #05070c;
  183. touch-action: none;
  184. }
  185. .game-overlay,
  186. .overlay {
  187. position: absolute;
  188. inset: 0;
  189. z-index: 4;
  190. display: grid;
  191. place-items: center;
  192. padding: 24px;
  193. color: #ffffff;
  194. text-align: center;
  195. background: rgba(1, 3, 10, 0.58);
  196. backdrop-filter: blur(3px);
  197. }
  198. .game-overlay[hidden],
  199. .overlay.hidden {
  200. display: none;
  201. }
  202. .overlay-card {
  203. max-width: 520px;
  204. }
  205. .overlay-card h2 {
  206. margin: 0 0 10px;
  207. color: var(--space-cyan);
  208. font-family: "Courier New", monospace;
  209. font-size: clamp(27px, 4vw, 48px);
  210. text-shadow: 0 0 14px rgba(36, 246, 210, 0.55);
  211. }
  212. .overlay-card p {
  213. margin: 0 0 20px;
  214. color: #ffffff;
  215. font-size: 15px;
  216. line-height: 1.55;
  217. }
  218. .overlay-actions {
  219. display: flex;
  220. justify-content: center;
  221. gap: 12px;
  222. }
  223. .overlay-button,
  224. .overlay-actions button {
  225. min-width: 140px;
  226. padding: 12px 18px;
  227. border: 1px solid var(--space-cyan);
  228. border-radius: 10px;
  229. color: var(--space-cyan);
  230. background: rgba(6, 30, 32, 0.9);
  231. font-weight: 900;
  232. text-transform: uppercase;
  233. box-shadow: 0 0 16px rgba(36, 246, 210, 0.24);
  234. }
  235. .game-help {
  236. display: flex;
  237. justify-content: center;
  238. gap: 22px;
  239. margin-top: 17px;
  240. color: var(--space-muted);
  241. font-size: 13px;
  242. }
  243. .game-help strong {
  244. color: var(--space-cyan);
  245. }
  246. .control-column {
  247. display: flex;
  248. min-width: 0;
  249. flex-direction: column;
  250. padding: 24px 28px 22px;
  251. }
  252. .desktop-stats,
  253. .mobile-stats {
  254. display: grid;
  255. grid-template-columns: repeat(2, minmax(0, 1fr));
  256. gap: 12px;
  257. }
  258. .mobile-stats,
  259. .mobile-hud {
  260. display: none;
  261. }
  262. .mobile-hud span {
  263. padding: 11px 14px;
  264. border-radius: 12px;
  265. color: #1c1724;
  266. background: #f4f2f5;
  267. font-size: 15px;
  268. font-weight: 700;
  269. }
  270. .stat-card {
  271. display: flex;
  272. align-items: center;
  273. justify-content: center;
  274. gap: 9px;
  275. min-width: 0;
  276. padding: 12px 9px;
  277. border: 1px solid rgba(255, 255, 255, 0.14);
  278. border-radius: 16px;
  279. background: rgba(255, 255, 255, 0.035);
  280. box-shadow: inset 0 1px rgba(255, 255, 255, 0.04);
  281. font-size: 11px;
  282. font-weight: 700;
  283. letter-spacing: 0.04em;
  284. }
  285. .stat-icon {
  286. flex: 0 0 auto;
  287. color: var(--space-yellow);
  288. font-size: 22px;
  289. }
  290. .stat-icon.red {
  291. color: var(--space-red);
  292. }
  293. .stat-card span:last-child {
  294. display: flex;
  295. min-width: 0;
  296. flex-direction: column;
  297. gap: 2px;
  298. }
  299. .stat-card b {
  300. color: var(--space-yellow);
  301. font-size: 19px;
  302. line-height: 1;
  303. }
  304. .controls-block {
  305. display: grid;
  306. grid-template-columns: minmax(120px, 1fr) 190px;
  307. align-items: center;
  308. gap: 24px;
  309. margin: 34px 0 28px;
  310. }
  311. .keyboard-help h2,
  312. .score-preview h2 {
  313. margin: 0 0 17px;
  314. color: var(--space-purple);
  315. font-size: 16px;
  316. font-weight: 900;
  317. letter-spacing: 0.05em;
  318. }
  319. .keyboard-help p {
  320. margin: 10px 0;
  321. color: var(--space-muted);
  322. font-size: 13px;
  323. }
  324. .keyboard-help strong {
  325. color: var(--space-cyan);
  326. }
  327. .control-preview {
  328. display: flex;
  329. align-items: center;
  330. justify-content: center;
  331. gap: 22px;
  332. }
  333. .mini-dpad,
  334. .dpad-preview {
  335. display: grid;
  336. grid-template-columns: repeat(3, 48px);
  337. grid-template-rows: repeat(3, 48px);
  338. gap: 6px;
  339. }
  340. .mini-key,
  341. .dpad-preview span {
  342. display: grid;
  343. place-items: center;
  344. border: 1px solid rgba(173, 85, 255, 0.45);
  345. border-radius: 13px;
  346. color: var(--space-purple);
  347. background: #1a122d;
  348. font-size: 19px;
  349. }
  350. .mini-key.up,
  351. .dpad-preview .up {
  352. grid-column: 2;
  353. }
  354. .mini-key.left,
  355. .dpad-preview .left {
  356. grid-column: 1;
  357. grid-row: 2;
  358. }
  359. .mini-key.center,
  360. .dpad-preview .center {
  361. grid-column: 2;
  362. grid-row: 2;
  363. color: #090710;
  364. background: var(--space-yellow);
  365. }
  366. .mini-key.right,
  367. .dpad-preview .right {
  368. grid-column: 3;
  369. grid-row: 2;
  370. }
  371. .mini-key.down,
  372. .dpad-preview .down {
  373. grid-column: 2;
  374. grid-row: 3;
  375. }
  376. .mini-fire {
  377. display: grid;
  378. width: 68px;
  379. height: 68px;
  380. place-items: center;
  381. border: 1px solid rgba(36, 246, 210, 0.55);
  382. border-radius: 50%;
  383. color: var(--space-cyan);
  384. background: rgba(6, 30, 32, 0.85);
  385. font-size: 27px;
  386. box-shadow: 0 0 18px rgba(36, 246, 210, 0.18);
  387. }
  388. .action-row {
  389. display: grid;
  390. grid-template-columns: repeat(2, minmax(0, 1fr));
  391. gap: 12px;
  392. }
  393. .arcade-action {
  394. min-height: 48px;
  395. padding: 10px 13px;
  396. border: 1px solid rgba(173, 85, 255, 0.65);
  397. border-radius: 12px;
  398. color: var(--space-purple);
  399. background: rgba(27, 17, 47, 0.85);
  400. font-size: 12px;
  401. font-weight: 900;
  402. text-transform: uppercase;
  403. }
  404. .arcade-action:hover {
  405. border-color: var(--space-purple);
  406. color: #ffffff;
  407. }
  408. .score-preview {
  409. margin-top: 25px;
  410. }
  411. .ranking-preview {
  412. overflow: hidden;
  413. border: 1px solid rgba(255, 255, 255, 0.13);
  414. border-radius: 13px;
  415. background: rgba(255, 255, 255, 0.025);
  416. list-style: none;
  417. }
  418. .ranking-preview li {
  419. display: grid;
  420. grid-template-columns: 30px 1fr auto;
  421. gap: 8px;
  422. padding: 11px 14px;
  423. border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  424. font-size: 13px;
  425. }
  426. .ranking-preview li:last-child {
  427. border-bottom: 0;
  428. }
  429. .rank-position {
  430. color: var(--space-purple);
  431. font-weight: 900;
  432. }
  433. .ranking-preview li:first-child .rank-position {
  434. color: var(--space-yellow);
  435. }
  436. .rank-name {
  437. overflow: hidden;
  438. text-overflow: ellipsis;
  439. white-space: nowrap;
  440. }
  441. .rank-score {
  442. color: var(--space-yellow);
  443. font-weight: 900;
  444. }
  445. .ranking-loading,
  446. .ranking-empty {
  447. display: block !important;
  448. color: var(--space-muted);
  449. text-align: center;
  450. }
  451. .btn-start,
  452. .btn-start-space {
  453. width: 100%;
  454. min-height: 58px;
  455. margin-top: auto;
  456. padding: 12px 20px;
  457. border: 2px solid var(--space-cyan);
  458. border-radius: 12px;
  459. color: var(--space-cyan);
  460. background: rgba(5, 27, 29, 0.92);
  461. font-family: "Courier New", monospace;
  462. font-size: 19px;
  463. font-weight: 900;
  464. letter-spacing: 0.07em;
  465. text-transform: uppercase;
  466. box-shadow:
  467. 0 0 18px rgba(36, 246, 210, 0.28),
  468. inset 0 0 16px rgba(36, 246, 210, 0.08);
  469. }
  470. .btn-start:hover,
  471. .btn-start-space:hover {
  472. color: #07110f;
  473. background: var(--space-cyan);
  474. }
  475. .btn-start-space svg {
  476. width: 20px;
  477. height: 22px;
  478. margin-right: 9px;
  479. fill: currentColor;
  480. vertical-align: middle;
  481. }
  482. .mobile-controls {
  483. display: none;
  484. }
  485. @media (max-width: 1120px) {
  486. .arcade-shell {
  487. grid-template-columns: minmax(0, 1.5fr) minmax(310px, 0.8fr);
  488. }
  489. .board-column {
  490. padding-right: 24px;
  491. padding-left: 24px;
  492. }
  493. .control-column {
  494. padding-right: 22px;
  495. padding-left: 22px;
  496. }
  497. .controls-block {
  498. grid-template-columns: 1fr;
  499. }
  500. .keyboard-help {
  501. text-align: center;
  502. }
  503. }
  504. @media (max-width: 820px) {
  505. .arcade-nav {
  506. min-height: 68px;
  507. padding: 10px 16px;
  508. }
  509. .arcade-nav-links a {
  510. padding: 10px;
  511. }
  512. .arcade-nav-links a span {
  513. display: none;
  514. }
  515. .space-page {
  516. min-height: calc(100vh - 68px);
  517. padding: 18px 14px 24px;
  518. }
  519. .arcade-shell {
  520. display: block;
  521. min-height: 0;
  522. overflow: visible;
  523. border-radius: 24px;
  524. }
  525. .board-column {
  526. padding: 24px 18px 18px;
  527. border-right: 0;
  528. }
  529. .control-column {
  530. display: none;
  531. }
  532. .space-logo {
  533. margin-bottom: 18px;
  534. font-size: clamp(24px, 7vw, 36px);
  535. }
  536. .space-logo-icon,
  537. .ship-mark {
  538. width: 31px;
  539. height: 26px;
  540. }
  541. .mobile-stats,
  542. .mobile-hud {
  543. display: grid;
  544. grid-template-columns: repeat(2, minmax(0, 1fr));
  545. justify-content: space-between;
  546. gap: 12px;
  547. margin-bottom: 14px;
  548. }
  549. .mobile-hud span:last-child {
  550. text-align: right;
  551. }
  552. .mobile-stats .stat-card {
  553. padding: 12px 14px;
  554. }
  555. .mobile-stats .stat-card b {
  556. font-size: 22px;
  557. }
  558. .game-stage {
  559. height: min(60vh, 620px);
  560. min-height: 430px;
  561. flex: none;
  562. border-radius: 14px;
  563. }
  564. .game-help {
  565. display: none;
  566. }
  567. .mobile-controls {
  568. display: grid;
  569. grid-template-columns: 1fr 104px;
  570. align-items: center;
  571. gap: 28px;
  572. margin-top: 14px;
  573. padding: 12px 14px;
  574. border-radius: 15px;
  575. background: rgba(255, 255, 255, 0.07);
  576. }
  577. .touch-dpad,
  578. .dpad {
  579. display: grid;
  580. grid-template-columns: repeat(3, 55px);
  581. grid-template-rows: repeat(3, 55px);
  582. justify-content: center;
  583. gap: 0;
  584. }
  585. .touch-button,
  586. .dpad-btn {
  587. display: grid;
  588. place-items: center;
  589. border: 0;
  590. border-radius: 11px;
  591. color: #ffffff;
  592. background: #2a2a2a;
  593. font-size: 23px;
  594. box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
  595. touch-action: none;
  596. user-select: none;
  597. }
  598. .touch-button.up,
  599. .dpad-btn.up {
  600. grid-column: 2;
  601. }
  602. .touch-button.left,
  603. .dpad-btn.left {
  604. grid-column: 1;
  605. grid-row: 2;
  606. }
  607. .touch-dpad-center,
  608. .dpad::after {
  609. grid-column: 2;
  610. grid-row: 2;
  611. background: #ffffff;
  612. }
  613. .dpad::after {
  614. display: block;
  615. content: "";
  616. }
  617. .touch-button.right,
  618. .dpad-btn.right {
  619. grid-column: 3;
  620. grid-row: 2;
  621. }
  622. .touch-button.down,
  623. .dpad-btn.down {
  624. grid-column: 2;
  625. grid-row: 3;
  626. }
  627. .touch-fire {
  628. display: grid;
  629. width: 84px;
  630. height: 84px;
  631. place-items: center;
  632. justify-self: center;
  633. border: 0;
  634. border-radius: 50%;
  635. color: #ffffff;
  636. background: #222222;
  637. font-size: 37px;
  638. box-shadow: 0 10px 24px rgba(0, 0, 0, 0.3);
  639. touch-action: none;
  640. user-select: none;
  641. }
  642. }
  643. @media (max-width: 480px) {
  644. .arcade-brand span:last-child {
  645. font-size: 14px;
  646. }
  647. .arcade-nav-links {
  648. gap: 2px;
  649. padding: 4px;
  650. }
  651. .arcade-nav-links a {
  652. padding: 8px;
  653. }
  654. .game-toolbar {
  655. margin-bottom: 13px;
  656. }
  657. .toolbar-link {
  658. font-size: 12px;
  659. }
  660. .board-column {
  661. padding: 20px 14px 14px;
  662. }
  663. .space-logo {
  664. gap: 8px;
  665. font-size: clamp(19px, 6.2vw, 27px);
  666. letter-spacing: 0.03em;
  667. }
  668. .space-logo-icon {
  669. width: 25px;
  670. }
  671. .mobile-stats {
  672. gap: 10px;
  673. }
  674. .mobile-stats .stat-card {
  675. gap: 8px;
  676. padding: 10px;
  677. }
  678. .mobile-stats .stat-icon {
  679. font-size: 20px;
  680. }
  681. .mobile-stats .stat-card > span:last-child {
  682. font-size: 10px;
  683. }
  684. .game-stage {
  685. height: 60vh;
  686. min-height: 430px;
  687. }
  688. .game-overlay {
  689. padding: 16px;
  690. }
  691. .overlay-card p {
  692. font-size: 13px;
  693. }
  694. .mobile-controls {
  695. grid-template-columns: minmax(180px, 1fr) 88px;
  696. gap: 12px;
  697. padding: 12px 8px;
  698. }
  699. .touch-dpad,
  700. .dpad {
  701. grid-template-columns: repeat(3, 50px);
  702. grid-template-rows: repeat(3, 50px);
  703. }
  704. .touch-fire {
  705. width: 76px;
  706. height: 76px;
  707. }
  708. }
  709. @media (max-width: 370px) {
  710. .arcade-brand span:last-child {
  711. display: none;
  712. }
  713. .space-page {
  714. padding-right: 8px;
  715. padding-left: 8px;
  716. }
  717. .board-column {
  718. padding-right: 10px;
  719. padding-left: 10px;
  720. }
  721. .touch-dpad,
  722. .dpad {
  723. grid-template-columns: repeat(3, 46px);
  724. grid-template-rows: repeat(3, 46px);
  725. }
  726. }
  727. #game,
  728. .game-stage,
  729. .mobile-controls,
  730. .dpad-btn,
  731. .touch-fire {
  732. touch-action: none;
  733. user-select: none;
  734. -webkit-user-select: none;
  735. overscroll-behavior: contain;
  736. }

JS

  1. (() => {
  2. // ===== i18n (ES por defecto / EN opcional) =====
  3. const T =
  4. window.APP_LOCALE === "en"
  5. ? {
  6. scoreLabel: "Score:",
  7. livesLabel: "Lives:",
  8. gameOver: "Game Over",
  9. gameOverText: "Press R or Restart",
  10. pause: "Pause",
  11. pauseText: "Press P to continue",
  12. ready: "Ready",
  13. readyTouch: "Move with the d-pad and shoot with 🔥",
  14. readyKeys: "Space to shoot · P pause · R restart",
  15. }
  16. : {
  17. scoreLabel: "Puntos:",
  18. livesLabel: "Vidas:",
  19. gameOver: "Game Over",
  20. gameOverText: "Pulsa R o Reiniciar",
  21. pause: "Pausa",
  22. pauseText: "Pulsa P para continuar",
  23. ready: "Listo",
  24. readyTouch: "Mueve con la cruceta y dispara con 🔥",
  25. readyKeys: "Espacio para disparar · P pausa · R reiniciar",
  26. };
  27. // ===== DOM =====
  28. const canvas = document.getElementById("game");
  29. const ctx = canvas?.getContext("2d");
  30. const hudScore = document.getElementById("hudScore");
  31. const hudLives = document.getElementById("hudLives");
  32. const overlay = document.getElementById("overlay");
  33. const overlayTitle = document.getElementById("overlayTitle");
  34. const overlayText = document.getElementById("overlayText");
  35. const btnResume = document.getElementById("btnResume");
  36. const btnRestart = document.getElementById("btnRestart");
  37. // Controles táctiles
  38. const btnStartTouch = document.getElementById("btnStartTouch");
  39. const btnUp = document.getElementById("btnUp");
  40. const btnDown = document.getElementById("btnDown");
  41. const btnLeft = document.getElementById("btnLeft");
  42. const btnRight = document.getElementById("btnRight");
  43. const touchFire = document.getElementById("touchFire");
  44. if (!canvas || !ctx) {
  45. return;
  46. }
  47. // ===== Configuración =====
  48. const SPRITES_BASE = "/sources/img/space/";
  49. const SPRITES = {
  50. player: SPRITES_BASE + "player.png",
  51. enemy1: SPRITES_BASE + "enemy1.png",
  52. enemy2: SPRITES_BASE + "enemy2.png",
  53. };
  54. // ===== Utilidades =====
  55. const clamp = (value, min, max) => {
  56. return Math.max(min, Math.min(max, value));
  57. };
  58. const rand = (min, max) => {
  59. return min + Math.random() * (max - min);
  60. };
  61. function rectsOverlap(a, b) {
  62. return (
  63. a.x < b.x + b.w &&
  64. a.x + a.w > b.x &&
  65. a.y < b.y + b.h &&
  66. a.y + a.h > b.y
  67. );
  68. }
  69. function roundRectFill(x, y, width, height, radius) {
  70. const finalRadius = Math.min(
  71. radius,
  72. width / 2,
  73. height / 2
  74. );
  75. ctx.beginPath();
  76. ctx.moveTo(x + finalRadius, y);
  77. ctx.arcTo(
  78. x + width,
  79. y,
  80. x + width,
  81. y + height,
  82. finalRadius
  83. );
  84. ctx.arcTo(
  85. x + width,
  86. y + height,
  87. x,
  88. y + height,
  89. finalRadius
  90. );
  91. ctx.arcTo(
  92. x,
  93. y + height,
  94. x,
  95. y,
  96. finalRadius
  97. );
  98. ctx.arcTo(
  99. x,
  100. y,
  101. x + width,
  102. y,
  103. finalRadius
  104. );
  105. ctx.closePath();
  106. ctx.fill();
  107. }
  108. // ===== Carga de sprites =====
  109. const images = {};
  110. function loadImage(key, src) {
  111. return new Promise((resolve) => {
  112. const image = new Image();
  113. image.onload = () => {
  114. images[key] = image;
  115. resolve(true);
  116. };
  117. image.onerror = () => {
  118. resolve(false);
  119. };
  120. image.src = src;
  121. });
  122. }
  123. // ===== Input de teclado =====
  124. const keys = new Set();
  125. /*
  126. * Estas teclas pertenecen al juego.
  127. * Se bloquea su comportamiento dentro del navegador.
  128. */
  129. const gameKeys = new Set([
  130. "ArrowUp",
  131. "ArrowDown",
  132. "ArrowLeft",
  133. "ArrowRight",
  134. "Space",
  135. "KeyW",
  136. "KeyA",
  137. "KeyS",
  138. "KeyD",
  139. "KeyP",
  140. "KeyR",
  141. ]);
  142. window.addEventListener(
  143. "keydown",
  144. (event) => {
  145. if (gameKeys.has(event.code)) {
  146. event.preventDefault();
  147. }
  148. const key = event.key.toLowerCase();
  149. keys.add(key);
  150. /*
  151. * event.repeat evita que al mantener P pulsada
  152. * el juego pause y continúe repetidamente.
  153. */
  154. if (!event.repeat && key === "p") {
  155. togglePause();
  156. }
  157. if (!event.repeat && key === "r") {
  158. reset();
  159. }
  160. },
  161. {
  162. passive: false,
  163. }
  164. );
  165. window.addEventListener(
  166. "keyup",
  167. (event) => {
  168. if (gameKeys.has(event.code)) {
  169. event.preventDefault();
  170. }
  171. keys.delete(event.key.toLowerCase());
  172. },
  173. {
  174. passive: false,
  175. }
  176. );
  177. /*
  178. * Evita que un botón conserve el foco.
  179. * Así ESPACIO no vuelve a pulsar accidentalmente
  180. * el último botón utilizado.
  181. */
  182. document.addEventListener("click", (event) => {
  183. if (event.target instanceof HTMLButtonElement) {
  184. event.target.blur();
  185. }
  186. });
  187. /*
  188. * Si el navegador pierde el foco, se limpian
  189. * las teclas para evitar movimientos bloqueados.
  190. */
  191. window.addEventListener("blur", () => {
  192. keys.clear();
  193. });
  194. // ===== Input móvil =====
  195. const isTouchMode = () => {
  196. return (
  197. window.matchMedia("(max-width: 900px)").matches &&
  198. window.matchMedia("(hover: none)").matches &&
  199. window.matchMedia("(pointer: coarse)").matches
  200. );
  201. };
  202. const touchDir = {
  203. up: false,
  204. down: false,
  205. left: false,
  206. right: false,
  207. };
  208. let touchFiring = false;
  209. function bindHold(button, directionKey) {
  210. if (!button) {
  211. return;
  212. }
  213. const press = (event) => {
  214. if (!isTouchMode()) {
  215. return;
  216. }
  217. event.preventDefault();
  218. touchDir[directionKey] = true;
  219. };
  220. const release = (event) => {
  221. event.preventDefault();
  222. touchDir[directionKey] = false;
  223. };
  224. // iOS y Safari
  225. button.addEventListener(
  226. "touchstart",
  227. press,
  228. {
  229. passive: false,
  230. }
  231. );
  232. button.addEventListener(
  233. "touchend",
  234. release,
  235. {
  236. passive: false,
  237. }
  238. );
  239. button.addEventListener(
  240. "touchcancel",
  241. release,
  242. {
  243. passive: false,
  244. }
  245. );
  246. // Android y Chrome
  247. button.addEventListener("pointerdown", press);
  248. button.addEventListener("pointerup", release);
  249. button.addEventListener("pointercancel", release);
  250. button.addEventListener("pointerleave", release);
  251. }
  252. bindHold(btnUp, "up");
  253. bindHold(btnDown, "down");
  254. bindHold(btnLeft, "left");
  255. bindHold(btnRight, "right");
  256. if (touchFire) {
  257. const fireOn = (event) => {
  258. if (!isTouchMode()) {
  259. return;
  260. }
  261. event.preventDefault();
  262. touchFiring = true;
  263. };
  264. const fireOff = (event) => {
  265. event.preventDefault();
  266. touchFiring = false;
  267. };
  268. touchFire.addEventListener(
  269. "touchstart",
  270. fireOn,
  271. {
  272. passive: false,
  273. }
  274. );
  275. touchFire.addEventListener(
  276. "touchend",
  277. fireOff,
  278. {
  279. passive: false,
  280. }
  281. );
  282. touchFire.addEventListener(
  283. "touchcancel",
  284. fireOff,
  285. {
  286. passive: false,
  287. }
  288. );
  289. touchFire.addEventListener("pointerdown", fireOn);
  290. touchFire.addEventListener("pointerup", fireOff);
  291. touchFire.addEventListener("pointercancel", fireOff);
  292. touchFire.addEventListener("pointerleave", fireOff);
  293. }
  294. // START móvil: pausa o continuación
  295. btnStartTouch?.addEventListener("click", (event) => {
  296. if (!isTouchMode()) {
  297. return;
  298. }
  299. event.preventDefault();
  300. togglePause();
  301. });
  302. // ===== Overlay =====
  303. function showOverlay(title, text) {
  304. if (overlayTitle) {
  305. overlayTitle.textContent = title;
  306. }
  307. if (overlayText) {
  308. overlayText.textContent = text;
  309. }
  310. overlay?.classList.remove("hidden");
  311. }
  312. function hideOverlay() {
  313. overlay?.classList.add("hidden");
  314. }
  315. btnResume?.addEventListener("click", () => {
  316. if (!running) {
  317. return;
  318. }
  319. paused = false;
  320. hideOverlay();
  321. });
  322. btnRestart?.addEventListener("click", () => {
  323. reset();
  324. });
  325. // ===== HUD =====
  326. function updateHUD() {
  327. if (hudScore) {
  328. hudScore.textContent = String(score);
  329. }
  330. if (hudLives) {
  331. hudLives.textContent = String(lives);
  332. }
  333. }
  334. // ===== Estado del juego =====
  335. let running = true;
  336. let paused = false;
  337. let lastTime = performance.now();
  338. let score = 0;
  339. let lives = 3;
  340. const player = {
  341. x: canvas.width * 0.5 - 18,
  342. y: canvas.height - 64,
  343. w: 40,
  344. h: 40,
  345. speed: 360,
  346. cooldown: 0,
  347. fireRate: 0.2,
  348. invuln: 0,
  349. };
  350. let bullets = [];
  351. let enemies = [];
  352. let particles = [];
  353. let spawnTimer = 0;
  354. let spawnEvery = 1.2;
  355. let difficultyTimer = 0;
  356. const stars = Array.from(
  357. {
  358. length: 140,
  359. },
  360. () => ({
  361. x: Math.random() * canvas.width,
  362. y: Math.random() * canvas.height,
  363. s: rand(0.6, 2.2),
  364. v: rand(10, 30),
  365. })
  366. );
  367. // ===== Fondo de estrellas =====
  368. function drawStars(deltaTime) {
  369. ctx.fillStyle = "rgba(255,255,255,.65)";
  370. const step = deltaTime || 1 / 60;
  371. for (const star of stars) {
  372. star.y += star.v * step;
  373. if (star.y > canvas.height) {
  374. star.y = -4;
  375. star.x = Math.random() * canvas.width;
  376. star.v = rand(10, 30);
  377. star.s = rand(0.6, 2.2);
  378. }
  379. ctx.fillRect(
  380. star.x,
  381. star.y,
  382. star.s,
  383. star.s
  384. );
  385. }
  386. }
  387. // ===== Disparo =====
  388. function fireBullet() {
  389. bullets.push({
  390. x: player.x + player.w / 2 - 3,
  391. y: player.y - 10,
  392. w: 6,
  393. h: 14,
  394. vy: -700,
  395. });
  396. for (let index = 0; index < 5; index++) {
  397. particles.push({
  398. x: player.x + player.w / 2,
  399. y: player.y,
  400. vx: rand(-90, 90),
  401. vy: rand(-150, 30),
  402. life: rand(0.18, 0.28),
  403. r: rand(1, 2.2),
  404. });
  405. }
  406. }
  407. // ===== Enemigos =====
  408. function spawnEnemy() {
  409. const size = rand(28, 46);
  410. const x = rand(
  411. 8,
  412. canvas.width - size - 8
  413. );
  414. const speed = rand(90, 160);
  415. enemies.push({
  416. x,
  417. y: -size - 6,
  418. w: size,
  419. h: size,
  420. vy: speed,
  421. hp: size > 40 ? 2 : 1,
  422. });
  423. }
  424. // ===== Partículas =====
  425. function explode(x, y, quantity = 16) {
  426. for (let index = 0; index < quantity; index++) {
  427. particles.push({
  428. x,
  429. y,
  430. vx: rand(-220, 220),
  431. vy: rand(-220, 220),
  432. life: rand(0.22, 0.5),
  433. r: rand(1.2, 3),
  434. });
  435. }
  436. }
  437. // ===== Daño =====
  438. function takeHit() {
  439. if (player.invuln > 0) {
  440. return;
  441. }
  442. lives -= 1;
  443. player.invuln = 1.2;
  444. updateHUD();
  445. explode(
  446. player.x + player.w / 2,
  447. player.y + player.h / 2,
  448. 24
  449. );
  450. if (lives <= 0) {
  451. running = false;
  452. paused = false;
  453. showOverlay(
  454. T.gameOver,
  455. T.gameOverText
  456. );
  457. if (window.GameScores) {
  458. GameScores.submit(
  459. "space",
  460. score
  461. );
  462. }
  463. }
  464. }
  465. // ===== Pausa =====
  466. function togglePause() {
  467. if (!running) {
  468. return;
  469. }
  470. paused = !paused;
  471. if (paused) {
  472. showOverlay(
  473. T.pause,
  474. T.pauseText
  475. );
  476. } else {
  477. hideOverlay();
  478. }
  479. }
  480. // ===== Reinicio =====
  481. function reset() {
  482. score = 0;
  483. lives = 3;
  484. bullets = [];
  485. enemies = [];
  486. particles = [];
  487. spawnTimer = 0;
  488. spawnEvery = 1.2;
  489. difficultyTimer = 0;
  490. player.x =
  491. canvas.width * 0.5 -
  492. player.w / 2;
  493. player.y =
  494. canvas.height - 64;
  495. player.cooldown = 0;
  496. player.invuln = 0;
  497. running = true;
  498. paused = false;
  499. touchDir.up = false;
  500. touchDir.down = false;
  501. touchDir.left = false;
  502. touchDir.right = false;
  503. touchFiring = false;
  504. keys.clear();
  505. updateHUD();
  506. hideOverlay();
  507. showOverlay(
  508. T.ready,
  509. isTouchMode()
  510. ? T.readyTouch
  511. : T.readyKeys
  512. );
  513. setTimeout(() => {
  514. if (!paused && running) {
  515. hideOverlay();
  516. }
  517. }, 900);
  518. }
  519. // ===== Bucle principal =====
  520. function tick(time) {
  521. const deltaTime = Math.min(
  522. 0.033,
  523. (time - lastTime) / 1000
  524. );
  525. lastTime = time;
  526. if (!paused) {
  527. update(deltaTime);
  528. render(deltaTime);
  529. }
  530. requestAnimationFrame(tick);
  531. }
  532. // ===== Actualización =====
  533. function update(deltaTime) {
  534. if (!running) {
  535. return;
  536. }
  537. difficultyTimer += deltaTime;
  538. if (difficultyTimer > 10) {
  539. difficultyTimer = 0;
  540. spawnEvery = Math.max(
  541. 0.75,
  542. spawnEvery - 0.05
  543. );
  544. }
  545. let directionX = 0;
  546. let directionY = 0;
  547. if (
  548. keys.has("a") ||
  549. keys.has("arrowleft")
  550. ) {
  551. directionX -= 1;
  552. }
  553. if (
  554. keys.has("d") ||
  555. keys.has("arrowright")
  556. ) {
  557. directionX += 1;
  558. }
  559. if (
  560. keys.has("w") ||
  561. keys.has("arrowup")
  562. ) {
  563. directionY -= 1;
  564. }
  565. if (
  566. keys.has("s") ||
  567. keys.has("arrowdown")
  568. ) {
  569. directionY += 1;
  570. }
  571. // Controles táctiles
  572. if (isTouchMode()) {
  573. directionX =
  574. (touchDir.right ? 1 : 0) -
  575. (touchDir.left ? 1 : 0);
  576. directionY =
  577. (touchDir.down ? 1 : 0) -
  578. (touchDir.up ? 1 : 0);
  579. }
  580. // Normalizar movimiento diagonal
  581. if (
  582. directionX !== 0 &&
  583. directionY !== 0
  584. ) {
  585. const inverse = 1 / Math.sqrt(2);
  586. directionX *= inverse;
  587. directionY *= inverse;
  588. }
  589. player.x +=
  590. directionX *
  591. player.speed *
  592. deltaTime;
  593. player.y +=
  594. directionY *
  595. player.speed *
  596. deltaTime;
  597. player.x = clamp(
  598. player.x,
  599. 8,
  600. canvas.width -
  601. player.w -
  602. 8
  603. );
  604. player.y = clamp(
  605. player.y,
  606. 8,
  607. canvas.height -
  608. player.h -
  609. 8
  610. );
  611. player.cooldown -= deltaTime;
  612. const firing =
  613. keys.has(" ") ||
  614. keys.has("space") ||
  615. (isTouchMode() && touchFiring);
  616. if (
  617. firing &&
  618. player.cooldown <= 0
  619. ) {
  620. player.cooldown =
  621. player.fireRate;
  622. fireBullet();
  623. }
  624. if (player.invuln > 0) {
  625. player.invuln -= deltaTime;
  626. }
  627. spawnTimer += deltaTime;
  628. while (
  629. spawnTimer >= spawnEvery
  630. ) {
  631. spawnTimer -= spawnEvery;
  632. spawnEnemy();
  633. }
  634. for (const bullet of bullets) {
  635. bullet.y +=
  636. bullet.vy *
  637. deltaTime;
  638. }
  639. bullets = bullets.filter(
  640. (bullet) =>
  641. bullet.y +
  642. bullet.h >
  643. -40
  644. );
  645. for (const enemy of enemies) {
  646. enemy.y +=
  647. enemy.vy *
  648. deltaTime;
  649. }
  650. // Colisiones entre balas y enemigos
  651. for (
  652. let enemyIndex =
  653. enemies.length - 1;
  654. enemyIndex >= 0;
  655. enemyIndex--
  656. ) {
  657. const enemy =
  658. enemies[enemyIndex];
  659. for (
  660. let bulletIndex =
  661. bullets.length - 1;
  662. bulletIndex >= 0;
  663. bulletIndex--
  664. ) {
  665. const bullet =
  666. bullets[bulletIndex];
  667. if (
  668. rectsOverlap(
  669. enemy,
  670. bullet
  671. )
  672. ) {
  673. bullets.splice(
  674. bulletIndex,
  675. 1
  676. );
  677. enemy.hp -= 1;
  678. explode(
  679. bullet.x +
  680. bullet.w / 2,
  681. bullet.y,
  682. 10
  683. );
  684. if (enemy.hp <= 0) {
  685. enemies.splice(
  686. enemyIndex,
  687. 1
  688. );
  689. score += 10;
  690. updateHUD();
  691. explode(
  692. enemy.x +
  693. enemy.w / 2,
  694. enemy.y +
  695. enemy.h / 2,
  696. 18
  697. );
  698. }
  699. break;
  700. }
  701. }
  702. }
  703. // Colisiones entre enemigos y jugador
  704. for (
  705. let enemyIndex =
  706. enemies.length - 1;
  707. enemyIndex >= 0;
  708. enemyIndex--
  709. ) {
  710. const enemy =
  711. enemies[enemyIndex];
  712. if (
  713. enemy.y >
  714. canvas.height + 40
  715. ) {
  716. enemies.splice(
  717. enemyIndex,
  718. 1
  719. );
  720. takeHit();
  721. continue;
  722. }
  723. if (
  724. rectsOverlap(
  725. enemy,
  726. player
  727. )
  728. ) {
  729. enemies.splice(
  730. enemyIndex,
  731. 1
  732. );
  733. takeHit();
  734. }
  735. }
  736. for (const particle of particles) {
  737. particle.life -= deltaTime;
  738. particle.x +=
  739. particle.vx *
  740. deltaTime;
  741. particle.y +=
  742. particle.vy *
  743. deltaTime;
  744. particle.vx *=
  745. 1 - 2.6 * deltaTime;
  746. particle.vy *=
  747. 1 - 2.6 * deltaTime;
  748. }
  749. particles = particles.filter(
  750. (particle) =>
  751. particle.life > 0
  752. );
  753. }
  754. // ===== Renderizado =====
  755. function render(deltaTime) {
  756. ctx.clearRect(
  757. 0,
  758. 0,
  759. canvas.width,
  760. canvas.height
  761. );
  762. drawStars(deltaTime);
  763. // Balas
  764. ctx.fillStyle =
  765. "rgba(170,220,255,.95)";
  766. for (const bullet of bullets) {
  767. ctx.fillRect(
  768. bullet.x,
  769. bullet.y,
  770. bullet.w,
  771. bullet.h
  772. );
  773. }
  774. // Enemigos
  775. for (const enemy of enemies) {
  776. const enemyImage =
  777. enemy.w > 40
  778. ? images.enemy2
  779. : images.enemy1;
  780. if (enemyImage) {
  781. ctx.imageSmoothingEnabled =
  782. false;
  783. ctx.drawImage(
  784. enemyImage,
  785. enemy.x,
  786. enemy.y,
  787. enemy.w,
  788. enemy.h
  789. );
  790. } else {
  791. ctx.fillStyle =
  792. enemy.w > 40
  793. ? "rgba(255,120,120,.95)"
  794. : "rgba(255,200,120,.95)";
  795. roundRectFill(
  796. enemy.x,
  797. enemy.y,
  798. enemy.w,
  799. enemy.h,
  800. 10
  801. );
  802. }
  803. }
  804. // Jugador
  805. const blink =
  806. player.invuln > 0 &&
  807. Math.floor(
  808. performance.now() / 80
  809. ) %
  810. 2 ===
  811. 0;
  812. if (!blink) {
  813. const playerImage =
  814. images.player;
  815. if (playerImage) {
  816. ctx.imageSmoothingEnabled =
  817. false;
  818. ctx.drawImage(
  819. playerImage,
  820. player.x,
  821. player.y,
  822. player.w,
  823. player.h
  824. );
  825. } else {
  826. ctx.fillStyle =
  827. "rgba(200,220,255,.95)";
  828. roundRectFill(
  829. player.x,
  830. player.y,
  831. player.w,
  832. player.h,
  833. 10
  834. );
  835. }
  836. }
  837. // Partículas
  838. ctx.fillStyle =
  839. "rgba(255,255,255,.9)";
  840. for (const particle of particles) {
  841. ctx.beginPath();
  842. ctx.arc(
  843. particle.x,
  844. particle.y,
  845. particle.r,
  846. 0,
  847. Math.PI * 2
  848. );
  849. ctx.fill();
  850. }
  851. // Borde interior
  852. ctx.strokeStyle =
  853. "rgba(255,255,255,.07)";
  854. ctx.lineWidth = 2;
  855. ctx.strokeRect(
  856. 1,
  857. 1,
  858. canvas.width - 2,
  859. canvas.height - 2
  860. );
  861. }
  862. // ===== Inicio =====
  863. Promise.all([
  864. loadImage(
  865. "player",
  866. SPRITES.player
  867. ),
  868. loadImage(
  869. "enemy1",
  870. SPRITES.enemy1
  871. ),
  872. loadImage(
  873. "enemy2",
  874. SPRITES.enemy2
  875. ),
  876. ]).finally(() => {
  877. updateHUD();
  878. reset();
  879. requestAnimationFrame(tick);
  880. });
  881. })();