← Volver a código

Tetris

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>Tetris</title>
  7. <link rel="stylesheet" href="css/tetris.css">
  8. </head>
  9. <body>
  10. <!-- Estructura del juego (canvas, HUD, botones…) -->
  11. <script src="js/tetris.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: #7cff00;
  11. --blue: #4aa3ff;
  12. --cyan: #27f3dc;
  13. --red: #ff4b5c;
  14. --text: #ffffff;
  15. --muted: rgba(255,255,255,.64);
  16. }
  17. html,
  18. body {
  19. width: 100%;
  20. min-height: 100%;
  21. overflow-x: hidden;
  22. }
  23. body {
  24. display: flex;
  25. min-width: 320px;
  26. min-height: 100svh;
  27. flex-direction: column;
  28. color: var(--text);
  29. background-color: var(--bg);
  30. background-position: center;
  31. background-size: cover;
  32. background-attachment: fixed;
  33. font-family: "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
  34. }
  35. button,
  36. a {
  37. font: inherit;
  38. -webkit-tap-highlight-color: transparent;
  39. }
  40. button {
  41. cursor: pointer;
  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,.72);
  53. backdrop-filter: blur(10px);
  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. border-radius: 999px;
  86. color: rgba(255,255,255,.78);
  87. text-decoration: none;
  88. font-size: 12px;
  89. font-weight: 800;
  90. letter-spacing: .04em;
  91. text-transform: uppercase;
  92. }
  93. .arcade-nav-links a:hover {
  94. color: #fff;
  95. background: rgba(123,92,255,.18);
  96. }
  97. .arcade-nav-links i {
  98. width: 17px;
  99. color: var(--purple);
  100. text-align: center;
  101. }
  102. /* MÁS ESTRECHO: misma sensación que Space Shooter */
  103. .tetris-page {
  104. width: min(1320px, 92vw);
  105. flex: 1 0 auto;
  106. margin: 0 auto;
  107. padding-bottom: 26px;
  108. }
  109. .site-footer {
  110. flex: 0 0 auto;
  111. margin-top: auto;
  112. }
  113. .game-toolbar {
  114. display: flex;
  115. align-items: center;
  116. justify-content: space-between;
  117. min-height: 56px;
  118. padding: 0 4px;
  119. }
  120. .toolbar-link {
  121. color: var(--purple);
  122. background: none;
  123. text-decoration: none;
  124. font-size: 13px;
  125. font-weight: 850;
  126. letter-spacing: .06em;
  127. }
  128. .toolbar-button {
  129. border: 0;
  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,.94),rgba(5,4,14,.92));
  139. box-shadow: 0 35px 100px rgba(0,0,0,.62);
  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. /* TÍTULO estilo Space Shooter */
  150. .tetris-logo {
  151. display: flex;
  152. align-items: center;
  153. justify-content: center;
  154. gap: 17px;
  155. min-height: 70px;
  156. margin: 0 auto 7px;
  157. font-family: "Courier New", monospace;
  158. font-size: clamp(34px,3.6vw,50px);
  159. font-weight: 1000;
  160. letter-spacing: .08em;
  161. }
  162. .tetris-title-text {
  163. color: var(--cyan);
  164. text-shadow:
  165. 2px 2px #285b77,
  166. 0 0 8px rgba(39,243,220,.8),
  167. 0 0 22px rgba(39,243,220,.4);
  168. }
  169. .tetris-mark {
  170. font-size: clamp(15px,1.7vw,23px);
  171. letter-spacing: -.24em;
  172. transform: skewX(-8deg);
  173. }
  174. .tetris-mark-left {
  175. color: var(--yellow);
  176. text-shadow: 0 0 10px rgba(255,215,25,.75);
  177. }
  178. .tetris-mark-right {
  179. color: var(--green);
  180. text-shadow: 0 0 10px rgba(124,255,0,.7);
  181. }
  182. .canvas-shell {
  183. position: relative;
  184. width: 100%;
  185. min-height: 390px;
  186. flex: 1;
  187. overflow: hidden;
  188. border: 2px solid rgba(18,104,255,.9);
  189. border-radius: 16px;
  190. background: #03060c;
  191. box-shadow:
  192. 0 0 28px rgba(32,70,255,.22),
  193. inset 0 0 28px rgba(0,0,0,.7);
  194. }
  195. #gameCanvas {
  196. display: block;
  197. width: 100%;
  198. height: 100%;
  199. min-height: 390px;
  200. border-radius: 14px;
  201. background: #03060c;
  202. touch-action: none;
  203. }
  204. .overlay {
  205. position: absolute;
  206. inset: 0;
  207. z-index: 4;
  208. display: grid;
  209. place-items: center;
  210. padding: 20px;
  211. color: #fff;
  212. text-align: center;
  213. background: rgba(0,0,0,.38);
  214. backdrop-filter: blur(2px);
  215. }
  216. .overlay-start {
  217. pointer-events: none;
  218. }
  219. .start-copy {
  220. color: rgba(255,255,255,.9);
  221. font-size: 17px;
  222. font-weight: 850;
  223. text-shadow: 0 4px 16px rgba(0,0,0,.7);
  224. }
  225. .start-copy b {
  226. color: var(--cyan);
  227. }
  228. .overlay-end {
  229. background: rgba(0,0,0,.6);
  230. }
  231. .ov-title {
  232. margin-bottom: 10px;
  233. color: var(--red);
  234. font-size: clamp(32px,4vw,48px);
  235. font-weight: 900;
  236. }
  237. .ov-sub {
  238. color: rgba(255,255,255,.92);
  239. font-size: 16px;
  240. }
  241. .btn-play-again {
  242. min-width: 180px;
  243. min-height: 48px;
  244. margin-top: 18px;
  245. padding: 10px 18px;
  246. border: 2px solid var(--cyan);
  247. border-radius: 12px;
  248. color: var(--cyan);
  249. background: rgba(6,34,38,.92);
  250. font-weight: 900;
  251. box-shadow: 0 0 18px rgba(39,243,220,.18);
  252. }
  253. .game-help {
  254. display: flex;
  255. justify-content: center;
  256. flex-wrap: wrap;
  257. gap: 11px 24px;
  258. padding: 14px 5px 0;
  259. color: var(--muted);
  260. font-size: 12px;
  261. }
  262. .game-help b {
  263. color: rgba(255,255,255,.92);
  264. }
  265. .control-column {
  266. display: flex;
  267. min-width: 0;
  268. flex-direction: column;
  269. padding: 24px 28px 22px;
  270. }
  271. .desktop-stats,
  272. .mobile-stats {
  273. display: grid;
  274. grid-template-columns: repeat(3,1fr);
  275. gap: 12px;
  276. }
  277. .mobile-stats {
  278. display: none;
  279. }
  280. .stat-card {
  281. display: flex;
  282. align-items: center;
  283. justify-content: center;
  284. gap: 9px;
  285. min-width: 0;
  286. padding: 12px 9px;
  287. border: 1px solid rgba(255,255,255,.14);
  288. border-radius: 16px;
  289. background: rgba(255,255,255,.035);
  290. font-size: 11px;
  291. font-weight: 700;
  292. letter-spacing: .04em;
  293. }
  294. .stat-card > span:last-child {
  295. display: flex;
  296. flex-direction: column;
  297. gap: 2px;
  298. }
  299. .stat-card b {
  300. color: var(--yellow);
  301. font-size: 19px;
  302. }
  303. .stat-icon {
  304. color: var(--yellow);
  305. font-size: 22px;
  306. }
  307. .stat-icon.cyan {
  308. color: var(--cyan);
  309. }
  310. .stat-icon.purple {
  311. color: var(--purple);
  312. }
  313. /* CONTROLES ORDENADOS */
  314. .controls-block {
  315. margin: 30px 0 26px;
  316. }
  317. .keyboard-help h2,
  318. .score-preview h2 {
  319. margin-bottom: 17px;
  320. color: var(--purple);
  321. font-size: 15px;
  322. font-weight: 900;
  323. letter-spacing: .045em;
  324. }
  325. .control-grid {
  326. display: grid;
  327. grid-template-columns: repeat(2,minmax(0,1fr));
  328. gap: 9px;
  329. }
  330. .control-item {
  331. display: flex;
  332. align-items: center;
  333. gap: 9px;
  334. min-height: 42px;
  335. padding: 7px 9px;
  336. border: 1px solid rgba(255,255,255,.075);
  337. border-radius: 10px;
  338. color: rgba(255,255,255,.68);
  339. background: rgba(255,255,255,.025);
  340. font-size: 11px;
  341. font-weight: 700;
  342. }
  343. .control-key {
  344. display: inline-flex;
  345. align-items: center;
  346. justify-content: center;
  347. min-width: 46px;
  348. height: 26px;
  349. padding: 0 7px;
  350. border: 1px solid rgba(39,243,220,.33);
  351. border-radius: 7px;
  352. color: var(--cyan);
  353. background: rgba(39,243,220,.055);
  354. font-family: "Courier New", monospace;
  355. font-weight: 900;
  356. box-shadow: inset 0 0 10px rgba(39,243,220,.025);
  357. }
  358. .control-key-wide {
  359. min-width: 68px;
  360. font-size: 10px;
  361. }
  362. .action-row {
  363. display: grid;
  364. grid-template-columns: repeat(2,1fr);
  365. gap: 12px;
  366. }
  367. .arcade-action {
  368. min-height: 48px;
  369. border: 1px solid rgba(181,108,255,.45);
  370. border-radius: 13px;
  371. color: var(--purple);
  372. background: rgba(123,92,255,.08);
  373. font-size: 12px;
  374. font-weight: 900;
  375. transition: .18s ease;
  376. }
  377. .arcade-action:hover {
  378. transform: translateY(-1px);
  379. background: rgba(123,92,255,.14);
  380. box-shadow: 0 0 16px rgba(165,92,255,.12);
  381. }
  382. .score-preview {
  383. margin-top: 25px;
  384. }
  385. .ranking-preview {
  386. overflow: hidden;
  387. border: 1px solid rgba(255,255,255,.13);
  388. border-radius: 13px;
  389. background: rgba(255,255,255,.025);
  390. list-style: none;
  391. }
  392. .ranking-preview li {
  393. display: grid;
  394. grid-template-columns: 30px 1fr auto;
  395. gap: 8px;
  396. padding: 11px 14px;
  397. border-bottom: 1px solid rgba(255,255,255,.1);
  398. font-size: 13px;
  399. }
  400. .ranking-preview li:last-child {
  401. border-bottom: 0;
  402. }
  403. .rank-position {
  404. color: var(--purple);
  405. font-weight: 900;
  406. }
  407. .ranking-preview li:first-child .rank-position,
  408. .rank-score {
  409. color: var(--yellow);
  410. font-weight: 900;
  411. }
  412. .rank-name {
  413. overflow: hidden;
  414. text-overflow: ellipsis;
  415. white-space: nowrap;
  416. }
  417. .ranking-loading,
  418. .ranking-empty {
  419. display: block !important;
  420. color: var(--muted);
  421. text-align: center;
  422. }
  423. /* START dinámico estilo Space */
  424. .btn-start-tetris {
  425. position: relative;
  426. display: flex;
  427. align-items: center;
  428. justify-content: center;
  429. gap: 13px;
  430. width: 100%;
  431. min-height: 58px;
  432. margin-top: auto;
  433. overflow: hidden;
  434. border: 2px solid var(--cyan);
  435. border-radius: 12px;
  436. color: var(--cyan);
  437. background:
  438. linear-gradient(135deg,rgba(5,31,35,.96),rgba(6,21,38,.96));
  439. font-family: "Courier New", monospace;
  440. font-size: 18px;
  441. font-weight: 900;
  442. letter-spacing: .08em;
  443. box-shadow:
  444. 0 0 15px rgba(39,243,220,.18),
  445. inset 0 0 20px rgba(39,243,220,.035);
  446. transition: transform .18s ease,border-color .18s ease,box-shadow .18s ease,color .18s ease;
  447. }
  448. .btn-start-tetris::before {
  449. content: "";
  450. position: absolute;
  451. top: -45%;
  452. left: -30%;
  453. width: 22%;
  454. height: 190%;
  455. background: linear-gradient(90deg,transparent,rgba(255,255,255,.26),transparent);
  456. transform: skewX(-20deg);
  457. animation: startSweep 2.7s ease-in-out infinite;
  458. }
  459. .btn-start-tetris > span {
  460. position: relative;
  461. z-index: 1;
  462. }
  463. .start-piece {
  464. animation: piecePulse 1s ease-in-out infinite alternate;
  465. }
  466. .start-arrow {
  467. font-size: 13px;
  468. opacity: .7;
  469. animation: arrowNudge .8s ease-in-out infinite alternate;
  470. }
  471. .btn-start-tetris:hover {
  472. transform: translateY(-2px);
  473. color: #fff;
  474. border-color: var(--green);
  475. box-shadow:
  476. 0 0 24px rgba(39,243,220,.24),
  477. 0 0 18px rgba(124,255,0,.13);
  478. }
  479. @keyframes startSweep {
  480. 0% { left: -30%; }
  481. 45%,100% { left: 125%; }
  482. }
  483. @keyframes piecePulse {
  484. from { opacity: .55; transform: scale(.94); }
  485. to { opacity: 1; transform: scale(1.07); text-shadow: 0 0 10px rgba(39,243,220,.8); }
  486. }
  487. @keyframes arrowNudge {
  488. from { transform: translateX(0); }
  489. to { transform: translateX(4px); }
  490. }
  491. /* Mount vacío en desktop */
  492. #tetrisMobileMount {
  493. display: none;
  494. }
  495. @media (max-width: 1050px) {
  496. .tetris-page {
  497. width: min(1180px,94vw);
  498. }
  499. .arcade-shell {
  500. grid-template-columns: minmax(0,1.4fr) minmax(310px,.85fr);
  501. }
  502. .control-grid {
  503. grid-template-columns: 1fr;
  504. }
  505. }
  506. @media (max-width: 820px) {
  507. .arcade-nav {
  508. min-height: 68px;
  509. padding: 10px 16px;
  510. }
  511. .arcade-nav-links a {
  512. padding: 9px 10px;
  513. }
  514. .arcade-nav-links a span {
  515. display: none;
  516. }
  517. .tetris-page {
  518. width: calc(100% - 24px);
  519. padding-bottom: 20px;
  520. }
  521. .game-toolbar {
  522. min-height: 50px;
  523. }
  524. .arcade-shell {
  525. display: block;
  526. min-height: 0;
  527. border-radius: 22px;
  528. }
  529. .board-column {
  530. padding: 18px 16px 16px;
  531. border-right: 0;
  532. }
  533. .control-column {
  534. display: none;
  535. }
  536. .tetris-logo {
  537. min-height: 48px;
  538. margin-bottom: 12px;
  539. font-size: clamp(27px,8vw,40px);
  540. }
  541. .mobile-stats {
  542. display: grid;
  543. margin-bottom: 12px;
  544. }
  545. /* En móvil el tablero tiene más altura útil */
  546. .canvas-shell {
  547. min-height: 0;
  548. aspect-ratio: 10 / 13.5;
  549. flex: none;
  550. }
  551. #gameCanvas {
  552. width: 100%;
  553. height: 100%;
  554. min-height: 0;
  555. }
  556. #tetrisMobileMount {
  557. display: block;
  558. }
  559. /* Cruceta arcade compacta */
  560. .tetris-mobile-controls {
  561. display: grid;
  562. grid-template-columns: auto 94px;
  563. align-items: center;
  564. justify-content: center;
  565. gap: 22px;
  566. padding: 16px 0 5px;
  567. }
  568. .tetris-mobile-dpad {
  569. display: grid;
  570. grid-template-columns: repeat(3,48px);
  571. grid-template-rows: repeat(2,48px);
  572. gap: 7px;
  573. }
  574. .tetris-mobile-key {
  575. display: grid;
  576. place-items: center;
  577. width: 48px;
  578. height: 48px;
  579. border: 1px solid rgba(165,92,255,.55);
  580. border-radius: 13px;
  581. color: var(--purple);
  582. background: linear-gradient(180deg,#211539,#140d25);
  583. font-size: 19px;
  584. box-shadow:
  585. inset 0 1px 0 rgba(255,255,255,.055),
  586. 0 5px 13px rgba(0,0,0,.22);
  587. touch-action: none;
  588. user-select: none;
  589. }
  590. .tetris-mobile-key:active {
  591. transform: scale(.93);
  592. color: #fff;
  593. background: rgba(165,92,255,.3);
  594. }
  595. .tetris-mobile-rotate {
  596. grid-column: 2;
  597. grid-row: 1;
  598. color: var(--green);
  599. border-color: rgba(124,255,0,.46);
  600. }
  601. .tetris-mobile-left {
  602. grid-column: 1;
  603. grid-row: 2;
  604. }
  605. .tetris-mobile-down {
  606. grid-column: 2;
  607. grid-row: 2;
  608. }
  609. .tetris-mobile-right {
  610. grid-column: 3;
  611. grid-row: 2;
  612. }
  613. .tetris-mobile-drop {
  614. min-width: 94px;
  615. min-height: 58px;
  616. padding: 10px 12px;
  617. border: 2px solid var(--cyan);
  618. border-radius: 13px;
  619. color: var(--cyan);
  620. background: linear-gradient(135deg,rgba(5,31,35,.96),rgba(6,21,38,.96));
  621. font-family: "Courier New", monospace;
  622. font-size: 12px;
  623. font-weight: 900;
  624. letter-spacing: .06em;
  625. box-shadow: 0 0 17px rgba(39,243,220,.18);
  626. touch-action: none;
  627. }
  628. .tetris-mobile-drop::before {
  629. content: "▼▼";
  630. display: block;
  631. margin-bottom: 3px;
  632. color: var(--green);
  633. font-size: 15px;
  634. }
  635. .tetris-mobile-drop:active {
  636. transform: scale(.95);
  637. border-color: var(--green);
  638. }
  639. .game-help {
  640. justify-content: center;
  641. gap: 7px 14px;
  642. font-size: 10px;
  643. line-height: 1.35;
  644. }
  645. }
  646. @media (max-width: 480px) {
  647. .arcade-brand {
  648. font-size: 14px;
  649. }
  650. .arcade-nav-links {
  651. padding: 4px;
  652. }
  653. .arcade-nav-links a {
  654. padding: 8px;
  655. }
  656. .tetris-page {
  657. width: calc(100% - 16px);
  658. }
  659. .toolbar-link {
  660. font-size: 12px;
  661. }
  662. .board-column {
  663. padding: 14px 10px 12px;
  664. }
  665. .tetris-logo {
  666. gap: 10px;
  667. font-size: 28px;
  668. }
  669. .mobile-stats {
  670. gap: 7px;
  671. }
  672. .stat-card {
  673. gap: 6px;
  674. padding: 9px 6px;
  675. font-size: 9px;
  676. }
  677. .stat-card b {
  678. font-size: 17px;
  679. }
  680. .canvas-shell {
  681. aspect-ratio: 10 / 14.5;
  682. }
  683. .tetris-mobile-controls {
  684. grid-template-columns: auto 82px;
  685. gap: 15px;
  686. padding-top: 13px;
  687. }
  688. .tetris-mobile-dpad {
  689. grid-template-columns: repeat(3,43px);
  690. grid-template-rows: repeat(2,43px);
  691. gap: 6px;
  692. }
  693. .tetris-mobile-key {
  694. width: 43px;
  695. height: 43px;
  696. font-size: 17px;
  697. }
  698. .tetris-mobile-drop {
  699. min-width: 82px;
  700. min-height: 54px;
  701. font-size: 10px;
  702. }
  703. .ov-title {
  704. font-size: clamp(26px,9vw,38px);
  705. }
  706. .ov-sub {
  707. font-size: 13px;
  708. }
  709. }
  710. @media (max-width: 360px) {
  711. .arcade-brand span:last-child {
  712. display: none;
  713. }
  714. .tetris-logo {
  715. font-size: 24px;
  716. }
  717. .tetris-mark {
  718. display: none;
  719. }
  720. }

