Meta Description" name="description" />

Share this result

Previews are deleted daily. Get a permanent share link sent to your inbox:
Script
<!-- 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(); } } /*
Landing Page
This ad does not have a landing page available
Network Timeline
Performance Summary

1

Requests

1

Domains

20KB

Transfer Size

20KB

Content Size

129.0ms

Dom Content Loaded

276.0ms

First Paint

166.0ms

Load Time
Domain Breakdown
Transfer Size (bytes)
Loading...
Content Size (bytes)
Loading...
Header Size (bytes)
Loading...
Requests
Loading...
Timings (ms)
Loading...
Total Time
Loading...
Content Breakdown
Transfer Size (bytes)
Loading...
Content Size (bytes)
Loading...
Header Size (bytes)
Loading...
Requests
Loading...