← Volver a código

Snake

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>Snake</title>
  7. <link rel="stylesheet" href="css/snake.css">
  8. </head>
  9. <body>
  10. <!-- Estructura del juego (canvas, HUD, botones…) -->
  11. <script src="js/snake.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. --panel: rgba(8, 6, 20, 0.96);
  9. --purple: #a55cff;
  10. --green: #7cff00;
  11. --yellow: #ffd719;
  12. --red: #ff4343;
  13. --text: #ffffff;
  14. --muted: rgba(255, 255, 255, 0.64);
  15. }
  16. html,
  17. body {
  18. width: 100%;
  19. min-height: 100%;
  20. overflow-x: hidden;
  21. }
  22. body {
  23. min-width: 320px;
  24. color: var(--text);
  25. background-color: var(--bg);
  26. background-position: center;
  27. background-size: cover;
  28. background-attachment: fixed;
  29. font-family: "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
  30. }
  31. button,
  32. a {
  33. font: inherit;
  34. -webkit-tap-highlight-color: transparent;
  35. }
  36. button {
  37. cursor: pointer;
  38. }
  39. .arcade-nav {
  40. position: relative;
  41. z-index: 20;
  42. display: flex;
  43. align-items: center;
  44. justify-content: space-between;
  45. min-height: 78px;
  46. padding: 14px max(30px, 7vw);
  47. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  48. background: rgba(4, 3, 11, 0.72);
  49. backdrop-filter: blur(10px);
  50. }
  51. .arcade-brand {
  52. display: inline-flex;
  53. align-items: center;
  54. gap: 12px;
  55. color: #ffffff;
  56. text-decoration: none;
  57. font-size: 20px;
  58. font-weight: 900;
  59. letter-spacing: 0.035em;
  60. white-space: nowrap;
  61. }
  62. .brand-code {
  63. color: var(--purple);
  64. font-size: 28px;
  65. letter-spacing: -0.12em;
  66. }
  67. .arcade-nav-links {
  68. display: flex;
  69. align-items: center;
  70. gap: 3px;
  71. padding: 6px;
  72. border: 1px solid rgba(255, 255, 255, 0.11);
  73. border-radius: 999px;
  74. background: rgba(15, 12, 29, 0.78);
  75. }
  76. .arcade-nav-links a {
  77. display: inline-flex;
  78. align-items: center;
  79. gap: 8px;
  80. padding: 10px 15px;
  81. border-radius: 999px;
  82. color: rgba(255, 255, 255, 0.78);
  83. text-decoration: none;
  84. font-size: 12px;
  85. font-weight: 800;
  86. letter-spacing: 0.04em;
  87. text-transform: uppercase;
  88. transition: 0.2s ease;
  89. }
  90. .arcade-nav-links a:hover {
  91. color: #ffffff;
  92. background: rgba(123, 92, 255, 0.18);
  93. }
  94. .arcade-nav-links i {
  95. width: 17px;
  96. color: var(--purple);
  97. text-align: center;
  98. }
  99. .snake-page {
  100. width: min(1400px, 94vw);
  101. margin: 0 auto;
  102. padding-bottom: 26px;
  103. }
  104. .game-toolbar {
  105. display: flex;
  106. align-items: center;
  107. justify-content: space-between;
  108. min-height: 56px;
  109. padding: 0 4px;
  110. }
  111. .toolbar-link {
  112. color: var(--purple);
  113. text-decoration: none;
  114. font-size: 13px;
  115. font-weight: 850;
  116. letter-spacing: 0.06em;
  117. }
  118. .toolbar-button {
  119. border: 0;
  120. background: none;
  121. }
  122. .toolbar-link:hover {
  123. color: #d3a7ff;
  124. }
  125. .arcade-shell {
  126. display: grid;
  127. grid-template-columns: minmax(0, 1.55fr) minmax(330px, 0.95fr);
  128. min-height: calc(100svh - 160px);
  129. overflow: hidden;
  130. border: 1px solid rgba(181, 108, 255, 0.48);
  131. border-radius: 28px;
  132. background: linear-gradient(135deg, rgba(11, 8, 25, 0.94), rgba(5, 4, 14, 0.92));
  133. box-shadow: 0 35px 100px rgba(0, 0, 0, 0.62);
  134. backdrop-filter: blur(9px);
  135. }
  136. .board-column {
  137. display: flex;
  138. min-width: 0;
  139. flex-direction: column;
  140. padding: 18px 26px 20px;
  141. border-right: 1px solid rgba(255, 255, 255, 0.08);
  142. }
  143. .snake-logo {
  144. display: flex;
  145. align-items: center;
  146. justify-content: center;
  147. gap: 16px;
  148. min-height: 70px;
  149. margin: 0 auto 7px;
  150. color: var(--green);
  151. font-family: "Courier New", monospace;
  152. font-size: clamp(34px, 4vw, 54px);
  153. font-weight: 1000;
  154. line-height: 1;
  155. letter-spacing: 0.08em;
  156. text-shadow: 2px 2px #188c43, 0 0 16px rgba(124, 255, 0, 0.45);
  157. }
  158. .snake-mark {
  159. color: var(--green);
  160. font-size: clamp(8px, 1vw, 13px);
  161. letter-spacing: -0.3em;
  162. filter: drop-shadow(0 0 6px rgba(124, 255, 0, 0.7));
  163. }
  164. .snake-mark-reverse {
  165. transform: rotate(180deg);
  166. }
  167. .canvas-shell {
  168. position: relative;
  169. width: 100%;
  170. min-height: 360px;
  171. flex: 1;
  172. overflow: hidden;
  173. border: 2px solid rgba(18, 104, 255, 0.9);
  174. border-radius: 16px;
  175. background: #03060c;
  176. box-shadow: 0 0 28px rgba(32, 70, 255, 0.22), inset 0 0 28px rgba(0, 0, 0, 0.7);
  177. }
  178. #gameCanvas {
  179. display: block;
  180. width: 100%;
  181. height: 100%;
  182. min-height: 360px;
  183. border-radius: 14px;
  184. background: #03060c;
  185. touch-action: none;
  186. }
  187. .overlay {
  188. position: absolute;
  189. inset: 0;
  190. z-index: 3;
  191. display: grid;
  192. place-items: center;
  193. padding: 20px;
  194. color: #ffffff;
  195. text-align: center;
  196. font-size: clamp(50px, 7vw, 78px);
  197. font-weight: 900;
  198. text-shadow: 0 10px 30px rgba(0, 0, 0, 0.7);
  199. pointer-events: none;
  200. }
  201. .overlay-gameover {
  202. display: none;
  203. background: rgba(3, 6, 12, 0.55);
  204. backdrop-filter: blur(3px);
  205. pointer-events: auto;
  206. }
  207. .go-title {
  208. margin-bottom: 10px;
  209. color: var(--red);
  210. font-size: clamp(32px, 4vw, 48px);
  211. }
  212. .go-sub {
  213. color: rgba(255, 255, 255, 0.92);
  214. font-size: 16px;
  215. }
  216. .btn-restart-mobile {
  217. display: none;
  218. }
  219. .game-help {
  220. display: flex;
  221. justify-content: center;
  222. gap: 34px;
  223. padding: 14px 5px 0;
  224. color: var(--muted);
  225. font-size: 12px;
  226. }
  227. .control-column {
  228. display: flex;
  229. min-width: 0;
  230. flex-direction: column;
  231. padding: 24px 28px 22px;
  232. }
  233. .desktop-stats,
  234. .mobile-stats {
  235. display: grid;
  236. grid-template-columns: repeat(2, 1fr);
  237. gap: 12px;
  238. }
  239. .mobile-stats {
  240. display: none;
  241. }
  242. .stat-card {
  243. display: flex;
  244. align-items: center;
  245. justify-content: center;
  246. gap: 9px;
  247. min-width: 0;
  248. padding: 12px 9px;
  249. border: 1px solid rgba(255, 255, 255, 0.14);
  250. border-radius: 16px;
  251. background: rgba(255, 255, 255, 0.035);
  252. box-shadow: inset 0 1px rgba(255, 255, 255, 0.04);
  253. font-size: 11px;
  254. font-weight: 700;
  255. letter-spacing: 0.04em;
  256. }
  257. .stat-card > span:last-child {
  258. display: flex;
  259. min-width: 0;
  260. flex-direction: column;
  261. gap: 2px;
  262. }
  263. .stat-card b {
  264. color: var(--yellow);
  265. font-size: 19px;
  266. line-height: 1;
  267. }
  268. .stat-icon {
  269. color: var(--yellow);
  270. font-size: 22px;
  271. }
  272. .stat-icon.green {
  273. color: var(--green);
  274. }
  275. .controls-block {
  276. display: grid;
  277. grid-template-columns: 1fr 175px;
  278. align-items: center;
  279. gap: 18px;
  280. margin: 30px 0 26px;
  281. }
  282. .keyboard-help h2,
  283. .score-preview h2 {
  284. margin-bottom: 17px;
  285. color: var(--purple);
  286. font-size: 15px;
  287. font-weight: 900;
  288. letter-spacing: 0.045em;
  289. }
  290. .keyboard-help p {
  291. margin: 10px 0;
  292. color: rgba(255, 255, 255, 0.7);
  293. font-size: 12px;
  294. }
  295. .keyboard-help strong {
  296. color: var(--green);
  297. }
  298. .dpad {
  299. display: grid;
  300. grid-template-columns: repeat(3, 48px);
  301. grid-template-rows: repeat(3, 48px);
  302. justify-content: center;
  303. gap: 6px;
  304. }
  305. .dpad-preview span,
  306. .dpad-btn,
  307. .dpad-center {
  308. display: grid;
  309. place-items: center;
  310. border: 1px solid rgba(165, 92, 255, 0.5);
  311. border-radius: 13px;
  312. color: var(--purple);
  313. background: #1a122d;
  314. font-size: 19px;
  315. user-select: none;
  316. touch-action: none;
  317. }
  318. .dpad .up {
  319. grid-column: 2;
  320. }
  321. .dpad .left {
  322. grid-column: 1;
  323. grid-row: 2;
  324. }
  325. .dpad .center,
  326. .dpad-center {
  327. grid-column: 2;
  328. grid-row: 2;
  329. color: #071005;
  330. background: var(--green);
  331. }
  332. .dpad .right {
  333. grid-column: 3;
  334. grid-row: 2;
  335. }
  336. .dpad .down {
  337. grid-column: 2;
  338. grid-row: 3;
  339. }
  340. .action-row {
  341. display: grid;
  342. grid-template-columns: repeat(2, 1fr);
  343. gap: 12px;
  344. }
  345. .arcade-action {
  346. min-height: 48px;
  347. border: 1px solid rgba(181, 108, 255, 0.45);
  348. border-radius: 13px;
  349. color: var(--purple);
  350. background: rgba(123, 92, 255, 0.08);
  351. font-size: 12px;
  352. font-weight: 900;
  353. letter-spacing: 0.035em;
  354. }
  355. .arcade-action:hover {
  356. color: #ffffff;
  357. background: rgba(123, 92, 255, 0.19);
  358. }
  359. .score-preview {
  360. margin-top: 25px;
  361. }
  362. .ranking-preview {
  363. overflow: hidden;
  364. border: 1px solid rgba(255, 255, 255, 0.13);
  365. border-radius: 13px;
  366. background: rgba(255, 255, 255, 0.025);
  367. list-style: none;
  368. }
  369. .ranking-preview li {
  370. display: grid;
  371. grid-template-columns: 30px 1fr auto;
  372. gap: 8px;
  373. padding: 11px 14px;
  374. border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  375. font-size: 13px;
  376. }
  377. .ranking-preview li:last-child {
  378. border-bottom: 0;
  379. }
  380. .rank-position {
  381. color: var(--purple);
  382. font-weight: 900;
  383. }
  384. .ranking-preview li:first-child .rank-position,
  385. .rank-score {
  386. color: var(--yellow);
  387. font-weight: 900;
  388. }
  389. .rank-name {
  390. overflow: hidden;
  391. text-overflow: ellipsis;
  392. white-space: nowrap;
  393. }
  394. .ranking-loading,
  395. .ranking-empty {
  396. display: block !important;
  397. color: var(--muted);
  398. text-align: center;
  399. }
  400. .btn-start-snake {
  401. display: flex;
  402. align-items: center;
  403. justify-content: center;
  404. gap: 14px;
  405. width: 100%;
  406. min-height: 58px;
  407. margin-top: auto;
  408. border: 2px solid var(--green);
  409. border-radius: 12px;
  410. color: var(--green);
  411. background: rgba(10, 35, 17, 0.88);
  412. font-family: "Courier New", monospace;
  413. font-size: 19px;
  414. font-weight: 900;
  415. letter-spacing: 0.07em;
  416. box-shadow: 0 0 18px rgba(124, 255, 0, 0.2);
  417. }
  418. .mobile-controls {
  419. display: none;
  420. }
  421. @media (max-width: 1050px) {
  422. .arcade-shell {
  423. grid-template-columns: minmax(0, 1.4fr) minmax(310px, 0.85fr);
  424. }
  425. .controls-block {
  426. grid-template-columns: 1fr;
  427. }
  428. .keyboard-help {
  429. text-align: center;
  430. }
  431. }
  432. @media (max-width: 820px) {
  433. .arcade-nav {
  434. min-height: 68px;
  435. padding: 10px 16px;
  436. }
  437. .arcade-nav-links a {
  438. padding: 9px 10px;
  439. }
  440. .arcade-nav-links a span {
  441. display: none;
  442. }
  443. .snake-page {
  444. width: calc(100% - 24px);
  445. padding-bottom: 20px;
  446. }
  447. .game-toolbar {
  448. min-height: 50px;
  449. }
  450. .arcade-shell {
  451. display: block;
  452. min-height: 0;
  453. border-radius: 22px;
  454. }
  455. .board-column {
  456. padding: 18px 16px 16px;
  457. border-right: 0;
  458. }
  459. .control-column {
  460. display: none;
  461. }
  462. .snake-logo {
  463. min-height: 48px;
  464. margin-bottom: 12px;
  465. font-size: clamp(29px, 9vw, 42px);
  466. }
  467. .mobile-stats {
  468. display: grid;
  469. margin-bottom: 12px;
  470. }
  471. .canvas-shell {
  472. min-height: 0;
  473. aspect-ratio: 14 / 9;
  474. flex: none;
  475. }
  476. #gameCanvas {
  477. width: 100%;
  478. height: 100%;
  479. min-height: 0;
  480. }
  481. .mobile-controls {
  482. display: flex;
  483. justify-content: center;
  484. margin-top: 12px;
  485. padding: 12px;
  486. border: 1px solid rgba(165, 92, 255, 0.22);
  487. border-radius: 15px;
  488. background: rgba(255, 255, 255, 0.045);
  489. }
  490. .touch-dpad {
  491. grid-template-columns: repeat(3, 54px);
  492. grid-template-rows: repeat(3, 54px);
  493. gap: 4px;
  494. }
  495. .dpad-btn,
  496. .dpad-center {
  497. border-radius: 12px;
  498. font-size: 20px;
  499. }
  500. .game-help {
  501. gap: 22px;
  502. padding-top: 12px;
  503. }
  504. .btn-restart-mobile {
  505. display: inline-flex;
  506. align-items: center;
  507. justify-content: center;
  508. min-width: 190px;
  509. min-height: 52px;
  510. margin-top: 18px;
  511. padding: 10px 22px;
  512. border: 2px solid var(--green);
  513. border-radius: 12px;
  514. color: #081006;
  515. background: var(--green);
  516. font-size: 17px;
  517. font-weight: 900;
  518. }
  519. }
  520. @media (max-width: 480px) {
  521. .arcade-brand {
  522. font-size: 14px;
  523. }
  524. .arcade-nav-links {
  525. padding: 4px;
  526. }
  527. .arcade-nav-links a {
  528. padding: 8px;
  529. }
  530. .snake-page {
  531. width: calc(100% - 16px);
  532. }
  533. .toolbar-link {
  534. font-size: 12px;
  535. }
  536. .board-column {
  537. padding: 14px 10px 12px;
  538. }
  539. .snake-logo {
  540. gap: 10px;
  541. font-size: 31px;
  542. }
  543. .mobile-stats {
  544. gap: 8px;
  545. }
  546. .stat-card {
  547. gap: 7px;
  548. padding: 10px 7px;
  549. font-size: 10px;
  550. }
  551. .stat-icon {
  552. font-size: 19px;
  553. }
  554. .stat-card b {
  555. font-size: 18px;
  556. }
  557. .go-title {
  558. font-size: clamp(26px, 9vw, 38px);
  559. }
  560. .go-sub {
  561. font-size: 14px;
  562. }
  563. .btn-restart-mobile {
  564. min-width: 170px;
  565. min-height: 48px;
  566. margin-top: 13px;
  567. }
  568. .touch-dpad {
  569. grid-template-columns: repeat(3, 49px);
  570. grid-template-rows: repeat(3, 49px);
  571. }
  572. .game-help {
  573. justify-content: space-between;
  574. gap: 8px;
  575. font-size: 10px;
  576. }
  577. }
  578. @media (max-width: 360px) {
  579. .arcade-brand span:last-child {
  580. display: none;
  581. }
  582. .snake-logo {
  583. font-size: 27px;
  584. }
  585. .touch-dpad {
  586. grid-template-columns: repeat(3, 45px);
  587. grid-template-rows: repeat(3, 45px);
  588. }
  589. }

