← Volver a código

Pinball

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>Pinball</title>
  7. <link rel="stylesheet" href="css/pinball.css">
  8. </head>
  9. <body>
  10. <!-- Estructura del juego (canvas, HUD, botones…) -->
  11. <script src="js/pinball.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. --red: #ff4b5c;
  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. display: flex;
  24. min-width: 320px;
  25. min-height: 100svh;
  26. flex-direction: column;
  27. color: var(--text);
  28. background-color: var(--bg);
  29. background-position: center;
  30. background-size: cover;
  31. background-attachment: fixed;
  32. font-family: "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
  33. }
  34. button,
  35. a {
  36. font: inherit;
  37. -webkit-tap-highlight-color: transparent;
  38. }
  39. button {
  40. cursor: pointer;
  41. }
  42. .arcade-nav {
  43. position: relative;
  44. z-index: 20;
  45. display: flex;
  46. align-items: center;
  47. justify-content: space-between;
  48. min-height: 78px;
  49. padding: 14px max(30px, 7vw);
  50. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  51. background: rgba(4, 3, 11, 0.72);
  52. backdrop-filter: blur(10px);
  53. }
  54. .arcade-brand {
  55. display: inline-flex;
  56. align-items: center;
  57. gap: 12px;
  58. color: #ffffff;
  59. text-decoration: none;
  60. font-size: 20px;
  61. font-weight: 900;
  62. letter-spacing: 0.035em;
  63. white-space: nowrap;
  64. }
  65. .brand-code {
  66. color: var(--purple);
  67. font-size: 28px;
  68. letter-spacing: -0.12em;
  69. }
  70. .arcade-nav-links {
  71. display: flex;
  72. align-items: center;
  73. gap: 3px;
  74. padding: 6px;
  75. border: 1px solid rgba(255, 255, 255, 0.11);
  76. border-radius: 999px;
  77. background: rgba(15, 12, 29, 0.78);
  78. }
  79. .arcade-nav-links a {
  80. display: inline-flex;
  81. align-items: center;
  82. gap: 8px;
  83. padding: 10px 15px;
  84. border-radius: 999px;
  85. color: rgba(255, 255, 255, 0.78);
  86. text-decoration: none;
  87. font-size: 12px;
  88. font-weight: 800;
  89. letter-spacing: 0.04em;
  90. text-transform: uppercase;
  91. }
  92. .arcade-nav-links a:hover {
  93. color: #ffffff;
  94. background: rgba(123, 92, 255, 0.18);
  95. }
  96. .arcade-nav-links i {
  97. width: 17px;
  98. color: var(--purple);
  99. text-align: center;
  100. }
  101. .break-page {
  102. width: min(1400px, 94vw);
  103. flex: 1 0 auto;
  104. margin: 0 auto;
  105. padding-bottom: 26px;
  106. }
  107. .site-footer {
  108. flex: 0 0 auto;
  109. margin-top: auto;
  110. }
  111. .game-toolbar {
  112. display: flex;
  113. align-items: center;
  114. justify-content: space-between;
  115. min-height: 56px;
  116. padding: 0 4px;
  117. }
  118. .toolbar-link {
  119. color: var(--purple);
  120. background: none;
  121. text-decoration: none;
  122. font-size: 13px;
  123. font-weight: 850;
  124. letter-spacing: 0.06em;
  125. }
  126. .toolbar-button {
  127. border: 0;
  128. }
  129. .arcade-shell {
  130. display: grid;
  131. grid-template-columns: minmax(0, 1.55fr) minmax(330px, 0.95fr);
  132. min-height: calc(100svh - 160px);
  133. overflow: hidden;
  134. border: 1px solid rgba(181, 108, 255, 0.48);
  135. border-radius: 28px;
  136. background: linear-gradient(135deg, rgba(11, 8, 25, 0.94), rgba(5, 4, 14, 0.92));
  137. box-shadow: 0 35px 100px rgba(0, 0, 0, 0.62);
  138. backdrop-filter: blur(9px);
  139. }
  140. .board-column {
  141. display: flex;
  142. min-width: 0;
  143. flex-direction: column;
  144. padding: 18px 26px 20px;
  145. border-right: 1px solid rgba(255, 255, 255, 0.08);
  146. }
  147. .break-logo {
  148. display: flex;
  149. align-items: center;
  150. justify-content: center;
  151. gap: 16px;
  152. min-height: 70px;
  153. margin: 0 auto 7px;
  154. color: var(--blue);
  155. font-family: "Courier New", monospace;
  156. font-size: clamp(32px, 3.8vw, 52px);
  157. font-weight: 1000;
  158. letter-spacing: 0.06em;
  159. text-shadow: 2px 2px #4835bc, 0 0 16px rgba(74, 163, 255, 0.48);
  160. }
  161. .brick-mark {
  162. color: var(--red);
  163. font-size: clamp(15px, 1.8vw, 25px);
  164. letter-spacing: -0.25em;
  165. text-shadow: 0 0 8px rgba(255, 75, 92, 0.8);
  166. }
  167. .brick-mark.alt {
  168. color: var(--green);
  169. text-shadow: 0 0 8px rgba(124, 255, 0, 0.7);
  170. }
  171. .canvas-shell {
  172. position: relative;
  173. width: 100%;
  174. min-height: 390px;
  175. flex: 1;
  176. overflow: hidden;
  177. border: 2px solid rgba(18, 104, 255, 0.9);
  178. border-radius: 16px;
  179. background: #03060c;
  180. box-shadow: 0 0 28px rgba(32, 70, 255, 0.22), inset 0 0 28px rgba(0, 0, 0, 0.7);
  181. }
  182. #gameCanvas {
  183. display: block;
  184. width: 100%;
  185. height: 100%;
  186. min-height: 390px;
  187. border-radius: 14px;
  188. background: #03060c;
  189. touch-action: none;
  190. }
  191. .overlay {
  192. position: absolute;
  193. inset: 0;
  194. z-index: 4;
  195. display: grid;
  196. place-items: center;
  197. padding: 20px;
  198. color: #ffffff;
  199. text-align: center;
  200. background: rgba(0, 0, 0, 0.38);
  201. backdrop-filter: blur(2px);
  202. }
  203. .overlay-countdown {
  204. font-size: clamp(55px, 7vw, 78px);
  205. font-weight: 900;
  206. pointer-events: none;
  207. }
  208. .overlay-end {
  209. background: rgba(0, 0, 0, 0.6);
  210. }
  211. .ov-title {
  212. margin-bottom: 10px;
  213. color: var(--red);
  214. font-size: clamp(32px, 4vw, 48px);
  215. font-weight: 900;
  216. }
  217. .ov-sub {
  218. color: rgba(255, 255, 255, 0.92);
  219. font-size: 16px;
  220. }
  221. .btn-play-again {
  222. min-width: 180px;
  223. min-height: 48px;
  224. margin-top: 18px;
  225. padding: 10px 18px;
  226. border: 2px solid var(--blue);
  227. border-radius: 12px;
  228. color: var(--blue);
  229. background: rgba(9, 22, 45, 0.92);
  230. font-weight: 900;
  231. }
  232. .btn-launch-mobile {
  233. display: none;
  234. position: absolute;
  235. left: 50%;
  236. bottom: 18%;
  237. z-index: 5;
  238. min-width: 180px;
  239. min-height: 48px;
  240. padding: 10px 18px;
  241. transform: translateX(-50%);
  242. border: 2px solid var(--green);
  243. border-radius: 12px;
  244. color: #091108;
  245. background: var(--green);
  246. font-weight: 900;
  247. box-shadow: 0 0 20px rgba(124, 255, 0, 0.35);
  248. }
  249. .game-help {
  250. display: flex;
  251. justify-content: center;
  252. gap: 28px;
  253. padding: 14px 5px 0;
  254. color: var(--muted);
  255. font-size: 12px;
  256. }
  257. .control-column {
  258. display: flex;
  259. min-width: 0;
  260. flex-direction: column;
  261. padding: 24px 28px 22px;
  262. }
  263. .desktop-stats,
  264. .mobile-stats {
  265. display: grid;
  266. grid-template-columns: repeat(2, 1fr);
  267. gap: 12px;
  268. }
  269. .mobile-stats {
  270. display: none;
  271. }
  272. .stat-card {
  273. display: flex;
  274. align-items: center;
  275. justify-content: center;
  276. gap: 9px;
  277. min-width: 0;
  278. padding: 12px 9px;
  279. border: 1px solid rgba(255, 255, 255, 0.14);
  280. border-radius: 16px;
  281. background: rgba(255, 255, 255, 0.035);
  282. font-size: 11px;
  283. font-weight: 700;
  284. letter-spacing: 0.04em;
  285. }
  286. .stat-card > span:last-child {
  287. display: flex;
  288. flex-direction: column;
  289. gap: 2px;
  290. }
  291. .stat-card b {
  292. color: var(--yellow);
  293. font-size: 19px;
  294. }
  295. .stat-icon {
  296. color: var(--yellow);
  297. font-size: 22px;
  298. }
  299. .stat-icon.red {
  300. color: var(--red);
  301. }
  302. .controls-block {
  303. display: grid;
  304. grid-template-columns: 1fr 175px;
  305. align-items: center;
  306. gap: 18px;
  307. margin: 30px 0 26px;
  308. }
  309. .keyboard-help h2,
  310. .score-preview h2 {
  311. margin-bottom: 17px;
  312. color: var(--purple);
  313. font-size: 15px;
  314. font-weight: 900;
  315. letter-spacing: 0.045em;
  316. }
  317. .keyboard-help p {
  318. margin: 10px 0;
  319. color: rgba(255, 255, 255, 0.7);
  320. font-size: 12px;
  321. }
  322. .keyboard-help strong {
  323. color: var(--green);
  324. }
  325. .break-controls {
  326. display: grid;
  327. grid-template-columns: repeat(3, 50px);
  328. justify-content: center;
  329. gap: 7px;
  330. }
  331. .break-controls span {
  332. display: grid;
  333. height: 50px;
  334. place-items: center;
  335. border: 1px solid rgba(165, 92, 255, 0.5);
  336. border-radius: 13px;
  337. color: var(--purple);
  338. background: #1a122d;
  339. font-size: 20px;
  340. }
  341. .break-controls .ball-key {
  342. color: #071005;
  343. background: var(--green);
  344. }
  345. .action-row {
  346. display: grid;
  347. grid-template-columns: repeat(2, 1fr);
  348. gap: 12px;
  349. }
  350. .arcade-action {
  351. min-height: 48px;
  352. border: 1px solid rgba(181, 108, 255, 0.45);
  353. border-radius: 13px;
  354. color: var(--purple);
  355. background: rgba(123, 92, 255, 0.08);
  356. font-size: 12px;
  357. font-weight: 900;
  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-break {
  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(--blue);
  409. border-radius: 12px;
  410. color: var(--blue);
  411. background: rgba(9, 22, 45, 0.9);
  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(74, 163, 255, 0.24);
  417. }
  418. @media (max-width: 1050px) {
  419. .arcade-shell {
  420. grid-template-columns: minmax(0, 1.4fr) minmax(310px, 0.85fr);
  421. }
  422. .controls-block {
  423. grid-template-columns: 1fr;
  424. }
  425. .keyboard-help {
  426. text-align: center;
  427. }
  428. }
  429. @media (max-width: 820px) {
  430. .arcade-nav {
  431. min-height: 68px;
  432. padding: 10px 16px;
  433. }
  434. .arcade-nav-links a {
  435. padding: 9px 10px;
  436. }
  437. .arcade-nav-links a span {
  438. display: none;
  439. }
  440. .break-page {
  441. width: calc(100% - 24px);
  442. padding-bottom: 20px;
  443. }
  444. .game-toolbar {
  445. min-height: 50px;
  446. }
  447. .arcade-shell {
  448. display: block;
  449. min-height: 0;
  450. border-radius: 22px;
  451. }
  452. .board-column {
  453. padding: 18px 16px 16px;
  454. border-right: 0;
  455. }
  456. .control-column {
  457. display: none;
  458. }
  459. .break-logo {
  460. min-height: 48px;
  461. margin-bottom: 12px;
  462. font-size: clamp(27px, 8vw, 40px);
  463. }
  464. .mobile-stats {
  465. display: grid;
  466. margin-bottom: 12px;
  467. }
  468. .canvas-shell {
  469. min-height: 0;
  470. aspect-ratio: 12 / 7;
  471. flex: none;
  472. }
  473. #gameCanvas {
  474. width: 100%;
  475. height: 100%;
  476. min-height: 0;
  477. }
  478. .game-help {
  479. justify-content: space-between;
  480. gap: 10px;
  481. font-size: 10px;
  482. }
  483. .btn-launch-mobile {
  484. min-width: 150px;
  485. min-height: 44px;
  486. bottom: 14%;
  487. }
  488. }
  489. @media (max-width: 480px) {
  490. .arcade-brand {
  491. font-size: 14px;
  492. }
  493. .arcade-nav-links {
  494. padding: 4px;
  495. }
  496. .arcade-nav-links a {
  497. padding: 8px;
  498. }
  499. .break-page {
  500. width: calc(100% - 16px);
  501. }
  502. .toolbar-link {
  503. font-size: 12px;
  504. }
  505. .board-column {
  506. padding: 14px 10px 12px;
  507. }
  508. .break-logo {
  509. gap: 9px;
  510. font-size: 27px;
  511. }
  512. .mobile-stats {
  513. gap: 8px;
  514. }
  515. .stat-card {
  516. gap: 7px;
  517. padding: 10px 7px;
  518. font-size: 10px;
  519. }
  520. .stat-card b {
  521. font-size: 18px;
  522. }
  523. .ov-title {
  524. font-size: clamp(26px, 9vw, 38px);
  525. }
  526. .ov-sub {
  527. font-size: 13px;
  528. }
  529. .btn-play-again {
  530. min-width: 160px;
  531. min-height: 44px;
  532. margin-top: 13px;
  533. }
  534. }
  535. @media (max-width: 360px) {
  536. .arcade-brand span:last-child {
  537. display: none;
  538. }
  539. .break-logo {
  540. font-size: 24px;
  541. }
  542. .brick-mark {
  543. display: none;
  544. }
  545. }
  546. /* =========================================================
  547. PINBALL — FONDO Y ADAPTACIÓN DE LA MESA
  548. ========================================================= */
  549. .pinball-page {
  550. --purple: #a55cff;
  551. --yellow: #ffd719;
  552. --green: #7cff00;
  553. --blue: #22e7ff;
  554. --red: #ff0049;
  555. }
  556. .pinball-canvas-shell {
  557. position: relative;
  558. width: min(100%, 650px);
  559. height: clamp(590px, calc(100svh - 275px), 760px);
  560. min-height: 590px;
  561. flex: none;
  562. margin: 0 auto;
  563. border-color: rgba(165, 92, 255, 0.9);
  564. background:
  565. linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
  566. linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px),
  567. radial-gradient(circle at 50% 20%, rgba(165, 92, 255, 0.3), transparent 34%),
  568. radial-gradient(circle at 50% 72%, rgba(255, 0, 73, 0.16), transparent 43%),
  569. linear-gradient(180deg, #120925 0%, #090413 52%, #030208 100%);
  570. background-size: 54px 54px, 54px 54px, auto, auto, auto;
  571. box-shadow:
  572. 0 0 32px rgba(165, 92, 255, 0.28),
  573. inset 0 0 65px rgba(0, 0, 0, 0.75);
  574. }
  575. #pinball-canvas {
  576. display: block;
  577. width: 100%;
  578. height: 100%;
  579. min-height: 0;
  580. border-radius: 14px;
  581. background: transparent;
  582. touch-action: none;
  583. }
  584. /* Sobrescribe el display:grid de .overlay del juego Breakout. */
  585. #pinball-message {
  586. opacity: 0;
  587. visibility: hidden;
  588. pointer-events: none;
  589. transition: opacity 180ms ease, visibility 180ms ease;
  590. }
  591. #pinball-message.is-visible {
  592. opacity: 1;
  593. visibility: visible;
  594. }
  595. .pinball-logo {
  596. color: #ff0049;
  597. text-shadow: 2px 2px #55279c, 0 0 18px rgba(255, 0, 73, 0.65);
  598. }
  599. .pinball-ball-logo {
  600. color: #22e7ff;
  601. text-shadow: 0 0 14px rgba(34, 231, 255, 0.9);
  602. }
  603. .pinball-ball-logo.alt {
  604. color: #ffd719;
  605. text-shadow: 0 0 14px rgba(255, 215, 25, 0.85);
  606. }
  607. .pinball-desktop-stats {
  608. grid-template-columns: repeat(2, 1fr);
  609. }
  610. .pinball-secondary-stats {
  611. display: grid;
  612. grid-template-columns: 1fr 72px;
  613. gap: 12px;
  614. align-items: center;
  615. margin-top: 12px;
  616. }
  617. .pinball-record {
  618. display: flex;
  619. align-items: center;
  620. justify-content: space-between;
  621. min-height: 58px;
  622. padding: 10px 16px;
  623. border: 1px solid rgba(255, 255, 255, 0.13);
  624. border-radius: 14px;
  625. background: rgba(255, 255, 255, 0.025);
  626. font-size: 11px;
  627. font-weight: 800;
  628. letter-spacing: 0.05em;
  629. }
  630. .pinball-record strong {
  631. color: #22e7ff;
  632. font-size: 20px;
  633. font-variant-numeric: tabular-nums;
  634. }
  635. .pinball-multiplier {
  636. display: grid;
  637. width: 62px;
  638. height: 62px;
  639. place-items: center;
  640. border: 2px solid #22e7ff;
  641. border-radius: 50%;
  642. color: #22e7ff;
  643. background: rgba(34, 231, 255, 0.07);
  644. font-size: 19px;
  645. font-weight: 900;
  646. box-shadow: 0 0 20px rgba(34, 231, 255, 0.3);
  647. }
  648. .pinball-status {
  649. min-height: 20px;
  650. margin: 14px 0 8px;
  651. color: #22e7ff;
  652. font-size: 12px;
  653. font-weight: 800;
  654. text-align: center;
  655. }
  656. .btn-start-pinball {
  657. border-color: #ff0049;
  658. color: #ff0049;
  659. background: rgba(42, 5, 22, 0.9);
  660. box-shadow: 0 0 18px rgba(255, 0, 73, 0.25);
  661. }
  662. .btn-start-pinball:hover {
  663. color: #ffffff;
  664. background: #ff0049;
  665. box-shadow: 0 0 28px rgba(255, 0, 73, 0.5);
  666. }
  667. .pinball-touch-controls {
  668. position: absolute;
  669. right: 12px;
  670. bottom: 12px;
  671. left: 12px;
  672. z-index: 6;
  673. display: none;
  674. grid-template-columns: repeat(3, 64px);
  675. justify-content: center;
  676. gap: 14px;
  677. pointer-events: none;
  678. }
  679. .pinball-touch-controls button {
  680. display: grid;
  681. width: 64px;
  682. height: 54px;
  683. place-items: center;
  684. border: 1px solid rgba(165, 92, 255, 0.72);
  685. border-radius: 13px;
  686. color: #a55cff;
  687. background: rgba(18, 10, 35, 0.94);
  688. font-size: 21px;
  689. font-weight: 900;
  690. pointer-events: auto;
  691. touch-action: none;
  692. user-select: none;
  693. }
  694. .pinball-touch-controls button.is-pressed,
  695. .pinball-touch-controls button:active {
  696. color: #ffffff;
  697. border-color: #ff0049;
  698. background: #ff0049;
  699. }
  700. .pinball-touch-controls .pinball-launch-key {
  701. color: #071005;
  702. border-color: #7cff00;
  703. background: #7cff00;
  704. }
  705. @media (max-width: 820px) {
  706. .pinball-canvas-shell {
  707. width: min(100%, 580px);
  708. height: auto;
  709. min-height: 0;
  710. aspect-ratio: 3 / 4;
  711. }
  712. .pinball-touch-controls {
  713. display: grid;
  714. }
  715. .pinball-page .control-column {
  716. display: block;
  717. padding: 0 16px 18px;
  718. }
  719. .pinball-page .control-column > * {
  720. display: none;
  721. }
  722. .pinball-page .control-column .btn-start-pinball {
  723. display: flex;
  724. min-height: 54px;
  725. margin-top: 0;
  726. }
  727. }
  728. @media (max-width: 480px) {
  729. .pinball-touch-controls {
  730. bottom: 8px;
  731. grid-template-columns: repeat(3, 58px);
  732. gap: 10px;
  733. }
  734. .pinball-touch-controls button {
  735. width: 58px;
  736. height: 48px;
  737. }
  738. }

