diff --git a/README.md b/README.md index 5c4780a..94d8c88 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,56 @@ -# Welcome to React Router! +# PlayAI Pac‑Man (React + TypeScript) -A modern, production-ready template for building full-stack React applications using React Router. +This project started from the React Router full‑stack template and evolved into an experimental Pac‑Man clone featuring: -[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) +- Authentic ghost behaviors (scatter, chase, frightened, eyes) with personalities (Blinky, Pinky, Inky, Clyde) +- Internal adaptive ghost difficulty that subtly adjusts prediction and randomness as you advance +- Deterministic pellet generation with automatic removal of unreachable pellets +- Multi‑map level rotation +- Optional Pac‑Man AI Mode ("IA") where an adaptive algorithm takes control and learns over lives and levels +- Canvas rendering loop with smooth movement & mouth animation +- Fully in TypeScript with modular architecture (ghostAI, pacAI, learning, types, map, logic, render) -## Features +> The original template documentation is kept below for deployment & tooling reference. -- 🚀 Server-side rendering -- ⚡️ Hot Module Replacement (HMR) -- 📦 Asset bundling and optimization -- 🔄 Data loading and mutations +## Gameplay & Controls + +- Arrow keys: Manual Pac‑Man movement. +- R: Restart after game over (resets lives & level; keeps learned AI parameters unless you clear storage). +- IA Mode Button (🤖): Toggles autonomous Pac‑Man. When enabled, keyboard direction inputs are ignored except restart. + +## Pac‑Man AI Mode (IA) + +When you toggle IA mode: + +1. Each life starts with a fresh life metrics record (pellets, power pellets, time, deaths). +2. At intersections near the center of a cell, the AI scores possible directions using factors: + - Pellet density and power pellet proximity (pelletFocus + aggression) + - Ghost avoidance radius (avoidance) + - Exploration/randomness (exploration) +3. On death or level completion the life performance score adjusts four parameters: `aggression`, `exploration`, `pelletFocus`, `avoidance` (clamped 0–1). These persist across sessions. + +Persistence key in `localStorage`: + +``` +pacman_ai_params_v1 +``` + +Delete that key (or use DevTools > Application > Local Storage) to reset learned Pac‑Man AI back to defaults. + +## Ghost Adaptive Difficulty + +Ghost parameters (prediction horizon, chase weight, scatter factor, randomness) are internally adjusted after levels based on player performance. No UI panel is shown—adaptation is silent to keep the interface clean. + +## Project Scripts + +Standard Vite + React Router setup still applies: + +- � Server-side rendering +- ⚡️ HMR during development +- 📦 Optimized production build - 🔒 TypeScript by default -- 🎉 TailwindCSS for styling -- 📖 [React Router docs](https://reactrouter.com/) +- 🎉 TailwindCSS (baseline; you can layer custom CSS) +- 📖 React Router for routing & data loading ## Getting Started @@ -84,4 +122,22 @@ This template comes with [Tailwind CSS](https://tailwindcss.com/) already config --- -Built with ❤️ using React Router. +Built with ❤️ using React Router + a dash of retro arcade AI. + +## Troubleshooting + +| Issue | What to Try | +| ----------------------------------- | ----------------------------------------------------------------------------------------------- | +| Pac‑Man AI seems too strong or weak | Clear `pacman_ai_params_v1` in localStorage to reset learning. | +| IA button not visible | Ensure you're on the main game screen (`Game.tsx` rendered) and build assets updated. | +| Pellets appear unreachable | BFS sanitation should remove them; press R to rebuild level. If persists, clear cache & reload. | +| Ghosts feel static | Progress a few levels; adaptation increments after each completion. | + +## Next Ideas (Not Implemented Yet) + +- Parameter sliders to manually tune Pac‑Man AI +- Replay or visualization of AI decisions +- Difficulty presets (Casual / Classic / Hardcore) +- Analytics export for longer training sessions + +Feel free to fork and experiment! diff --git a/app/games/pacman/Game.tsx b/app/games/pacman/Game.tsx new file mode 100644 index 0000000..589518c --- /dev/null +++ b/app/games/pacman/Game.tsx @@ -0,0 +1,1552 @@ +import React, { useEffect, useRef, useState } from "react"; +import type { Dir, Cell, Pos, Pacman, Ghost, GameState } from "./types"; +import { + TILE, + DIRS, + ALL_DIRS, + REVERSE, + PAC_SPEED, + GHOST_SPEED, + FRIGHTENED_SPEED, + FRIGHTENED_MS, +} from "./constants"; +import { + MAP, + ROWS, + COLS, + replaceMapChar, + resetMapToOriginal, + autoFillPellets, +} from "./map"; +import { + isWall, + isPellet, + isPower, + nextCell, + manhattan, + choice, + cellCenter, + lerp, + validDirs, + canTurn, + chooseClosest, +} from "./logic"; +import { drawMap, drawPacman, drawGhost, overlay } from "./render"; +import { + initGhostBrain, + updateGhostBrain, + decideGhostDirection, +} from "./ghostAI"; +import type { GhostBrainState } from "./ghostAI"; +import { + ensureAdaptive, + ensureMetrics, + computePerformanceScore, + updateAdaptiveParams, +} from "./learning"; +// GA / pacAI removido (substituído exclusivamente por RL) +import { + ensureRL, + chooseRLAction, + selectDirFromAction, + updateQLearning, + computeReward, + endEpisode, + nearestPelletDistance, + minGhostDistance, +} from "./rl"; +import "../pacman/styles/index.css"; + +// Funções de lógica removidas (agora importadas de ./logic) + +export default function Game() { + const canvasRef = useRef(null); + const [running, setRunning] = useState(true); + const [score, setScore] = useState(0); + const [lives, setLives] = useState(3); + const [level, setLevel] = useState(1); + const [iaMode, setIaMode] = useState(false); // IA (Q-Learning) único modo automático + const iaModeRef = useRef(false); + useEffect(() => { + iaModeRef.current = iaMode; + }, [iaMode]); + const pelletsLeftRef = useRef(0); + const stateRef = useRef(null); + const lastLogRef = useRef>({}); + const lastDirRef = useRef>({}); + const lastModeRef = useRef>({}); + + useEffect(() => { + resetMapToOriginal(); + resetLevel(true); + const onKey = (e: KeyboardEvent) => { + const s = stateRef.current; + if (!s) return; + // Reiniciar mesmo em game over + if (e.key === "r" || e.key === "R") { + setScore(0); + setLives(3); + setLevel(1); + resetMapToOriginal(); + resetLevel(true); + setRunning(true); + return; + } + // Bloqueia demais controles se game over + if (s.gameOver) return; + if (e.key === "p" || e.key === "P") { + setRunning((v) => !v); + return; + } + if (iaModeRef.current) return; // ignora setas em modo IA + if (e.key === "ArrowLeft") s.pacman.nextDir = "left"; + else if (e.key === "ArrowRight") s.pacman.nextDir = "right"; + else if (e.key === "ArrowUp") s.pacman.nextDir = "up"; + else if (e.key === "ArrowDown") s.pacman.nextDir = "down"; + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + useEffect(() => { + let raf: number | null = null; + function frame() { + const s = stateRef.current; + if (!s) { + raf = requestAnimationFrame(frame); + return; + } + const now = performance.now(); + let dt = now - s.lastTime; + // clamp para evitar saltos em background + if (dt > 50) dt = 50; + s.lastTime = now; + if (running && !s.gameOver) { + updateGame(s, dt / 1000); + } + draw(canvasRef.current, s); + raf = requestAnimationFrame(frame); + } + raf = requestAnimationFrame(frame); + return () => { + if (raf !== null) cancelAnimationFrame(raf); + }; + }, [running]); + + function resetLevel(hard = false): void { + // Selecionar mapa conforme fase atual + resetMapToOriginal(level); + if (hard) { + // Recria pellets deterministicamente (fantasma da aleatoriedade removido) + autoFillPellets(); + } + // contar pellets sempre após possível refill + let pellets = 0; + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (isPellet(r, c) || isPower(r, c)) pellets++; + } + } + pelletsLeftRef.current = pellets; + + const pacSpawn: Cell = { r: 11, c: 9 }; + const ghostHome: Cell = { r: 8, c: 9 }; + + const pacman: Pacman = { + cell: { ...pacSpawn }, + dir: "left", + nextDir: "left", + progress: 0, + speedTiles: PAC_SPEED, + alive: true, + mouthPhase: 0, + }; + const ghosts: Ghost[] = [ + makeGhost("blinky", "#ff0000", ghostHome), + makeGhost("pinky", "#ffb8ff", { + r: ghostHome.r, + c: ghostHome.c - 1, + }), + makeGhost("inky", "#00ffff", { + r: ghostHome.r, + c: ghostHome.c + 1, + }), + makeGhost("clyde", "#ffb852", { + r: ghostHome.r - 1, + c: ghostHome.c, + }), + ]; + + const ghostBrains: Record = {}; + for (const g of ghosts) ghostBrains[g.name] = initGhostBrain(); + const adaptive = ensureAdaptive({} as any as GameState); // inicialização isolada + const metrics = ensureMetrics({ adaptive } as any as GameState, level); + + const st: GameState = { + pacman, + ghosts, + frightenedUntil: 0, + lastTime: performance.now(), + gameOver: false, + won: false, + ghostBrains, + adaptive, + metrics, + }; + stateRef.current = st; + } + + function makeGhost(name: string, color: string, cell: Cell): Ghost { + return { + name, + color, + cell: { ...cell }, + dir: "left", + progress: 0, + baseSpeed: GHOST_SPEED, + eaten: false, + eyesHome: false, + }; + } + + function updateGame(s: GameState, dt: number): void { + // Single Pac-Man only + const pac = s.pacman; // referência principal + + // Helper: BFS limitada para encontrar primeiro passo em direção a pellet/power pouco visitado + function planPathToFreshPellet(maxDepth = 40): Dir | null { + const rlAny = (s.rl as any) || {}; + const visitCounts = rlAny._visitCounts || {}; + type Node = { r: number; c: number; path: Dir[] }; + const start: Node = { r: pac.cell.r, c: pac.cell.c, path: [] }; + const queue: Node[] = [start]; + const seen = new Set([start.r + "," + start.c]); + let iters = 0; + while (queue.length && iters < 1200) { + // guarda de segurança + const cur = queue.shift()!; + iters++; + const depth = cur.path.length; + if (depth > maxDepth) continue; + const ch = MAP[cur.r][cur.c]; + const key = cur.r + "," + cur.c; + const visits = visitCounts[key] || 0; + // Critério de alvo: pellet/power nunca visitado ou muito pouco visitado + if ((ch === "." || ch === "o") && visits === 0 && depth > 0) { + return cur.path[0]; + } + // Expandir vizinhos + const dirsHere = validDirs({ r: cur.r, c: cur.c }); + for (const d of dirsHere) { + const nc = nextCell({ r: cur.r, c: cur.c }, d); + const nKey = nc.r + "," + nc.c; + if (seen.has(nKey)) continue; + if (isWall(nc.r, nc.c)) continue; + seen.add(nKey); + queue.push({ r: nc.r, c: nc.c, path: [...cur.path, d] }); + } + } + return null; + } + + // Helper: detecção de "canto" / confinamento (bounding box muito pequena recentemente) + function detectCornerTrap(): boolean { + const rlAny = (s.rl as any) || {}; + rlAny._recentPositions = rlAny._recentPositions || []; + rlAny._recentPositions.push(pac.cell.r + "," + pac.cell.c); + if (rlAny._recentPositions.length > 30) + rlAny._recentPositions.shift(); + const coords = rlAny._recentPositions.map((k: string) => + k.split(",").map(Number) + ); + let minR = Infinity, + maxR = -Infinity, + minC = Infinity, + maxC = -Infinity; + for (const [r, c] of coords) { + if (r < minR) minR = r; + if (r > maxR) maxR = r; + if (c < minC) minC = c; + if (c > maxC) maxC = c; + } + const width = maxC - minC + 1; + const height = maxR - minR + 1; + const uniq = new Set(rlAny._recentPositions).size; + // Pequena caixa e baixa diversidade => possível aprisionamento + return width + height <= 6 && uniq <= 0.6 * (width * height); + } + + // Atualiza fase da boca com velocidade constante (estilo clássico) + const MOUTH_ANIM_SPEED = 6; // ciclos por segundo + pac.mouthPhase += dt * MOUTH_ANIM_SPEED * Math.PI; // escala para radianos + if (pac.mouthPhase > Math.PI * 2) pac.mouthPhase -= Math.PI * 2; + + if (iaModeRef.current) { + // IA (Q-Learning) decide a cada célula / quando pode virar + if (pac.progress < 0.2) { + const rl = ensureRL(s); + const actionIdx = chooseRLAction(s); + let dir = selectDirFromAction(actionIdx); + if (s.rl) s.rl.prevActionIndex = actionIdx as any; + + // (Revertido) Sem override especial para perseguir fantasmas em modo frightened + + // ================= Anti Ping-Pong / Anti-Stuck ================= + const rlAny = s.rl as any; + rlAny._cellSequence = rlAny._cellSequence || []; + const cellKey = pac.cell.r + "," + pac.cell.c; + rlAny._cellSequence.push(cellKey); + if (rlAny._cellSequence.length > 12) + rlAny._cellSequence.shift(); + + // Detecta ping-pong simples ABAB nas últimas 4 posições + if (rlAny._cellSequence.length >= 4) { + const a = + rlAny._cellSequence[rlAny._cellSequence.length - 4]; + const b = + rlAny._cellSequence[rlAny._cellSequence.length - 3]; + const c = + rlAny._cellSequence[rlAny._cellSequence.length - 2]; + const d = + rlAny._cellSequence[rlAny._cellSequence.length - 1]; + const pingPong = a === c && b === d && a !== b; + if (pingPong) { + // Se existe mais de uma direção livre, evita reverter + const dirsLivres = validDirs(pac.cell); + const reverseDir = REVERSE[pac.dir]; + const alternativas = dirsLivres.filter( + (d) => d !== reverseDir + ); + if (alternativas.length > 0) { + // Escolhe alternativa com menor distância Manhattan a algum pellet (heurística simples) + let melhor = alternativas[0]; + let melhorScore = Infinity; + for (const cand of alternativas) { + const nc = nextCell(pac.cell, cand); + // heurística: distância para encontrar pellet mais próximo a partir da célula vizinha + // reutiliza nearestPelletDistance de forma aproximada: criar estado temporário + const tmpState: GameState = { + ...s, + pacman: { ...pac, cell: nc }, + } as GameState; + const dist = nearestPelletDistance( + tmpState, + 20 + ); + if (dist < melhorScore) { + melhorScore = dist; + melhor = cand; + } + } + dir = melhor; + } + } + } + + // Detecta ficar parado (mesma célula repetida várias vezes) e força rotação + if (rlAny._cellSequence.length >= 6) { + const last6 = rlAny._cellSequence.slice(-6); + const uniq = new Set(last6); + if (uniq.size <= 2) { + const dirsLivres = validDirs(pac.cell); + const reverseDir = REVERSE[pac.dir]; + const alternativas = dirsLivres.filter( + (d) => d !== reverseDir + ); + if (alternativas.length > 0) { + dir = + alternativas[ + Math.floor( + Math.random() * alternativas.length + ) + ]; + } + } + } + // Viés exploratório adicional: quando epsilon já está baixo, escolher direção que leva a célula menos visitada / com pellet + if (s.rl && s.rl.params.epsilon < 0.25) { + rlAny._visitCounts = + rlAny._visitCounts || Object.create(null); + const dirsValid = validDirs(pac.cell); + if (dirsValid.length > 1) { + const reverseDir = REVERSE[pac.dir]; + const candidates = dirsValid.filter( + (d) => d !== reverseDir + ); + const usable = candidates.length + ? candidates + : dirsValid; + let bestDir = dir; + let bestScore = -Infinity; + for (const cand of usable) { + const nc = nextCell(pac.cell, cand); + const key = nc.r + "," + nc.c; + const visits = rlAny._visitCounts[key] || 0; + // Peso maior para células nunca visitadas + let score = + visits === 0 ? 12 : 5 - Math.min(visits, 5); + const tileCh = MAP[nc.r][nc.c]; + if (tileCh === ".") score += visits === 0 ? 3.5 : 2; + else if (tileCh === "o") + score += visits === 0 ? 6 : 4; + // bônus leve se todas as adjacentes já visitadas (escassez local) + const adj = validDirs(nc); + let freshAdj = 0; + for (const ad of adj) { + const ac = nextCell(nc, ad); + if ( + !(rlAny._visitCounts[ac.r + "," + ac.c] > 0) + ) + freshAdj++; + } + score += freshAdj * 0.4; + score += Math.random() * 0.05; // pequena aleatoriedade + if (score > bestScore) { + bestScore = score; + bestDir = cand; + } + } + dir = bestDir; + } + } + + // Corner escape: se detectado confinamento, tentar planejar saída + if (detectCornerTrap()) { + const planned = planPathToFreshPellet(60); + if (planned) { + dir = planned; + (s.rl as any)._forcedEscape = true; + } + } else if ((s.rl as any)?._forcedEscape) { + // Reset flag quando já não confinado + (s.rl as any)._forcedEscape = false; + } + + // Planejamento BFS quando nenhuma opção realmente fresca imediata + if (s.rl) { + const dirsValid = validDirs(pac.cell); + const reverseDir = REVERSE[pac.dir]; + const usable = + dirsValid.filter((d) => d !== reverseDir) || dirsValid; + const allVisited = usable.every((d) => { + const nc = nextCell(pac.cell, d); + const key = nc.r + "," + nc.c; + return ( + rlAny._visitCounts && + rlAny._visitCounts[key] > 0 && + MAP[nc.r][nc.c] !== "." && + MAP[nc.r][nc.c] !== "o" + ); + }); + if (allVisited) { + const step = planPathToFreshPellet(); + if (step) dir = step; + } + } + // ================================================================ + + pac.nextDir = dir; + const tryCell = nextCell(pac.cell, dir); + if (!isWall(tryCell.r, tryCell.c)) pac.dir = dir; + if (rl) { + (rl as any)._lastPelletDist = nearestPelletDistance(s); + (rl as any)._lastGhostMinDist = minGhostDistance(s); + } + } + } + + // Buffer de virada: permitir virar próximo ao centro + const applyTurn = (pc: Pacman) => { + if ( + pc.nextDir && + canTurn(pc.cell, pc.nextDir) && + pc.progress < 0.15 + ) + pc.dir = pc.nextDir; + }; + applyTurn(pac); + + const moveAndConsume = (pc: Pacman) => { + const target = nextCell(pc.cell, pc.dir); + const blockedLocal = isWall(target.r, target.c); + if (!blockedLocal) { + pc.progress += pc.speedTiles * dt; + while (pc.progress >= 1) { + pc.progress -= 1; + pc.cell = nextCell(pc.cell, pc.dir); + const ch = MAP[pc.cell.r][pc.cell.c]; + let pelletEaten = false; + let powerEaten = false; + if (ch === "." || ch === "o") { + setScore((prev) => prev + (ch === "o" ? 50 : 10)); + pelletsLeftRef.current -= 1; + replaceMapChar(pc.cell.r, pc.cell.c, " "); + const st = stateRef.current; + if (st?.metrics) { + if (ch === "o") st.metrics.powerPelletsEaten += 1; + else st.metrics.pelletsEaten += 1; + } + pelletEaten = ch === "."; + powerEaten = ch === "o"; + if (ch === "o") { + s.frightenedUntil = + performance.now() + FRIGHTENED_MS; + s.ghosts.forEach((g) => (g.eaten = false)); + s.ghosts.forEach((g) => { + if (!g.eyesHome) { + g.dir = REVERSE[g.dir]; + const brain = s.ghostBrains?.[g.name]; + if (brain) brain.mode = "frightened"; + } + }); + } + if (pelletsLeftRef.current <= 0) { + s.won = true; + setLevel((l) => { + const next = l + 1; + if (s.metrics && s.adaptive) { + const perf = computePerformanceScore( + s.metrics + ); + const after = updateAdaptiveParams( + s.adaptive, + perf, + next + ); + s.adaptive = after; + } + resetMapToOriginal(next); + autoFillPellets(); + setTimeout(() => resetLevel(false), 50); + return next; + }); + if (iaModeRef.current) { + // Recompensa de vitória mais alta + updateQLearning(s, 50); + endEpisode(s, "win"); + if (s.rl) { + const rlAny = s.rl as any; + rlAny._visitCounts = {}; + rlAny._decayTick = 0; + } + } + return; + } + } + // Recompensa por passo se RL + if (iaModeRef.current && s.rl) { + // base reward (event based) + // Penalização de looping: se Pac-Man visitou a mesma célula X vezes seguidas sem colher pellet/power, aplicar custo. + const rlAny = s.rl as any; + rlAny._visitHist = rlAny._visitHist || []; + rlAny._starve = rlAny._starve || 0; + const cellKey = pac.cell.r + "," + pac.cell.c; + rlAny._visitHist.push(cellKey); + if (rlAny._visitHist.length > 30) + rlAny._visitHist.shift(); + // Contagem cumulativa de visitas para exploração + rlAny._visitCounts = + rlAny._visitCounts || Object.create(null); + rlAny._visitCounts[cellKey] = + (rlAny._visitCounts[cellKey] || 0) + 1; + let loopPenalty = 0; + if (!pelletEaten && !powerEaten) { + rlAny._starve += 1; + // contar últimas 10 visitas + const recent = rlAny._visitHist.slice(-10); + const repeats = recent.filter( + (v: string) => v === cellKey + ).length; + // se repetido muitas vezes recentemente, penaliza de forma crescente + if (repeats >= 4) { + loopPenalty = -0.05 * (repeats - 3); // -0.05, -0.10, ... + } + } else { + rlAny._starve = 0; // reset starvation quando coleta + // limpamos histórico parcial quando faz progresso + rlAny._visitHist = rlAny._visitHist.slice(-5); // mantém uma cauda pequena + } + let reward = computeReward(s, s, { + pelletEaten, + powerEaten, + loopPenalty, + starvationSteps: rlAny._starve, + }); + // Bônus de novidade focado em coleta (estrutura robusta): usar objeto simples para evitar inconsistências de serialização + if ( + !rlAny._collectedCells || + typeof rlAny._collectedCells !== "object" || + Array.isArray(rlAny._collectedCells) + ) { + rlAny._collectedCells = Object.create(null); // mapa plano + } + if (pelletEaten || powerEaten) { + if (!rlAny._collectedCells[cellKey]) { + rlAny._collectedCells[cellKey] = 1; + reward += 0.5; // bônus por remover pellet/power inédito + } + } + // Bônus de primeira visita (exploração pura) apenas se não coletou nada aqui + if (!pelletEaten && !powerEaten) { + const visits = rlAny._visitCounts[cellKey]; + if (visits === 1) reward += 0.2; // primeira vez (aumentado novamente) + } + // Penalidade leve por revisitar exageradamente sem coletar (um pouco menor) + const visitsNow = rlAny._visitCounts[cellKey]; + if (!pelletEaten && !powerEaten && visitsNow > 8) { + // Penalidade progressiva mais cedo e um pouco mais forte + reward += -0.012 * Math.min(visitsNow - 8, 14); // até ~ -0.168 + } + // Decaimento suave periódico das contagens para reabrir exploração depois de muito tempo + rlAny._decayTick = (rlAny._decayTick || 0) + 1; + if (rlAny._decayTick >= 120) { + // a cada ~120 passos + rlAny._decayTick = 0; + for (const k in rlAny._visitCounts) { + rlAny._visitCounts[k] *= 0.85; // reduz 15% + if (rlAny._visitCounts[k] < 0.5) + delete rlAny._visitCounts[k]; + } + } + // incremental shaping (pellet distance improvement) + const rl = s.rl as any; + const newPelletDist = nearestPelletDistance(s); + if (typeof rl._lastPelletDist === "number") { + const delta = rl._lastPelletDist - newPelletDist; + // bonus for getting closer, small penalty for going away + reward += delta * 0.06; // ligeiramente mais forte + } + rl._lastPelletDist = newPelletDist; + const newGhostDist = minGhostDistance(s); + if (typeof rl._lastGhostMinDist === "number") { + const gdelta = newGhostDist - rl._lastGhostMinDist; + // Revertido: apenas recompensa leve por aumentar distância mínima dos fantasmas + reward += gdelta * 0.015; + } + rl._lastGhostMinDist = newGhostDist; + updateQLearning(s, reward); + // Timeout de episódio se exceder 1000 passos sem vitória + if ( + s.rl && + s.rl.metrics.steps >= 1000 && + !s.won && + !s.gameOver + ) { + // penalidade explícita por estagnação longa + updateQLearning(s, -5); + endEpisode(s, "timeout"); + // Limpa históricos de exploração para novo episódio (novidade renovada) + if (s.rl) { + const rlX = s.rl as any; + rlX._visitHist = []; + rlX._visitCounts = {}; + rlX._cellSequence = []; + rlX._starve = 0; + rlX._decayTick = 0; + } + // Reinicia posições (mantém mapa/pellets restantes) para novo episódio de exploração + const pacSpawn: Cell = { r: 11, c: 9 }; + s.pacman = { + cell: { ...pacSpawn }, + dir: "left", + nextDir: "left", + progress: 0, + speedTiles: PAC_SPEED, + alive: true, + mouthPhase: 0, + }; + // Fantasmas reset básicos + s.ghosts = [ + { + name: "blinky", + color: "#ff0000", + cell: { r: 8, c: 9 }, + dir: "left", + progress: 0, + baseSpeed: GHOST_SPEED, + eaten: false, + eyesHome: false, + }, + { + name: "pinky", + color: "#ffb8ff", + cell: { r: 8, c: 8 }, + dir: "left", + progress: 0, + baseSpeed: GHOST_SPEED, + eaten: false, + eyesHome: false, + }, + { + name: "inky", + color: "#00ffff", + cell: { r: 8, c: 10 }, + dir: "left", + progress: 0, + baseSpeed: GHOST_SPEED, + eaten: false, + eyesHome: false, + }, + { + name: "clyde", + color: "#ffb852", + cell: { r: 7, c: 9 }, + dir: "left", + progress: 0, + baseSpeed: GHOST_SPEED, + eaten: false, + eyesHome: false, + }, + ]; + // Reinicia cérebros dos fantasmas + const ghostBrains: Record = + {}; + for (const g of s.ghosts) + ghostBrains[g.name] = initGhostBrain(); + s.ghostBrains = ghostBrains; + s.frightenedUntil = 0; + } + } + } + } else pc.progress = 0; + }; + moveAndConsume(pac); + + // (Decisão da IA já executada antes do bloco de movimento acima para reação mais rápida) + + // Atualizar Fantasmas (direção decide ao chegar no centro) + const frightened = performance.now() < s.frightenedUntil; + // Atualiza cérebro dos fantasmas (scatter/chase alternância) + for (const g of s.ghosts) { + const brain = s.ghostBrains?.[g.name]; + if (brain) { + // Se ainda frightened, manter modo frightened; senão atualizar ciclo + if (!frightened && brain.mode === "frightened") { + // Sai de frightened retomando ciclo + brain.mode = "scatter"; // reinicia em scatter para simplificar + brain.modeEndsAt = performance.now() + 3000; // pequeno período scatter antes de chase + } + if (!frightened) updateGhostBrain(brain, s); + } + } + for (const g of s.ghosts) { + const speedTiles = g.eyesHome + ? GHOST_SPEED + : frightened + ? FRIGHTENED_SPEED + : g.baseSpeed; + const targetG = nextCell(g.cell, g.dir); + const blockedG = isWall(targetG.r, targetG.c); + + // decisão de direção só quando no centro (progress ~ 0) + if (g.progress < 0.0001) { + const opts = validDirs(g.cell); + const brain = s.ghostBrains?.[g.name]; + if (brain) { + // Ajustar modo se olhos ou terminado frightened + if (g.eyesHome) brain.mode = "eyes"; + else if (frightened && !g.eaten) brain.mode = "frightened"; + else if (brain.mode === "frightened" && !frightened) + brain.mode = "scatter"; + const prevDir = g.dir; + g.dir = decideGhostDirection( + g, + brain, + pac, + s.ghosts, + opts, + s.adaptive + ); + // Throttled logging: only when direction or mode changes and >300ms since last log for this ghost + const now = performance.now(); + const lastT = lastLogRef.current[g.name] || 0; + const prevLoggedDir = lastDirRef.current[g.name]; + const prevLoggedMode = lastModeRef.current[g.name]; + if ( + (prevDir !== g.dir || prevLoggedMode !== brain.mode) && + now - lastT > 300 + ) { + // Derivar target para log (simplificado: rely on mode logic similar to decideGhostDirection) + let target: Cell; + if (brain.mode === "eyes") target = { r: 8, c: 9 }; + else if (brain.mode === "frightened") + target = g.cell; // random + else { + // recompute using chase/scatter helpers inline to avoid circular import; minimal duplication + if (brain.mode === "scatter") { + if (g.name === "blinky") + target = { r: 0, c: MAP[0].length - 1 }; + else if (g.name === "pinky") + target = { r: 0, c: 0 }; + else if (g.name === "inky") + target = { + r: MAP.length - 1, + c: MAP[0].length - 1, + }; + else target = { r: MAP.length - 1, c: 0 }; + } else { + // chase target approximation mirroring personality logic + if (g.name === "blinky") target = pac.cell; + else if (g.name === "pinky") { + target = pac.cell; + for (let i = 0; i < 4; i++) + target = nextCell(target, pac.dir); + } else if (g.name === "inky") { + let twoAhead = pac.cell; + for (let i = 0; i < 2; i++) + twoAhead = nextCell(twoAhead, pac.dir); + const blinky = s.ghosts.find( + (x) => x.name === "blinky" + ); + if (blinky) { + const vecR = twoAhead.r - blinky.cell.r; + const vecC = twoAhead.c - blinky.cell.c; + target = { + r: twoAhead.r + vecR, + c: twoAhead.c + vecC, + }; + } else target = twoAhead; + } else { + // clyde + const dist = + Math.abs(g.cell.r - pac.cell.r) + + Math.abs(g.cell.c - pac.cell.c); + if (dist > 8) target = pac.cell; + else target = { r: MAP.length - 1, c: 0 }; + } + } + } + // Distância Manhattan ao alvo (para chase/scatter) + const dist = + Math.abs(g.cell.r - target.r) + + Math.abs(g.cell.c - target.c); + // Razão textual + let razao: string; + if (brain.mode === "frightened") + razao = "Modo assustado: movimento aleatório"; + else if (brain.mode === "eyes") + razao = "Olhos retornando para casa"; + else if (brain.mode === "scatter") + razao = "Espalhar: indo para canto designado"; + else { + if (g.name === "blinky") + razao = "Perseguição: alvo = Pac-Man"; + else if (g.name === "pinky") + razao = "Perseguição: 4 células à frente"; + else if (g.name === "inky") + razao = "Perseguição: vetor duplo (Inky)"; + else + razao = + "Perseguição: alterna canto vs distância (Clyde)"; + } + // Tempo no modo (aprox: diferença desde última mudança de modo) + const modeChanged = prevLoggedMode !== brain.mode; + const elapsedModoMs = modeChanged + ? 0 + : Math.round(now - lastT); + const pelletsRestantes = pelletsLeftRef.current; + const cor = g.color; + console.log( + `%c[FANTASMA] nome=${g.name} cor=${cor} modo=${brain.mode} célula=(${g.cell.r},${g.cell.c}) alvo=(${target.r},${target.c}) dir=${g.dir} distância=${dist} msModo=${elapsedModoMs} pelletsRestantes=${pelletsRestantes} razão="${razao}"`, + `color:${cor}; font-weight:bold;` + ); + lastLogRef.current[g.name] = now; + lastDirRef.current[g.name] = g.dir; + lastModeRef.current[g.name] = brain.mode; + } + } else { + // fallback antigo + const filtered = opts.filter((d) => d !== REVERSE[g.dir]); + if (filtered.length) + g.dir = chooseClosest(filtered, g.cell, pac.cell); + } + } + + if (!blockedG) { + g.progress += speedTiles * dt; + while (g.progress >= 1) { + g.progress -= 1; + g.cell = nextCell(g.cell, g.dir); + // se chegou na casa com olhos, sai do modo eyesHome + if (g.eyesHome && g.cell.r === 8 && g.cell.c === 9) { + g.eyesHome = false; + g.dir = "left"; + const brain = s.ghostBrains?.[g.name]; + if (brain) + brain.mode = frightened ? "frightened" : "scatter"; + } + } + } else { + g.progress = 0; + // se bloqueado, tente outra direção válida + const opts = validDirs(g.cell); + const alt = opts.find((d) => d !== REVERSE[g.dir]); + if (alt) g.dir = alt; + } + } + + const collideAgent = (pc: Pacman) => { + const pPos = lerp( + cellCenter(pc.cell), + cellCenter(nextCell(pc.cell, pc.dir)), + pc.progress + ); + for (const g of s.ghosts) { + const gPos = lerp( + cellCenter(g.cell), + cellCenter(nextCell(g.cell, g.dir)), + g.progress + ); + const dx = pPos.x - gPos.x; + const dy = pPos.y - gPos.y; + const dist2 = dx * dx + dy * dy; + const collide = dist2 < (TILE * 0.45 + TILE * 0.4) ** 2; + if (!collide) continue; + const ghostIsFrightened = frightened && !g.eyesHome; + if (ghostIsFrightened && !g.eaten) { + g.eaten = true; + g.eyesHome = true; + setScore((prev) => prev + 200); + const st2 = stateRef.current; + if (st2?.metrics) st2.metrics.ghostsEaten += 1; + if (iaModeRef.current) + updateQLearning( + s, + computeReward(s, s, { ghostEaten: true }) + ); + } else if (!g.eyesHome) { + handleDeath(); + break; + } + } + }; + collideAgent(pac); + } + + function handleDeath(): void { + const s = stateRef.current; + if (!s) return; + s.pacman.alive = false; + setLives((v) => { + const nv = v - 1; + if (nv <= 0) { + s.gameOver = true; + } else { + // reset posições, manter pellets + const pacSpawn: Cell = { r: 11, c: 9 }; + // Mantenha o mesmo mapa da fase atual (não chamar resetMapToOriginal aqui) + s.pacman = { + cell: pacSpawn, + dir: "left", + nextDir: "left", + progress: 0, + speedTiles: PAC_SPEED, + alive: true, + mouthPhase: 0, + }; + s.ghosts = [ + makeGhost("blinky", "#ff0000", { r: 8, c: 9 }), + makeGhost("pinky", "#ffb8ff", { r: 8, c: 8 }), + makeGhost("inky", "#00ffff", { r: 8, c: 10 }), + makeGhost("clyde", "#ffb852", { r: 7, c: 9 }), + ]; + // reinit brains + const ghostBrains: Record = {}; + for (const g of s.ghosts) + ghostBrains[g.name] = initGhostBrain(); + s.ghostBrains = ghostBrains; + s.frightenedUntil = 0; + } + if (s.metrics) s.metrics.deaths += 1; + if (iaModeRef.current) { + updateQLearning(s, computeReward(s, s, { died: true })); + endEpisode(s, "death"); + // Limpa contagens de exploração para novo episódio + if (s.rl) { + const rlAny = s.rl as any; + rlAny._visitCounts = {}; + rlAny._decayTick = 0; + } + } + return nv; + }); + } + + function draw(canvas: HTMLCanvasElement | null, s: GameState | null): void { + const dpi = window.devicePixelRatio || 1; + const width = COLS * TILE; + const height = ROWS * TILE; + if (!canvas) return; + if (canvas.width !== width * dpi || canvas.height !== height * dpi) { + canvas.width = width * dpi; + canvas.height = height * dpi; + canvas.style.width = width + "px"; + canvas.style.height = height + "px"; + } + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpi, 0, 0, dpi, 0, 0); + ctx.clearRect(0, 0, width, height); + + // mapa + drawMap(ctx); + if (!s) return; + + // posições interpoladas para desenho suave + const pac = s.pacman; + const pacPos = lerp( + cellCenter(pac.cell), + cellCenter(nextCell(pac.cell, pac.dir)), + pac.progress + ); + if (iaModeRef.current) { + ctx.save(); + ctx.beginPath(); + ctx.strokeStyle = "rgba(0,255,200,0.65)"; + ctx.lineWidth = 4; + ctx.shadowColor = "rgba(0,255,200,0.8)"; + ctx.shadowBlur = 12; + ctx.arc(pacPos.x, pacPos.y, TILE * 0.65, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + drawPacman(ctx, pacPos, pac); + + const frightened = performance.now() < s.frightenedUntil; + for (const g of s.ghosts) { + const gPos = lerp( + cellCenter(g.cell), + cellCenter(nextCell(g.cell, g.dir)), + g.progress + ); + drawGhost(ctx, gPos, g, frightened && !g.eyesHome); + } + + if (iaModeRef.current && s.rl) { + // Badge IA + ctx.save(); + ctx.font = "bold 18px sans-serif"; + ctx.fillStyle = "rgba(255,220,0,0.9)"; + ctx.textAlign = "right"; + ctx.fillText("IA", width - 8, 22); + ctx.restore(); + // Métricas IA (RL interno) + { + const rl = s.rl; + const lines = [ + `IA Ep ${rl.metrics.episode} ε=${rl.params.epsilon.toFixed(2)}`, + `RewTot ${rl.metrics.totalReward.toFixed(1)} last ${rl.metrics.lastReward.toFixed(2)}`, + `AvgWin ${rl.metrics.avgRewardWindow.toFixed(2)} steps ${rl.metrics.steps}`, + ]; + ctx.save(); + ctx.font = "12px monospace"; + ctx.textAlign = "left"; + ctx.fillStyle = "rgba(255,220,0,0.8)"; + const longest = lines.reduce( + (m, l) => (l.length > m ? l.length : m), + 0 + ); + const boxW = longest * 7.2 + 12; + const boxH = lines.length * 14 + 8; + ctx.fillStyle = "rgba(0,0,0,0.45)"; + ctx.fillRect(width - boxW - 4, 4, boxW, boxH); + ctx.fillStyle = "rgba(255,220,0,0.85)"; + lines.forEach((ln, i) => + ctx.fillText(ln, width - boxW + 6, 20 + i * 14) + ); + ctx.restore(); + } + } + if (s.gameOver) { + overlay(ctx, "GAME OVER", "R para reiniciar"); + } else if (s.won) { + overlay(ctx, "Fase completa!", "Carregando próxima..."); + } + } + + // (Funções de desenho movidas para render.ts) + + return ( +
+
+
+ + Score + + {score} + +
+
+ + Vidas + +
+
+ + Fase + + {level} + +
+ + + +
+ +
+

