← Volver a código

Pacman

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

CSS

  1. *{box-sizing:border-box;margin:0;padding:0}
  2. :root{
  3. --bg:#06050c;
  4. --panel:rgba(8,7,18,.88);
  5. --panel-soft:rgba(19,15,37,.76);
  6. --purple:#a55cff;
  7. --purple-2:#7b5cff;
  8. --yellow:#ffd719;
  9. --text:#fff;
  10. --muted:rgba(255,255,255,.62);
  11. --line:rgba(181,108,255,.28);
  12. }
  13. html,body{width:100%;min-height:100%;overflow-x:hidden}
  14. body{
  15. min-height:100svh;
  16. color:var(--text);
  17. background-color:var(--bg);
  18. background-position:center;
  19. background-size:cover;
  20. background-attachment:fixed;
  21. font-family:"Segoe UI",system-ui,-apple-system,Arial,sans-serif;
  22. }
  23. body::before{
  24. content:"";
  25. position:fixed;
  26. inset:0;
  27. z-index:-1;
  28. pointer-events:none;
  29. background:
  30. radial-gradient(circle at 50% 34%,rgba(25,18,54,.16),rgba(4,3,10,.7) 82%),
  31. rgba(3,2,8,.35);
  32. }
  33. button,a{-webkit-tap-highlight-color:transparent}
  34. button{font:inherit}
  35. /* Navegación superior */
  36. .arcade-nav{
  37. position:relative;
  38. z-index:20;
  39. display:flex;
  40. align-items:center;
  41. justify-content:space-between;
  42. min-height:78px;
  43. padding:14px max(30px,7vw);
  44. border-bottom:1px solid rgba(255,255,255,.08);
  45. background:rgba(4,3,11,.38);
  46. backdrop-filter:blur(8px);
  47. }
  48. .arcade-brand{
  49. display:inline-flex;
  50. align-items:center;
  51. gap:12px;
  52. color:#fff;
  53. text-decoration:none;
  54. font-size:20px;
  55. font-weight:900;
  56. letter-spacing:.035em;
  57. white-space:nowrap;
  58. }
  59. .brand-code{color:var(--purple);font-size:28px;letter-spacing:-.12em}
  60. .arcade-nav-links{
  61. display:flex;
  62. align-items:center;
  63. gap:3px;
  64. padding:6px;
  65. border:1px solid rgba(255,255,255,.11);
  66. border-radius:999px;
  67. background:rgba(15,12,29,.78);
  68. }
  69. .arcade-nav-links a{
  70. display:inline-flex;
  71. align-items:center;
  72. gap:8px;
  73. padding:10px 15px;
  74. color:rgba(255,255,255,.78);
  75. border-radius:999px;
  76. text-decoration:none;
  77. font-size:12px;
  78. font-weight:800;
  79. letter-spacing:.04em;
  80. text-transform:uppercase;
  81. transition:background .2s ease,color .2s ease,transform .2s ease;
  82. }
  83. .arcade-nav-links a:hover{color:#fff;background:rgba(123,92,255,.18);transform:translateY(-1px)}
  84. .arcade-nav-links svg{width:17px;height:17px;fill:var(--purple);flex:0 0 auto}
  85. /* Página y barra secundaria */
  86. .pacman-page{width:min(1400px,94vw);margin:0 auto;padding:0 0 26px}
  87. .game-toolbar{
  88. display:flex;
  89. align-items:center;
  90. justify-content:space-between;
  91. min-height:56px;
  92. padding:0 4px;
  93. }
  94. .toolbar-link{
  95. color:var(--purple);
  96. text-decoration:none;
  97. font-size:13px;
  98. font-weight:850;
  99. letter-spacing:.06em;
  100. }
  101. .toolbar-button{border:0;background:none;cursor:pointer}
  102. .toolbar-link:hover{color:#d3a7ff}
  103. /* Carcasa principal */
  104. .arcade-shell{
  105. display:grid;
  106. grid-template-columns:minmax(0,1.55fr) minmax(330px,.95fr);
  107. min-height:calc(100svh - 160px);
  108. overflow:hidden;
  109. border:1px solid rgba(181,108,255,.48);
  110. border-radius:28px;
  111. background:linear-gradient(135deg,rgba(11,8,25,.9),rgba(5,4,14,.88));
  112. box-shadow:0 35px 100px rgba(0,0,0,.62),inset 0 1px rgba(255,255,255,.04);
  113. backdrop-filter:blur(9px);
  114. }
  115. .board-column{display:flex;min-width:0;flex-direction:column;padding:18px 26px 22px;border-right:1px solid rgba(255,255,255,.08)}
  116. .pacman-logo{
  117. margin:0 auto 12px;
  118. color:var(--yellow);
  119. font-size:clamp(46px,5vw,78px);
  120. font-weight:950;
  121. line-height:.95;
  122. letter-spacing:.035em;
  123. text-shadow:0 0 2px #201300,0 4px 0 #4d2300,0 0 24px rgba(165,92,255,.52);
  124. }
  125. .pacman-logo img{
  126. display:block;
  127. width:min(360px,100%);
  128. height:auto;
  129. max-height:90px;
  130. margin:0 auto;
  131. object-fit:contain;
  132. }
  133. .canvas-shell{
  134. position:relative;
  135. display:grid;
  136. place-items:center;
  137. width:100%;
  138. min-height:0;
  139. flex:1;
  140. overflow:hidden;
  141. border:2px solid rgba(29,88,255,.88);
  142. border-radius:16px;
  143. background:#01030a;
  144. box-shadow:0 0 28px rgba(32,70,255,.22),inset 0 0 28px rgba(0,0,0,.85);
  145. touch-action:none;
  146. }
  147. #gameCanvas{display:block;width:100%;height:100%;background:#02040b}
  148. .overlay{
  149. position:absolute;
  150. inset:0;
  151. display:grid;
  152. place-items:center;
  153. padding:20px;
  154. color:#fff;
  155. border-radius:14px;
  156. background:rgba(0,0,0,.35);
  157. text-align:center;
  158. pointer-events:none;
  159. backdrop-filter:blur(2px);
  160. }
  161. .overlay-start{font-size:clamp(20px,2.2vw,34px);font-weight:900;text-shadow:0 8px 24px #000}
  162. .overlay-gameover{display:none;background:rgba(1,2,8,.72)}
  163. .go-title{margin-bottom:8px;color:#ff4c64;font-size:clamp(28px,4vw,48px);font-weight:950}
  164. .go-sub{font-size:16px;color:rgba(255,255,255,.86)}
  165. /* Panel derecho */
  166. .control-column{display:flex;min-width:0;flex-direction:column;padding:24px 28px 22px}
  167. .desktop-stats,.mobile-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
  168. .mobile-stats{display:none}
  169. .stat-card{
  170. display:flex;
  171. align-items:center;
  172. justify-content:center;
  173. gap:9px;
  174. min-width:0;
  175. padding:12px 9px;
  176. border:1px solid rgba(255,255,255,.14);
  177. border-radius:16px;
  178. background:rgba(255,255,255,.035);
  179. box-shadow:inset 0 1px rgba(255,255,255,.04);
  180. font-size:11px;
  181. font-weight:700;
  182. letter-spacing:.04em;
  183. }
  184. .stat-card span:last-child{display:flex;min-width:0;flex-direction:column;gap:2px}
  185. .stat-card b{color:var(--yellow);font-size:19px;line-height:1}
  186. .stat-icon{color:var(--yellow);font-size:22px;line-height:1}
  187. .stat-icon.purple{color:var(--purple)}
  188. .stat-icon.red{color:#ff5264}
  189. .controls-block{display:grid;grid-template-columns:1fr 190px;align-items:center;gap:18px;margin:26px 0 20px}
  190. .keyboard-help h2,.score-preview h2{margin-bottom:17px;color:var(--purple);font-size:15px;font-weight:900;letter-spacing:.045em}
  191. .keyboard-help p{margin:12px 0;color:rgba(255,255,255,.78);font-size:13px}
  192. .keyboard-help strong{color:var(--yellow)}
  193. .dpad{position:relative;width:190px;aspect-ratio:1;user-select:none;touch-action:none}
  194. .dpad-btn,.dpad-center{
  195. position:absolute;
  196. width:32%;
  197. height:32%;
  198. cursor:pointer;
  199. border:1px solid rgba(181,108,255,.42);
  200. border-radius:15px;
  201. color:var(--purple);
  202. background:linear-gradient(145deg,rgba(42,33,70,.96),rgba(13,10,25,.98));
  203. box-shadow:0 10px 24px rgba(0,0,0,.35),inset 0 1px rgba(255,255,255,.08);
  204. font-size:20px;
  205. font-weight:900;
  206. transition:transform .1s ease,filter .15s ease;
  207. touch-action:none;
  208. }
  209. .dpad-btn:active,.dpad-center:active{transform:scale(.94);filter:brightness(1.2)}
  210. .dpad-btn.up{top:0;left:34%}.dpad-btn.down{bottom:0;left:34%}.dpad-btn.left{top:34%;left:0}.dpad-btn.right{top:34%;right:0}
  211. .dpad-center{top:34%;left:34%;color:#17100b;border-color:#ffe158;border-radius:50%;background:linear-gradient(145deg,#ffe45b,#ffc400);font-size:25px}
  212. .action-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
  213. .arcade-action,.mobile-pause-label,.btn-start{
  214. min-height:48px;
  215. cursor:pointer;
  216. border:1px solid rgba(181,108,255,.45);
  217. border-radius:13px;
  218. color:var(--purple);
  219. background:rgba(123,92,255,.08);
  220. font-size:12px;
  221. font-weight:900;
  222. letter-spacing:.035em;
  223. transition:background .2s ease,color .2s ease,transform .1s ease;
  224. }
  225. .arcade-action:hover,.mobile-pause-label:hover{color:#fff;background:rgba(123,92,255,.19)}
  226. .arcade-action:active,.mobile-pause-label:active,.btn-start:active{transform:scale(.98)}
  227. .mobile-pause-label{display:none}
  228. .btn-start{display:none;color:#17100b;border-color:var(--yellow);background:var(--yellow)}
  229. .score-preview{margin-top:25px}
  230. .ranking-preview{overflow:hidden;list-style:none;border:1px solid rgba(255,255,255,.13);border-radius:13px;background:rgba(255,255,255,.025)}
  231. .ranking-preview li{display:grid;grid-template-columns:30px 1fr auto;gap:8px;padding:11px 14px;border-bottom:1px solid rgba(255,255,255,.1);font-size:13px}
  232. .ranking-preview li:last-child{border-bottom:0}
  233. .rank-position{color:var(--purple);font-weight:900}.ranking-preview li:first-child .rank-position{color:var(--yellow)}
  234. .rank-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rank-score{color:var(--yellow);font-weight:900}
  235. .ranking-loading,.ranking-empty{display:block!important;color:var(--muted);text-align:center}
  236. /* Tablet */
  237. @media(max-width:1050px){
  238. .pacman-page{width:min(96vw,850px)}
  239. .arcade-shell{grid-template-columns:1fr;overflow:visible}
  240. .board-column{border-right:0;border-bottom:1px solid rgba(255,255,255,.08)}
  241. .canvas-shell{height:min(72vw,650px);flex:none}
  242. .control-column{padding:22px 30px 28px}
  243. .controls-block{grid-template-columns:1fr 190px;max-width:560px;width:100%;margin:24px auto 20px}
  244. }
  245. /* Móvil */
  246. @media(max-width:700px){
  247. body{background-attachment:scroll}
  248. .arcade-nav{min-height:76px;padding:12px 16px}
  249. .arcade-brand{gap:8px;font-size:15px}.brand-code{font-size:22px}
  250. .arcade-nav-links{gap:0;padding:5px}.arcade-nav-links a{padding:9px 10px}.arcade-nav-links a span{display:none}.arcade-nav-links svg{width:16px;height:16px}
  251. .pacman-page{width:100%;padding-bottom:0}
  252. .game-toolbar{min-height:58px;padding:0 20px}
  253. .arcade-shell{min-height:0;border-right:0;border-left:0;border-radius:0;background:rgba(7,5,17,.83)}
  254. .board-column{padding:20px 14px 16px}
  255. .pacman-logo{margin-bottom:20px;font-size:clamp(44px,16vw,64px)}
  256. .desktop-stats{display:none}.mobile-stats{display:grid;gap:7px;margin-bottom:18px}
  257. .stat-card{gap:5px;padding:10px 5px;border-radius:13px;font-size:9px}.stat-card b{font-size:15px}.stat-icon{font-size:17px}
  258. .canvas-shell{height:auto;aspect-ratio:28/27;border-radius:13px}
  259. .overlay-start{font-size:21px}
  260. .control-column{align-items:center;padding:20px 16px 34px}
  261. .controls-block{display:block;width:auto;margin:0}.keyboard-help{display:none}
  262. .dpad{width:min(210px,56vw)}
  263. .mobile-pause-label{display:block;min-height:42px;margin-top:16px;padding:0 28px;color:var(--yellow)}
  264. .action-row,.score-preview{display:none}
  265. .btn-start{display:block;width:min(310px,100%);margin-top:17px}
  266. }
  267. @media(max-width:390px){
  268. .arcade-nav{padding-inline:11px}.arcade-brand{font-size:14px}.arcade-nav-links a{padding:8px}
  269. .game-toolbar{padding-inline:15px}.board-column{padding-inline:10px}
  270. .mobile-stats{gap:5px}.stat-card{font-size:8px}.stat-card b{font-size:14px}.stat-icon{display:none}
  271. }
  272. @media(max-height:800px) and (min-width:1051px){
  273. .arcade-nav{min-height:68px;padding-block:9px}.game-toolbar{min-height:46px}
  274. .pacman-page{width:min(1280px,93vw)}.arcade-shell{min-height:calc(100svh - 128px)}
  275. .board-column{padding-top:12px}.pacman-logo{font-size:52px;margin-bottom:8px}
  276. .control-column{padding-block:18px}.controls-block{margin:18px 0 14px}.score-preview{margin-top:17px}
  277. }

JS

  1. (() => {
  2. /* ================= I18N ================= */
  3. const es = {
  4. startSpace: "Pulsa <b>ESPACIO</b> para empezar",
  5. startBtn: "Pulsa <b>START</b> para empezar",
  6. continueSpace: "Pulsa <b>ESPACIO</b> para continuar",
  7. continueBtn: "Pulsa <b>START</b> para continuar",
  8. levelSpace: "¡Nivel completado! Pulsa <b>ESPACIO</b>",
  9. levelBtn: "¡Nivel completado! Pulsa <b>START</b>",
  10. };
  11. const en = {
  12. startSpace: "Press <b>SPACE</b> to start",
  13. startBtn: "Press <b>START</b> to start",
  14. continueSpace: "Press <b>SPACE</b> to continue",
  15. continueBtn: "Press <b>START</b> to continue",
  16. levelSpace: "Level complete! Press <b>SPACE</b>",
  17. levelBtn: "Level complete! Press <b>START</b>",
  18. };
  19. const T = window.APP_LOCALE === "en"
  20. ? { ...en }
  21. : { ...es };
  22. /* ================= ELEMENTOS ================= */
  23. const canvas = document.getElementById("gameCanvas");
  24. const shell = document.getElementById("canvasShell");
  25. const ctx = canvas.getContext("2d");
  26. const scoreEl = document.getElementById("scoreValue");
  27. const livesEl = document.getElementById("livesValue");
  28. const mobileScoreEl =
  29. document.getElementById("mobileScoreValue");
  30. const mobileLivesEl =
  31. document.getElementById("mobileLivesValue");
  32. const bestEl = document.getElementById("bestValue");
  33. const mobileBestEl =
  34. document.getElementById("mobileBestValue");
  35. const startOverlay =
  36. document.getElementById("startMessage");
  37. const gameOverOverlay =
  38. document.getElementById("gameOverMessage");
  39. const dpad = document.getElementById("dpad");
  40. const btnStart = document.getElementById("btnStart");
  41. const btnPause = document.getElementById("btnPause");
  42. const btnPauseMobile =
  43. document.getElementById("btnPauseMobile");
  44. const btnRestart =
  45. document.getElementById("btnRestart");
  46. const btnShowRanking =
  47. document.getElementById("btnShowRanking");
  48. const btnRankingTop =
  49. document.getElementById("btnRankingTop");
  50. const rankingPreview =
  51. document.getElementById("rankingPreview");
  52. const isMobile =
  53. window.matchMedia("(max-width: 520px)").matches;
  54. /* ================= MAPA =================
  55. 0 = vacío
  56. 1 = muro
  57. 2 = punto
  58. 3 = bola de poder
  59. ======================================== */
  60. const MAP_TEMPLATE = [
  61. "1111111111111111111111111111",
  62. "1322222222222112222222222231",
  63. "1211112111112112111112111121",
  64. "1211112111112112111112111121",
  65. "1222222222222222222222222221",
  66. "1211112112111111112112111121",
  67. "1222222112222112222112222221",
  68. "1111112111112112111112111111",
  69. "0000012111112112111112100000",
  70. "0000012112222222222112100000",
  71. "1111112112111101112112111111",
  72. "0000002222110000112222000000",
  73. "1111112112110000112112111111",
  74. "0000002112100000012112000000",
  75. "2222222222100000012222222222",
  76. "1111112112100000012112111111",
  77. "0000012112111111112112100000",
  78. "0000012112222222222112100000",
  79. "1111112112111111112112111111",
  80. "1222222222222222222222222221",
  81. "1211112111112112111112111121",
  82. "1222112222222222222222112221",
  83. "1112112112111111112112112111",
  84. "1222222112222112222112222221",
  85. "1211111111112112111111111121",
  86. "1222222222222222222222222221",
  87. "1211112111111111112111112121",
  88. "1222222222222112222222222221",
  89. "1211111111112112111111111121",
  90. "1322222222222222222222222231",
  91. "1111111111111111111111111111",
  92. ];
  93. const MAP = MAP_TEMPLATE.map((row) =>
  94. row.split("").map(Number)
  95. );
  96. const ROWS = MAP.length;
  97. const COLS = MAP[0].length;
  98. /* ================= CONFIG ================= */
  99. const dirs = {
  100. left: {
  101. x: -1,
  102. y: 0,
  103. },
  104. right: {
  105. x: 1,
  106. y: 0,
  107. },
  108. up: {
  109. x: 0,
  110. y: -1,
  111. },
  112. down: {
  113. x: 0,
  114. y: 1,
  115. },
  116. };
  117. const dirList = Object.values(dirs);
  118. let TILE = 16;
  119. let boardW = 0;
  120. let boardH = 0;
  121. let offsetX = 0;
  122. let offsetY = 0;
  123. /* ================= HELPERS ================= */
  124. function tileAt(tx, ty) {
  125. if (
  126. ty < 0 ||
  127. ty >= ROWS ||
  128. tx < 0 ||
  129. tx >= COLS
  130. ) {
  131. return 0;
  132. }
  133. return MAP[ty][tx];
  134. }
  135. function isWalkable(tx, ty) {
  136. const value = tileAt(tx, ty);
  137. return (
  138. value === 0 ||
  139. value === 2 ||
  140. value === 3
  141. );
  142. }
  143. function toTileCoord(x, y) {
  144. return {
  145. tx: Math.floor(x),
  146. ty: Math.floor(y),
  147. };
  148. }
  149. function centerTile(tx, ty) {
  150. return {
  151. x: tx + 0.5,
  152. y: ty + 0.5,
  153. };
  154. }
  155. function nearCenter(entity) {
  156. const { tx, ty } = toTileCoord(
  157. entity.x,
  158. entity.y
  159. );
  160. return (
  161. Math.abs(entity.x - (tx + 0.5)) < 0.03 &&
  162. Math.abs(entity.y - (ty + 0.5)) < 0.03
  163. );
  164. }
  165. function snapCenter(entity) {
  166. const { tx, ty } = toTileCoord(
  167. entity.x,
  168. entity.y
  169. );
  170. entity.x = tx + 0.5;
  171. entity.y = ty + 0.5;
  172. }
  173. function oppositeDir(a, b) {
  174. return (
  175. a.x === -b.x &&
  176. a.y === -b.y
  177. );
  178. }
  179. function dist2(ax, ay, bx, by) {
  180. const dx = ax - bx;
  181. const dy = ay - by;
  182. return dx * dx + dy * dy;
  183. }
  184. /* ================= RESIZE ================= */
  185. function resizeCanvas() {
  186. const rect = shell.getBoundingClientRect();
  187. const dpr = Math.max(
  188. 1,
  189. Math.min(
  190. 2.5,
  191. window.devicePixelRatio || 1
  192. )
  193. );
  194. canvas.style.width = "100%";
  195. canvas.style.height = "100%";
  196. canvas.width = Math.floor(
  197. rect.width * dpr
  198. );
  199. canvas.height = Math.floor(
  200. rect.height * dpr
  201. );
  202. ctx.setTransform(
  203. dpr,
  204. 0,
  205. 0,
  206. dpr,
  207. 0,
  208. 0
  209. );
  210. const tileByWidth =
  211. rect.width / COLS;
  212. const tileByHeight =
  213. rect.height / ROWS;
  214. TILE = Math.min(
  215. tileByWidth,
  216. tileByHeight
  217. );
  218. boardW = TILE * COLS;
  219. boardH = TILE * ROWS;
  220. offsetX =
  221. (rect.width - boardW) / 2;
  222. offsetY =
  223. (rect.height - boardH) / 2;
  224. }
  225. window.addEventListener(
  226. "resize",
  227. resizeCanvas
  228. );
  229. /* ================= STATE ================= */
  230. let running = false;
  231. let gameOver = false;
  232. let paused = false;
  233. let score = 0;
  234. let lives = 3;
  235. let bestScore = 0;
  236. let frightenedMs = 0;
  237. let pelletsLeft = 0;
  238. function updateScoreDisplay() {
  239. if (score > bestScore) {
  240. bestScore = score;
  241. updateBestDisplay();
  242. }
  243. scoreEl.textContent =
  244. String(score);
  245. if (mobileScoreEl) {
  246. mobileScoreEl.textContent =
  247. String(score).padStart(4, "0");
  248. }
  249. }
  250. function updateLivesDisplay() {
  251. livesEl.textContent =
  252. String(lives);
  253. if (mobileLivesEl) {
  254. mobileLivesEl.textContent =
  255. String(lives);
  256. }
  257. }
  258. function updateBestDisplay() {
  259. if (bestEl) {
  260. bestEl.textContent =
  261. String(bestScore);
  262. }
  263. if (mobileBestEl) {
  264. mobileBestEl.textContent =
  265. String(bestScore);
  266. }
  267. }
  268. function setPauseButtonState() {
  269. if (btnPause) {
  270. btnPause.textContent =
  271. paused ? "▶" : "Ⅱ";
  272. }
  273. if (btnPauseMobile) {
  274. btnPauseMobile.textContent =
  275. paused
  276. ? window.APP_LOCALE === "en"
  277. ? "CONTINUE"
  278. : "CONTINUAR"
  279. : window.APP_LOCALE === "en"
  280. ? "PAUSE"
  281. : "PAUSA";
  282. }
  283. }
  284. function togglePause() {
  285. if (gameOver) {
  286. return;
  287. }
  288. if (!running && !paused) {
  289. startGame();
  290. return;
  291. }
  292. paused = !paused;
  293. running = !paused;
  294. if (paused) {
  295. const pauseDesktop =
  296. window.APP_LOCALE === "en"
  297. ? "PAUSED · Press <b>P</b> to continue"
  298. : "PAUSA · Pulsa <b>P</b> para continuar";
  299. showStartMessage(
  300. pauseDesktop,
  301. window.APP_LOCALE === "en"
  302. ? "PAUSED"
  303. : "PAUSA"
  304. );
  305. } else {
  306. startOverlay.style.display =
  307. "none";
  308. }
  309. setPauseButtonState();
  310. }
  311. function countPellets() {
  312. let count = 0;
  313. for (let y = 0; y < ROWS; y++) {
  314. for (let x = 0; x < COLS; x++) {
  315. if (
  316. MAP[y][x] === 2 ||
  317. MAP[y][x] === 3
  318. ) {
  319. count++;
  320. }
  321. }
  322. }
  323. return count;
  324. }
  325. /* ================= RANKING ================= */
  326. function renderRanking(rows) {
  327. if (!rankingPreview) {
  328. return;
  329. }
  330. rankingPreview.innerHTML = "";
  331. const top = Array.isArray(rows)
  332. ? rows.slice(0, 3)
  333. : [];
  334. if (!top.length) {
  335. const empty =
  336. document.createElement("li");
  337. empty.className =
  338. "ranking-empty";
  339. empty.textContent =
  340. window.APP_LOCALE === "en"
  341. ? "No scores yet"
  342. : "Aún no hay puntuaciones";
  343. rankingPreview.appendChild(
  344. empty
  345. );
  346. return;
  347. }
  348. top.forEach((row, index) => {
  349. const li =
  350. document.createElement("li");
  351. const position =
  352. document.createElement("span");
  353. const name =
  354. document.createElement("span");
  355. const points =
  356. document.createElement("span");
  357. position.className =
  358. "rank-position";
  359. name.className =
  360. "rank-name";
  361. points.className =
  362. "rank-score";
  363. position.textContent =
  364. String(index + 1);
  365. name.textContent =
  366. String(row.name || "Anon");
  367. points.textContent =
  368. String(Number(row.score) || 0);
  369. li.append(
  370. position,
  371. name,
  372. points
  373. );
  374. rankingPreview.appendChild(li);
  375. });
  376. }
  377. function loadRankingPreview() {
  378. const url =
  379. window.PACMAN_SCORE_TOP_URL;
  380. if (!url) {
  381. return;
  382. }
  383. fetch(url, {
  384. headers: {
  385. Accept: "application/json",
  386. },
  387. })
  388. .then((response) =>
  389. response.json()
  390. )
  391. .then((data) => {
  392. const rows =
  393. data &&
  394. Array.isArray(data.top)
  395. ? data.top
  396. : [];
  397. bestScore = rows.length
  398. ? Number(rows[0].score) || 0
  399. : 0;
  400. updateBestDisplay();
  401. renderRanking(rows);
  402. })
  403. .catch(() => {
  404. renderRanking([]);
  405. });
  406. }
  407. /* ================= ENTIDADES ================= */
  408. const pacman = {
  409. x: 0,
  410. y: 0,
  411. dir: {
  412. x: 0,
  413. y: 0,
  414. },
  415. nextDir: {
  416. x: 0,
  417. y: 0,
  418. },
  419. speed: 6.5,
  420. radius: 0.42,
  421. mouth: 0,
  422. };
  423. function makeGhost(
  424. name,
  425. color,
  426. tx,
  427. ty
  428. ) {
  429. const center =
  430. centerTile(tx, ty);
  431. return {
  432. name,
  433. x: center.x,
  434. y: center.y,
  435. dir: {
  436. x: 0,
  437. y: -1,
  438. },
  439. speed: 5.7,
  440. baseColor: color,
  441. frightened: false,
  442. eaten: false,
  443. radius: 0.42,
  444. };
  445. }
  446. const ghosts = [
  447. makeGhost(
  448. "blinky",
  449. "#ff3b3b",
  450. 14,
  451. 11
  452. ),
  453. makeGhost(
  454. "pinky",
  455. "#ff6bd6",
  456. 13,
  457. 14
  458. ),
  459. makeGhost(
  460. "inky",
  461. "#4de1ff",
  462. 14,
  463. 14
  464. ),
  465. makeGhost(
  466. "clyde",
  467. "#ffb84d",
  468. 15,
  469. 14
  470. ),
  471. ];
  472. /* ================= START / PAUSE ================= */
  473. function startGame() {
  474. if (!running && !gameOver) {
  475. paused = false;
  476. running = true;
  477. startOverlay.style.display =
  478. "none";
  479. setPauseButtonState();
  480. }
  481. }
  482. function showStartMessage(
  483. desktopMessage,
  484. mobileMessage
  485. ) {
  486. startOverlay.style.display =
  487. "grid";
  488. startOverlay.innerHTML =
  489. isMobile
  490. ? mobileMessage
  491. : desktopMessage;
  492. }
  493. /* ================= SPAWN SEGURO ================= */
  494. function findNearestWalkable(
  495. startTx,
  496. startTy
  497. ) {
  498. const queue = [
  499. {
  500. x: startTx,
  501. y: startTy,
  502. },
  503. ];
  504. const seen = new Set([
  505. `${startTx},${startTy}`,
  506. ]);
  507. const neighbours = [
  508. {
  509. x: 1,
  510. y: 0,
  511. },
  512. {
  513. x: -1,
  514. y: 0,
  515. },
  516. {
  517. x: 0,
  518. y: 1,
  519. },
  520. {
  521. x: 0,
  522. y: -1,
  523. },
  524. ];
  525. while (queue.length) {
  526. const current =
  527. queue.shift();
  528. if (
  529. isWalkable(
  530. current.x,
  531. current.y
  532. )
  533. ) {
  534. return current;
  535. }
  536. for (
  537. const neighbour
  538. of neighbours
  539. ) {
  540. const nx =
  541. current.x + neighbour.x;
  542. const ny =
  543. current.y + neighbour.y;
  544. if (
  545. nx < 0 ||
  546. nx >= COLS ||
  547. ny < 0 ||
  548. ny >= ROWS
  549. ) {
  550. continue;
  551. }
  552. const key =
  553. `${nx},${ny}`;
  554. if (seen.has(key)) {
  555. continue;
  556. }
  557. seen.add(key);
  558. queue.push({
  559. x: nx,
  560. y: ny,
  561. });
  562. }
  563. }
  564. return {
  565. x: 1,
  566. y: 1,
  567. };
  568. }
  569. function safeSpawnPacman() {
  570. const wanted = {
  571. tx: 14,
  572. ty: 21,
  573. };
  574. const validPosition =
  575. findNearestWalkable(
  576. wanted.tx,
  577. wanted.ty
  578. );
  579. const center =
  580. centerTile(
  581. validPosition.x,
  582. validPosition.y
  583. );
  584. pacman.x = center.x;
  585. pacman.y = center.y;
  586. pacman.dir = {
  587. x: 0,
  588. y: 0,
  589. };
  590. pacman.nextDir = {
  591. x: 0,
  592. y: 0,
  593. };
  594. }
  595. /* ================= INPUT ================= */
  596. window.addEventListener(
  597. "keydown",
  598. (event) => {
  599. const code = event.code;
  600. if (
  601. [
  602. "ArrowLeft",
  603. "ArrowRight",
  604. "ArrowUp",
  605. "ArrowDown",
  606. "Space",
  607. ].includes(code)
  608. ) {
  609. event.preventDefault();
  610. }
  611. if (code === "Space") {
  612. startGame();
  613. return;
  614. }
  615. if (code === "KeyP") {
  616. togglePause();
  617. return;
  618. }
  619. if (code === "KeyR") {
  620. resetGame();
  621. return;
  622. }
  623. if (
  624. code === "ArrowLeft" ||
  625. code === "KeyA"
  626. ) {
  627. pacman.nextDir =
  628. dirs.left;
  629. }
  630. if (
  631. code === "ArrowRight" ||
  632. code === "KeyD"
  633. ) {
  634. pacman.nextDir =
  635. dirs.right;
  636. }
  637. if (
  638. code === "ArrowUp" ||
  639. code === "KeyW"
  640. ) {
  641. pacman.nextDir =
  642. dirs.up;
  643. }
  644. if (
  645. code === "ArrowDown" ||
  646. code === "KeyS"
  647. ) {
  648. pacman.nextDir =
  649. dirs.down;
  650. }
  651. },
  652. {
  653. passive: false,
  654. }
  655. );
  656. btnStart?.addEventListener(
  657. "click",
  658. (event) => {
  659. event.preventDefault();
  660. startGame();
  661. }
  662. );
  663. btnPause?.addEventListener(
  664. "click",
  665. togglePause
  666. );
  667. btnPauseMobile?.addEventListener(
  668. "click",
  669. togglePause
  670. );
  671. btnRestart?.addEventListener(
  672. "click",
  673. resetGame
  674. );
  675. btnShowRanking?.addEventListener(
  676. "click",
  677. () => {
  678. window.GameScores?.showTop(
  679. "pacman"
  680. );
  681. }
  682. );
  683. btnRankingTop?.addEventListener(
  684. "click",
  685. () => {
  686. window.GameScores?.showTop(
  687. "pacman"
  688. );
  689. }
  690. );
  691. dpad?.addEventListener(
  692. "pointerdown",
  693. (event) => {
  694. const button =
  695. event.target.closest(
  696. "[data-dir]"
  697. );
  698. if (!button) {
  699. return;
  700. }
  701. event.preventDefault();
  702. const directionName =
  703. button.dataset.dir;
  704. if (dirs[directionName]) {
  705. pacman.nextDir =
  706. dirs[directionName];
  707. }
  708. },
  709. {
  710. passive: false,
  711. }
  712. );
  713. /* ================= LÓGICA PACMAN ================= */
  714. function updatePacman(dt) {
  715. if (nearCenter(pacman)) {
  716. snapCenter(pacman);
  717. const { tx, ty } =
  718. toTileCoord(
  719. pacman.x,
  720. pacman.y
  721. );
  722. const nextTx =
  723. tx + pacman.nextDir.x;
  724. const nextTy =
  725. ty + pacman.nextDir.y;
  726. if (
  727. isWalkable(
  728. nextTx,
  729. nextTy
  730. )
  731. ) {
  732. pacman.dir =
  733. pacman.nextDir;
  734. }
  735. const forwardTx =
  736. tx + pacman.dir.x;
  737. const forwardTy =
  738. ty + pacman.dir.y;
  739. if (
  740. !isWalkable(
  741. forwardTx,
  742. forwardTy
  743. )
  744. ) {
  745. pacman.dir = {
  746. x: 0,
  747. y: 0,
  748. };
  749. }
  750. }
  751. pacman.x +=
  752. pacman.dir.x *
  753. pacman.speed *
  754. dt;
  755. pacman.y +=
  756. pacman.dir.y *
  757. pacman.speed *
  758. dt;
  759. /*
  760. * Túnel horizontal.
  761. */
  762. if (pacman.x < -0.5) {
  763. pacman.x = COLS - 0.5;
  764. }
  765. if (pacman.x > COLS + 0.5) {
  766. pacman.x = -0.5;
  767. }
  768. if (
  769. pacman.dir.x ||
  770. pacman.dir.y
  771. ) {
  772. pacman.mouth += dt * 10;
  773. }
  774. const { tx, ty } =
  775. toTileCoord(
  776. pacman.x,
  777. pacman.y
  778. );
  779. const cell =
  780. tileAt(tx, ty);
  781. if (cell === 2) {
  782. MAP[ty][tx] = 0;
  783. score += 10;
  784. pelletsLeft--;
  785. updateScoreDisplay();
  786. } else if (cell === 3) {
  787. MAP[ty][tx] = 0;
  788. score += 50;
  789. pelletsLeft--;
  790. frightenedMs = 7000;
  791. ghosts.forEach((ghost) => {
  792. ghost.frightened = true;
  793. ghost.eaten = false;
  794. });
  795. updateScoreDisplay();
  796. }
  797. }
  798. /* ================= LÓGICA FANTASMAS ================= */
  799. function pathDistance(
  800. startX,
  801. startY,
  802. targetX,
  803. targetY
  804. ) {
  805. const queue = [
  806. {
  807. x: startX,
  808. y: startY,
  809. distance: 0,
  810. },
  811. ];
  812. const visited = new Set([
  813. `${startX},${startY}`,
  814. ]);
  815. let index = 0;
  816. while (index < queue.length) {
  817. const current =
  818. queue[index++];
  819. if (
  820. current.x === targetX &&
  821. current.y === targetY
  822. ) {
  823. return current.distance;
  824. }
  825. for (
  826. const direction
  827. of dirList
  828. ) {
  829. const nx =
  830. current.x + direction.x;
  831. const ny =
  832. current.y + direction.y;
  833. const key =
  834. `${nx},${ny}`;
  835. if (
  836. nx < 0 ||
  837. nx >= COLS ||
  838. ny < 0 ||
  839. ny >= ROWS ||
  840. visited.has(key) ||
  841. !isWalkable(nx, ny)
  842. ) {
  843. continue;
  844. }
  845. visited.add(key);
  846. queue.push({
  847. x: nx,
  848. y: ny,
  849. distance:
  850. current.distance + 1,
  851. });
  852. }
  853. }
  854. return Infinity;
  855. }
  856. function chooseGhostDir(ghost) {
  857. const { tx, ty } =
  858. toTileCoord(
  859. ghost.x,
  860. ghost.y
  861. );
  862. const candidates = [];
  863. for (
  864. const direction
  865. of dirList
  866. ) {
  867. const nx =
  868. tx + direction.x;
  869. const ny =
  870. ty + direction.y;
  871. if (!isWalkable(nx, ny)) {
  872. continue;
  873. }
  874. if (
  875. oppositeDir(
  876. direction,
  877. ghost.dir
  878. )
  879. ) {
  880. continue;
  881. }
  882. candidates.push(direction);
  883. }
  884. /*
  885. * Si no puede continuar,
  886. * puede dar la vuelta.
  887. */
  888. if (!candidates.length) {
  889. for (
  890. const direction
  891. of dirList
  892. ) {
  893. const nx =
  894. tx + direction.x;
  895. const ny =
  896. ty + direction.y;
  897. if (isWalkable(nx, ny)) {
  898. candidates.push(
  899. direction
  900. );
  901. }
  902. }
  903. }
  904. if (!candidates.length) {
  905. return ghost.dir;
  906. }
  907. /*
  908. * Movimiento aleatorio cuando
  909. * el fantasma está asustado.
  910. */
  911. if (ghost.frightened) {
  912. return candidates[
  913. Math.floor(
  914. Math.random() *
  915. candidates.length
  916. )
  917. ];
  918. }
  919. const pacmanTile =
  920. toTileCoord(
  921. pacman.x,
  922. pacman.y
  923. );
  924. let bestDirection =
  925. candidates[0];
  926. let bestDistance =
  927. Infinity;
  928. /*
  929. * Calcula el recorrido real.
  930. * Así los fantasmas encuentran
  931. * la salida de la casa.
  932. */
  933. for (
  934. const direction
  935. of candidates
  936. ) {
  937. const nx =
  938. tx + direction.x;
  939. const ny =
  940. ty + direction.y;
  941. const distance =
  942. pathDistance(
  943. nx,
  944. ny,
  945. pacmanTile.tx,
  946. pacmanTile.ty
  947. );
  948. if (
  949. distance <
  950. bestDistance
  951. ) {
  952. bestDistance =
  953. distance;
  954. bestDirection =
  955. direction;
  956. }
  957. }
  958. return bestDirection;
  959. }
  960. function updateGhost(
  961. ghost,
  962. dt
  963. ) {
  964. if (nearCenter(ghost)) {
  965. snapCenter(ghost);
  966. ghost.dir =
  967. chooseGhostDir(ghost);
  968. }
  969. ghost.x +=
  970. ghost.dir.x *
  971. ghost.speed *
  972. dt;
  973. ghost.y +=
  974. ghost.dir.y *
  975. ghost.speed *
  976. dt;
  977. /*
  978. * Túnel horizontal.
  979. */
  980. if (ghost.x < -0.5) {
  981. ghost.x = COLS - 0.5;
  982. }
  983. if (ghost.x > COLS + 0.5) {
  984. ghost.x = -0.5;
  985. }
  986. const { tx, ty } =
  987. toTileCoord(
  988. ghost.x,
  989. ghost.y
  990. );
  991. if (!isWalkable(tx, ty)) {
  992. ghost.x -=
  993. ghost.dir.x *
  994. ghost.speed *
  995. dt;
  996. ghost.y -=
  997. ghost.dir.y *
  998. ghost.speed *
  999. dt;
  1000. if (nearCenter(ghost)) {
  1001. ghost.dir =
  1002. chooseGhostDir(ghost);
  1003. }
  1004. }
  1005. }
  1006. /* ================= VIDAS Y COLISIONES ================= */
  1007. function getGhostPositions() {
  1008. return [
  1009. [14, 11],
  1010. [13, 14],
  1011. [14, 14],
  1012. [15, 14],
  1013. ];
  1014. }
  1015. function resetGhostPositions() {
  1016. const positions =
  1017. getGhostPositions();
  1018. ghosts.forEach(
  1019. (ghost, index) => {
  1020. const center =
  1021. centerTile(
  1022. positions[index][0],
  1023. positions[index][1]
  1024. );
  1025. ghost.x = center.x;
  1026. ghost.y = center.y;
  1027. ghost.dir = {
  1028. x: 0,
  1029. y: -1,
  1030. };
  1031. ghost.frightened = false;
  1032. ghost.eaten = false;
  1033. }
  1034. );
  1035. }
  1036. function loseLife() {
  1037. lives--;
  1038. updateLivesDisplay();
  1039. if (lives <= 0) {
  1040. gameOver = true;
  1041. running = false;
  1042. gameOverOverlay.style.display =
  1043. "grid";
  1044. if (window.GameScores) {
  1045. GameScores.submit(
  1046. "pacman",
  1047. score
  1048. );
  1049. }
  1050. return;
  1051. }
  1052. safeSpawnPacman();
  1053. resetGhostPositions();
  1054. frightenedMs = 0;
  1055. showStartMessage(
  1056. T.continueSpace,
  1057. T.continueBtn
  1058. );
  1059. }
  1060. function handleCollisions() {
  1061. for (const ghost of ghosts) {
  1062. const distance =
  1063. Math.sqrt(
  1064. dist2(
  1065. pacman.x,
  1066. pacman.y,
  1067. ghost.x,
  1068. ghost.y
  1069. )
  1070. );
  1071. if (distance < 0.5) {
  1072. if (
  1073. ghost.frightened &&
  1074. !ghost.eaten
  1075. ) {
  1076. ghost.eaten = true;
  1077. score += 200;
  1078. updateScoreDisplay();
  1079. const home =
  1080. centerTile(14, 14);
  1081. ghost.x = home.x;
  1082. ghost.y = home.y;
  1083. ghost.frightened = false;
  1084. } else if (
  1085. !ghost.frightened
  1086. ) {
  1087. loseLife();
  1088. break;
  1089. }
  1090. }
  1091. }
  1092. }
  1093. /* ================= DIBUJO ================= */
  1094. function roundRect(
  1095. x,
  1096. y,
  1097. width,
  1098. height,
  1099. radius,
  1100. fill = false,
  1101. stroke = false
  1102. ) {
  1103. const finalRadius =
  1104. Math.min(
  1105. radius,
  1106. width / 2,
  1107. height / 2
  1108. );
  1109. ctx.beginPath();
  1110. ctx.moveTo(
  1111. x + finalRadius,
  1112. y
  1113. );
  1114. ctx.arcTo(
  1115. x + width,
  1116. y,
  1117. x + width,
  1118. y + height,
  1119. finalRadius
  1120. );
  1121. ctx.arcTo(
  1122. x + width,
  1123. y + height,
  1124. x,
  1125. y + height,
  1126. finalRadius
  1127. );
  1128. ctx.arcTo(
  1129. x,
  1130. y + height,
  1131. x,
  1132. y,
  1133. finalRadius
  1134. );
  1135. ctx.arcTo(
  1136. x,
  1137. y,
  1138. x + width,
  1139. y,
  1140. finalRadius
  1141. );
  1142. ctx.closePath();
  1143. if (fill) {
  1144. ctx.fill();
  1145. }
  1146. if (stroke) {
  1147. ctx.stroke();
  1148. }
  1149. }
  1150. let animationTime = 0;
  1151. function drawMaze() {
  1152. const rect =
  1153. shell.getBoundingClientRect();
  1154. const width =
  1155. rect.width;
  1156. const height =
  1157. rect.height;
  1158. ctx.clearRect(
  1159. 0,
  1160. 0,
  1161. width,
  1162. height
  1163. );
  1164. ctx.fillStyle =
  1165. "#02040b";
  1166. ctx.fillRect(
  1167. 0,
  1168. 0,
  1169. width,
  1170. height
  1171. );
  1172. for (let y = 0; y < ROWS; y++) {
  1173. for (let x = 0; x < COLS; x++) {
  1174. const cell = MAP[y][x];
  1175. const px =
  1176. offsetX + x * TILE;
  1177. const py =
  1178. offsetY + y * TILE;
  1179. if (cell === 1) {
  1180. /*
  1181. * Fondo oscuro del muro.
  1182. */
  1183. ctx.fillStyle =
  1184. "#01030a";
  1185. ctx.fillRect(
  1186. px,
  1187. py,
  1188. TILE,
  1189. TILE
  1190. );
  1191. /*
  1192. * Contorno azul únicamente
  1193. * en los bordes del pasillo.
  1194. */
  1195. ctx.save();
  1196. ctx.beginPath();
  1197. ctx.strokeStyle =
  1198. "#075dff";
  1199. ctx.lineWidth =
  1200. Math.max(
  1201. 1.4,
  1202. TILE * 0.1
  1203. );
  1204. ctx.lineCap =
  1205. "round";
  1206. ctx.shadowColor =
  1207. "rgba(25,90,255,.9)";
  1208. ctx.shadowBlur =
  1209. TILE * 0.32;
  1210. if (
  1211. tileAt(x, y - 1) !== 1
  1212. ) {
  1213. ctx.moveTo(
  1214. px,
  1215. py
  1216. );
  1217. ctx.lineTo(
  1218. px + TILE,
  1219. py
  1220. );
  1221. }
  1222. if (
  1223. tileAt(x + 1, y) !== 1
  1224. ) {
  1225. ctx.moveTo(
  1226. px + TILE,
  1227. py
  1228. );
  1229. ctx.lineTo(
  1230. px + TILE,
  1231. py + TILE
  1232. );
  1233. }
  1234. if (
  1235. tileAt(x, y + 1) !== 1
  1236. ) {
  1237. ctx.moveTo(
  1238. px + TILE,
  1239. py + TILE
  1240. );
  1241. ctx.lineTo(
  1242. px,
  1243. py + TILE
  1244. );
  1245. }
  1246. if (
  1247. tileAt(x - 1, y) !== 1
  1248. ) {
  1249. ctx.moveTo(
  1250. px,
  1251. py + TILE
  1252. );
  1253. ctx.lineTo(
  1254. px,
  1255. py
  1256. );
  1257. }
  1258. ctx.stroke();
  1259. ctx.restore();
  1260. } else if (cell === 2) {
  1261. /*
  1262. * Punto normal.
  1263. */
  1264. ctx.save();
  1265. ctx.fillStyle =
  1266. "#ffd719";
  1267. ctx.shadowColor =
  1268. "rgba(255,215,25,.72)";
  1269. ctx.shadowBlur =
  1270. TILE * 0.35;
  1271. ctx.beginPath();
  1272. ctx.arc(
  1273. px + TILE / 2,
  1274. py + TILE / 2,
  1275. TILE * 0.09,
  1276. 0,
  1277. Math.PI * 2
  1278. );
  1279. ctx.fill();
  1280. ctx.restore();
  1281. } else if (cell === 3) {
  1282. /*
  1283. * Bola de poder.
  1284. */
  1285. ctx.save();
  1286. ctx.fillStyle =
  1287. "#ffd719";
  1288. ctx.shadowColor =
  1289. "rgba(255,215,25,.95)";
  1290. ctx.shadowBlur =
  1291. TILE * 0.6;
  1292. const pulse =
  1293. 1 +
  1294. 0.18 *
  1295. Math.sin(
  1296. animationTime * 6
  1297. );
  1298. ctx.beginPath();
  1299. ctx.arc(
  1300. px + TILE / 2,
  1301. py + TILE / 2,
  1302. TILE * 0.22 * pulse,
  1303. 0,
  1304. Math.PI * 2
  1305. );
  1306. ctx.fill();
  1307. ctx.restore();
  1308. }
  1309. }
  1310. }
  1311. /*
  1312. * Puerta rosa de la casa
  1313. * de los fantasmas.
  1314. */
  1315. ctx.save();
  1316. ctx.strokeStyle =
  1317. "#ff79d8";
  1318. ctx.lineWidth =
  1319. Math.max(
  1320. 2,
  1321. TILE * 0.14
  1322. );
  1323. ctx.shadowColor =
  1324. "rgba(255,90,215,.85)";
  1325. ctx.shadowBlur =
  1326. TILE * 0.35;
  1327. ctx.beginPath();
  1328. ctx.moveTo(
  1329. offsetX + 13 * TILE,
  1330. offsetY + 13 * TILE
  1331. );
  1332. ctx.lineTo(
  1333. offsetX + 15 * TILE,
  1334. offsetY + 13 * TILE
  1335. );
  1336. ctx.stroke();
  1337. ctx.restore();
  1338. }
  1339. function drawPacman() {
  1340. const x =
  1341. offsetX +
  1342. pacman.x * TILE;
  1343. const y =
  1344. offsetY +
  1345. pacman.y * TILE;
  1346. const mouthAngle =
  1347. 0.35 +
  1348. 0.2 *
  1349. Math.sin(
  1350. pacman.mouth
  1351. );
  1352. const direction =
  1353. pacman.dir;
  1354. let rotation = 0;
  1355. if (direction.x === 1) {
  1356. rotation = 0;
  1357. } else if (
  1358. direction.x === -1
  1359. ) {
  1360. rotation = Math.PI;
  1361. } else if (
  1362. direction.y === -1
  1363. ) {
  1364. rotation =
  1365. -Math.PI / 2;
  1366. } else if (
  1367. direction.y === 1
  1368. ) {
  1369. rotation =
  1370. Math.PI / 2;
  1371. }
  1372. const radius =
  1373. pacman.radius * TILE;
  1374. ctx.save();
  1375. ctx.translate(
  1376. x,
  1377. y
  1378. );
  1379. ctx.rotate(rotation);
  1380. ctx.fillStyle =
  1381. "#ffd400";
  1382. ctx.shadowColor =
  1383. "rgba(255,212,0,.7)";
  1384. ctx.shadowBlur =
  1385. TILE * 0.7;
  1386. ctx.beginPath();
  1387. ctx.moveTo(0, 0);
  1388. ctx.arc(
  1389. 0,
  1390. 0,
  1391. radius,
  1392. mouthAngle,
  1393. Math.PI * 2 -
  1394. mouthAngle
  1395. );
  1396. ctx.closePath();
  1397. ctx.fill();
  1398. ctx.restore();
  1399. }
  1400. function drawGhost(ghost) {
  1401. const x =
  1402. offsetX +
  1403. ghost.x * TILE;
  1404. const y =
  1405. offsetY +
  1406. ghost.y * TILE;
  1407. const radius =
  1408. ghost.radius * TILE;
  1409. const color =
  1410. ghost.frightened
  1411. ? "#2f7bff"
  1412. : ghost.baseColor;
  1413. ctx.save();
  1414. ctx.translate(
  1415. x,
  1416. y
  1417. );
  1418. ctx.fillStyle =
  1419. color;
  1420. ctx.shadowColor =
  1421. ghost.frightened
  1422. ? "rgba(90,170,255,.9)"
  1423. : "rgba(255,255,255,.25)";
  1424. ctx.shadowBlur =
  1425. TILE * 0.7;
  1426. ctx.beginPath();
  1427. ctx.arc(
  1428. 0,
  1429. -radius * 0.15,
  1430. radius,
  1431. Math.PI,
  1432. 0,
  1433. false
  1434. );
  1435. ctx.lineTo(
  1436. radius,
  1437. radius * 0.95
  1438. );
  1439. for (let i = 0; i < 4; i++) {
  1440. const waveX =
  1441. radius -
  1442. i *
  1443. (radius * 0.5);
  1444. ctx.quadraticCurveTo(
  1445. waveX -
  1446. radius * 0.25,
  1447. radius * 0.65,
  1448. waveX -
  1449. radius * 0.5,
  1450. radius * 0.95
  1451. );
  1452. }
  1453. ctx.closePath();
  1454. ctx.fill();
  1455. ctx.shadowBlur = 0;
  1456. ctx.fillStyle = "#fff";
  1457. ctx.beginPath();
  1458. ctx.arc(
  1459. -radius * 0.35,
  1460. -radius * 0.1,
  1461. radius * 0.22,
  1462. 0,
  1463. Math.PI * 2
  1464. );
  1465. ctx.fill();
  1466. ctx.beginPath();
  1467. ctx.arc(
  1468. radius * 0.35,
  1469. -radius * 0.1,
  1470. radius * 0.22,
  1471. 0,
  1472. Math.PI * 2
  1473. );
  1474. ctx.fill();
  1475. ctx.fillStyle = "#111";
  1476. const eyeX =
  1477. ghost.dir.x *
  1478. radius *
  1479. 0.07;
  1480. const eyeY =
  1481. ghost.dir.y *
  1482. radius *
  1483. 0.07;
  1484. ctx.beginPath();
  1485. ctx.arc(
  1486. -radius * 0.35 + eyeX,
  1487. -radius * 0.1 + eyeY,
  1488. radius * 0.1,
  1489. 0,
  1490. Math.PI * 2
  1491. );
  1492. ctx.fill();
  1493. ctx.beginPath();
  1494. ctx.arc(
  1495. radius * 0.35 + eyeX,
  1496. -radius * 0.1 + eyeY,
  1497. radius * 0.1,
  1498. 0,
  1499. Math.PI * 2
  1500. );
  1501. ctx.fill();
  1502. ctx.restore();
  1503. }
  1504. /* ================= RESET ================= */
  1505. function resetMap() {
  1506. for (let y = 0; y < ROWS; y++) {
  1507. for (let x = 0; x < COLS; x++) {
  1508. MAP[y][x] =
  1509. Number(
  1510. MAP_TEMPLATE[y][x]
  1511. );
  1512. }
  1513. }
  1514. pelletsLeft =
  1515. countPellets();
  1516. }
  1517. function resetGame() {
  1518. gameOver = false;
  1519. running = false;
  1520. paused = false;
  1521. score = 0;
  1522. lives = 3;
  1523. frightenedMs = 0;
  1524. updateScoreDisplay();
  1525. updateLivesDisplay();
  1526. setPauseButtonState();
  1527. resetMap();
  1528. safeSpawnPacman();
  1529. resetGhostPositions();
  1530. gameOverOverlay.style.display =
  1531. "none";
  1532. showStartMessage(
  1533. T.startSpace,
  1534. T.startBtn
  1535. );
  1536. }
  1537. /* ================= LOOP ================= */
  1538. let last =
  1539. performance.now();
  1540. function tick(now) {
  1541. const dt =
  1542. Math.min(
  1543. 0.03,
  1544. (now - last) / 1000
  1545. );
  1546. last = now;
  1547. animationTime += dt;
  1548. if (
  1549. running &&
  1550. !gameOver
  1551. ) {
  1552. if (frightenedMs > 0) {
  1553. frightenedMs -=
  1554. dt * 1000;
  1555. if (frightenedMs <= 0) {
  1556. ghosts.forEach(
  1557. (ghost) => {
  1558. ghost.frightened =
  1559. false;
  1560. }
  1561. );
  1562. }
  1563. }
  1564. updatePacman(dt);
  1565. ghosts.forEach(
  1566. (ghost) => {
  1567. updateGhost(
  1568. ghost,
  1569. dt
  1570. );
  1571. }
  1572. );
  1573. handleCollisions();
  1574. if (pelletsLeft <= 0) {
  1575. resetMap();
  1576. showStartMessage(
  1577. T.levelSpace,
  1578. T.levelBtn
  1579. );
  1580. running = false;
  1581. }
  1582. }
  1583. drawMaze();
  1584. drawPacman();
  1585. ghosts.forEach(
  1586. (ghost) => {
  1587. drawGhost(ghost);
  1588. }
  1589. );
  1590. requestAnimationFrame(
  1591. tick
  1592. );
  1593. }
  1594. /* ================= INIT ================= */
  1595. resizeCanvas();
  1596. loadRankingPreview();
  1597. resetGame();
  1598. requestAnimationFrame(
  1599. tick
  1600. );
  1601. })();