JS

  1. (() => {
  2. // ===== i18n =====
  3. const es = {};
  4. const en = {};
  5. const T =
  6. window.APP_LOCALE === "en"
  7. ? { ...en }
  8. : { ...es };
  9. // ===== DOM =====
  10. const canvas =
  11. document.getElementById("gameCanvas");
  12. const ctx =
  13. canvas.getContext("2d");
  14. const countdownEl =
  15. document.getElementById("countdown");
  16. const gameOverMessage =
  17. document.getElementById(
  18. "gameOverMessage"
  19. );
  20. const scoreValueEl =
  21. document.getElementById(
  22. "scoreValue"
  23. );
  24. const bestValueEl =
  25. document.getElementById(
  26. "bestValue"
  27. );
  28. const btnRestartMobile =
  29. document.getElementById(
  30. "btnRestartMobile"
  31. );
  32. const btnRestartDesktop =
  33. document.getElementById(
  34. "btnRestartDesktop"
  35. );
  36. const btnStartSnake =
  37. document.getElementById(
  38. "btnStartSnake"
  39. );
  40. const btnUp =
  41. document.getElementById("btnUp");
  42. const btnDown =
  43. document.getElementById("btnDown");
  44. const btnLeft =
  45. document.getElementById("btnLeft");
  46. const btnRight =
  47. document.getElementById("btnRight");
  48. // ===== Configuración =====
  49. const GRID = 20;
  50. canvas.width = 560;
  51. canvas.height = 360;
  52. const BEST_KEY = "snake_best";
  53. let bestScore = parseInt(
  54. localStorage.getItem(BEST_KEY) ||
  55. "0",
  56. 10
  57. );
  58. bestValueEl.textContent =
  59. String(bestScore);
  60. // ===== Estado =====
  61. let snake;
  62. let dx;
  63. let dy;
  64. let food;
  65. let score;
  66. let changingDirection = false;
  67. let running = false;
  68. let loopTimer = null;
  69. /*
  70. * Teclas controladas por el juego.
  71. * Se bloquea su comportamiento en el navegador.
  72. */
  73. const gameKeys = new Set([
  74. "ArrowUp",
  75. "ArrowDown",
  76. "ArrowLeft",
  77. "ArrowRight",
  78. "KeyW",
  79. "KeyA",
  80. "KeyS",
  81. "KeyD",
  82. "KeyR",
  83. ]);
  84. // ===== Fondo de estrellas =====
  85. const stars = Array.from(
  86. {
  87. length: 80,
  88. },
  89. () => ({
  90. x: Math.random() * canvas.width,
  91. y: Math.random() * canvas.height,
  92. r: Math.random() * 1.6 + 0.2,
  93. a: Math.random() * 0.6 + 0.2,
  94. })
  95. );
  96. // ===== Modo táctil =====
  97. const isTouchMode = () => {
  98. return (
  99. window
  100. .matchMedia(
  101. "(max-width: 900px)"
  102. )
  103. .matches &&
  104. window
  105. .matchMedia(
  106. "(hover: none)"
  107. )
  108. .matches &&
  109. window
  110. .matchMedia(
  111. "(pointer: coarse)"
  112. )
  113. .matches
  114. );
  115. };
  116. // ===== Dirección =====
  117. function setDirection(direction) {
  118. if (changingDirection) {
  119. return;
  120. }
  121. changingDirection = true;
  122. const goingUp =
  123. dy === -GRID;
  124. const goingDown =
  125. dy === GRID;
  126. const goingRight =
  127. dx === GRID;
  128. const goingLeft =
  129. dx === -GRID;
  130. if (
  131. direction === "left" &&
  132. !goingRight
  133. ) {
  134. dx = -GRID;
  135. dy = 0;
  136. }
  137. if (
  138. direction === "up" &&
  139. !goingDown
  140. ) {
  141. dx = 0;
  142. dy = -GRID;
  143. }
  144. if (
  145. direction === "right" &&
  146. !goingLeft
  147. ) {
  148. dx = GRID;
  149. dy = 0;
  150. }
  151. if (
  152. direction === "down" &&
  153. !goingUp
  154. ) {
  155. dx = 0;
  156. dy = GRID;
  157. }
  158. }
  159. // ===== Cruceta táctil =====
  160. function bindTap(
  161. button,
  162. direction
  163. ) {
  164. if (!button) {
  165. return;
  166. }
  167. const press = (event) => {
  168. if (!isTouchMode()) {
  169. return;
  170. }
  171. event.preventDefault();
  172. if (!running) {
  173. return;
  174. }
  175. setDirection(direction);
  176. };
  177. button.addEventListener(
  178. "touchstart",
  179. press,
  180. {
  181. passive: false,
  182. }
  183. );
  184. button.addEventListener(
  185. "pointerdown",
  186. press
  187. );
  188. }
  189. bindTap(btnUp, "up");
  190. bindTap(btnDown, "down");
  191. bindTap(btnLeft, "left");
  192. bindTap(btnRight, "right");
  193. // ===== Swipe táctil =====
  194. let swipeStart = null;
  195. canvas.addEventListener(
  196. "touchstart",
  197. (event) => {
  198. if (
  199. !isTouchMode() ||
  200. !running
  201. ) {
  202. return;
  203. }
  204. event.preventDefault();
  205. const touch =
  206. event.touches[0];
  207. swipeStart = {
  208. x: touch.clientX,
  209. y: touch.clientY,
  210. };
  211. },
  212. {
  213. passive: false,
  214. }
  215. );
  216. canvas.addEventListener(
  217. "touchend",
  218. (event) => {
  219. if (
  220. !isTouchMode() ||
  221. !running ||
  222. !swipeStart
  223. ) {
  224. return;
  225. }
  226. event.preventDefault();
  227. const touch =
  228. event.changedTouches[0];
  229. const swipeX =
  230. touch.clientX -
  231. swipeStart.x;
  232. const swipeY =
  233. touch.clientY -
  234. swipeStart.y;
  235. swipeStart = null;
  236. const absoluteX =
  237. Math.abs(swipeX);
  238. const absoluteY =
  239. Math.abs(swipeY);
  240. if (
  241. Math.max(
  242. absoluteX,
  243. absoluteY
  244. ) < 18
  245. ) {
  246. return;
  247. }
  248. if (absoluteX > absoluteY) {
  249. setDirection(
  250. swipeX > 0
  251. ? "right"
  252. : "left"
  253. );
  254. } else {
  255. setDirection(
  256. swipeY > 0
  257. ? "down"
  258. : "up"
  259. );
  260. }
  261. },
  262. {
  263. passive: false,
  264. }
  265. );
  266. // ===== Reiniciar partida =====
  267. function resetGame() {
  268. snake = [
  269. {
  270. x: 200,
  271. y: 200,
  272. },
  273. ];
  274. dx = GRID;
  275. dy = 0;
  276. food = createFood();
  277. score = 0;
  278. scoreValueEl.textContent =
  279. String(score);
  280. changingDirection = false;
  281. gameOverMessage.style.display =
  282. "none";
  283. }
  284. function restartNow() {
  285. /*
  286. * Detiene el bucle anterior.
  287. * Evita que el juego se acelere al reiniciar
  288. * varias veces.
  289. */
  290. if (loopTimer) {
  291. clearTimeout(loopTimer);
  292. loopTimer = null;
  293. }
  294. countdownEl.style.display =
  295. "none";
  296. gameOverMessage.style.display =
  297. "none";
  298. running = true;
  299. changingDirection = false;
  300. resetGame();
  301. main();
  302. }
  303. // ===== Bucle del juego =====
  304. function main() {
  305. if (!running) {
  306. return;
  307. }
  308. if (didGameEnd()) {
  309. running = false;
  310. gameOverMessage.style.display =
  311. "grid";
  312. if (score > bestScore) {
  313. bestScore = score;
  314. localStorage.setItem(
  315. BEST_KEY,
  316. String(bestScore)
  317. );
  318. bestValueEl.textContent =
  319. String(bestScore);
  320. }
  321. if (window.GameScores) {
  322. GameScores.submit(
  323. "snake",
  324. score
  325. );
  326. }
  327. return;
  328. }
  329. changingDirection = false;
  330. loopTimer = setTimeout(() => {
  331. clearCanvas();
  332. drawStars();
  333. drawFood();
  334. moveSnake();
  335. drawSnake();
  336. main();
  337. }, 95);
  338. }
  339. // ===== Empezar partida =====
  340. function startGame() {
  341. countdownEl.style.display =
  342. "none";
  343. running = true;
  344. resetGame();
  345. main();
  346. }
  347. function startCountdown() {
  348. let countdown = 3;
  349. countdownEl.style.display =
  350. "grid";
  351. countdownEl.textContent =
  352. String(countdown);
  353. const interval =
  354. setInterval(() => {
  355. countdown--;
  356. if (countdown > 0) {
  357. countdownEl.textContent =
  358. String(countdown);
  359. } else {
  360. clearInterval(interval);
  361. startGame();
  362. }
  363. }, 1000);
  364. }
  365. // ===== Limpiar canvas =====
  366. function clearCanvas() {
  367. ctx.fillStyle = "#03060c";
  368. ctx.fillRect(
  369. 0,
  370. 0,
  371. canvas.width,
  372. canvas.height
  373. );
  374. const gradient =
  375. ctx.createRadialGradient(
  376. canvas.width * 0.5,
  377. canvas.height * 0.45,
  378. 40,
  379. canvas.width * 0.5,
  380. canvas.height * 0.45,
  381. Math.max(
  382. canvas.width,
  383. canvas.height
  384. )
  385. );
  386. gradient.addColorStop(
  387. 0,
  388. "rgba(255,255,255,0.03)"
  389. );
  390. gradient.addColorStop(
  391. 1,
  392. "rgba(0,0,0,0.65)"
  393. );
  394. ctx.fillStyle = gradient;
  395. ctx.fillRect(
  396. 0,
  397. 0,
  398. canvas.width,
  399. canvas.height
  400. );
  401. }
  402. // ===== Dibujar estrellas =====
  403. function drawStars() {
  404. ctx.save();
  405. ctx.fillStyle = "white";
  406. for (const star of stars) {
  407. ctx.globalAlpha = star.a;
  408. ctx.beginPath();
  409. ctx.arc(
  410. star.x,
  411. star.y,
  412. star.r,
  413. 0,
  414. Math.PI * 2
  415. );
  416. ctx.fill();
  417. star.y += 0.15;
  418. if (
  419. star.y >
  420. canvas.height + 2
  421. ) {
  422. star.y = -2;
  423. star.x =
  424. Math.random() *
  425. canvas.width;
  426. }
  427. }
  428. ctx.restore();
  429. }
  430. // ===== Dibujar serpiente =====
  431. function drawSnake() {
  432. ctx.save();
  433. ctx.shadowBlur = 10;
  434. ctx.shadowColor = "#7cff00";
  435. snake.forEach(
  436. (part, index) => {
  437. ctx.beginPath();
  438. ctx.fillStyle =
  439. index === 0
  440. ? "#7cff00"
  441. : "#4caf50";
  442. ctx.arc(
  443. part.x + GRID / 2,
  444. part.y + GRID / 2,
  445. 9,
  446. 0,
  447. Math.PI * 2
  448. );
  449. ctx.fill();
  450. if (index === 0) {
  451. ctx.shadowBlur = 0;
  452. ctx.fillStyle = "#0b0b0b";
  453. let eyeX1 = 6;
  454. let eyeY1 = 7;
  455. let eyeX2 = 14;
  456. let eyeY2 = 7;
  457. if (dx === GRID) {
  458. eyeX1 = 12;
  459. eyeY1 = 7;
  460. eyeX2 = 12;
  461. eyeY2 = 13;
  462. } else if (dx === -GRID) {
  463. eyeX1 = 8;
  464. eyeY1 = 7;
  465. eyeX2 = 8;
  466. eyeY2 = 13;
  467. } else if (dy === GRID) {
  468. eyeX1 = 7;
  469. eyeY1 = 12;
  470. eyeX2 = 13;
  471. eyeY2 = 12;
  472. } else if (dy === -GRID) {
  473. eyeX1 = 7;
  474. eyeY1 = 8;
  475. eyeX2 = 13;
  476. eyeY2 = 8;
  477. }
  478. ctx.beginPath();
  479. ctx.arc(
  480. part.x + eyeX1,
  481. part.y + eyeY1,
  482. 2,
  483. 0,
  484. Math.PI * 2
  485. );
  486. ctx.arc(
  487. part.x + eyeX2,
  488. part.y + eyeY2,
  489. 2,
  490. 0,
  491. Math.PI * 2
  492. );
  493. ctx.fill();
  494. ctx.fillStyle =
  495. "rgba(255,255,255,.7)";
  496. ctx.beginPath();
  497. ctx.arc(
  498. part.x + eyeX1 + 0.7,
  499. part.y + eyeY1 - 0.7,
  500. 0.7,
  501. 0,
  502. Math.PI * 2
  503. );
  504. ctx.arc(
  505. part.x + eyeX2 + 0.7,
  506. part.y + eyeY2 - 0.7,
  507. 0.7,
  508. 0,
  509. Math.PI * 2
  510. );
  511. ctx.fill();
  512. }
  513. }
  514. );
  515. ctx.restore();
  516. }
  517. // ===== Movimiento =====
  518. function moveSnake() {
  519. const head = {
  520. x: snake[0].x + dx,
  521. y: snake[0].y + dy,
  522. };
  523. snake.unshift(head);
  524. const didEatFood =
  525. snake[0].x === food.x &&
  526. snake[0].y === food.y;
  527. if (didEatFood) {
  528. score += 10;
  529. scoreValueEl.textContent =
  530. String(score);
  531. food = createFood();
  532. } else {
  533. snake.pop();
  534. }
  535. }
  536. // ===== Crear comida =====
  537. function createFood() {
  538. function randomGrid(
  539. min,
  540. max
  541. ) {
  542. return (
  543. Math.round(
  544. (
  545. Math.random() *
  546. (max - min) +
  547. min
  548. ) /
  549. GRID
  550. ) * GRID
  551. );
  552. }
  553. let foodX = randomGrid(
  554. 0,
  555. canvas.width - GRID
  556. );
  557. let foodY = randomGrid(
  558. 0,
  559. canvas.height - GRID
  560. );
  561. snake?.forEach((part) => {
  562. if (
  563. part.x === foodX &&
  564. part.y === foodY
  565. ) {
  566. foodX = randomGrid(
  567. 0,
  568. canvas.width - GRID
  569. );
  570. foodY = randomGrid(
  571. 0,
  572. canvas.height - GRID
  573. );
  574. }
  575. });
  576. return {
  577. x: foodX,
  578. y: foodY,
  579. };
  580. }
  581. // ===== Dibujar comida =====
  582. function drawFood() {
  583. ctx.save();
  584. ctx.shadowBlur = 12;
  585. ctx.shadowColor = "#ff3b3b";
  586. ctx.fillStyle = "#ff3b3b";
  587. ctx.beginPath();
  588. ctx.arc(
  589. food.x + GRID / 2,
  590. food.y + GRID / 2,
  591. 7,
  592. 0,
  593. Math.PI * 2
  594. );
  595. ctx.fill();
  596. ctx.shadowBlur = 0;
  597. ctx.fillStyle =
  598. "rgba(255,255,255,.55)";
  599. ctx.beginPath();
  600. ctx.arc(
  601. food.x +
  602. GRID / 2 -
  603. 2,
  604. food.y +
  605. GRID / 2 -
  606. 2,
  607. 2,
  608. 0,
  609. Math.PI * 2
  610. );
  611. ctx.fill();
  612. ctx.restore();
  613. }
  614. // ===== Controles del teclado =====
  615. function changeDirection(event) {
  616. /*
  617. * Impide que flechas, WASD y R
  618. * actúen sobre el navegador.
  619. */
  620. if (gameKeys.has(event.code)) {
  621. event.preventDefault();
  622. }
  623. if (
  624. event.key === "r" ||
  625. event.key === "R"
  626. ) {
  627. if (!event.repeat) {
  628. restartNow();
  629. }
  630. return;
  631. }
  632. if (changingDirection) {
  633. return;
  634. }
  635. changingDirection = true;
  636. const key = event.key;
  637. const goingUp =
  638. dy === -GRID;
  639. const goingDown =
  640. dy === GRID;
  641. const goingRight =
  642. dx === GRID;
  643. const goingLeft =
  644. dx === -GRID;
  645. if (
  646. (
  647. key === "ArrowLeft" ||
  648. key === "a" ||
  649. key === "A"
  650. ) &&
  651. !goingRight
  652. ) {
  653. dx = -GRID;
  654. dy = 0;
  655. }
  656. if (
  657. (
  658. key === "ArrowUp" ||
  659. key === "w" ||
  660. key === "W"
  661. ) &&
  662. !goingDown
  663. ) {
  664. dx = 0;
  665. dy = -GRID;
  666. }
  667. if (
  668. (
  669. key === "ArrowRight" ||
  670. key === "d" ||
  671. key === "D"
  672. ) &&
  673. !goingLeft
  674. ) {
  675. dx = GRID;
  676. dy = 0;
  677. }
  678. if (
  679. (
  680. key === "ArrowDown" ||
  681. key === "s" ||
  682. key === "S"
  683. ) &&
  684. !goingUp
  685. ) {
  686. dx = 0;
  687. dy = GRID;
  688. }
  689. }
  690. // ===== Comprobar derrota =====
  691. function didGameEnd() {
  692. for (
  693. let index = 4;
  694. index < snake.length;
  695. index++
  696. ) {
  697. if (
  698. snake[index].x ===
  699. snake[0].x &&
  700. snake[index].y ===
  701. snake[0].y
  702. ) {
  703. return true;
  704. }
  705. }
  706. const hitLeftWall =
  707. snake[0].x < 0;
  708. const hitRightWall =
  709. snake[0].x >
  710. canvas.width - GRID;
  711. const hitTopWall =
  712. snake[0].y < 0;
  713. const hitBottomWall =
  714. snake[0].y >
  715. canvas.height - GRID;
  716. return (
  717. hitLeftWall ||
  718. hitRightWall ||
  719. hitTopWall ||
  720. hitBottomWall
  721. );
  722. }
  723. // ===== Botones =====
  724. btnRestartMobile?.addEventListener(
  725. "click",
  726. (event) => {
  727. event.preventDefault();
  728. restartNow();
  729. event.currentTarget.blur();
  730. }
  731. );
  732. btnRestartDesktop?.addEventListener(
  733. "click",
  734. (event) => {
  735. event.preventDefault();
  736. restartNow();
  737. event.currentTarget.blur();
  738. }
  739. );
  740. btnStartSnake?.addEventListener(
  741. "click",
  742. (event) => {
  743. event.preventDefault();
  744. restartNow();
  745. event.currentTarget.blur();
  746. }
  747. );
  748. // ===== Eventos de teclado =====
  749. document.addEventListener(
  750. "keydown",
  751. changeDirection,
  752. {
  753. passive: false,
  754. }
  755. );
  756. document.addEventListener(
  757. "keyup",
  758. (event) => {
  759. if (
  760. gameKeys.has(event.code)
  761. ) {
  762. event.preventDefault();
  763. }
  764. },
  765. {
  766. passive: false,
  767. }
  768. );
  769. /*
  770. * Evita que los controles continúen activos
  771. * si el navegador pierde el foco.
  772. */
  773. window.addEventListener(
  774. "blur",
  775. () => {
  776. changingDirection = false;
  777. }
  778. );
  779. // ===== Inicio =====
  780. startCountdown();
  781. })();