Inteligência Artificial – Visão Geral

+
+
+

+ Pac-Man (Aprendizado por Reforço) +

+ {stateRef.current?.rl && ( +
+ {(() => { + const gs = stateRef.current!; + const rl = gs.rl!; + const pelletsLeft = pelletsLeftRef.current; + // distância heurística (salva em runtime via campos auxiliares) + const lastPelletDist = (rl as any) + ._lastPelletDist; + const lastGhostMin = (rl as any) + ._lastGhostMinDist; + return ( +
    +
  • + + Episódio + + + {rl.metrics.episode} + +
  • +
  • + ε + + {rl.params.epsilon.toFixed( + 3 + )} + +
  • +
  • + Steps + + {rl.metrics.steps} + +
  • +
  • + + Reward Tot + + + {rl.metrics.totalReward.toFixed( + 2 + )} + +
  • +
  • + + Reward Último + + + {rl.metrics.lastReward.toFixed( + 2 + )} + +
  • +
  • + + Reward Média (exp) + + + {rl.metrics.avgRewardWindow.toFixed( + 2 + )} + +
  • +
  • + + Pellets Rest. + + + {pelletsLeft} + +
  • +
  • + + Power Pellets + + + {gs.metrics + ?.powerPelletsEaten ?? + 0} + +
  • +
  • + + Pellets Colet. + + + {gs.metrics?.pelletsEaten ?? + 0} + +
  • +
  • + + Fantasmas Comidos + + + {gs.metrics?.ghostsEaten ?? + 0} + +
  • +
  • + + Mortes + + + {gs.metrics?.deaths ?? 0} + +
  • + {typeof lastPelletDist === + "number" && ( +
  • + + Dist. Próx. Pellet + + + {lastPelletDist} + +
  • + )} + {typeof lastGhostMin === + "number" && ( +
  • + + Dist. Fantasma Min + + + {lastGhostMin} + +
  • + )} +
+ ); + })()} +
+ )} +
    +
  • + Algoritmo: Q-Learning tabular + com política ε-greedy adaptativa. +
  • +
  • + Estado Compacto: posição, + máscara de direções livres, proximidade das 2 + ameaças mais próximas e se há pellet / power no + tile. +
  • +
  • + Ações: {`{←, →, ↑, ↓}`}{" "} + filtrando movimentos inválidos (paredes). +
  • +
  • + Recompensas Base: pellet +1.2, + power +10, fantasma +8, vitória +50, morte −30, + passo neutro −0.02. +
  • +
  • + Penalizações Dinâmicas:{" "} + starvation (cresce quadrático), looping + (detecção local), revisita excessiva escalonada. +
  • +
  • + Shaping Positivo: aproximação + de pellets, maior distância mínima de fantasmas, + bônus primeira visita e primeira coleta. +
  • +
  • + Exploração Direcionada: viés + forte para células nunca visitadas / com + pellets; decaimento periódico de contagens. +
  • +
  • + Planejamento Local: BFS + limitada para encontrar rota ao próximo pellet + fresco quando entorno está esgotado. +
  • +
  • + Escape de Confinamento:{" "} + detecção de "caixa" (bounding box reduzida + + baixa diversidade) gera rota forçada de saída. +
  • +
  • + Adaptação de ε: vitória acelera + redução; morte precoce ou timeout aumentam / + mantêm exploração controlada. +
  • +