JS

  1. (() => {
  2. const T = window.APP_LOCALE === "en"
  3. ? {
  4. start: "Press <b>SPACE</b> to start",
  5. pause: "Paused — press <b>P</b> to continue",
  6. drop: "DROP",
  7. }
  8. : {
  9. start: "Pulsa <b>ESPACIO</b> para empezar",
  10. pause: "Pausa — pulsa <b>P</b> para continuar",
  11. drop: "SOLTAR",
  12. };
  13. const canvas = document.getElementById("gameCanvas");
  14. const shell = document.getElementById("canvasShell");
  15. const ctx = canvas.getContext("2d");
  16. const startOverlay = document.getElementById("startMessage");
  17. const gameOverOverlay = document.getElementById("gameOverMessage");
  18. const btnStart = document.getElementById("btnStartTetris");
  19. const resetBtn = document.getElementById("resetBtn");
  20. const playAgainBtn = document.getElementById("playAgainBtn");
  21. const scoreEl = document.getElementById("scoreValue");
  22. const linesEl = document.getElementById("linesValue");
  23. const levelEl = document.getElementById("levelValue");
  24. const mobileMount = document.getElementById("tetrisMobileMount");
  25. const COLS = 10;
  26. const ROWS = 20;
  27. const DROP_START_MS = 700;
  28. const COLORS = {
  29. I: "#00d2ff",
  30. O: "#ffd166",
  31. T: "#b388ff",
  32. S: "#7cfc00",
  33. Z: "#ff4d6d",
  34. J: "#4ea8de",
  35. L: "#ff9f1c",
  36. X: "rgba(255,255,255,.065)",
  37. };
  38. const SHAPES = {
  39. I: [
  40. [0, 0, 0, 0],
  41. [1, 1, 1, 1],
  42. [0, 0, 0, 0],
  43. [0, 0, 0, 0],
  44. ],
  45. O: [
  46. [0, 1, 1, 0],
  47. [0, 1, 1, 0],
  48. [0, 0, 0, 0],
  49. [0, 0, 0, 0],
  50. ],
  51. T: [
  52. [0, 1, 0, 0],
  53. [1, 1, 1, 0],
  54. [0, 0, 0, 0],
  55. [0, 0, 0, 0],
  56. ],
  57. S: [
  58. [0, 1, 1, 0],
  59. [1, 1, 0, 0],
  60. [0, 0, 0, 0],
  61. [0, 0, 0, 0],
  62. ],
  63. Z: [
  64. [1, 1, 0, 0],
  65. [0, 1, 1, 0],
  66. [0, 0, 0, 0],
  67. [0, 0, 0, 0],
  68. ],
  69. J: [
  70. [1, 0, 0, 0],
  71. [1, 1, 1, 0],
  72. [0, 0, 0, 0],
  73. [0, 0, 0, 0],
  74. ],
  75. L: [
  76. [0, 0, 1, 0],
  77. [1, 1, 1, 0],
  78. [0, 0, 0, 0],
  79. [0, 0, 0, 0],
  80. ],
  81. };
  82. const TYPES = Object.keys(SHAPES);
  83. let grid;
  84. let current;
  85. let next;
  86. let score = 0;
  87. let lines = 0;
  88. let level = 1;
  89. let running = false;
  90. let paused = false;
  91. let gameOver = false;
  92. let dropMilliseconds = DROP_START_MS;
  93. let dropAccumulator = 0;
  94. let lastTime = 0;
  95. let dpr = 1;
  96. let viewportWidth = 0;
  97. let viewportHeight = 0;
  98. let cellSize = 20;
  99. let offsetX = 0;
  100. let offsetY = 0;
  101. let holdTimer = null;
  102. let mobileControlsCreated = false;
  103. function makeGrid() {
  104. return Array.from(
  105. { length: ROWS },
  106. () => Array(COLS).fill(null)
  107. );
  108. }
  109. function cloneMatrix(matrix) {
  110. return matrix.map((row) => row.slice());
  111. }
  112. function randomPiece() {
  113. const type = TYPES[Math.floor(Math.random() * TYPES.length)];
  114. return {
  115. type,
  116. mat: cloneMatrix(SHAPES[type]),
  117. x: 3,
  118. y: -1,
  119. };
  120. }
  121. function rotateMatrix(matrix) {
  122. const size = matrix.length;
  123. const rotated = Array.from(
  124. { length: size },
  125. () => Array(size).fill(0)
  126. );
  127. for (let y = 0; y < size; y++) {
  128. for (let x = 0; x < size; x++) {
  129. rotated[x][size - 1 - y] = matrix[y][x];
  130. }
  131. }
  132. return rotated;
  133. }
  134. function resizeCanvas() {
  135. const rect = shell.getBoundingClientRect();
  136. dpr = Math.max(
  137. 1,
  138. Math.min(2.5, window.devicePixelRatio || 1)
  139. );
  140. canvas.width = Math.max(1, Math.floor(rect.width * dpr));
  141. canvas.height = Math.max(1, Math.floor(rect.height * dpr));
  142. canvas.style.width = "100%";
  143. canvas.style.height = "100%";
  144. ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  145. viewportWidth = rect.width;
  146. viewportHeight = rect.height;
  147. /*
  148. * El canvas conserva EXACTAMENTE el tamaño visual de Break.
  149. * Solo centramos el tablero Tetris dentro.
  150. */
  151. const maxBoardHeight = viewportHeight * 0.90;
  152. const maxBoardWidth = viewportWidth * 0.44;
  153. const byHeight = Math.floor(maxBoardHeight / ROWS);
  154. const byWidth = Math.floor(maxBoardWidth / COLS);
  155. cellSize = Math.max(9, Math.min(byHeight, byWidth));
  156. const boardWidth = COLS * cellSize;
  157. const boardHeight = ROWS * cellSize;
  158. offsetX = Math.floor((viewportWidth - boardWidth) / 2);
  159. offsetY = Math.floor((viewportHeight - boardHeight) / 2);
  160. syncMobileControls();
  161. }
  162. function collide(piece, gridX, gridY) {
  163. for (let y = 0; y < 4; y++) {
  164. for (let x = 0; x < 4; x++) {
  165. if (!piece.mat[y][x]) {
  166. continue;
  167. }
  168. const x2 = gridX + x;
  169. const y2 = gridY + y;
  170. if (x2 < 0 || x2 >= COLS || y2 >= ROWS) {
  171. return true;
  172. }
  173. if (y2 >= 0 && grid[y2][x2]) {
  174. return true;
  175. }
  176. }
  177. }
  178. return false;
  179. }
  180. function merge(piece) {
  181. for (let y = 0; y < 4; y++) {
  182. for (let x = 0; x < 4; x++) {
  183. if (!piece.mat[y][x]) {
  184. continue;
  185. }
  186. const x2 = piece.x + x;
  187. const y2 = piece.y + y;
  188. if (
  189. y2 >= 0 &&
  190. y2 < ROWS &&
  191. x2 >= 0 &&
  192. x2 < COLS
  193. ) {
  194. grid[y2][x2] = piece.type;
  195. }
  196. }
  197. }
  198. }
  199. function clearLines() {
  200. let cleared = 0;
  201. for (let y = ROWS - 1; y >= 0; y--) {
  202. if (grid[y].every(Boolean)) {
  203. grid.splice(y, 1);
  204. grid.unshift(Array(COLS).fill(null));
  205. cleared++;
  206. y++;
  207. }
  208. }
  209. if (!cleared) {
  210. return;
  211. }
  212. lines += cleared;
  213. const points = {
  214. 1: 100,
  215. 2: 300,
  216. 3: 500,
  217. 4: 800,
  218. };
  219. score += (points[cleared] || 0) * level;
  220. level = Math.floor(lines / 10) + 1;
  221. dropMilliseconds = Math.max(
  222. 120,
  223. DROP_START_MS - (level - 1) * 60
  224. );
  225. updateHUD();
  226. }
  227. function spawn() {
  228. current = next;
  229. current.x = 3;
  230. current.y = -1;
  231. next = randomPiece();
  232. if (collide(current, current.x, current.y)) {
  233. gameOver = true;
  234. running = false;
  235. paused = false;
  236. gameOverOverlay.style.display = "grid";
  237. window.GameScores?.submit("tetris", score);
  238. }
  239. }
  240. function lockPiece() {
  241. merge(current);
  242. clearLines();
  243. spawn();
  244. }
  245. function moveLeft() {
  246. if (!running || gameOver) {
  247. return;
  248. }
  249. if (!collide(current, current.x - 1, current.y)) {
  250. current.x--;
  251. }
  252. }
  253. function moveRight() {
  254. if (!running || gameOver) {
  255. return;
  256. }
  257. if (!collide(current, current.x + 1, current.y)) {
  258. current.x++;
  259. }
  260. }
  261. function moveDown() {
  262. if (!running || gameOver) {
  263. return;
  264. }
  265. if (!collide(current, current.x, current.y + 1)) {
  266. current.y++;
  267. score++;
  268. updateHUD();
  269. }
  270. }
  271. function stepDown() {
  272. if (!running || gameOver) {
  273. return;
  274. }
  275. if (!collide(current, current.x, current.y + 1)) {
  276. current.y++;
  277. } else {
  278. lockPiece();
  279. }
  280. }
  281. function rotatePiece() {
  282. if (!running || gameOver) {
  283. return;
  284. }
  285. const previous = current.mat;
  286. current.mat = rotateMatrix(current.mat);
  287. if (collide(current, current.x, current.y)) {
  288. if (!collide(current, current.x - 1, current.y)) {
  289. current.x--;
  290. } else if (!collide(current, current.x + 1, current.y)) {
  291. current.x++;
  292. } else {
  293. current.mat = previous;
  294. }
  295. }
  296. }
  297. function hardDrop() {
  298. if (!running || gameOver) {
  299. return;
  300. }
  301. while (!collide(current, current.x, current.y + 1)) {
  302. current.y++;
  303. score++;
  304. }
  305. updateHUD();
  306. lockPiece();
  307. }
  308. function updateHUD() {
  309. scoreEl.textContent = String(score);
  310. linesEl.textContent = String(lines);
  311. levelEl.textContent = String(level);
  312. }
  313. function showStart(html) {
  314. startOverlay.innerHTML =
  315. '<div class="start-copy">' +
  316. html +
  317. "</div>";
  318. startOverlay.style.display = "grid";
  319. }
  320. function hideStart() {
  321. startOverlay.style.display = "none";
  322. }
  323. function startGame() {
  324. if (gameOver) {
  325. return;
  326. }
  327. running = true;
  328. paused = false;
  329. dropAccumulator = 0;
  330. hideStart();
  331. }
  332. function resetGame() {
  333. grid = makeGrid();
  334. score = 0;
  335. lines = 0;
  336. level = 1;
  337. running = false;
  338. paused = false;
  339. gameOver = false;
  340. dropMilliseconds = DROP_START_MS;
  341. dropAccumulator = 0;
  342. gameOverOverlay.style.display = "none";
  343. updateHUD();
  344. next = randomPiece();
  345. spawn();
  346. showStart(T.start);
  347. }
  348. function togglePause() {
  349. if (gameOver) {
  350. return;
  351. }
  352. if (paused) {
  353. paused = false;
  354. running = true;
  355. dropAccumulator = 0;
  356. hideStart();
  357. return;
  358. }
  359. if (!running) {
  360. startGame();
  361. return;
  362. }
  363. paused = true;
  364. running = false;
  365. showStart(T.pause);
  366. }
  367. /*
  368. * Teclas bloqueadas para que Chrome no mueva
  369. * la página ni navegue por los botones.
  370. */
  371. const gameKeys = new Set([
  372. "ArrowLeft",
  373. "ArrowRight",
  374. "ArrowUp",
  375. "ArrowDown",
  376. "Space",
  377. "KeyP",
  378. "KeyR",
  379. ]);
  380. function blockBrowserGameKeys(event) {
  381. if (gameKeys.has(event.code)) {
  382. event.preventDefault();
  383. }
  384. }
  385. window.addEventListener(
  386. "keydown",
  387. blockBrowserGameKeys,
  388. {
  389. capture: true,
  390. passive: false,
  391. }
  392. );
  393. window.addEventListener(
  394. "keyup",
  395. blockBrowserGameKeys,
  396. {
  397. capture: true,
  398. passive: false,
  399. }
  400. );
  401. window.addEventListener(
  402. "keydown",
  403. (event) => {
  404. if (event.code === "Space") {
  405. if (!running && !gameOver) {
  406. startGame();
  407. } else if (running && !gameOver) {
  408. hardDrop();
  409. }
  410. return;
  411. }
  412. if (event.code === "KeyP") {
  413. if (!event.repeat) {
  414. togglePause();
  415. }
  416. return;
  417. }
  418. if (event.code === "KeyR") {
  419. if (!event.repeat) {
  420. resetGame();
  421. }
  422. return;
  423. }
  424. if (!running || gameOver) {
  425. return;
  426. }
  427. if (event.code === "ArrowLeft") {
  428. moveLeft();
  429. } else if (event.code === "ArrowRight") {
  430. moveRight();
  431. } else if (event.code === "ArrowDown") {
  432. moveDown();
  433. } else if (event.code === "ArrowUp") {
  434. rotatePiece();
  435. }
  436. },
  437. {
  438. passive: false,
  439. }
  440. );
  441. btnStart?.addEventListener("click", () => {
  442. if (gameOver) {
  443. resetGame();
  444. }
  445. startGame();
  446. btnStart.blur();
  447. });
  448. resetBtn?.addEventListener("click", () => {
  449. resetGame();
  450. resetBtn.blur();
  451. });
  452. playAgainBtn?.addEventListener("click", () => {
  453. resetGame();
  454. startGame();
  455. playAgainBtn.blur();
  456. });
  457. function clearHold() {
  458. if (holdTimer) {
  459. clearInterval(holdTimer);
  460. holdTimer = null;
  461. }
  462. }
  463. function runMobileAction(action) {
  464. if (!running && !gameOver) {
  465. startGame();
  466. }
  467. if (!running || gameOver) {
  468. return;
  469. }
  470. if (action === "left") {
  471. moveLeft();
  472. } else if (action === "right") {
  473. moveRight();
  474. } else if (action === "down") {
  475. moveDown();
  476. } else if (action === "rotate") {
  477. rotatePiece();
  478. } else if (action === "drop") {
  479. hardDrop();
  480. }
  481. }
  482. function createMobileControls() {
  483. if (mobileControlsCreated || !mobileMount) {
  484. return;
  485. }
  486. const wrapper = document.createElement("div");
  487. wrapper.className = "tetris-mobile-controls";
  488. const dpad = document.createElement("div");
  489. dpad.className = "tetris-mobile-dpad";
  490. const rotate = document.createElement("button");
  491. rotate.type = "button";
  492. rotate.className = "tetris-mobile-key tetris-mobile-rotate";
  493. rotate.dataset.action = "rotate";
  494. rotate.textContent = "⟳";
  495. const left = document.createElement("button");
  496. left.type = "button";
  497. left.className = "tetris-mobile-key tetris-mobile-left";
  498. left.dataset.action = "left";
  499. left.textContent = "◀";
  500. const down = document.createElement("button");
  501. down.type = "button";
  502. down.className = "tetris-mobile-key tetris-mobile-down";
  503. down.dataset.action = "down";
  504. down.textContent = "▼";
  505. const right = document.createElement("button");
  506. right.type = "button";
  507. right.className = "tetris-mobile-key tetris-mobile-right";
  508. right.dataset.action = "right";
  509. right.textContent = "▶";
  510. dpad.append(
  511. rotate,
  512. left,
  513. down,
  514. right
  515. );
  516. const drop = document.createElement("button");
  517. drop.type = "button";
  518. drop.className = "tetris-mobile-drop";
  519. drop.dataset.action = "drop";
  520. drop.textContent = T.drop;
  521. wrapper.append(dpad, drop);
  522. mobileMount.appendChild(wrapper);
  523. wrapper.addEventListener(
  524. "pointerdown",
  525. (event) => {
  526. const button =
  527. event.target.closest("[data-action]");
  528. if (!button) {
  529. return;
  530. }
  531. event.preventDefault();
  532. const action =
  533. button.dataset.action;
  534. runMobileAction(action);
  535. if (
  536. action === "left" ||
  537. action === "right" ||
  538. action === "down"
  539. ) {
  540. clearHold();
  541. holdTimer = setInterval(
  542. () => runMobileAction(action),
  543. 70
  544. );
  545. }
  546. },
  547. {
  548. passive: false,
  549. }
  550. );
  551. wrapper.addEventListener(
  552. "pointerup",
  553. clearHold
  554. );
  555. wrapper.addEventListener(
  556. "pointercancel",
  557. clearHold
  558. );
  559. wrapper.addEventListener(
  560. "pointerleave",
  561. clearHold
  562. );
  563. mobileControlsCreated = true;
  564. }
  565. function destroyMobileControls() {
  566. clearHold();
  567. if (!mobileControlsCreated || !mobileMount) {
  568. return;
  569. }
  570. mobileMount.innerHTML = "";
  571. mobileControlsCreated = false;
  572. }
  573. function syncMobileControls() {
  574. if (window.matchMedia("(max-width: 820px)").matches) {
  575. createMobileControls();
  576. } else {
  577. destroyMobileControls();
  578. }
  579. }
  580. function drawCell(x, y, color) {
  581. const px = offsetX + x * cellSize;
  582. const py = offsetY + y * cellSize;
  583. ctx.fillStyle = color;
  584. ctx.fillRect(px, py, cellSize, cellSize);
  585. ctx.strokeStyle = "rgba(0,0,0,.30)";
  586. ctx.strokeRect(
  587. px + 0.5,
  588. py + 0.5,
  589. cellSize - 1,
  590. cellSize - 1
  591. );
  592. ctx.strokeStyle = "rgba(255,255,255,.10)";
  593. ctx.strokeRect(
  594. px + 2,
  595. py + 2,
  596. cellSize - 4,
  597. cellSize - 4
  598. );
  599. }
  600. function drawBackground() {
  601. ctx.fillStyle = "#03060c";
  602. ctx.fillRect(
  603. 0,
  604. 0,
  605. viewportWidth,
  606. viewportHeight
  607. );
  608. const gradient =
  609. ctx.createRadialGradient(
  610. viewportWidth * 0.5,
  611. viewportHeight * 0.45,
  612. 40,
  613. viewportWidth * 0.5,
  614. viewportHeight * 0.45,
  615. Math.max(viewportWidth, viewportHeight)
  616. );
  617. gradient.addColorStop(
  618. 0,
  619. "rgba(255,255,255,.03)"
  620. );
  621. gradient.addColorStop(
  622. 1,
  623. "rgba(0,0,0,.65)"
  624. );
  625. ctx.fillStyle = gradient;
  626. ctx.fillRect(
  627. 0,
  628. 0,
  629. viewportWidth,
  630. viewportHeight
  631. );
  632. }
  633. function drawBoard() {
  634. const boardWidth = COLS * cellSize;
  635. const boardHeight = ROWS * cellSize;
  636. ctx.save();
  637. ctx.fillStyle = "rgba(255,255,255,.018)";
  638. ctx.fillRect(
  639. offsetX,
  640. offsetY,
  641. boardWidth,
  642. boardHeight
  643. );
  644. ctx.strokeStyle = "rgba(74,163,255,.75)";
  645. ctx.lineWidth = 2;
  646. ctx.shadowBlur = 15;
  647. ctx.shadowColor = "rgba(74,163,255,.28)";
  648. ctx.strokeRect(
  649. offsetX - 1,
  650. offsetY - 1,
  651. boardWidth + 2,
  652. boardHeight + 2
  653. );
  654. ctx.restore();
  655. for (let y = 0; y < ROWS; y++) {
  656. for (let x = 0; x < COLS; x++) {
  657. drawCell(
  658. x,
  659. y,
  660. grid[y][x]
  661. ? COLORS[grid[y][x]]
  662. : COLORS.X
  663. );
  664. }
  665. }
  666. if (!current) {
  667. return;
  668. }
  669. for (let y = 0; y < 4; y++) {
  670. for (let x = 0; x < 4; x++) {
  671. if (!current.mat[y][x]) {
  672. continue;
  673. }
  674. const boardX = current.x + x;
  675. const boardY = current.y + y;
  676. if (boardY >= 0) {
  677. drawCell(
  678. boardX,
  679. boardY,
  680. COLORS[current.type]
  681. );
  682. }
  683. }
  684. }
  685. }
  686. function draw() {
  687. ctx.clearRect(
  688. 0,
  689. 0,
  690. viewportWidth,
  691. viewportHeight
  692. );
  693. drawBackground();
  694. drawBoard();
  695. }
  696. function loop(timestamp) {
  697. const delta =
  698. Math.min(
  699. 0.04,
  700. (timestamp - lastTime) / 1000
  701. );
  702. lastTime = timestamp;
  703. if (running && !gameOver) {
  704. dropAccumulator += delta * 1000;
  705. if (dropAccumulator >= dropMilliseconds) {
  706. stepDown();
  707. dropAccumulator = 0;
  708. }
  709. }
  710. draw();
  711. requestAnimationFrame(loop);
  712. }
  713. window.addEventListener("resize", resizeCanvas);
  714. window.addEventListener("blur", clearHold);
  715. resizeCanvas();
  716. resetGame();
  717. requestAnimationFrame(loop);
  718. })();