import React, { useEffect, useRef, useState } from "react";
// HarveyWillys Runner — prototipo tipo "dino game" con SPRITES reales
// AHORA: soporta prendas reales (PNG con fondo transparente)
// Cómo usar:
// 1) Pone tus PNGs en /public/sprites/{tops,bottoms,shoes}/...
// 2) Editá los arrays TOPS/BOTTOMS/SHOES para apuntar a los archivos.
// 3) Ideal: 160x160 px (o 128x128) en canvas simple, centrados, sin margen. Se escalan en runtime.
// 4) Si falta alguna imagen, el juego hace fallback a bloques de color.
const W = 800;
const H = 300;
const GROUND_Y = 250; // línea de suelo
const GRAVITY = 0.7;
const JUMP_VELOCITY = -12.5;
const PLAYER_X = 90;
// Catálogo de sprites (EDITABLE)
// Ruta relativa a /public. Podes cambiar nombres y sumar items.
const TOPS = [
{ name: "Remera blanca", color: "#f4f4f5", img: "/sprites/tops/remera_blanca.png" },
{ name: "Buzo negro", color: "#111827", img: "/sprites/tops/buzo_negro.png" },
{ name: "Camisa denim", color: "#3b82f6", img: "/sprites/tops/camisa_denim.png" },
{ name: "Campera bomber", color: "#22c55e", img: "/sprites/tops/campera_bomber.png" },
{ name: "Hoodie arena", color: "#d6d3d1", img: "/sprites/tops/hoodie_arena.png" },
];
const BOTTOMS = [
{ name: "Jean azul", color: "#1e3a8a", img: "/sprites/bottoms/jean_azul.png" },
{ name: "Cargo verde", color: "#065f46", img: "/sprites/bottoms/cargo_verde.png" },
{ name: "Chino negro", color: "#0f172a", img: "/sprites/bottoms/chino_negro.png" },
{ name: "Jogger gris", color: "#4b5563", img: "/sprites/bottoms/jogger_gris.png" },
];
const SHOES = [
{ name: "Zapatillas blancas", color: "#fafafa", img: "/sprites/shoes/zapas_blancas.png" },
{ name: "Zapatillas negras", color: "#111111", img: "/sprites/shoes/zapas_negras.png" },
{ name: "Zapatillas rojas", color: "#ef4444", img: "/sprites/shoes/zapas_rojas.png" },
];
function pickOutfit() {
const rand = (arr) => arr[Math.floor(Math.random() * arr.length)];
return {
top: rand(TOPS),
bottom: rand(BOTTOMS),
shoes: rand(SHOES),
};
}
function useRaf(callback, active) {
const rafRef = useRef();
const cbRef = useRef(callback);
cbRef.current = callback;
useEffect(() => {
if (!active) return;
let last = performance.now();
const loop = (t) => {
const dt = Math.min(32, t - last); // cap dt en 32ms
last = t;
cbRef.current(dt);
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [active]);
}
export default function HarveyWillysRunner() {
const canvasRef = useRef(null);
const imagesRef = useRef({}); // { src: HTMLImageElement }
const [assetsReady, setAssetsReady] = useState(false);
const [gameState, setGameState] = useState("idle"); // idle | playing | gameover
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(
() => Number(localStorage.getItem("hw_runner_hs") || 0)
);
const [outfit, setOutfit] = useState(pickOutfit);
// entidades del juego en refs (para evitar re-renders)
const playerRef = useRef({ y: GROUND_Y - 52, vy: 0, w: 36, h: 52 });
const obstaclesRef = useRef([]); // {x,y,w,h}
const speedRef = useRef(6);
const timeRef = useRef(0);
const spawnTimerRef = useRef(0);
// HiDPI scaling
useEffect(() => {
const canvas = canvasRef.current;
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + "px";
canvas.style.height = H + "px";
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}, []);
// Preload de sprites
useEffect(() => {
const all = [...TOPS, ...BOTTOMS, ...SHOES]
.map((i) => i.img)
.filter(Boolean);
if (all.length === 0) {
setAssetsReady(true);
return;
}
let loaded = 0;
const unique = Array.from(new Set(all));
unique.forEach((src) => {
const img = new Image();
img.decoding = "async";
img.onload = () => {
imagesRef.current[src] = img;
loaded += 1;
if (loaded === unique.length) setAssetsReady(true);
};
img.onerror = () => {
// si falla, seguimos con fallback
loaded += 1;
if (loaded === unique.length) setAssetsReady(true);
};
img.src = src;
});
}, []);
// input
useEffect(() => {
const onKey = (e) => {
if (e.repeat) return;
if (e.code === "Space" || e.code === "ArrowUp") {
e.preventDefault();
handleJump();
}
};
const onTouch = () => handleJump();
window.addEventListener("keydown", onKey);
window.addEventListener("touchstart", onTouch, { passive: true });
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("touchstart", onTouch);
};
}, [gameState]);
function resetGame(newOutfit = false) {
playerRef.current = { y: GROUND_Y - 52, vy: 0, w: 36, h: 52 };
obstaclesRef.current = [];
speedRef.current = 6;
timeRef.current = 0;
spawnTimerRef.current = 0;
setScore(0);
if (newOutfit) setOutfit(pickOutfit());
setGameState("idle");
}
function startGame() {
resetGame(false);
setGameState("playing");
}
function handleJump() {
if (gameState === "idle") {
if (!assetsReady) return; // esperar sprites
startGame();
return;
}
if (gameState !== "playing") return;
const p = playerRef.current;
const onGround = p.y >= GROUND_Y - p.h - 0.5;
if (onGround) p.vy = JUMP_VELOCITY;
}
function spawnObstacle() {
const h = 20 + Math.floor(Math.random() * 50); // 20..70
const w = 12 + Math.floor(Math.random() * 25); // 12..37
obstaclesRef.current.push({ x: W + 20, y: GROUND_Y - h, w, h });
}
function aabb(a, b) {
return (
PLAYER_X < b.x + b.w &&
PLAYER_X + playerRef.current.w > b.x &&
playerRef.current.y < b.y + b.h &&
playerRef.current.y + playerRef.current.h > b.y
);
}
// Utilidad de dibujo con fallback
function drawPiece(ctx, imgSrc, fallbackColor, x, y, w, h) {
const img = imagesRef.current[imgSrc];
if (img && img.complete) {
ctx.drawImage(img, x, y, w, h);
} else {
ctx.fillStyle = fallbackColor;
ctx.fillRect(x, y, w, h);
}
}
// game loop
useRaf((dt) => {
if (gameState !== "playing") return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
// update
const p = playerRef.current;
p.vy += GRAVITY;
p.y += p.vy;
if (p.y > GROUND_Y - p.h) {
p.y = GROUND_Y - p.h;
p.vy = 0;
}
const s = speedRef.current;
timeRef.current += dt;
spawnTimerRef.current += dt;
const spawnEvery = Math.max(700 - timeRef.current * 0.03, 380); // se hace más difícil
if (spawnTimerRef.current > spawnEvery) {
spawnObstacle();
spawnTimerRef.current = 0;
}
// mover obstáculos y colisiones
obstaclesRef.current.forEach((o) => (o.x -= s));
obstaclesRef.current = obstaclesRef.current.filter((o) => o.x + o.w > -10);
for (const o of obstaclesRef.current) {
if (aabb(p, o)) {
setGameState("gameover");
if (score > highScore) {
localStorage.setItem("hw_runner_hs", String(score));
setHighScore(score);
}
break;
}
}
// subir velocidad gradualmente
speedRef.current = Math.min(12, 6 + timeRef.current * 0.002);
setScore((s) => s + Math.floor(dt * 0.08));
// render
ctx.clearRect(0, 0, W, H);
// cielo
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, W, H);
// marca
ctx.fillStyle = "#0f172a";
ctx.font = "bold 16px Inter, ui-sans-serif, system-ui";
ctx.fillText("HARVEYWILLYS", 12, 24);
// suelo
ctx.strokeStyle = "#e5e7eb";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, GROUND_Y + 0.5);
ctx.lineTo(W, GROUND_Y + 0.5);
ctx.stroke();
// jugador (caja de destino por piezas)
const px = PLAYER_X;
const py = p.y;
const pw = p.w;
const ph = p.h;
// sombra
ctx.fillStyle = "rgba(0,0,0,0.08)";
const shadowScale = p.y < GROUND_Y - p.h ? 1 : 1.3;
ctx.beginPath();
ctx.ellipse(px + pw / 2, GROUND_Y + 6, 16 * shadowScale, 4 * shadowScale, 0, 0, Math.PI * 2);
ctx.fill();
// cabeza
ctx.fillStyle = "#f5d0c5";
ctx.fillRect(px + pw / 2 - 7, py - 8, 14, 14);
// DIMENSIONES de destino para sprites (ajustadas a la silueta base)
const TOP_BOX = { x: px - 2, y: py + 4, w: pw + 4, h: ph - 24 };
const BOTTOM_BOX = { x: px + 2, y: py + ph - 24, w: pw - 4, h: 18 };
const SHOES_BOX = { x: px + 2, y: py + ph - 7, w: pw - 4, h: 6 };
// prendas
drawPiece(ctx, outfit.top.img, outfit.top.color, TOP_BOX.x, TOP_BOX.y, TOP_BOX.w, TOP_BOX.h);
drawPiece(ctx, outfit.bottom.img, outfit.bottom.color, BOTTOM_BOX.x, BOTTOM_BOX.y, BOTTOM_BOX.w, BOTTOM_BOX.h);
drawPiece(ctx, outfit.shoes.img, outfit.shoes.color, SHOES_BOX.x, SHOES_BOX.y, SHOES_BOX.w, SHOES_BOX.h);
// obstáculos
for (const o of obstaclesRef.current) {
ctx.fillStyle = "#16a34a";
ctx.fillRect(o.x, o.y, o.w, o.h);
ctx.fillStyle = "rgba(255,255,255,0.35)";
ctx.fillRect(o.x + 2, o.y + 4, 2, Math.max(0, o.h - 6));
}
// UI: score
ctx.fillStyle = "#0f172a";
ctx.font = "bold 18px Inter, ui-sans-serif, system-ui";
ctx.textAlign = "right";
ctx.fillText(String(score).padStart(5, "0"), W - 16, 28);
ctx.font = "12px Inter, ui-sans-serif, system-ui";
ctx.fillText("REC " + String(highScore).padStart(5, "0"), W - 16, 44);
// outfit label
ctx.textAlign = "left";
ctx.font = "12px Inter, ui-sans-serif, system-ui";
ctx.fillStyle = "#334155";
ctx.fillText(
`${outfit.top.name} + ${outfit.bottom.name} + ${outfit.shoes.name}`,
12,
H - 12
);
}, gameState === "playing");
// overlays
const overlay = (
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
{gameState === "idle" && (
<div className="bg-white/90 backdrop-blur shadow-xl rounded-2xl p-6 text-center w-[min(92%,480px)]">
<h2 className="text-xl font-bold tracking-tight">HARVEYWILLYS RUNNER</h2>
<p className="text-sm text-slate-600 mt-1">Prototipo con sprites reales</p>
<ul className="text-sm text-slate-600 mt-4 list-disc list-inside text-left">
<li>SPACE o ↑ para saltar</li>
<li>Toque/click en móvil</li>
<li>Outfit random en cada sesión</li>
</ul>
{!assetsReady && (
<div className="mt-3 text-xs text-slate-500">Cargando sprites…</div>
)}
<div className="flex gap-2 mt-4 justify-center pointer-events-auto">
<button
onClick={() => assetsReady && setGameState("playing")}
className={`px-4 py-2 rounded-xl text-white ${assetsReady ? "bg-black hover:opacity-90 active:opacity-80" : "bg-slate-400 cursor-not-allowed"}`}
>
Jugar
</button>
<button
onClick={() => setOutfit(pic