+
+
+

+ Fantasmas (Personalidades & Modos) +

+ {stateRef.current && ( +
+
    + {stateRef.current.ghosts.map((g) => { + const brain = + stateRef.current!.ghostBrains?.[ + g.name + ]; + const pac = stateRef.current!.pacman; + const d = + Math.abs(g.cell.r - pac.cell.r) + + Math.abs(g.cell.c - pac.cell.c); + return ( +
  • +
  • + ); + })} +
+
+ )} +
    +
  • + Modos: scatter (cantinhos), + chase (perseguição), frightened (aleatório + lento), eyes (retorno à casa). +
  • +
  • + Ciclo Temporal: alternância + scatter↔chase baseada em tabela reduzida; + frightened interrompe ciclo temporariamente. +
  • +
  • + + + Blinky + + : + {" "} + mira diretamente o Pac-Man (pressão constante). +
  • +
  • + + + Pinky + + : + {" "} + projeta 4+ células à frente da direção atual do + Pac-Man. +
  • +
  • + + + Inky + + : + {" "} + vetoriza combinação de posição projetada do + Pac-Man e Blinky (efeito de cerco). +
  • +
  • + + + Clyde + + : + {" "} + persegue se distante; recua ao canto se perto + (<=8 células). +
  • +
  • + Frightened: movimento + pseudo-aleatório + velocidade reduzida; captura + gera olhos retornando. +
  • +
  • + Decisão em Interseções: evita + reversões gratuitas e escolhe direção que + minimiza distância ao alvo contextual. +
  • +
  • + Parâmetros Adaptativos: fatores + (ex.: scatterFactor, predictionAhead, + randomness) ajustam agressividade / + previsibilidade. +
  • +
  • + Separação de Responsabilidades:{" "} + Game controla transições especiais (frightened / + olhos); ghostAI gerencia alvo e direção. +
  • +