JS

  1. (() => {
  2. 'use strict';
  3. const canvas = document.getElementById('pinball-canvas');
  4. if (!canvas) return;
  5. const ctx = canvas.getContext('2d');
  6. const config = window.PINBALL_CONFIG || {};
  7. const labels = config.labels || {};
  8. const scoreEl = document.getElementById('pinball-score');
  9. const bestEl = document.getElementById('pinball-best');
  10. const ballsEl = document.getElementById('pinball-balls');
  11. const multiplierEl = document.getElementById('pinball-multiplier');
  12. const messageEl = document.getElementById('pinball-message');
  13. const statusEl = document.getElementById('pinball-status');
  14. const startButton = document.getElementById('pinball-start');
  15. const WIDTH = 900;
  16. const HEIGHT = 1200;
  17. const FIXED_STEP = 1 / 120;
  18. const BALL_RADIUS = 15;
  19. const GRAVITY = 470;
  20. const MAX_SPEED = 1120;
  21. const game = {
  22. running: false,
  23. waitingLaunch: false,
  24. score: 0,
  25. best: Number(localStorage.getItem('pinballBest') || 0),
  26. balls: 3,
  27. multiplier: 1,
  28. combo: 0,
  29. comboTimer: 0,
  30. accumulator: 0,
  31. lastTime: 0,
  32. shake: 0,
  33. flash: 0,
  34. ball: null,
  35. particles: [],
  36. keys: { left: false, right: false },
  37. flippers: {
  38. left: { pivot: { x: 355, y: 1010 }, length: 84, angle: 0.22, rest: 0.22, active: -0.43, pressed: false },
  39. right: { pivot: { x: 545, y: 1010 }, length: 84, angle: Math.PI - 0.22, rest: Math.PI - 0.22, active: Math.PI + 0.43, pressed: false }
  40. }
  41. };
  42. const bumpers = [
  43. { x: 300, y: 335, r: 54, value: 100, color: '#ff0049', pulse: 0 },
  44. { x: 450, y: 280, r: 56, value: 150, color: '#16d9ff', pulse: 0 },
  45. { x: 600, y: 335, r: 54, value: 100, color: '#ff4fd8', pulse: 0 },
  46. { x: 450, y: 510, r: 46, value: 200, color: '#8c3cff', pulse: 0 }
  47. ];
  48. const targets = [
  49. { x: 205, y: 540, w: 24, h: 78, hit: false },
  50. { x: 670, y: 540, w: 24, h: 78, hit: false },
  51. { x: 258, y: 690, w: 65, h: 18, hit: false },
  52. { x: 577, y: 690, w: 65, h: 18, hit: false }
  53. ];
  54. const walls = [
  55. [145, 112, 90, 870], [145, 112, 720, 112], [750, 260, 750, 1040],
  56. [90, 870, 250, 1115], [810, 870, 650, 1115],
  57. [190, 185, 175, 570], [710, 185, 725, 570],
  58. [175, 570, 255, 760], [725, 570, 645, 760],
  59. [255, 760, 300, 900], [645, 760, 600, 900],
  60. [750, 190, 840, 190], [840, 190, 840, 1050]
  61. ];
  62. function clamp(value, min, max) {
  63. return Math.max(min, Math.min(max, value));
  64. }
  65. function formatScore(value) {
  66. return Math.max(0, Math.floor(value)).toString().padStart(6, '0');
  67. }
  68. function updateHud() {
  69. scoreEl.textContent = formatScore(game.score);
  70. bestEl.textContent = formatScore(game.best);
  71. ballsEl.textContent = String(game.balls);
  72. multiplierEl.textContent = `x${game.multiplier}`;
  73. }
  74. function showMessage(title, subtitle) {
  75. messageEl.querySelector('strong').textContent = title;
  76. messageEl.querySelector('span').textContent = subtitle || '';
  77. messageEl.classList.add('is-visible');
  78. }
  79. function hideMessage() {
  80. messageEl.classList.remove('is-visible');
  81. }
  82. function newBall() {
  83. game.ball = {
  84. x: 795,
  85. y: 1010,
  86. vx: 0,
  87. vy: 0,
  88. r: BALL_RADIUS,
  89. trail: [],
  90. launching: false,
  91. launchTime: 0
  92. };
  93. game.waitingLaunch = true;
  94. statusEl.textContent = labels.launchReady || 'Press Space to launch';
  95. }
  96. function startGame() {
  97. game.score = 0;
  98. game.balls = 3;
  99. game.multiplier = 1;
  100. game.combo = 0;
  101. targets.forEach((target) => { target.hit = false; });
  102. game.running = true;
  103. hideMessage();
  104. startButton.textContent = labels.restart || 'Play again';
  105. newBall();
  106. updateHud();
  107. }
  108. function launchBall() {
  109. if (!game.running || !game.waitingLaunch || !game.ball) return;
  110. game.waitingLaunch = false;
  111. game.ball.x = 795;
  112. game.ball.y = 1010;
  113. /* Lanzamiento guiado: evita que la bola quede atrapada en el carril. */
  114. game.ball.vx = 0;
  115. game.ball.vy = 0;
  116. game.ball.launching = true;
  117. game.ball.launchTime = 0;
  118. statusEl.textContent = '';
  119. burst(game.ball.x, game.ball.y, '#16d9ff', 14);
  120. }
  121. function loseBall() {
  122. game.balls -= 1;
  123. updateHud();
  124. if (game.balls > 0) {
  125. window.setTimeout(newBall, 650);
  126. return;
  127. }
  128. endGame();
  129. }
  130. async function endGame() {
  131. game.running = false;
  132. game.waitingLaunch = false;
  133. const isBest = game.score > game.best;
  134. if (isBest) {
  135. game.best = game.score;
  136. localStorage.setItem('pinballBest', String(game.best));
  137. }
  138. updateHud();
  139. showMessage(labels.gameOver || 'Game over', isBest ? (labels.newBest || 'New high score!') : formatScore(game.score));
  140. await saveScore(game.score);
  141. }
  142. function addScore(base, x, y, color) {
  143. game.combo += 1;
  144. game.comboTimer = 2.5;
  145. if (game.combo >= 12) game.multiplier = 5;
  146. else if (game.combo >= 7) game.multiplier = 3;
  147. else if (game.combo >= 3) game.multiplier = 2;
  148. game.score += base * game.multiplier;
  149. game.flash = 0.16;
  150. burst(x, y, color, 12);
  151. updateHud();
  152. }
  153. function burst(x, y, color, count) {
  154. for (let i = 0; i < count; i += 1) {
  155. const angle = Math.random() * Math.PI * 2;
  156. const speed = 80 + Math.random() * 260;
  157. game.particles.push({
  158. x, y,
  159. vx: Math.cos(angle) * speed,
  160. vy: Math.sin(angle) * speed,
  161. life: 0.35 + Math.random() * 0.45,
  162. maxLife: 0.8,
  163. color
  164. });
  165. }
  166. }
  167. function closestPointOnSegment(px, py, ax, ay, bx, by) {
  168. const abx = bx - ax;
  169. const aby = by - ay;
  170. const lengthSq = abx * abx + aby * aby || 1;
  171. const t = clamp(((px - ax) * abx + (py - ay) * aby) / lengthSq, 0, 1);
  172. return { x: ax + abx * t, y: ay + aby * t, t };
  173. }
  174. function collideSegment(ball, wall, bounce = 0.84, boost = 0) {
  175. const [ax, ay, bx, by] = wall;
  176. const point = closestPointOnSegment(ball.x, ball.y, ax, ay, bx, by);
  177. let dx = ball.x - point.x;
  178. let dy = ball.y - point.y;
  179. let distance = Math.hypot(dx, dy);
  180. if (distance >= ball.r + 5) return false;
  181. if (distance < 0.001) {
  182. dx = -(by - ay);
  183. dy = bx - ax;
  184. distance = Math.hypot(dx, dy);
  185. }
  186. const nx = dx / distance;
  187. const ny = dy / distance;
  188. const overlap = ball.r + 5 - distance;
  189. ball.x += nx * overlap;
  190. ball.y += ny * overlap;
  191. const velocityAlongNormal = ball.vx * nx + ball.vy * ny;
  192. if (velocityAlongNormal < 0) {
  193. ball.vx -= (1 + bounce) * velocityAlongNormal * nx;
  194. ball.vy -= (1 + bounce) * velocityAlongNormal * ny;
  195. ball.vx += nx * boost;
  196. ball.vy += ny * boost;
  197. }
  198. return true;
  199. }
  200. function collideBumper(ball, bumper) {
  201. const dx = ball.x - bumper.x;
  202. const dy = ball.y - bumper.y;
  203. const distance = Math.hypot(dx, dy) || 1;
  204. const minimum = ball.r + bumper.r;
  205. if (distance >= minimum) return;
  206. const nx = dx / distance;
  207. const ny = dy / distance;
  208. ball.x = bumper.x + nx * minimum;
  209. ball.y = bumper.y + ny * minimum;
  210. const speed = Math.max(520, Math.hypot(ball.vx, ball.vy) * 1.05);
  211. ball.vx = nx * speed;
  212. ball.vy = ny * speed;
  213. bumper.pulse = 1;
  214. game.shake = 7;
  215. addScore(bumper.value, bumper.x, bumper.y, bumper.color);
  216. }
  217. function flipperSegment(flipper) {
  218. return [
  219. flipper.pivot.x,
  220. flipper.pivot.y,
  221. flipper.pivot.x + Math.cos(flipper.angle) * flipper.length,
  222. flipper.pivot.y + Math.sin(flipper.angle) * flipper.length
  223. ];
  224. }
  225. function updateFlipper(flipper, dt, isLeft) {
  226. const target = flipper.pressed ? flipper.active : flipper.rest;
  227. const difference = Math.atan2(Math.sin(target - flipper.angle), Math.cos(target - flipper.angle));
  228. flipper.angle += difference * Math.min(1, dt * 28);
  229. if (game.ball && collideSegment(game.ball, flipperSegment(flipper), 0.92, flipper.pressed ? 390 : 70)) {
  230. if (flipper.pressed) {
  231. game.ball.vy -= 260;
  232. game.ball.vx += isLeft ? 70 : -70;
  233. addScore(15, game.ball.x, game.ball.y, '#ffffff');
  234. }
  235. }
  236. }
  237. function collideTarget(ball, target) {
  238. const nearestX = clamp(ball.x, target.x, target.x + target.w);
  239. const nearestY = clamp(ball.y, target.y, target.y + target.h);
  240. const dx = ball.x - nearestX;
  241. const dy = ball.y - nearestY;
  242. if (dx * dx + dy * dy > ball.r * ball.r) return;
  243. target.hit = true;
  244. ball.vx *= -1.03;
  245. ball.vy -= 80;
  246. addScore(250, target.x + target.w / 2, target.y + target.h / 2, '#ffcc33');
  247. if (targets.every((item) => item.hit)) {
  248. game.balls += 1;
  249. targets.forEach((item) => { item.hit = false; });
  250. statusEl.textContent = 'EXTRA BALL';
  251. updateHud();
  252. }
  253. }
  254. function update(dt) {
  255. game.flippers.left.pressed = game.keys.left;
  256. game.flippers.right.pressed = game.keys.right;
  257. updateFlipper(game.flippers.left, dt, true);
  258. updateFlipper(game.flippers.right, dt, false);
  259. bumpers.forEach((bumper) => { bumper.pulse = Math.max(0, bumper.pulse - dt * 4); });
  260. game.flash = Math.max(0, game.flash - dt);
  261. game.shake = Math.max(0, game.shake - dt * 30);
  262. game.comboTimer -= dt;
  263. if (game.comboTimer <= 0 && game.combo > 0) {
  264. game.combo = 0;
  265. game.multiplier = 1;
  266. updateHud();
  267. }
  268. game.particles.forEach((particle) => {
  269. particle.x += particle.vx * dt;
  270. particle.y += particle.vy * dt;
  271. particle.vy += 180 * dt;
  272. particle.life -= dt;
  273. });
  274. game.particles = game.particles.filter((particle) => particle.life > 0);
  275. const ball = game.ball;
  276. if (!game.running || !ball || game.waitingLaunch) return;
  277. if (ball.launching) {
  278. ball.launchTime += dt;
  279. if (ball.launchTime < 0.72) {
  280. const progress = ball.launchTime / 0.72;
  281. ball.x = 795;
  282. ball.y = 1010 + (225 - 1010) * progress;
  283. } else if (ball.launchTime < 0.98) {
  284. const progress = (ball.launchTime - 0.72) / 0.26;
  285. const eased = progress * progress * (3 - 2 * progress);
  286. ball.x = 795 + (680 - 795) * eased;
  287. ball.y = 225 + (245 - 225) * eased;
  288. } else {
  289. ball.launching = false;
  290. ball.x = 680;
  291. ball.y = 245;
  292. ball.vx = -360;
  293. ball.vy = 135;
  294. }
  295. ball.trail.unshift({ x: ball.x, y: ball.y });
  296. ball.trail.length = Math.min(ball.trail.length, 13);
  297. return;
  298. }
  299. ball.vy += GRAVITY * dt;
  300. const speed = Math.hypot(ball.vx, ball.vy);
  301. if (speed > MAX_SPEED) {
  302. ball.vx = ball.vx / speed * MAX_SPEED;
  303. ball.vy = ball.vy / speed * MAX_SPEED;
  304. }
  305. ball.x += ball.vx * dt;
  306. ball.y += ball.vy * dt;
  307. ball.trail.unshift({ x: ball.x, y: ball.y });
  308. ball.trail.length = Math.min(ball.trail.length, 13);
  309. walls.forEach((wall) => collideSegment(ball, wall));
  310. bumpers.forEach((bumper) => collideBumper(ball, bumper));
  311. targets.forEach((target) => collideTarget(ball, target));
  312. /*
  313. * Perímetro físico de seguridad.
  314. * Los raíles decorativos tienen pequeñas separaciones; estas barreras
  315. * impiden que una bola rápida atraviese esos huecos.
  316. */
  317. if (ball.x - ball.r < 92) {
  318. ball.x = 92 + ball.r;
  319. ball.vx = Math.abs(ball.vx) * 0.86;
  320. }
  321. if (ball.x + ball.r > 742) {
  322. ball.x = 742 - ball.r;
  323. ball.vx = -Math.abs(ball.vx) * 0.86;
  324. }
  325. if (ball.y - ball.r < 112) {
  326. ball.y = 112 + ball.r;
  327. ball.vy = Math.abs(ball.vy) * 0.86;
  328. }
  329. if (!Number.isFinite(ball.x) || !Number.isFinite(ball.y)) {
  330. game.ball = null;
  331. loseBall();
  332. return;
  333. }
  334. if (ball.y > HEIGHT + 40) {
  335. game.ball = null;
  336. loseBall();
  337. }
  338. }
  339. function roundedRect(x, y, width, height, radius, fill, stroke) {
  340. ctx.beginPath();
  341. ctx.roundRect(x, y, width, height, radius);
  342. if (fill) { ctx.fillStyle = fill; ctx.fill(); }
  343. if (stroke) { ctx.strokeStyle = stroke; ctx.stroke(); }
  344. }
  345. function drawNeonLine(points, color, width = 8, glow = 20) {
  346. ctx.save();
  347. ctx.lineCap = 'round';
  348. ctx.lineJoin = 'round';
  349. ctx.strokeStyle = color;
  350. ctx.lineWidth = width;
  351. ctx.shadowColor = color;
  352. ctx.shadowBlur = glow;
  353. ctx.beginPath();
  354. points.forEach((point, index) => {
  355. if (index === 0) ctx.moveTo(point[0], point[1]);
  356. else ctx.lineTo(point[0], point[1]);
  357. });
  358. ctx.stroke();
  359. ctx.restore();
  360. }
  361. function drawRamp(points, color) {
  362. ctx.save();
  363. ctx.lineCap = 'round';
  364. ctx.lineJoin = 'round';
  365. ctx.strokeStyle = 'rgba(5, 3, 16, 0.88)';
  366. ctx.lineWidth = 34;
  367. ctx.beginPath();
  368. points.forEach((point, index) => {
  369. if (index === 0) ctx.moveTo(point[0], point[1]);
  370. else ctx.lineTo(point[0], point[1]);
  371. });
  372. ctx.stroke();
  373. ctx.strokeStyle = color;
  374. ctx.lineWidth = 6;
  375. ctx.shadowColor = color;
  376. ctx.shadowBlur = 20;
  377. ctx.stroke();
  378. ctx.restore();
  379. }
  380. function drawSlingShot(points, color) {
  381. ctx.save();
  382. ctx.fillStyle = 'rgba(17, 8, 35, 0.9)';
  383. ctx.strokeStyle = color;
  384. ctx.lineWidth = 7;
  385. ctx.shadowColor = color;
  386. ctx.shadowBlur = 20;
  387. ctx.beginPath();
  388. ctx.moveTo(points[0][0], points[0][1]);
  389. points.slice(1).forEach((point) => ctx.lineTo(point[0], point[1]));
  390. ctx.closePath();
  391. ctx.fill();
  392. ctx.stroke();
  393. ctx.restore();
  394. }
  395. function drawArrow(x, y, rotation, color) {
  396. ctx.save();
  397. ctx.translate(x, y);
  398. ctx.rotate(rotation);
  399. ctx.fillStyle = color;
  400. ctx.shadowColor = color;
  401. ctx.shadowBlur = 18;
  402. ctx.beginPath();
  403. ctx.moveTo(0, -22);
  404. ctx.lineTo(18, 10);
  405. ctx.lineTo(6, 10);
  406. ctx.lineTo(6, 27);
  407. ctx.lineTo(-6, 27);
  408. ctx.lineTo(-6, 10);
  409. ctx.lineTo(-18, 10);
  410. ctx.closePath();
  411. ctx.fill();
  412. ctx.restore();
  413. }
  414. function drawBoard() {
  415. /* El fondo se controla desde pinball.css. El canvas solo dibuja piezas. */
  416. roundedRect(38, 25, 824, 1148, 38, 'rgba(4, 2, 12, 0.34)', 'rgba(165, 92, 255, 0.8)');
  417. ctx.save();
  418. ctx.textAlign = 'center';
  419. ctx.fillStyle = '#ffffff';
  420. ctx.font = '900 30px Arial';
  421. ctx.shadowColor = '#a55cff';
  422. ctx.shadowBlur = 18;
  423. ctx.fillText('DANILOPEZ25.ES', WIDTH / 2, 78);
  424. ctx.fillStyle = '#ff0049';
  425. ctx.font = '800 16px Arial';
  426. ctx.fillText('NEON PINBALL', WIDTH / 2, 105);
  427. ctx.restore();
  428. /* Arco superior y carriles principales. */
  429. ctx.save();
  430. ctx.strokeStyle = 'rgba(34, 231, 255, 0.72)';
  431. ctx.lineWidth = 5;
  432. ctx.shadowColor = '#22e7ff';
  433. ctx.shadowBlur = 18;
  434. ctx.beginPath();
  435. ctx.arc(450, 355, 260, Math.PI * 1.08, Math.PI * 1.92);
  436. ctx.stroke();
  437. ctx.restore();
  438. drawRamp([[245, 610], [205, 490], [235, 300], [350, 195]], '#ff0049');
  439. drawRamp([[655, 610], [695, 490], [665, 300], [550, 195]], '#22e7ff');
  440. drawNeonLine([[145, 112], [90, 870], [250, 1115]], '#8c3cff', 10, 24);
  441. drawNeonLine([[755, 112], [785, 1040], [650, 1115]], '#8c3cff', 10, 24);
  442. drawNeonLine([[145, 112], [755, 112]], '#ff0049', 8, 22);
  443. /* Canal de lanzamiento derecho. */
  444. roundedRect(750, 178, 90, 875, 30, 'rgba(10, 7, 24, 0.75)', 'rgba(255, 0, 73, 0.75)');
  445. drawNeonLine([[840, 1035], [840, 190], [750, 190]], '#ff0049', 5, 15);
  446. for (let y = 880; y >= 330; y -= 95) drawArrow(795, y, 0, 'rgba(255, 0, 73, 0.55)');
  447. /* Decoración central con profundidad. */
  448. ctx.save();
  449. const core = ctx.createRadialGradient(450, 505, 10, 450, 505, 145);
  450. core.addColorStop(0, 'rgba(255, 0, 73, 0.24)');
  451. core.addColorStop(0.45, 'rgba(140, 60, 255, 0.12)');
  452. core.addColorStop(1, 'rgba(0, 0, 0, 0)');
  453. ctx.fillStyle = core;
  454. ctx.beginPath();
  455. ctx.arc(450, 505, 145, 0, Math.PI * 2);
  456. ctx.fill();
  457. ctx.restore();
  458. bumpers.forEach(drawBumper);
  459. targets.forEach(drawTarget);
  460. drawSlingShot([[210, 780], [315, 900], [255, 920]], '#ff0049');
  461. drawSlingShot([[690, 780], [585, 900], [645, 920]], '#22e7ff');
  462. drawArrow(300, 655, -0.2, '#ffcc19');
  463. drawArrow(600, 655, 0.2, '#22e7ff');
  464. drawArrow(450, 830, 0, '#ff0049');
  465. drawLaneLights();
  466. drawFlipper(game.flippers.left, '#ff0049');
  467. drawFlipper(game.flippers.right, '#ff0049');
  468. /* Zona de pérdida y guías inferiores. */
  469. ctx.save();
  470. const drain = ctx.createLinearGradient(0, 960, 0, 1160);
  471. drain.addColorStop(0, 'rgba(255, 0, 73, 0)');
  472. drain.addColorStop(1, 'rgba(255, 0, 73, 0.28)');
  473. ctx.fillStyle = drain;
  474. ctx.beginPath();
  475. ctx.moveTo(90, 870);
  476. ctx.lineTo(300, 1160);
  477. ctx.lineTo(600, 1160);
  478. ctx.lineTo(810, 870);
  479. ctx.lineTo(650, 1115);
  480. ctx.lineTo(250, 1115);
  481. ctx.closePath();
  482. ctx.fill();
  483. ctx.restore();
  484. }
  485. function drawBumper(bumper) {
  486. const scale = 1 + bumper.pulse * 0.15;
  487. ctx.save();
  488. ctx.translate(bumper.x, bumper.y);
  489. ctx.scale(scale, scale);
  490. ctx.shadowColor = bumper.color;
  491. ctx.shadowBlur = 28 + bumper.pulse * 30;
  492. ctx.fillStyle = '#120b22';
  493. ctx.strokeStyle = bumper.color;
  494. ctx.lineWidth = 8;
  495. ctx.beginPath();
  496. ctx.arc(0, 0, bumper.r, 0, Math.PI * 2);
  497. ctx.fill();
  498. ctx.stroke();
  499. ctx.shadowBlur = 0;
  500. ctx.fillStyle = '#fff';
  501. ctx.font = '900 22px Arial';
  502. ctx.textAlign = 'center';
  503. ctx.textBaseline = 'middle';
  504. ctx.fillText(String(bumper.value), 0, 1);
  505. ctx.restore();
  506. }
  507. function drawTarget(target) {
  508. ctx.save();
  509. ctx.shadowColor = target.hit ? '#ffcc33' : '#8c3cff';
  510. ctx.shadowBlur = target.hit ? 24 : 10;
  511. roundedRect(target.x, target.y, target.w, target.h, 7, target.hit ? '#ffcc33' : '#5a279f', '#e7d6ff');
  512. ctx.restore();
  513. }
  514. function drawLaneLights() {
  515. const multipliers = [1, 2, 3, 5];
  516. multipliers.forEach((value, index) => {
  517. const y = 625 + index * 62;
  518. const active = game.multiplier >= value;
  519. ctx.beginPath();
  520. ctx.arc(450, y, 23, 0, Math.PI * 2);
  521. ctx.fillStyle = active ? 'rgba(22, 217, 255, .28)' : 'rgba(255, 255, 255, .04)';
  522. ctx.fill();
  523. ctx.strokeStyle = active ? '#16d9ff' : '#6f6481';
  524. ctx.lineWidth = 3;
  525. ctx.stroke();
  526. ctx.fillStyle = active ? '#fff' : '#988da8';
  527. ctx.font = '800 17px Arial';
  528. ctx.fillText(`x${value}`, 450, y + 6);
  529. });
  530. }
  531. function drawFlipper(flipper, color) {
  532. const segment = flipperSegment(flipper);
  533. ctx.save();
  534. ctx.lineCap = 'round';
  535. ctx.shadowColor = color;
  536. ctx.shadowBlur = 22;
  537. ctx.strokeStyle = '#ffffff';
  538. ctx.lineWidth = 31;
  539. ctx.beginPath();
  540. ctx.moveTo(segment[0], segment[1]);
  541. ctx.lineTo(segment[2], segment[3]);
  542. ctx.stroke();
  543. ctx.shadowBlur = 0;
  544. ctx.strokeStyle = color;
  545. ctx.lineWidth = 8;
  546. ctx.stroke();
  547. ctx.restore();
  548. }
  549. function drawBall() {
  550. if (!game.ball) return;
  551. const ball = game.ball;
  552. ball.trail.forEach((point, index) => {
  553. const alpha = (1 - index / ball.trail.length) * 0.28;
  554. ctx.fillStyle = `rgba(22, 217, 255, ${alpha})`;
  555. ctx.beginPath();
  556. ctx.arc(point.x, point.y, Math.max(2, ball.r - index), 0, Math.PI * 2);
  557. ctx.fill();
  558. });
  559. const gradient = ctx.createRadialGradient(ball.x - 6, ball.y - 7, 2, ball.x, ball.y, ball.r);
  560. gradient.addColorStop(0, '#ffffff');
  561. gradient.addColorStop(0.35, '#d8f9ff');
  562. gradient.addColorStop(1, '#76709a');
  563. ctx.shadowColor = '#16d9ff';
  564. ctx.shadowBlur = 20;
  565. ctx.fillStyle = gradient;
  566. ctx.beginPath();
  567. ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
  568. ctx.fill();
  569. ctx.shadowBlur = 0;
  570. }
  571. function drawParticles() {
  572. game.particles.forEach((particle) => {
  573. ctx.globalAlpha = clamp(particle.life / particle.maxLife, 0, 1);
  574. ctx.fillStyle = particle.color;
  575. ctx.fillRect(particle.x - 3, particle.y - 3, 6, 6);
  576. });
  577. ctx.globalAlpha = 1;
  578. }
  579. function render() {
  580. ctx.clearRect(0, 0, WIDTH, HEIGHT);
  581. ctx.save();
  582. if (game.shake > 0) {
  583. ctx.translate((Math.random() - 0.5) * game.shake, (Math.random() - 0.5) * game.shake);
  584. }
  585. drawBoard();
  586. drawBall();
  587. drawParticles();
  588. if (game.flash > 0) {
  589. ctx.fillStyle = `rgba(255,255,255,${game.flash * 0.6})`;
  590. ctx.fillRect(0, 0, WIDTH, HEIGHT);
  591. }
  592. ctx.restore();
  593. }
  594. function loop(time) {
  595. if (!game.lastTime) game.lastTime = time;
  596. const frameTime = Math.min(0.04, (time - game.lastTime) / 1000);
  597. game.lastTime = time;
  598. game.accumulator += frameTime;
  599. while (game.accumulator >= FIXED_STEP) {
  600. update(FIXED_STEP);
  601. game.accumulator -= FIXED_STEP;
  602. }
  603. render();
  604. requestAnimationFrame(loop);
  605. }
  606. async function loadBest() {
  607. try {
  608. const response = await fetch(config.topUrl, { headers: { Accept: 'application/json' } });
  609. if (!response.ok) return;
  610. const data = await response.json();
  611. const rows = Array.isArray(data) ? data : (data.top || data.scores || []);
  612. if (rows.length) game.best = Math.max(game.best, Number(rows[0].score || 0));
  613. updateHud();
  614. } catch (_) {
  615. updateHud();
  616. }
  617. }
  618. async function saveScore(score) {
  619. if (!config.saveUrl || score <= 0) return;
  620. try {
  621. const form = new FormData();
  622. form.append('game', 'pinball');
  623. form.append('score', String(Math.floor(score)));
  624. await fetch(config.saveUrl, { method: 'POST', body: form, headers: { Accept: 'application/json' } });
  625. } catch (_) {
  626. // The local high score remains available even if the server is offline.
  627. }
  628. }
  629. function setKey(code, pressed) {
  630. if (code === 'ArrowLeft' || code === 'KeyA') game.keys.left = pressed;
  631. if (code === 'ArrowRight' || code === 'KeyD') game.keys.right = pressed;
  632. if (pressed && code === 'Space') launchBall();
  633. }
  634. const blockedKeys = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Space']);
  635. window.addEventListener('keydown', (event) => {
  636. if (blockedKeys.has(event.code)) event.preventDefault();
  637. setKey(event.code, true);
  638. }, { passive: false });
  639. window.addEventListener('keyup', (event) => {
  640. if (blockedKeys.has(event.code)) event.preventDefault();
  641. setKey(event.code, false);
  642. }, { passive: false });
  643. window.addEventListener('blur', () => {
  644. game.keys.left = false;
  645. game.keys.right = false;
  646. });
  647. function bindTouch(id, key) {
  648. const button = document.getElementById(id);
  649. if (!button) return;
  650. const press = (event) => {
  651. event.preventDefault();
  652. button.classList.add('is-pressed');
  653. if (key === 'launch') launchBall();
  654. else game.keys[key] = true;
  655. };
  656. const release = (event) => {
  657. event.preventDefault();
  658. button.classList.remove('is-pressed');
  659. if (key !== 'launch') game.keys[key] = false;
  660. };
  661. button.addEventListener('pointerdown', press);
  662. button.addEventListener('pointerup', release);
  663. button.addEventListener('pointercancel', release);
  664. button.addEventListener('pointerleave', release);
  665. }
  666. startButton.addEventListener('click', startGame);
  667. canvas.addEventListener('pointerdown', (event) => {
  668. if (event.pointerType !== 'mouse') launchBall();
  669. });
  670. bindTouch('pinball-touch-left', 'left');
  671. bindTouch('pinball-touch-right', 'right');
  672. bindTouch('pinball-touch-launch', 'launch');
  673. updateHud();
  674. /* El ranking previo ya se carga desde la view. Evitamos repetir la petición. */
  675. render();
  676. requestAnimationFrame(loop);
  677. })();