Meta Description" name="description" />
<!--
README + LICENSE (MIT) + RUN INSTRUCTIONS
MIT License
Copyright (c) 2025 Ignacio? / Generated
Permission is hereby granted, free of charge, to any person obtaining a copy
of this file and associated documentation files (the "Software"), to deal
in the Software without restriction...
NOTE: All in-file assets (icons/sounds) are placeholders generated by code.
No copyrighted assets included.
INSTRUCCIONES (qué probar primero en móvil)
1. Abrir este archivo index.html en Chrome móvil / Safari iOS / WebView.
2. Permitir vibración si el navegador lo solicita (opcional).
3. Tocar la tarjeta inferior (Resource) -> luego tocar una celda vacía en la grid para plantar.
4. Esperar a que la planta produzca recurso (Luz) -> tocar el recurso que cae para recoger.
5. Pulsar el botón ▶ (play) para comenzar oleadas o dejar que el tutorial lo haga.
6. Activar/Desactivar audio con el botón 🔈.
7. Mantén pulsado (long-press) sobre una planta para ver el menú contextual (quitar/upgrade/inspect).
8. Activar modo debug tocando 3 veces la esquina superior derecha.
9. Exportar progreso: botón ⚙ -> Export / Import.
PRUEBAS (pasos exactos para móvil)
1. Abrir index.html en Chrome móvil.
2. Tap en la tarjeta de planta "Generador" (Resource) → Tap en la casilla izquierda para colocarla.
3. Esperar 5s -> verá una gota de "Luz" caer → Tap sobre ella para recoger.
4. Tap en tarjeta Shooter -> Tap en casilla de otra fila -> comienza a disparar cuando zombies aparezcan.
5. Pulsar Mute icon (🔈) para silenciar. Se guarda la preferencia en localStorage.
6. Activar debug tocando 3 veces esquina superior derecha (ver FPS y entidades).
7. Exportar JSON desde settings -> descarga archivo -> recargar -> Import -> pegar JSON -> Restore.
NOTAS
- Diseñado para portrait; funciona en landscape (el grid recalcula).
- Si tu dispositivo es lento, aparece "Low Power" y efectos se reducen.
- Si WebAudio no está disponible, audio será silencioso (no falla).
- El archivo es autocontenido: HTML + CSS + JS. No depende de librerías externas.
-->
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/>
<title>MiniPVZ - Mobile Prototype</title>
<style>
/* ===== Global & layout ===== */
:root{
--bg:#0e1720;
--panel:#11161b;
--accent:#ffd54a;
--muted:#9aa5b1;
--danger:#ff6b6b;
--glass: rgba(255,255,255,0.04);
--ui-size: 48px;
--touch-target:44px;
--font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
}
html,body{height:100%;margin:0;background:var(--bg);font-family:var(--font-family);-webkit-tap-highlight-color:transparent}
#gameRoot{position:relative;width:100%;height:100vh;overflow:hidden;display:flex;flex-direction:column;align-items:stretch}
canvas#gameCanvas{display:block;width:100%;height:100%;background:linear-gradient(180deg,#0b1116,#0f1720)}
/* top HUD */
.hud{
position:absolute;left:8px;right:8px;top:8px;height:56px;display:flex;align-items:center;justify-content:space-between;gap:8px;pointer-events:none;
}
.hud-left,.hud-right{pointer-events:auto;display:flex;align-items:center;gap:8px}
.resourceBadge{display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:12px;background:var(--glass);backdrop-filter:blur(4px);box-shadow:0 1px 0 rgba(255,255,255,0.02)}
.resourceBadge .icon{width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-weight:700}
.btn{height:var(--ui-size);min-width:var(--ui-size);display:inline-flex;align-items:center;justify-content:center;border-radius:12px;background:var(--panel);color:#fff;font-size:18px;padding:6px 10px;box-shadow:0 2px 6px rgba(0,0,0,0.4);border:1px solid rgba(255,255,255,0.03);pointer-events:auto}
.small{height:40px;font-size:16px;border-radius:10px;padding:6px}
.plantPanel{position:absolute;left:8px;right:8px;bottom:8px;height:110px;display:flex;align-items:center;gap:8px;padding:10px;border-radius:12px;background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.0));backdrop-filter: blur(6px)}
.plantCard{width:86px;min-width:86px;height:86px;border-radius:10px;background:var(--panel);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:6px;box-shadow:0 6px 14px rgba(0,0,0,0.5);position:relative;overflow:hidden}
.plantCard.grayed{filter:grayscale(70%) brightness(0.7);opacity:0.6}
.plantCard .cost{font-size:12px;color:var(--muted)}
.tooltip{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);background:#0b1014;color:#fff;padding:6px 8px;border-radius:6px;font-size:12px;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.6)}
/* overlays and modal */
.modal{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#081016;color:#fff;padding:16px;border-radius:12px;z-index:20;box-shadow:0 10px 30px rgba(0,0,0,0.7)}
.overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;z-index:10}
.visible{display:flex}
/* small responsive tweaks */
@media (min-width:700px){
.plantCard{width:110px;min-width:110px;height:110px}
}
/* debug visuals toggle */
.debugBox{position:absolute;right:8px;top:8px;background:rgba(0,0,0,0.5);color:#fff;padding:8px;border-radius:8px;font-size:12px;pointer-events:none}
/* accessibility high contrast */
.highContrast *{outline: 1px solid rgba(255,255,255,0.02)}
/* ripple helper */
.ripple{position:absolute;border-radius:50%;transform:scale(0);opacity:0.6;pointer-events:none}
</style>
</head>
<body>
<div id="gameRoot" aria-label="MiniPVZ juego para móvil">
<canvas id="gameCanvas" role="img" aria-label="Lienzo del juego"></canvas>
<!-- HUD -->
<div class="hud" id="hud">
<div class="hud-left">
<div class="resourceBadge" id="resourceBadge" aria-live="polite">
<div class="icon" id="resourceIcon">☀️</div>
<div>
<div style="font-size:12px;color:var(--muted)">Luz</div>
<div id="resourceAmount" style="font-weight:700">0</div>
</div>
</div>
<div class="btn small" id="waveInfo" aria-label="Información de oleada">Wave 0 / 0</div>
</div>
<div class="hud-right">
<button class="btn" id="btnPause" aria-label="Pausa">⏸</button>
<button class="btn" id="btnMute" aria-label="Silenciar">🔈</button>
<button class="btn" id="btnSettings" aria-label="Configuración">⚙</button>
</div>
</div>
<!-- plant selection panel -->
<div class="plantPanel" id="plantPanel" aria-label="Panel de plantas" role="toolbar">
<!-- cards generated by UIManager -->
</div>
<!-- overlays -->
<div class="overlay" id="tutorialOverlay">
<div class="modal" id="tutorialModal" role="dialog" aria-label="Tutorial">
<h3 style="margin:0 0 8px 0">Tutorial rápido</h3>
<p style="margin:0 0 10px 0">Toca una tarjeta abajo → toca una casilla para plantar. Mantén pulsado para opciones.</p>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn" id="tutorialSkip">Omitir</button>
<button class="btn" id="tutorialStart">Empezar</button>
</div>
</div>
</div>
<div class="overlay" id="settingsOverlay">
<div class="modal" id="settingsModal" role="dialog" aria-label="Ajustes">
<h3 style="margin:0 0 6px 0">Ajustes</h3>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:8px">
<label><input type="checkbox" id="toggleLowPower"/> Modo baja potencia</label>
<label><input type="checkbox" id="toggleParticles" checked/> Partículas</label>
<label><input type="checkbox" id="toggleHighContrast"/> Alto contraste</label>
<div style="display:flex;gap:8px">
<button class="btn small" id="exportBtn">Exportar JSON</button>
<button class="btn small" id="importBtn">Importar JSON</button>
<button class="btn small" id="resetBtn" style="background:var(--danger)">Reset</button>
</div>
<textarea id="importArea" placeholder="Pega JSON aquí" style="width:260px;height:80px"></textarea>
<div style="text-align:right"><button class="btn" id="closeSettings">Cerrar</button></div>
</div>
</div>
</div>
<div class="debugBox" id="debugBox" style="display:none" aria-hidden="true"></div>
</div>
<script>
/* ============================================================
MiniPVZ - index.html
Author: Generated (ES6 modular inside single file)
License: MIT
------------------------------------------------------------
Structure (sections below):
1) Utility functions
2) InputTouch - gesture abstraction
3) SaveManager - localStorage export/import
4) AudioManager - WebAudio simple tones
5) Grid class
6) Entity base classes: Entity, Plant (+subclasses), Zombie (+subclasses), Projectile
7) WaveManager
8) UIManager
9) Game loop & Game class
10) Initialization and DOM hooks
============================================================ */
/* ===================== 1) Utilities ===================== */
const Util = {
now: ()=>performance.now(),
randRange(min,max){return Math.random()*(max-min)+min},
clamp(v,a,b){return Math.max(a,Math.min(b,v))},
lerp(a,b,t){return a+(b-a)*t},
downloadJSON(obj, filename='save.json'){
const blob = new Blob([JSON.stringify(obj, null, 2)], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
};
/* ===================== 2) InputTouch ===================== */
/* Handles tap, longpress, drag, double-tap, and debouncing */
class InputTouch {
constructor(el){
this.el = el;
this.longpressTime = 500; // ms
this.dragThreshold = 10; // px
this.active = false;
this.start = null;
this.lastTap = 0;
this.handlers = {}; // tap, longpress, dragstart, dragmove, dragend
this._bind();
}
on(evt, fn){ this.handlers[evt] = fn; }
_bind(){
const toPos = (e)=>{
const touch = e.touches ? e.touches[0] : e;
const rect = this.el.getBoundingClientRect();
return {x: (touch.clientX - rect.left), y: (touch.clientY - rect.top), rawX: touch.clientX, rawY: touch.clientY};
};
let longTimer=null;
const start = (e)=>{
if(e.touches && e.touches.length>1) return;
this.active = true;
this.start = toPos(e);
this.start.time = performance.now();
this.moved = false;
longTimer = setTimeout(()=>{
if(this.active && !this.moved){
this.handlers.longpress && this.handlers.longpress(this.start);
}
}, this.longpressTime);
this.handlers.touchstart && this.handlers.touchstart(this.start);
};
const move = (e)=>{
if(!this.active) return;
const pos = toPos(e);
const dx = pos.x - this.start.x, dy = pos.y - this.start.y;
if(Math.hypot(dx,dy) > this.dragThreshold){ this.moved = true; clearTimeout(longTimer); this.handlers.dragmove && this.handlers.dragmove(pos); }
};
const end = (e)=>{
if(!this.active) return;
clearTimeout(longTimer);
const pos = this.start; // final approximate
const elapsed = performance.now() - this.start.time;
if(!this.moved && elapsed < this.longpressTime){
// tap
this.handlers.tap && this.handlers.tap(pos);
} else if(this.moved){
this.handlers.dragend && this.handlers.dragend(pos);
}
this.handlers.touchend && this.handlers.touchend(pos);
this.active=false;
};
this.el.addEventListener('touchstart', start, {passive:true});
this.el.addEventListener('touchmove', move, {passive:true});
this.el.addEventListener('touchend', end, {passive:true});
// support mouse for desktop testing
this.el.addEventListener('mousedown', (e)=>{ e.preventDefault(); start(e); });
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', end);
}
}
/* ===================== 3) SaveManager ===================== */
class SaveManager {
constructor(key='miniPVZ_v1'){
this.key = key;
}
save(state){
try{
localStorage.setItem(this.key, JSON.stringify(state));
}catch(e){ console.warn('Save failed', e); }
}
load(){
try{
const raw = localStorage.getItem(this.key);
return raw ? JSON.parse(raw) : null;
}catch(e){ return null; }
}
export(){
const s = this.load() || {meta:'empty'};
Util.downloadJSON(s, 'miniPVZ_save.json');
}
import(json){
try{
const parsed = typeof json === 'string' ? JSON.parse(json) : json;
localStorage.setItem(this.key, JSON.stringify(parsed));
return true;
}catch(e){ return false; }
}
reset(){
localStorage.removeItem(this.key);
}
}
/* ===================== 4) AudioManager ===================== */
/* Lightweight wrapper around WebAudio - uses simple tones & envelopes */
class AudioManager {
constructor(){
this.enabled = true;
this.ctx = null;
this.masterGain = null;
this.init();
}
init(){
try{
const Ctx = window.AudioContext || window.webkitAudioContext;
this.ctx = new Ctx();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.9;
this.masterGain.connect(this.ctx.destination);
}catch(e){ this.ctx = null; console.warn('WebAudio not available'); }
}
toggle(enabled){
this.enabled = enabled;
if(this.ctx) this.masterGain.gain.value = enabled ? 0.9 : 0;
localStorage.setItem('miniPVZ_audio', enabled ? '1' : '0');
}
resume(){
if(this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
}
playTone({freq=440,duration=0.12,type='sine',gain=0.2}={}){
if(!this.ctx || !this.enabled) return;
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = type;
o.frequency.value = freq;
g.gain.value = gain;
o.connect(g); g.connect(this.masterGain);
o.start();
g.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime + duration);
o.stop(this.ctx.currentTime + duration + 0.02);
}
// small effects
punch(){ this.playTone({freq:220, duration:0.06, gain:0.25, type:'square'}); }
collect(){ this.playTone({freq:880, duration:0.08, gain:0.18, type:'sine'}); }
shoot(){ this.playTone({freq:560, duration:0.06, gain:0.16, type:'sawtooth'}); }
hit(){ this.playTone({freq:160, duration:0.12, gain:0.18, type:'sawtooth'}); }
}
/* ===================== 5) Grid ===================== */
/* Maps logical grid (rows x cols) to pixel areas, occupancy checks, conversions */
class Grid {
constructor(options={}){
this.rows = options.rows || 5; // 5-6 recommended
this.cols = options.cols || 9; // dynamic
this.margin = 12;
this.tileSize = 64;
this.offsetX = 40;
this.offsetY = 120;
this.occupancy = []; // 2D array of plants
this._initOccupancy();
}
_initOccupancy(){
this.occupancy = new Array(this.rows).fill(0).map(()=> new Array(this.cols).fill(null));
}
resize(width, height, dpr=1){
// prefer portrait grid: cols depend on width
// ensure tile size fits comfortably
const usableW = width - this.offsetX*2;
const tileW = Math.floor(usableW / this.cols);
const maxTile = Math.min(tileW, Math.floor((height - this.offsetY*2)/this.rows));
this.tileSize = Math.max(48, Math.min(96, maxTile));
// center grid
const gridW = this.tileSize * this.cols;
this.startX = Math.round((width - gridW)/2);
this.startY = this.offsetY;
}
pixelToTile(px, py){
const x = Math.floor((px - this.startX) / this.tileSize);
const y = Math.floor((py - this.startY) / this.tileSize);
return {col:x, row:y};
}
tileToPixel(col,row){
return {x: this.startX + col * this.tileSize, y: this.startY + row * this.tileSize, w: this.tileSize, h: this.tileSize};
}
isValidTile(col,row){
return col>=0 && row>=0 && row<this.rows && col<this.cols;
}
isOccupied(col,row){
if(!this.isValidTile(col,row)) return true;
return !!this.occupancy[row][col];
}
placePlant(col,row, plant){
if(!this.isValidTile(col,row)) return false;
if(this.occupancy[row][col]) return false;
this.occupancy[row][col] = plant;
plant.setGridPosition(col,row);
return true;
}
removePlant(col,row){
if(this.isValidTile(col,row) && this.occupancy[row][col]){
const p = this.occupancy[row][col];
this.occupancy[row][col] = null;
return p;
}
return null;
}
iteratePlants(fn){
for(let r=0;r<this.rows;r++){
for(let c=0;c<this.cols;c++){
const p = this.occupancy[r][c];
if(p) fn(p,c,r);
}
}
}
}
/* ===================== 6) Entities: Plant / Zombie / Projectile ===================== */
/* Base Entity class */
class Entity {
constructor(x,y){
this.x = x; this.y = y;
this.alive = true;
this._id = Math.random().toString(36).slice(2,9);
}
update(dt){}
render(ctx,interp=1){}
destroy(){ this.alive=false; }
}
/* --- Plant base and subclasses --- */
class Plant extends Entity {
constructor(config){
super(0,0);
// config fields: id,name,type,cost,cooldown,health,desc
Object.assign(this, config);
this.cooldownLeft = 0;
this.health = config.health || 100;
this.gridCol = 0; this.gridRow = 0;
this._lastAction = 0;
}
setGridPosition(col,row){
this.gridCol = col; this.gridRow = row;
}
canAct(now){
return (now - this._lastAction) >= (this.cooldown || 1000);
}
takeDamage(dmg){
this.health -= dmg;
if(this.health <= 0) this.destroy();
}
onRemove(){}
}
/* ResourcePlant - generates pickups periodically */
class ResourcePlant extends Plant {
constructor(cfg){
super(cfg);
this.produceInterval = cfg.produceInterval || 5000;
this._since = 0;
}
update(dt, game){
if(!this.alive) return;
this._since += dt;
if(this._since >= this.produceInterval){
this._since = 0;
// spawn pickup above tile
const tile = game.grid.tileToPixel(this.gridCol,this.gridRow);
game.spawnResource({x: tile.x + tile.w/2, y: tile.y + tile.h/2});
game.audio.collect();
}
}
render(ctx){
const tile = ctx.__tile(this.gridCol,this.gridRow);
// simple sunflower-like circle
ctx.save();
ctx.translate(tile.x + tile.w/2, tile.y + tile.h/2);
ctx.beginPath(); ctx.arc(0,0, tile.w*0.22, 0, Math.PI*2); ctx.fillStyle='#ffd54a'; ctx.fill();
ctx.beginPath(); ctx.arc(0,0, tile.w*0.09, 0, Math.PI*2); ctx.fillStyle='#ff8a00'; ctx.fill();
ctx.restore();
}
}
/* ShooterPlant - fires projectiles straight to the right */
class ShooterPlant extends Plant {
constructor(cfg){
super(cfg);
this.projectileSpeed = cfg.projectileSpeed || 300; // px/s
this.damage = cfg.damage || 20;
this.rate = cfg.rate || 1.0; // shots per cooldown?
this._acc=0;
}
update(dt, game){
this._acc += dt;
if(this.canAct(this._acc)){
// find nearest zombie in same row to the right
const row = this.gridRow;
const zombies = game.zombiesByRow[row] || [];
if(zombies.length){
// shoot first zombie
const tile = game.grid.tileToPixel(this.gridCol,this.gridRow);
const px = tile.x + tile.w;
const py = tile.y + tile.h/2;
game.spawnProjectile(new Projectile({
x:px, y:py, vx: this.projectileSpeed, vy:0, damage: this.damage, targetRow: row
}));
this._lastAction = this._acc;
game.audio.shoot();
}
}
}
render(ctx){
const tile = ctx.__tile(this.gridCol,this.gridRow);
ctx.save();
ctx.translate(tile.x + tile.w/2, tile.y + tile.h/2);
ctx.fillStyle='#7ed957'; ctx.fillRect(-tile.w*0.18, -tile.h*0.18, tile.w*0.36, tile.h*0.36);
ctx.fillStyle='#2e7d32'; ctx.fillRect(0, -tile.h*0.06, tile.w*0.28, tile.h*0.12);
ctx.restore();
}
}
/* 1
1
20KB
20KB
129.0ms
276.0ms
166.0ms