+
+
+

+ Racional & Estratégias +

+
    +
  • + Equilíbrio: forte incentivo a + novidade evita estagnação; penalidades calibram + risco vs. progresso. +
  • +
  • + Complexidade Controlada: estado + discretizado reduz explosão de Q-table mantendo + sinais suficientes. +
  • +
  • + Pathfinding Híbrido: Q-Learning + lida com longo prazo; BFS fornece + micro-planejamento imediato em zonas exploradas. +
  • +
  • + Anti-Stuck: múltiplas camadas + (ping-pong, baixa diversidade, starvation, + corner escape). +
  • +
  • + Transparência: painel expõe + heurísticas para análise acadêmica e ajustes + futuros. +
  • +
+
+
+

+ Trabalho acadêmico – Demonstração de integração de + Q-Learning com heurísticas de exploração e IA clássica + baseada em estados para inimigos. +

+
+
+ ); + + // respawnPellets substituída por lógica determinística via autoFillPellets (mantida apenas se necessário). + // function respawnPellets() {} + + // resetMapToOriginal importada de map.ts + + // Overlay genérico + // overlay movida para render.ts +} + +// Painel de IA removido a pedido do usuário; histórico permanece apenas interno. diff --git a/app/games/pacman/constants.ts b/app/games/pacman/constants.ts new file mode 100644 index 0000000..045b6d0 --- /dev/null +++ b/app/games/pacman/constants.ts @@ -0,0 +1,26 @@ +import type { Dir } from "./types"; + +export const TILE = 24; + +export const DIRS: Record< + Dir, + { r: number; c: number; vx: number; vy: number } +> = { + left: { r: 0, c: -1, vx: -1, vy: 0 }, + right: { r: 0, c: 1, vx: 1, vy: 0 }, + up: { r: -1, c: 0, vx: 0, vy: -1 }, + down: { r: 1, c: 0, vx: 0, vy: 1 }, +}; + +export const ALL_DIRS: Dir[] = ["left", "right", "up", "down"]; +export const REVERSE: Record = { + left: "right", + right: "left", + up: "down", + down: "up", +}; + +export const PAC_SPEED = 4.4; // células/seg +export const GHOST_SPEED = 4.0; // células/seg +export const FRIGHTENED_SPEED = 3.2; +export const FRIGHTENED_MS = 6000; diff --git a/app/games/pacman/ghostAI.ts b/app/games/pacman/ghostAI.ts new file mode 100644 index 0000000..9903231 --- /dev/null +++ b/app/games/pacman/ghostAI.ts @@ -0,0 +1,231 @@ +import type { + Ghost, + Pacman, + GameState, + Cell, + Dir, + GhostAdaptiveParams, +} from "./types"; +import { REVERSE, ALL_DIRS } from "./constants"; +import { nextCell } from "./logic"; +import { MAP } from "./map"; +import { chooseClosest } from "./logic"; + +// Modos globais: scatter (fantasmas miram cantos), chase (perseguem Pac-Man), frightened (já tratado em Game), eyes (voltando casa) +export type GhostMode = "scatter" | "chase" | "frightened" | "eyes"; + +export interface GhostBrainState { + mode: GhostMode; + // timestamps para alternância + modeEndsAt: number; // quando o modo atual termina (scatter <-> chase) + scatterIndex: number; // ciclo atual +} + +// Duração dos ciclos iniciais (simplificado) +// Arcade clássico tem uma sequência específica; aqui uma versão reduzida. +const BASE_SCATTER_CHASE_CYCLE: { scatter: number; chase: number }[] = [ + { scatter: 7000, chase: 20000 }, + { scatter: 7000, chase: 20000 }, + { scatter: 5000, chase: 20000 }, + { scatter: 5000, chase: 20000 }, // depois fica chase contínuo +]; + +// Cantos alvo para scatter +const SCATTER_TARGETS: Record = { + blinky: { r: 0, c: MAP[0].length - 1 }, // topo-direito + pinky: { r: 0, c: 0 }, // topo-esquerdo + inky: { r: MAP.length - 1, c: MAP[0].length - 1 }, // baixo-direito + clyde: { r: MAP.length - 1, c: 0 }, // baixo-esquerdo +}; + +// Calcula célula alvo no modo chase conforme personalidade +export function computeChaseTarget( + g: Ghost, + pac: Pacman, + ghosts: Ghost[] +): Cell { + switch (g.name) { + case "blinky": + return pac.cell; // direto + case "pinky": { + // quatro células à frente na direção de Pac-Man + const ahead = projectAhead(pac.cell, pac.dir, 4); + return ahead; + } + case "inky": { + // Inky usa posição 2 à frente do Pac-Man combinada com Blinky + const blinky = ghosts.find((x) => x.name === "blinky"); + const twoAhead = projectAhead(pac.cell, pac.dir, 2); + if (blinky) { + const vecR = twoAhead.r - blinky.cell.r; + const vecC = twoAhead.c - blinky.cell.c; + return { r: twoAhead.r + vecR, c: twoAhead.c + vecC }; // dobra vetor + } + return twoAhead; + } + case "clyde": { + // Se distante (>8) persegue; se perto retorna canto scatter + const dist = + Math.abs(g.cell.r - pac.cell.r) + + Math.abs(g.cell.c - pac.cell.c); + if (dist > 8) return pac.cell; + return SCATTER_TARGETS.clyde; + } + default: + return pac.cell; + } +} + +function projectAhead(start: Cell, dir: Dir, steps: number): Cell { + let cell = { ...start }; + for (let i = 0; i < steps; i++) { + cell = nextCell(cell, dir); + } + return cell; +} + +export function initGhostBrain(): GhostBrainState { + const now = performance.now(); + return { + mode: "scatter", + modeEndsAt: now + BASE_SCATTER_CHASE_CYCLE[0].scatter, + scatterIndex: 0, + }; +} + +export function updateGhostBrain( + brain: GhostBrainState, + state: GameState +): void { + const now = performance.now(); + if (brain.mode === "frightened" || brain.mode === "eyes") { + // controle desses modos fica em Game; aqui só mantemos até serem desativados + return; + } + const adaptive: GhostAdaptiveParams | undefined = state.adaptive; + const cycle = + BASE_SCATTER_CHASE_CYCLE[brain.scatterIndex] || + BASE_SCATTER_CHASE_CYCLE[BASE_SCATTER_CHASE_CYCLE.length - 1]; + const scatterScaled = adaptive + ? cycle.scatter * adaptive.scatterFactor + : cycle.scatter; + if (brain.scatterIndex >= BASE_SCATTER_CHASE_CYCLE.length) { + // chase permanente depois do último ciclo + brain.mode = "chase"; + return; + } + if (now >= brain.modeEndsAt) { + if (brain.mode === "scatter") { + brain.mode = "chase"; + brain.modeEndsAt = now + cycle.chase; // chase não escalado + } else if (brain.mode === "chase") { + brain.scatterIndex += 1; + brain.mode = "scatter"; + const nextBase = + BASE_SCATTER_CHASE_CYCLE[ + Math.min( + brain.scatterIndex, + BASE_SCATTER_CHASE_CYCLE.length - 1 + ) + ]; + const nextScatterScaled = adaptive + ? nextBase.scatter * adaptive.scatterFactor + : nextBase.scatter; + brain.modeEndsAt = now + nextScatterScaled; + } + } +} + +// Decide direção em interseção conforme modo/personalidade +export function decideGhostDirection( + g: Ghost, + brain: GhostBrainState, + pac: Pacman, + ghosts: Ghost[], + validDirs: Dir[], + adaptive?: GhostAdaptiveParams +): Dir { + // Remove reverso para evitar oscilar + const opts = validDirs.filter((d) => d !== REVERSE[g.dir]); + if (!opts.length) return g.dir; + + if (g.eyesHome) { + // Volta para casa (fixo) + return chooseClosest(opts, g.cell, { r: 8, c: 9 }); + } + + if (brain.mode === "frightened" && !g.eaten) { + // aleatório + return opts[Math.floor(Math.random() * opts.length)]; + } + + // adaptive é passado pelo chamador (Game) para evitar dependência direta de state + // Randomness oportunidade + if (adaptive && Math.random() < adaptive.randomness) { + return opts[Math.floor(Math.random() * opts.length)]; + } + let target: Cell; + if (brain.mode === "scatter") { + target = SCATTER_TARGETS[g.name] || pac.cell; + } else { + // Usar predictionAhead adaptativo para Pinky/Inky + target = computeChaseTargetAdaptive(g, pac, ghosts, adaptive); + } + // Escolha ponderada por chaseWeight: diminuir distância ao alvo + if (!adaptive || adaptive.chaseWeight === 1) { + return chooseClosest(opts, g.cell, target); + } + // Avalia cada opção + let best: Dir = opts[0]; + let bestScore = Infinity; + for (const d of opts) { + const nc = nextCell(g.cell, d); + const dist = Math.abs(nc.r - target.r) + Math.abs(nc.c - target.c); + const score = dist * (1 / adaptive.chaseWeight); // menor é melhor + if (score < bestScore) { + bestScore = score; + best = d; + } + } + return best; +} + +function computeChaseTargetAdaptive( + g: Ghost, + pac: Pacman, + ghosts: Ghost[], + adaptive?: GhostAdaptiveParams +): Cell { + // Reusa personalidade, mas ajusta projeções + const ahead = adaptive?.predictionAhead || 4; + switch (g.name) { + case "blinky": + return pac.cell; + case "pinky": { + return projectAhead(pac.cell, pac.dir, ahead); + } + case "inky": { + const blinky = ghosts.find((x) => x.name === "blinky"); + const twoAhead = projectAhead( + pac.cell, + pac.dir, + Math.max(2, Math.round(ahead / 2)) + ); + if (blinky) { + const vecR = twoAhead.r - blinky.cell.r; + const vecC = twoAhead.c - blinky.cell.c; + return { r: twoAhead.r + vecR, c: twoAhead.c + vecC }; + } + return twoAhead; + } + case "clyde": { + const dist = + Math.abs(g.cell.r - pac.cell.r) + + Math.abs(g.cell.c - pac.cell.c); + if (dist > 8) return pac.cell; + return SCATTER_TARGETS.clyde; + } + default: + return pac.cell; + } +} diff --git a/app/games/pacman/learning.ts b/app/games/pacman/learning.ts new file mode 100644 index 0000000..4862ee5 --- /dev/null +++ b/app/games/pacman/learning.ts @@ -0,0 +1,96 @@ +import type { GhostAdaptiveParams, GameMetrics, GameState } from "./types"; + +// Valor base referencial para scatter (usado nos cálculos de ajuste) +const BASE_SCATTER_MS = 7000; + +// Inicializa parâmetros adaptativos +export function initAdaptiveParams(): GhostAdaptiveParams { + return { + predictionAhead: 4, // parecido com Pinky clássico + chaseWeight: 1.05, + scatterFactor: 1.0, + randomness: 0.12, + levelLearned: 1, + }; +} + +export function initMetrics(level: number): GameMetrics { + return { + level, + startTime: performance.now(), + pelletsEaten: 0, + powerPelletsEaten: 0, + ghostsEaten: 0, + deaths: 0, + }; +} + +// Calcula pontuação de performance da fase +export function computePerformanceScore(m: GameMetrics): number { + const elapsedSec = (performance.now() - m.startTime) / 1000; + return ( + m.pelletsEaten * 1 + + m.powerPelletsEaten * 8 + + m.ghostsEaten * 15 - + m.deaths * 25 - + elapsedSec * 0.02 + ); +} + +export function clamp(v: number, min: number, max: number): number { + return v < min ? min : v > max ? max : v; +} + +// Atualiza parâmetros adaptativos sem histórico persistido +export function updateAdaptiveParams( + prev: GhostAdaptiveParams, + score: number, + level: number +): GhostAdaptiveParams { + const targetScore = 360; + const ratio = score / targetScore; + let { predictionAhead, chaseWeight, scatterFactor, randomness } = prev; + + if (ratio > 1.05) { + const boost = Math.min(ratio - 1.0, 0.6); + predictionAhead += 0.6 + boost * 0.6; + chaseWeight += 0.08 + boost * 0.12; + scatterFactor -= 0.12 + boost * 0.15; + randomness -= 0.05 + boost * 0.06; + } else if (ratio < 0.75) { + const drop = Math.min(1 - ratio, 0.6); + predictionAhead -= 0.45 + drop * 0.5; + chaseWeight -= 0.06 + drop * 0.09; + scatterFactor += 0.15 + drop * 0.2; + randomness += 0.06 + drop * 0.1; + } else { + predictionAhead += (ratio - 1) * 0.4; + chaseWeight += (ratio - 1) * 0.07; + scatterFactor += ratio < 1 ? (1 - ratio) * 0.1 : (ratio - 1) * -0.1; + } + + predictionAhead = clamp(predictionAhead, 2, 8); + chaseWeight = clamp(chaseWeight, 1.0, 1.6); + scatterFactor = clamp(scatterFactor, 0.2, 1.1); + randomness = clamp(randomness, 0.0, 0.5); + + return { + predictionAhead, + chaseWeight, + scatterFactor, + randomness, + levelLearned: level, + }; +} + +export function ensureAdaptive(state: GameState): GhostAdaptiveParams { + if (!state.adaptive) state.adaptive = initAdaptiveParams(); + return state.adaptive; +} + +export function ensureMetrics(state: GameState, level: number): GameMetrics { + if (!state.metrics || state.metrics.level !== level) { + state.metrics = initMetrics(level); + } + return state.metrics; +} diff --git a/app/games/pacman/logic.ts b/app/games/pacman/logic.ts new file mode 100644 index 0000000..48cebfe --- /dev/null +++ b/app/games/pacman/logic.ts @@ -0,0 +1,64 @@ +import type { Cell, Dir, Pos } from "./types"; +import { ALL_DIRS, DIRS } from "./constants"; +import { MAP, COLS, ROWS } from "./map"; + +export function isWall(r: number, c: number): boolean { + if (c < 0 || c >= COLS) return true; + if (r < 0 || r >= ROWS) return true; + return MAP[r][c] === "#"; +} +export function isPellet(r: number, c: number): boolean { + return MAP[r][c] === "."; +} +export function isPower(r: number, c: number): boolean { + return MAP[r][c] === "o"; +} +export function nextCell(cell: Cell, dir: Dir): Cell { + const d = DIRS[dir]; + let nr = cell.r + d.r; + let nc = cell.c + d.c; + if (nc < 0) nc = COLS - 1; // túnel horizontal + if (nc >= COLS) nc = 0; + return { r: nr, c: nc }; +} +export function manhattan(a: Cell, b: Cell): number { + return Math.abs(a.r - b.r) + Math.abs(a.c - b.c); +} +export function choice(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} +export function cellCenter(cell: Cell): Pos { + return { x: cell.c * 24 + 12, y: cell.r * 24 + 12 }; // usa TILE fixo local para evitar import circular +} +export function lerp(a: Pos, b: Pos, t: number): Pos { + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} +export function validDirs(cell: Cell): Dir[] { + const res: Dir[] = []; + for (const d of ALL_DIRS) { + const n = nextCell(cell, d); + if (!isWall(n.r, n.c)) res.push(d); + } + return res; +} +export function canTurn(cell: Cell, dir: Dir): boolean { + const n = nextCell(cell, dir); + return !isWall(n.r, n.c); +} +export function chooseClosest( + opts: Dir[], + fromCell: Cell, + targetCell: Cell +): Dir { + let best = opts[0]; + let bestDist = Infinity; + for (const d of opts) { + const n = nextCell(fromCell, d); + const dist = manhattan(n, targetCell); + if (dist < bestDist) { + bestDist = dist; + best = d; + } + } + return best; +} diff --git a/app/games/pacman/map.ts b/app/games/pacman/map.ts new file mode 100644 index 0000000..b7f3c5a --- /dev/null +++ b/app/games/pacman/map.ts @@ -0,0 +1,157 @@ +// Mapa e operações diretas de mutação do texto +// Mapa base original (imutável) +// Mapa base original (imutável) - variante 1 +const MAP_VARIANT_1: string[] = [ + "###################", + "#........#........#", + "#.####.#.#.#.####.#", + "#o# #.#.#.#.# #o#", + "#.####.#.#.#.####.#", + "#.................#", + "#####.#.#####.#.###", + "#####.#.#####.#.###", + "#.....#...GG..#...#", + "###.#.#######.#.###", + "#...#...###...#...#", + "#.#####.#.#.#####.#", + "#o....#.#.#.#....o#", + "#####.#.#.#.#.#####", + "#.....#.....#.....#", + "###################", +]; + +// Variante 2: corredores alternados, menos bloqueios centrais +const MAP_VARIANT_2: string[] = [ + "###################", + "#...#......#.....o#", + "#.##.#.####.#.##..#", + "#o..#.#....#.#..o.#", + "#.##.#.####.#.##..#", + "#.................#", + "###.#.###GG###.#.##", + "###.#.###GG###.#.##", + "#...#....##....#..#", + "#.#######..#######.#", + "#.....##....##.....#", + "#.###.#.####.#.###.#", + "#o..#.#....#.#..o.#", + "#.##.#.####.#.##..#", + "#.....#......#.....#", + "###################", +]; + +// Variante 3: mais áreas abertas e power pellets posicionados nos quatro cantos +const MAP_VARIANT_3: string[] = [ + "###################", + "#o.....##..##.....o#", + "#.###..#....#..###.#", + "#.....###..###.....#", + "#.###.#......#.###.#", + "#....#...##...#....#", + "###.#.###GG###.#.###", + "###.#.###GG###.#.###", + "#....#...##...#....#", + "#.###.#......#.###.#", + "#.....###..###.....#", + "#.###..#....#..###.#", + "#o.....##..##.....o#", + "#..................#", + "#........##........#", + "###################", +]; + +// Lista de variantes +const MAP_VARIANTS: string[][] = [MAP_VARIANT_1, MAP_VARIANT_2, MAP_VARIANT_3]; + +// Seleciona variante pelo nível (1-based) +export function selectMapForLevel(level: number): string[] { + const idx = (level - 1) % MAP_VARIANTS.length; + // retorna cópia nova para mutação + return MAP_VARIANTS[idx].map((r) => r); +} + +// Mapa mutável usado pelo jogo (inicialmente variante 1) +export let MAP: string[] = MAP_VARIANT_1.map((row) => row); + +export const ROWS = MAP.length; +export const COLS = MAP[0].length; + +export function replaceMapChar(r: number, c: number, toChar: string): void { + const row = MAP[r]; + MAP[r] = row.substring(0, c) + toChar + row.substring(c + 1); +} + +// Placeholder para futura restauração de estado original (se for clonado) +export function resetMapToOriginal(newLevel?: number): void { + // Se nível informado, troca variante conforme level + if (typeof newLevel === "number" && newLevel > 0) { + MAP = selectMapForLevel(newLevel); + } else { + MAP = MAP_VARIANT_1.map((r) => r); + } + // Após trocar o mapa, remover pellets inalcançáveis + sanitizePellets(); +} + +// Preenche automaticamente pellets em espaços vazios caminháveis (exceto dentro da casa dos fantasmas) +export function autoFillPellets(): void { + for (let r = 0; r < MAP.length; r++) { + for (let c = 0; c < MAP[0].length; c++) { + const ch = MAP[r][c]; + if (ch === " ") { + if (r === 8 && c >= 7 && c <= 11) continue; + MAP[r] = MAP[r].substring(0, c) + "." + MAP[r].substring(c + 1); + } + } + } + sanitizePellets(); +} + +// Remove pellets que não possuem caminho até o spawn de Pac-Man (assumido 11,9) +function sanitizePellets(): void { + const spawnR = 11; + const spawnC = 9; + if (spawnR < 0 || spawnR >= MAP.length) return; + if (spawnC < 0 || spawnC >= MAP[0].length) return; + const rows = MAP.length; + const cols = MAP[0].length; + const visited: boolean[][] = Array.from({ length: rows }, () => + Array(cols).fill(false) + ); + const q: [number, number][] = []; + if (MAP[spawnR][spawnC] !== "#") { + q.push([spawnR, spawnC]); + visited[spawnR][spawnC] = true; + } + const dirs = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + ]; + while (q.length) { + const [r, c] = q.shift()!; + for (const [dr, dc] of dirs) { + let nr = r + dr; + let nc = c + dc; + // túnel horizontal wrap + if (nc < 0) nc = cols - 1; + if (nc >= cols) nc = 0; + if (nr < 0 || nr >= rows) continue; + if (visited[nr][nc]) continue; + if (MAP[nr][nc] === "#") continue; + visited[nr][nc] = true; + q.push([nr, nc]); + } + } + // Convert pellets inalcançáveis em espaço vazio + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const ch = MAP[r][c]; + if ((ch === "." || ch === "o") && !visited[r][c]) { + // Se era power pellet, apenas remover (vira espaço) + MAP[r] = MAP[r].substring(0, c) + " " + MAP[r].substring(c + 1); + } + } + } +} diff --git a/app/games/pacman/render.ts b/app/games/pacman/render.ts new file mode 100644 index 0000000..f9e5708 --- /dev/null +++ b/app/games/pacman/render.ts @@ -0,0 +1,129 @@ +import type { Pacman, Ghost, Pos } from "./types"; +import { TILE } from "./constants"; +import { MAP, COLS, ROWS } from "./map"; + +// Desenho do mapa (paredes e pellets) +export function drawMap(ctx: CanvasRenderingContext2D): void { + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + const ch = MAP[r][c]; + const x = c * TILE; + const y = r * TILE; + if (ch === "#") { + ctx.fillStyle = "#0a1e7a"; + ctx.fillRect(x, y, TILE, TILE); + ctx.strokeStyle = "#1a4cff"; + ctx.strokeRect(x + 0.5, y + 0.5, TILE - 1, TILE - 1); + } else { + ctx.fillStyle = "#000"; + ctx.fillRect(x, y, TILE, TILE); + if (ch === ".") { + ctx.fillStyle = "#f7f7f7"; + ctx.beginPath(); + ctx.arc(x + TILE / 2, y + TILE / 2, 2.2, 0, Math.PI * 2); + ctx.fill(); + } else if (ch === "o") { + ctx.fillStyle = "#f7f7f7"; + ctx.beginPath(); + ctx.arc(x + TILE / 2, y + TILE / 2, 4, 0, Math.PI * 2); + ctx.fill(); + } + } + } + } +} + +export function drawPacman( + ctx: CanvasRenderingContext2D, + pos: Pos, + pac: Pacman +): void { + const dir = pac.dir; + const baseAngle = + dir === "left" + ? Math.PI + : dir === "right" + ? 0 + : dir === "up" + ? -Math.PI / 2 + : Math.PI / 2; + const cycle = (Math.sin(pac.mouthPhase) + 1) / 2; + const maxOpen = 0.7; + const mouthAngle = cycle * maxOpen; + const R = TILE * 0.5 - 1; + ctx.fillStyle = "#ffec00"; + ctx.beginPath(); + ctx.moveTo(pos.x, pos.y); + ctx.arc( + pos.x, + pos.y, + R, + baseAngle + mouthAngle, + baseAngle - mouthAngle, + true + ); + ctx.lineTo(pos.x, pos.y); + ctx.closePath(); + ctx.fill(); +} + +export function drawGhost( + ctx: CanvasRenderingContext2D, + pos: Pos, + g: Ghost, + frightened: boolean +): void { + const r = TILE * 0.45; + const baseY = pos.y + r * 0.6; + ctx.beginPath(); + ctx.fillStyle = g.eyesHome ? "#ffffff" : frightened ? "#1e90ff" : g.color; + ctx.moveTo(pos.x - r, baseY); + ctx.quadraticCurveTo(pos.x - r, pos.y - r, pos.x, pos.y - r); + ctx.quadraticCurveTo(pos.x + r, pos.y - r, pos.x + r, baseY); + for (let i = 3; i >= -3; i--) { + const dx = (i / 3) * r; + const dy = i % 2 === 0 ? 0 : 4; + ctx.lineTo(pos.x + dx, baseY + dy); + } + ctx.closePath(); + ctx.fill(); + // olhos + ctx.fillStyle = "#fff"; + const eyeDx = g.dir === "left" ? -4 : g.dir === "right" ? 4 : 0; + const eyeDy = g.dir === "up" ? -4 : g.dir === "down" ? 4 : 0; + for (const ex of [-6, 6]) { + ctx.beginPath(); + ctx.arc(pos.x + ex, pos.y - 2, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#1a2bff"; + ctx.beginPath(); + ctx.arc( + pos.x + ex + eyeDx / 2, + pos.y - 2 + eyeDy / 2, + 2, + 0, + Math.PI * 2 + ); + ctx.fill(); + ctx.fillStyle = "#fff"; + } +} + +export function overlay( + ctx: CanvasRenderingContext2D, + title: string, + subtitle: string +): void { + const dpi = window.devicePixelRatio || 1; + const w = ctx.canvas.width / dpi; + const h = ctx.canvas.height / dpi; + ctx.fillStyle = "rgba(0,0,0,0.55)"; + ctx.fillRect(0, 0, w, h); + ctx.fillStyle = "#ffd800"; + ctx.font = "bold 28px system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(title, w / 2, h / 2 - 8); + ctx.fillStyle = "#e6e8ef"; + ctx.font = "14px system-ui, sans-serif"; + ctx.fillText(subtitle, w / 2, h / 2 + 18); +} diff --git a/app/games/pacman/rl.ts b/app/games/pacman/rl.ts new file mode 100644 index 0000000..208da73 --- /dev/null +++ b/app/games/pacman/rl.ts @@ -0,0 +1,320 @@ +import type { GameState, PacmanRLState, PacmanRLParams } from "./types"; +import { manhattan, nextCell, validDirs, isWall } from "./logic"; +import { MAP } from "./map"; + +const LS_RL_KEY = "pacman_rl_state_v1"; + +export function initRLState(): PacmanRLState { + return { + qTable: {}, + params: { + epsilon: 0.25, + epsilonMin: 0.02, + epsilonDecay: 0.985, + alpha: 0.18, + gamma: 0.92, + }, + metrics: { + episode: 1, + totalReward: 0, + lastReward: 0, + avgRewardWindow: 0, + steps: 0, + }, + lastUpdateTime: performance.now(), + }; +} + +export function ensureRL(state: GameState): PacmanRLState { + if (!state.rl) { + state.rl = loadRLState() || initRLState(); + } + return state.rl; +} + +export function resetRLEpisodes(state: GameState) { + const rl = ensureRL(state); + rl.metrics.episode = 1; + rl.metrics.totalReward = 0; + rl.metrics.lastReward = 0; + rl.metrics.avgRewardWindow = 0; + rl.metrics.steps = 0; + rl.prevStateKey = undefined; + rl.prevActionIndex = undefined; + persistRLState(rl); +} + +// Encode state into compact string (coarse features to limit table size) +export function encodeState(state: GameState): string { + const pac = state.pacman; + const ghosts = state.ghosts; + // Distâncias Manhattan aos 2 fantasmas mais próximos (clamped) + const dists = ghosts + .map((g) => manhattan(pac.cell, g.cell)) + .sort((a, b) => a - b) + .slice(0, 2) + .map((d) => Math.min(d, 12)); + while (dists.length < 2) dists.push(12); + // Pellet / power pellet proximidade básica + const pelletHere = MAP[pac.cell.r][pac.cell.c] === "." ? 1 : 0; + const powerHere = MAP[pac.cell.r][pac.cell.c] === "o" ? 1 : 0; + // Direções livres (bitmask) + const dirs = validDirs(pac.cell); + const mask = ["left", "right", "up", "down"] + .map((d) => (dirs.includes(d as any) ? 1 : 0)) + .join(""); + return `${pac.cell.r},${pac.cell.c}|${dists[0]},${dists[1]}|${pelletHere}${powerHere}|${mask}`; +} + +function ensureQ(qTable: Record, key: string): number[] { + if (!qTable[key]) qTable[key] = [0, 0, 0, 0]; + return qTable[key]; +} + +const ACTIONS = ["left", "right", "up", "down"] as const; +export type ActionIndex = 0 | 1 | 2 | 3; + +export function chooseRLAction(state: GameState): ActionIndex { + const rl = ensureRL(state); + const key = encodeState(state); // estado corrente S_t + if (rl.prevStateKey == null) rl.prevStateKey = key; + const q = ensureQ(rl.qTable, key); + + // Filtrar apenas ações que levam a células não bloqueadas + const pac = state.pacman; + const DIR_INDEX: Record = { + left: 0, + right: 1, + up: 2, + down: 3, + }; + const candidates: ActionIndex[] = validDirs(pac.cell).map( + (d) => DIR_INDEX[d] + ); + // Se por algum motivo não houver candidatos (não deveria), retorna 0 + if (!candidates.length) return 0; + + // Exploração: escolher aleatório dentre válidos + if (Math.random() < rl.params.epsilon) { + const rnd = Math.floor(Math.random() * candidates.length); + return candidates[rnd]; + } + + // Exploração dirigida: melhor Q dentre candidatos + let best = candidates[0]; + for (let i = 1; i < candidates.length; i++) { + const idx = candidates[i]; + if (q[idx] > q[best]) best = idx; + } + return best; +} + +// Reward shaping heuristics +// Recompensa base remodelada: +// - Passo neutro: penalidade ligeiramente maior (-0.02) para evitar stalling. +// - Comer pellet normal: +1.2 +// - Comer power pellet: +10 +// - Comer fantasma: +8 +// - Morte: -30 (mais forte para desencorajar rotas arriscadas) +// - Vitória (fase completa) tratada externamente: +50 +// - Penalização de looping (quando detectada externamente): aplicada via reward adicional negativo. +export function computeReward( + prevState: GameState, + newState: GameState, + options: { + pelletEaten?: boolean; + powerEaten?: boolean; + ghostEaten?: boolean; + died?: boolean; + loopPenalty?: number; // valor negativo adicional se repetindo caminho + starvationSteps?: number; // passos consecutivos sem coletar + stepTime?: number; + } +): number { + let r = -0.02; // passo base + if (options.pelletEaten) r += 1.2; + if (options.powerEaten) r += 10.0; + if (options.ghostEaten) r += 8.0; + if (options.died) r -= 30.0; + if (options.loopPenalty) r += options.loopPenalty; // já negativo + if (!options.pelletEaten && !options.powerEaten) { + // Penalidade forte crescente (starvation) para encorajar avanço + const st = options.starvationSteps || 0; + if (st > 5) { + // escala quadrática suave após 5 passos sem coletar + const extra = -0.01 * (st - 5) * (st - 5); // -0.01, -0.04, -0.09, ... + r += extra * 4; // amplifica => -0.04, -0.16, -0.36 ... + } + if (st > 20) { + // endurecer ainda mais depois de 20 + r -= 1.0; // choque adicional + } + } + return r; +} + +export function updateQLearning(state: GameState, reward: number) { + const rl = ensureRL(state); + const newKey = encodeState(state); // S_{t+1} + const qNow = ensureQ(rl.qTable, newKey); + if (rl.prevStateKey != null && rl.prevActionIndex != null) { + const qPrev = ensureQ(rl.qTable, rl.prevStateKey); // S_t + const maxNext = Math.max(...qNow); + const a = rl.prevActionIndex; + qPrev[a] = + qPrev[a] + + rl.params.alpha * (reward + rl.params.gamma * maxNext - qPrev[a]); + } + // Não atualizamos prevStateKey aqui; isso acontecerá no próximo chooseRLAction se estiver undefined + rl.metrics.totalReward += reward; + rl.metrics.lastReward = reward; + rl.metrics.steps += 1; + rl.metrics.avgRewardWindow = + rl.metrics.avgRewardWindow * 0.98 + reward * 0.02; + persistRLState(rl); +} + +// ===== Reward shaping avançado util ===== +export function nearestPelletDistance( + state: GameState, + maxSearch = 30 +): number { + const start = state.pacman.cell; + const visited = new Set(); + const q: { r: number; c: number; d: number }[] = [ + { r: start.r, c: start.c, d: 0 }, + ]; + while (q.length) { + const { r, c, d } = q.shift()!; + const key = r + ":" + c; + if (visited.has(key)) continue; + visited.add(key); + if (MAP[r][c] === ".") return d; + if (d < maxSearch) { + for (const dir of [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + ]) { + let nr = r + dir[0]; + let nc = c + dir[1]; + if (nc < 0) nc = MAP[0].length - 1; + if (nc >= MAP[0].length) nc = 0; + if (nr < 0 || nr >= MAP.length) continue; + if (MAP[nr][nc] === "#") continue; + q.push({ r: nr, c: nc, d: d + 1 }); + } + } + } + return maxSearch + 1; // none +} + +export function minGhostDistance(state: GameState): number { + const pac = state.pacman.cell; + let best = Infinity; + for (const g of state.ghosts) { + const d = Math.abs(pac.r - g.cell.r) + Math.abs(pac.c - g.cell.c); + if (d < best) best = d; + } + return best === Infinity ? 0 : best; +} + +export type EpisodeEndReason = "win" | "death" | "timeout" | "other"; + +export function endEpisode( + state: GameState, + reason: EpisodeEndReason = "other" +) { + const rl = ensureRL(state); + // Adaptativo: ajusta fator conforme performance e razão + const baseDecay = rl.params.epsilonDecay; // 0.985 original + let effectiveDecay = baseDecay; + const avg = rl.metrics.avgRewardWindow; + const steps = rl.metrics.steps; + + if (reason === "win") { + // Acelera redução: aproxima do min mais rápido + effectiveDecay = 0.9; // queda forte neste episódio + } else if (reason === "death") { + // Se morte muito cedo (poucos passos) e reward ruim, manter ou até subir levemente para explorar + if (steps < 40 && avg < -0.2) { + // pequeno aumento exploratório (desfaz parte do decay) + rl.params.epsilon = Math.min(0.5, rl.params.epsilon * 1.08); + } else { + effectiveDecay = 0.992; // decai mais devagar para manter exploração + } + } else if (reason === "timeout") { + // Episódio longo sem ganhar: leve ajuste para mais exploração + rl.params.epsilon = Math.min(0.45, rl.params.epsilon * 1.02); + effectiveDecay = 0.99; + } else { + // default: usar base, mas se reward médio muito negativo, segura exploração + if (avg < -0.5) { + rl.params.epsilon = Math.min(0.6, rl.params.epsilon * 1.05); + effectiveDecay = 0.995; + } + } + + if (rl.params.epsilon > rl.params.epsilonMin) { + rl.params.epsilon = Math.max( + rl.params.epsilonMin, + rl.params.epsilon * effectiveDecay + ); + } + rl.metrics.episode += 1; + rl.metrics.totalReward = 0; + rl.metrics.steps = 0; + rl.metrics.lastReward = 0; + rl.prevStateKey = undefined; + rl.prevActionIndex = undefined; + persistRLState(rl); +} + +export function persistRLState(rl: PacmanRLState) { + try { + localStorage.setItem(LS_RL_KEY, JSON.stringify(rl)); + } catch {} +} + +export function loadRLState(): PacmanRLState | null { + try { + const raw = localStorage.getItem(LS_RL_KEY); + if (!raw) return null; + return JSON.parse(raw) as PacmanRLState; + } catch { + return null; + } +} + +export function selectDirFromAction( + idx: ActionIndex +): "left" | "right" | "up" | "down" { + return ACTIONS[idx]; +} + +// ===== Utilidades extras ===== +export function hardResetRL(state?: GameState) { + try { + localStorage.removeItem(LS_RL_KEY); + } catch {} + if (state) state.rl = initRLState(); +} + +export function exportRLState(state: GameState): string { + const rl = ensureRL(state); + return JSON.stringify(rl); +} + +export function importRLState(state: GameState, json: string): boolean { + try { + const parsed = JSON.parse(json); + if (!parsed || typeof parsed !== "object") return false; + if (!parsed.qTable || !parsed.params || !parsed.metrics) return false; + state.rl = parsed as PacmanRLState; + persistRLState(state.rl); + return true; + } catch { + return false; + } +} diff --git a/app/games/pacman/styles/index.css b/app/games/pacman/styles/index.css new file mode 100644 index 0000000..7b1b7fe --- /dev/null +++ b/app/games/pacman/styles/index.css @@ -0,0 +1,118 @@ +:root { + color-scheme: dark; + --bg: #06070d; + --fg: #e6e8ef; + --accent: #ffd800; + --accent-alt: #ff6b00; + --accent-pink: #ff3ea6; + --maze: #1a4cff; + --dot: #f7f7f7; + --panel: #121523; + --panel-border: #1f2335; + --panel-glow: 0 0 0 1px rgba(255,216,0,0.08),0 0 8px -2px rgba(255,216,0,.25),0 0 22px -4px rgba(255,216,0,.28); +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--fg); font-family: system-ui, Arial, sans-serif; } +.page { max-width: 900px; margin: 24px auto; padding: 0 16px; text-align: center; } +h1 { margin: 4px 0 12px; font-weight: 800; letter-spacing: 0.5px; } +canvas { background: #000; border: 2px solid #223; border-radius: 8px; box-shadow: 0 8px 30px rgba(0,0,0,0.35); } +.legend { margin: 12px 0 8px; opacity: 0.8; font-size: 14px; } +footer { margin-top: 12px; opacity: 0.6; font-size: 12px; } +.hud { position:relative; display:flex; flex-wrap:wrap; justify-content:center; gap:18px; margin:0 0 16px; padding:16px 20px 20px; border-radius:22px; background:linear-gradient(145deg,#161b29 0%,#0f1220 60%,#0d0f18 100%); box-shadow:0 14px 38px -14px rgba(0,0,0,.7), inset 0 0 0 1px #1c2234, inset 0 0 28px -8px rgba(255,216,0,.18); overflow:hidden; } +.hud:before, .hud:after { content:""; position:absolute; inset:0; pointer-events:none; border-radius:inherit; } +.hud:before { background: radial-gradient(circle at 18% 22%,rgba(255,216,0,0.18),transparent 60%), radial-gradient(circle at 82% 78%,rgba(255,62,166,0.16),transparent 55%); mix-blend-mode:screen; } +.hud:after { background: repeating-linear-gradient(-45deg,rgba(255,255,255,.04) 0 8px,transparent 8px 16px); opacity:.25; mask:linear-gradient(to bottom,rgba(0,0,0,.85),rgba(0,0,0,0.15)); } + +.stat, .badge { position:relative; background: var(--panel); border:1px solid var(--panel-border); padding:10px 16px 12px; border-radius:16px; display:flex; align-items:center; gap:10px; font-weight:600; font-size:13.5px; letter-spacing:.45px; line-height:1.05; isolation:isolate; min-width:130px; justify-content:center; transition: border-color .3s, box-shadow .35s, transform .25s; } +.stat:before { content:""; position:absolute; inset:0; border-radius:inherit; background:linear-gradient(120deg,rgba(255,216,0,.18),rgba(255,62,166,.18)); opacity:.55; mix-blend-mode:overlay; pointer-events:none; } +.stat:after { content:""; position:absolute; inset:0; border-radius:inherit; background:linear-gradient(190deg,rgba(255,255,255,.09),rgba(255,255,255,0)); pointer-events:none; } +.stat:hover { border-color:#313b56; box-shadow: var(--panel-glow); } +.stat:active { transform:translateY(2px); } +.stat-icon { font-size:18px; display:grid; place-items:center; width:34px; height:34px; border-radius:12px; background: radial-gradient(circle at 35% 35%,#fff6 0 18%,transparent 55%), linear-gradient(135deg,#242c45,#171c2d); position:relative; box-shadow: inset 0 0 0 1px #2a3148, 0 0 0 3px rgba(255,216,0,0.05), 0 4px 10px -3px rgba(0,0,0,.7); } +.stat-icon:before { content:""; position:absolute; inset:0; border-radius:inherit; background:linear-gradient(120deg,rgba(255,216,0,.22),rgba(255,62,166,.25)); mix-blend-mode:overlay; opacity:.85; } +.stat-icon:after { content:""; position:absolute; inset:0; border-radius:inherit; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); } + +/* palette accents by order */ +.hud .stat:nth-of-type(1) { --panel-border:#373000; } +.hud .stat:nth-of-type(2) { --panel-border:#3a1d2d; } +.hud .stat:nth-of-type(3) { --panel-border:#14304a; } + +/* fancy number styling */ +.stat strong { font-family:"IBM Plex Mono", monospace; font-size:18px; letter-spacing:1px; text-shadow:0 0 6px rgba(255,216,0,.4),0 0 18px -4px rgba(255,216,0,.35); position:relative; } +@media (max-width: 640px) { .stat, .badge { flex: 1 1 calc(50% - 20px); justify-content: center; } } + +.lives { display:flex; align-items:center; gap:8px; } +.life-icon { position:relative; width:24px; height:24px; font-size:0; } +.life-icon:before { content:""; position:absolute; inset:0; border-radius:50%; background: conic-gradient(from 30deg,var(--accent) 0 320deg, transparent 320deg 360deg); filter:drop-shadow(0 0 5px rgba(255,216,0,.55)); animation:chomp 1s infinite ease-in-out; } +.life-icon:nth-child(even):before { animation-delay:.33s; } +@keyframes chomp { 0%,100% { clip-path: polygon(0% 0%,100% 0%,100% 100%,0 100%); } 50% { clip-path: polygon(0% 0%,100% 25%,100% 75%,0 100%); } } + +/* buttons */ +.btn { appearance:none; border:1px solid #2d3550; background: linear-gradient(140deg,#151b2c 0%,#0c101b 55%); color:var(--fg); padding:9px 16px; border-radius:14px; cursor:pointer; font-weight:600; font-size:14px; letter-spacing:.5px; position:relative; overflow:hidden; transition: border-color .3s, background .35s, transform .16s, box-shadow .35s; display:inline-flex; align-items:center; gap:8px; } +.btn:before { content:""; position:absolute; top:-45%; left:-45%; width:190%; height:190%; background:radial-gradient(circle at 30% 30%,rgba(255,216,0,.28),transparent 60%), radial-gradient(circle at 70% 60%,rgba(255,62,166,.25),transparent 55%); opacity:.6; mix-blend-mode:overlay; transition: transform .7s ease; } +.btn:hover { border-color:#3c4664; box-shadow:0 0 0 1px rgba(255,216,0,.25),0 8px 22px -10px rgba(0,0,0,.75); } +.btn:hover:before { transform:scale(1.18) rotate(8deg); } +.btn:active { transform:translateY(2px); } +.btn:focus-visible { outline:2px solid var(--accent); outline-offset:4px; } +.btn .emoji { font-size:20px; filter:drop-shadow(0 0 4px rgba(255,216,0,.4)); } + +.btn.secondary { border-color:#3d2d40; background:linear-gradient(145deg,#221522,#120a14); } +.btn.secondary:hover { border-color:#52354f; box-shadow:0 0 0 1px rgba(255,62,166,.3),0 8px 22px -10px rgba(0,0,0,.75); } + +.btn.secondary { border-color: #394066; } +.btn.secondary:hover { border-color: #4b5480; } + +.divider { width: 100%; height: 2px; background: radial-gradient(circle at center, #20263a 0%, #121523 80%); margin-top: 4px; border-radius: 2px; } + +/* Optional subtle entrance animation */ +/* entrance animation */ +.hud { animation: hudFade .7s ease; } +@keyframes hudFade { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } } + +/* Accessibility prefers-reduced-motion respect */ +@media (prefers-reduced-motion: reduce) { + .life-icon { animation:none; } + .hud { animation:none; } + .btn { transition:none; } +} + +/* subtle shine for digits if needed */ +.stat strong:after { content:""; position:absolute; inset:0; background:linear-gradient(120deg,transparent 0 35%,rgba(255,255,255,.28) 48%,transparent 62%); opacity:0; mix-blend-mode:overlay; animation: shine 4s ease-in-out infinite; } +@keyframes shine { 0%,60%,100% { opacity:0; } 18% { opacity:.95; } } + +/* ================= AI EXPLANATION PANEL ================= */ +.ai-explain { width:100%; max-width:980px; margin:28px auto 40px; padding:28px clamp(18px,4vw,42px) 36px; background:linear-gradient(155deg,#101522 0%,#0b0e17 55%,#090b12 100%); border:1px solid #212838; border-radius:30px; position:relative; overflow:hidden; box-shadow:0 18px 46px -18px rgba(0,0,0,.75), inset 0 0 0 1px #1d2433, inset 0 0 32px -6px rgba(255,216,0,.18); } +.ai-explain:before, .ai-explain:after { content:""; position:absolute; inset:0; pointer-events:none; border-radius:inherit; } +.ai-explain:before { background:radial-gradient(circle at 22% 28%,rgba(255,216,0,0.17),transparent 60%), radial-gradient(circle at 78% 72%,rgba(255,62,166,0.16),transparent 58%); mix-blend-mode:screen; } +.ai-explain:after { background:repeating-linear-gradient(-42deg,rgba(255,255,255,.035) 0 9px,transparent 9px 18px); opacity:.22; mask:linear-gradient(to bottom,rgba(0,0,0,.9),rgba(0,0,0,.25)); } +.ai-explain h2 { margin:0 0 26px; font-size:clamp(1.35rem,2.2vw,2rem); letter-spacing:.8px; background:linear-gradient(90deg,var(--accent),var(--accent-pink)); -webkit-background-clip:text; background-clip:text; color:transparent; position:relative; } +.ai-grid { display:grid; gap:26px; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); position:relative; } +.ai-card { position:relative; background:#131927; border:1px solid #273045; border-radius:24px; padding:18px 20px 22px; display:flex; flex-direction:column; gap:14px; font-size:14px; line-height:1.25; isolation:isolate; box-shadow:0 6px 20px -10px rgba(0,0,0,.65), inset 0 0 0 1px rgba(255,255,255,.03); } +.ai-card:before { content:""; position:absolute; inset:0; border-radius:inherit; background:linear-gradient(135deg,rgba(255,216,0,.18),rgba(255,62,166,.18)); mix-blend-mode:overlay; opacity:.55; pointer-events:none; } +.ai-card h3 { margin:0; font-size:15px; letter-spacing:.9px; text-transform:uppercase; font-weight:700; background:linear-gradient(90deg,#ffd800,#ff8b2b,#ff3ea6); -webkit-background-clip:text; background-clip:text; color:transparent; filter:drop-shadow(0 0 4px rgba(255,216,0,.3)); } +.ai-points { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:6px; } +.ai-points li { position:relative; padding-left:20px; } +.ai-points li:before { content:""; position:absolute; left:0; top:8px; width:9px; height:9px; border-radius:50%; background:linear-gradient(120deg,var(--accent),var(--accent-pink)); box-shadow:0 0 6px -1px rgba(255,216,0,.8); } +.ai-points strong { color:var(--accent); font-weight:600; } +.ai-card:hover { border-color:#33425e; box-shadow:0 0 0 1px rgba(255,216,0,.25),0 10px 30px -18px rgba(0,0,0,.85); } +.ai-footnote { margin:30px 0 4px; font-size:12.5px; letter-spacing:.6px; opacity:.75; text-align:center; } +@media (max-width:660px){ .ai-explain { padding:24px 18px 30px; } .ai-card { padding:16px 16px 20px; } } + +/* Live metrics inside cards */ +.ai-live { background:linear-gradient(145deg,#101724,#0b1019); border:1px solid #252f44; border-radius:18px; padding:10px 14px 12px; margin-bottom:10px; position:relative; overflow:hidden; } +.ai-live:before { content:""; position:absolute; inset:0; background:radial-gradient(circle at 18% 24%,rgba(255,216,0,.14),transparent 60%), radial-gradient(circle at 82% 76%,rgba(255,62,166,.13),transparent 58%); mix-blend-mode:screen; pointer-events:none; } +.live-metrics, .ghost-status { list-style:none; margin:0; padding:0; display:grid; gap:6px; font-size:12px; font-family:"IBM Plex Mono",monospace; } +.live-metrics { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); } +.live-metrics li, .ghost-status li { display:flex; justify-content:space-between; gap:10px; align-items:center; background:#141c2a; padding:4px 8px 4px 10px; border-radius:10px; line-height:1.1; position:relative; } +.live-metrics li:before, .ghost-status li:before { content:""; position:absolute; inset:0; border-radius:inherit; background:linear-gradient(120deg,rgba(255,216,0,.12),rgba(255,62,166,.12)); mix-blend-mode:overlay; opacity:.5; pointer-events:none; } +.live-metrics .k { opacity:.75; font-weight:500; } +.live-metrics .v { font-weight:600; color:var(--accent); text-shadow:0 0 6px rgba(255,216,0,.3); } +.ghost-status { grid-template-columns: 1fr; font-size:12.5px; } +.ghost-line { gap:6px; } +.ghost-line .dot { width:10px; height:10px; border-radius:50%; box-shadow:0 0 6px 1px currentColor; } +.ghost-line .g-name { font-weight:600; text-transform:capitalize; letter-spacing:.5px; } +.ghost-line .g-mode { font-weight:500; opacity:.85; } +.ghost-line .g-dist, .ghost-line .g-cell { font-weight:500; font-size:11px; opacity:.65; } +.ghost-line .g-flag { background:linear-gradient(120deg,#ffd800,#ff3ea6); color:#111; font-size:10px; padding:2px 6px; border-radius:12px; font-weight:700; letter-spacing:.5px; text-transform:uppercase; } + + diff --git a/app/games/pacman/types.ts b/app/games/pacman/types.ts new file mode 100644 index 0000000..df0d99d --- /dev/null +++ b/app/games/pacman/types.ts @@ -0,0 +1,93 @@ +export type Dir = "left" | "right" | "up" | "down"; + +export interface Cell { + r: number; + c: number; +} +export interface Pos { + x: number; + y: number; +} + +export interface Pacman { + cell: Cell; // célula atual + dir: Dir; // direção atual + nextDir: Dir; // direção desejada (buffer) + progress: number; // progresso 0..1 para próxima célula + speedTiles: number; // velocidade em células/segundo + alive: boolean; + mouthPhase: number; // fase da animação da boca +} + +export interface Ghost { + name: string; + color: string; + cell: Cell; + dir: Dir; + progress: number; + baseSpeed: number; // células/segundo (base) + eaten: boolean; + eyesHome: boolean; // fantasma virou apenas olhos voltando para casa +} + +export interface GameState { + pacman: Pacman; + ghosts: Ghost[]; + frightenedUntil: number; + lastTime: number; + gameOver: boolean; + won: boolean; + ghostBrains?: Record; + adaptive?: GhostAdaptiveParams; + metrics?: GameMetrics; + // --- RL apenas --- + rl?: PacmanRLState; +} + +// Métricas de performance por fase para ajustar dificuldade +export interface GameMetrics { + level: number; // fase atual + startTime: number; // timestamp do início da fase + pelletsEaten: number; // pellets normais + powerPelletsEaten: number; // power pellets + ghostsEaten: number; // fantasmas comidos (em frightened) + deaths: number; // mortes do Pac-Man +} + +// Parâmetros adaptativos que influenciam decisão dos fantasmas +export interface GhostAdaptiveParams { + predictionAhead: number; // quantas células à frente Pinky / Inky projetam (2..8) + chaseWeight: number; // peso adicional para escolhas que aproximam do alvo (1..2) + scatterFactor: number; // fator multiplicador da duração de scatter (0.3..1) + randomness: number; // probabilidade de escolha aleatória em interseções (0..0.5) + levelLearned: number; // última fase em que atualização ocorreu +} + +// Parâmetros de IA do Pac-Man +// (Parâmetros e estruturas de GA removidos) + +// ================= Q-Learning ================= +export interface PacmanRLParams { + epsilon: number; // prob. de exploração atual + epsilonMin: number; // limite inferior + epsilonDecay: number; // multiplicador por episódio + alpha: number; // taxa de aprendizado + gamma: number; // fator de desconto +} + +export interface PacmanRLMetrics { + episode: number; // contador de episódios (vidas) + totalReward: number; // recompensa acumulada do episódio corrente + lastReward: number; // última recompensa aplicada + avgRewardWindow: number; // média móvel simples + steps: number; // passos no episódio +} + +export interface PacmanRLState { + qTable: Record; // stateKey -> [Q_left,Q_right,Q_up,Q_down] + params: PacmanRLParams; + metrics: PacmanRLMetrics; + prevStateKey?: string; + prevActionIndex?: number; // 0..3 + lastUpdateTime: number; // ms timestamp +} diff --git a/app/routes.ts b/app/routes.ts index d5dba77..34ce370 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -5,5 +5,6 @@ export default [ route("/games/pythonFlappyBird", "games/flappy-bird-py/PythonFlappyBird.tsx"), route("/games/pokemon", "games/pokemon/PokemonBattleAI.tsx"), route("/games/pong-game-js", "games/pong-game-js/page.tsx"), - route("/games/flappy-bird-js", "games/flappy-bird-js/componentes/Page.tsx") + route("/games/flappy-bird-js", "games/flappy-bird-js/componentes/Page.tsx"), + route("/games/pacman", "games/pacman/Game.tsx"), ] satisfies RouteConfig; diff --git a/app/routes/Home.tsx b/app/routes/Home.tsx index ccc1611..f79dfab 100644 --- a/app/routes/Home.tsx +++ b/app/routes/Home.tsx @@ -7,7 +7,10 @@ import flappyBird from "../games/flappy-bird-js/assets/blue-bird.png"; export function meta({}: Route.MetaArgs) { return [ { title: "GameHub - Jogos com Inteligência Artificial" }, - { name: "description", content: "Combinando Jogos com Inteligência Artificial" }, + { + name: "description", + content: "Combinando Jogos com Inteligência Artificial", + }, ]; } @@ -15,7 +18,7 @@ type GameLink = { name: string; description: string; image: string; - icon?: string; // Emoji fallback quando não houver imagem + icon?: string; // Emoji fallback quando não houver imagem url: string; }; @@ -23,13 +26,15 @@ type GameLink = { const games: GameLink[] = [ { name: "Flappy Bird AI", - description: "Uma IA em Python que aprende a jogar Flappy Bird sozinha!", + description: + "Uma IA em Python que aprende a jogar Flappy Bird sozinha!", image: "/PythonFlappyBird/FlappyBirdIcon.png", url: "/games/pythonFlappyBird", }, { name: "Pokemon Battle AI", - description: "Monte seu time e enfrente uma IA que evolui suas estratégias!", + description: + "Monte seu time e enfrente uma IA que evolui suas estratégias!", image: "/Pokemon/PokemonIcon.svg", url: "/games/pokemon", }, @@ -39,12 +44,19 @@ const games: GameLink[] = [ image: "app/games/pong-game-js/pong-icon.png", url: "/games/pong-game-js", }, - { + { name: "Flappy Bird", - description: "Um agente de Flappy Bird usando Perceptron e Algoritmo Genético", + description: + "Um agente de Flappy Bird usando Perceptron e Algoritmo Genético", image: flappyBird, url: "/games/flappy-bird-js", }, + { + name: "Pac Man", + description: "Jogue Pac Man", + image: "", + url: "/games/pacman", + }, ]; export default function Home() { @@ -52,7 +64,9 @@ export default function Home() {

🎮 GameHub

-

Combinando Jogos com Inteligência Artificial

+

+ Combinando Jogos com Inteligência Artificial +

Escolha um jogo e divirta-se!

@@ -61,9 +75,15 @@ export default function Home() {
{game.image ? ( - {game.name} + {game.name} ) : ( -
{game.icon || "🎮"}
+
+ {game.icon || "🎮"} +
)}

{game.name}

@@ -74,7 +94,8 @@ export default function Home() {