'use strict'; /* ============================================================ TOWER DEFENSE β€” 100 WAVES Place towers, evolve them through 4 stages, survive 100 waves. ============================================================ */ const cv = document.getElementById('td100game'); const ctx = cv.getContext('2d'); // ---------- Layout ---------- const W = 960, HUD_H = 64, COLS = 20, ROWS = 12, CELL = 48; const GRID_Y = HUD_H, GRID_H = ROWS * CELL; // 576 const SHOP_Y = GRID_Y + GRID_H; // 640 const H = SHOP_Y + 120; // 760 // ---------- Path (cell coords) ---------- const WAY_CELLS = [[-1,2],[3,2],[3,5],[8,5],[8,9],[14,9],[14,4],[21,4]]; const waypoints = WAY_CELLS.map(([c,r]) => ({ x:(c+0.5)*CELL, y:GRID_Y+(r+0.5)*CELL })); const blocked = new Set(); (function buildBlocked(){ for(let s=0;s=0&&c=0&&r({type:t.type,evo:t.evo,gx:t.gx,gy:t.gy,invested:t.invested})) })); }catch(e){} } function loadGame(){ try{ const s=JSON.parse(localStorage.getItem(SAVE_KEY)); if(!s||!s.wave||s.wave>100) return false; wave=s.wave; gold=s.gold; lives=s.lives; score=s.score||0; towers=[]; for(const st of (s.towers||[])){ towers.push({ type:st.type, evo:st.evo, gx:st.gx, gy:st.gy, x:(st.gx+0.5)*CELL, y:GRID_Y+(st.gy+0.5)*CELL, cd:0, angle:-Math.PI/2, invested:st.invested, beam:null, pulse:null, target:null }); } return true; }catch(e){ return false; } } function hasSave(){ try{ const s=JSON.parse(localStorage.getItem(SAVE_KEY)); return !!(s&&s.wave&&s.wave<=100); } catch(e){ return false; } } function clearSave(){ try{ localStorage.removeItem(SAVE_KEY); }catch(e){} } // ---------- Helpers ---------- function towerStats(t){ return TOWER_DEFS[t.type].evo[t.evo]; } function fmt(n){ return Math.floor(n).toLocaleString('en-US'); } function rr(x,y,w,h,r){ ctx.beginPath(); ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r); ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath(); } function addText(x,y,text,color,size){ texts.push({x,y,text,color:color||'#ffe27a',size:size||15,ttl:1.1}); } function cellCenter(gx,gy){ return { x:(gx+0.5)*CELL, y:GRID_Y+(gy+0.5)*CELL }; } function towerAt(gx,gy){ return towers.find(t=>t.gx===gx&&t.gy===gy); } // ================= WAVE GENERATION ================= function waveSpec(n){ const isBoss = (n%10===0), isFast = (n%5===0 && !isBoss); const count = isBoss ? Math.min(4+Math.floor(n/10),10) : Math.min(8+n, 40); const hp = Math.round(22*Math.pow(1.084,n) + 5*n); const speed = Math.min(52 + n*1.0, 145); const size = Math.min(9 + n*0.18, 27); const reward= 5 + n; return { n, isBoss, count, hp, speed, size, reward, interval: Math.max(0.22, 0.9 - n*0.006) }; } function startWave(){ if(state!=='play'||waveActive||wave>100) return; const s = waveSpec(wave); queue = []; if(s.isBoss){ // escorts first, then the boss for(let i=0;i=COLS||gy<0||gy>=ROWS) return; if(blocked.has(gx+','+gy)||towerAt(gx,gy)){ deny('Blocked'); return; } if(gold=3) return; const cost = TOWER_DEFS[t.type].evo[t.evo+1].cost; if(goldx!==t); towerSel = null; sfx('coin'); if(!waveActive) saveGame(); } function deny(msg){ shakeT=0.35; shakeMsg=msg; sfx('leak'); } function gameOver(){ state='defeat'; waveActive=false; clearSave(); sfx('leak'); } function victory(){ state='victory'; waveActive=false; clearSave(); sfx('evolve'); } function newGame(){ wave=1; gold=300; lives=20; score=0; towers=[]; enemies=[]; projectiles=[]; queue=[]; tracers=[]; sparks=[]; explosions=[]; pulses=[]; texts=[]; waveActive=false; speed=1; paused=false; time=0; shopSel=null; towerSel=null; leakFlash=0; clearSave(); state='play'; } // ================= UPDATE ================= function update(dt){ if(state!=='play'||paused) return; const sdt = Math.min(dt,0.05)*speed; time += sdt; if(leakFlash>0) leakFlash-=sdt; // spawning if(waveActive && queue.length){ spawnT -= sdt; while(spawnT<=0 && queue.length){ spawnEnemy(queue.shift()); spawnT += spawnInterval; } } // enemies move for(let i=enemies.length-1;i>=0;i--){ const e=enemies[i]; if(e.dead) continue; const slowed = time < e.slowUntil; const sp = e.baseSpeed * (slowed ? (1-e.slowAmt) : 1); let rem = sp*sdt; while(rem>0 && e.wp=waypoints.length){ e.dead=true; const dmg = e.boss?5:1; lives -= dmg; addText(W-90, GRID_Y+50, '-'+dmg+' ❀', '#ff5d5d', 22); leakFlash=0.4; sfx('leak'); if(lives<=0){ lives=0; gameOver(); return; } } } // towers fire for(const t of towers){ t.cd -= sdt; const st = towerStats(t), def = TOWER_DEFS[t.type]; const R = st.range*CELL; let best=null, bd=-1; for(const e of enemies){ if(e.dead) continue; if(Math.hypot(e.x-t.x,e.y-t.y) <= R + e.size*0.4 && e.dist>bd){ bd=e.dist; best=e; } } t.target = best; if(best) t.angle = Math.atan2(best.y-t.y, best.x-t.x); if(!best){ t.beam=null; continue; } if(t.type==='laser'){ best.hp -= st.dps*sdt; t.beam = {x:best.x, y:best.y, ttl:0.12}; if(best.hp<=0) killEnemy(best); } else if(t.type==='frost'){ if(t.cd<=0){ t.cd = 1/st.rate; pulses.push({x:t.x,y:t.y,r:10,max:R,ttl:0.4,t:0,color:def.color}); sfx('frost'); for(const e of enemies){ if(e.dead) continue; if(Math.hypot(e.x-t.x,e.y-t.y)<=R){ e.hp -= st.dmg; if(time>=e.slowUntil || st.slow>e.slowAmt){ e.slowUntil=time+1.6; e.slowAmt=st.slow; } if(e.hp<=0) killEnemy(e); } } } } else if(t.cd<=0){ t.cd = 1/st.rate; if(t.type==='cannon'){ const d = Math.hypot(best.x-t.x,best.y-t.y); projectiles.push({x:t.x,y:t.y,tx:best.x,ty:best.y,t:0, dur:Math.max(0.12,d/480),dmg:st.dmg,splash:st.splash*CELL}); sfx('cannon'); } else { best.hp -= st.dmg; tracers.push({x1:t.x,y1:t.y,x2:best.x,y2:best.y,ttl:0.09,color:def.color,big:t.type==='sniper'}); sparks.push({x:best.x,y:best.y,ttl:0.14,r:t.type==='sniper'?11:6}); sfx(t.type); if(best.hp<=0) killEnemy(best); } } } // projectiles for(let i=projectiles.length-1;i>=0;i--){ const p=projectiles[i]; p.t += sdt; const k = Math.min(1, p.t/p.dur); p.x += (p.tx-p.x)*Math.min(1,sdt*14); p.y += (p.ty-p.y)*Math.min(1,sdt*14); if(k>=1){ projectiles.splice(i,1); explosions.push({x:p.tx,y:p.ty,r:8,max:p.splash,ttl:0.32,t:0}); for(const e of enemies){ if(e.dead) continue; if(Math.hypot(e.x-p.tx,e.y-p.ty)<=p.splash){ e.hp-=p.dmg; if(e.hp<=0) killEnemy(e); } } } } // sweep dead if(enemies.some(e=>e.dead)) enemies = enemies.filter(e=>!e.dead); // effects decay for(const a of [tracers,sparks,explosions,pulses]) for(let i=a.length-1;i>=0;i--){ a[i].ttl-=sdt; if(a[i].ttl<=0) a.splice(i,1); } for(let i=texts.length-1;i>=0;i--){ const x=texts[i]; x.ttl-=sdt; x.y-=34*sdt; if(x.ttl<=0) texts.splice(i,1); } for(const t of towers){ if(t.beam){ t.beam.ttl-=sdt; if(t.beam.ttl<=0) t.beam=null; } } if(shakeT>0) shakeT-=sdt; // wave complete? if(waveActive && !queue.length && !enemies.length){ waveActive=false; const bonus = 25 + wave*3; gold += bonus; score += 100 + wave*10; addText(W/2, GRID_Y+170, 'WAVE '+wave+' CLEAR +$'+bonus, '#7CFC9A', 24); sfx('coin'); wave++; if(wave>100){ victory(); } else saveGame(); } } // ================= LAYOUT (shared by draw + clicks) ================= const UI = { mute: {x:W-96, y:12, w:40, h:40}, restart:{x:W-52, y:12, w:40, h:40}, cards: SHOP_ORDER.map((t,i)=>({x:12+i*160, y:SHOP_Y+12, w:150, h:96, type:t})), start: {x:824, y:SHOP_Y+8, w:124, h:54}, speed: {x:824, y:SHOP_Y+68, w:60, h:44}, pause: {x:890, y:SHOP_Y+68, w:58, h:44}, evolve: {x:560, y:SHOP_Y+16, w:170, h:46}, sell: {x:740, y:SHOP_Y+16, w:100, h:46}, pclose: {x:850, y:SHOP_Y+16, w:46, h:46}, menuStart: {x:W/2-150, y:470, w:300, h:60}, menuContinue:{x:W/2-150, y:545, w:300, h:54}, again: {x:W/2-150, y:500, w:300, h:60}, }; function inRect(x,y,r){ return x>=r.x&&x<=r.x+r.w&&y>=r.y&&y<=r.y+r.h; } // ================= RENDER ================= function render(){ ctx.save(); if(shakeT>0) ctx.translate((Math.random()-0.5)*8*shakeT*3,(Math.random()-0.5)*8*shakeT*3); drawBG(); drawGrid(); drawPath(); drawPortals(); drawPlacementGhost(); for(const t of towers) drawTower(t); for(const e of enemies) drawEnemy(e); drawProjectiles(); drawEffects(); drawTexts(); drawHUD(); drawBottom(); if(leakFlash>0){ ctx.fillStyle=`rgba(255,40,40,${leakFlash*0.5})`; ctx.fillRect(0,0,W,H); } if(shakeT>0&&shakeMsg){ ctx.fillStyle='#ff8a8a'; ctx.font='bold 18px sans-serif'; ctx.textAlign='center'; ctx.fillText(shakeMsg, W/2, GRID_Y+40); } if(state==='menu') drawMenu(); if(state==='victory') drawEnd(true); if(state==='defeat') drawEnd(false); ctx.restore(); } function drawBG(){ const g=ctx.createLinearGradient(0,0,0,H); g.addColorStop(0,'#070c1e'); g.addColorStop(0.6,'#0a1330'); g.addColorStop(1,'#060a1a'); ctx.fillStyle=g; ctx.fillRect(0,0,W,H); ctx.fillStyle='rgba(255,255,255,0.05)'; for(let i=0;i<70;i++){ const x=(i*137.5)%W, y=(i*89.3)%(GRID_Y+GRID_H); ctx.fillRect(x,y,2,2); } } function drawGrid(){ ctx.strokeStyle='rgba(120,160,255,0.06)'; ctx.lineWidth=1; ctx.beginPath(); for(let c=0;c<=COLS;c++){ ctx.moveTo(c*CELL,GRID_Y); ctx.lineTo(c*CELL,GRID_Y+GRID_H); } for(let r=0;r<=ROWS;r++){ ctx.moveTo(0,GRID_Y+r*CELL); ctx.lineTo(W,GRID_Y+r*CELL); } ctx.stroke(); } function strokePath(){ ctx.beginPath(); ctx.moveTo(waypoints[0].x,waypoints[0].y); for(let i=1;i=COLS||gy<0||gy>=ROWS) return; const c=cellCenter(gx,gy), def=TOWER_DEFS[shopSel], st=def.evo[0]; const ok=!blocked.has(gx+','+gy)&&!towerAt(gx,gy)&&gold>=def.cost; ctx.globalAlpha=0.35; ctx.fillStyle=ok?def.color:'#ff5252'; ctx.beginPath(); ctx.arc(c.x,c.y,st.range*CELL,0,7); ctx.fill(); ctx.globalAlpha=0.8; ctx.fillStyle=def.color; ctx.beginPath(); ctx.arc(c.x,c.y,16,0,7); ctx.fill(); ctx.globalAlpha=1; if(!ok){ ctx.strokeStyle='#ff5252'; ctx.lineWidth=4; ctx.beginPath(); ctx.moveTo(c.x-12,c.y-12); ctx.lineTo(c.x+12,c.y+12); ctx.moveTo(c.x+12,c.y-12); ctx.lineTo(c.x-12,c.y+12); ctx.stroke(); } } function drawTower(t){ const def=TOWER_DEFS[t.type], st=towerStats(t); // selection / placement range if(towerSel===t){ ctx.fillStyle='rgba(255,255,255,0.10)'; ctx.beginPath(); ctx.arc(t.x,t.y,st.range*CELL,0,7); ctx.fill(); ctx.strokeStyle='#fff'; ctx.lineWidth=2; ctx.setLineDash([8,6]); ctx.beginPath(); ctx.arc(t.x,t.y,st.range*CELL,0,7); ctx.stroke(); ctx.setLineDash([]); } // shadow + base ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.ellipse(t.x,t.y+16,20,8,0,0,7); ctx.fill(); ctx.fillStyle='#1a2447'; rr(t.x-20,t.y-20,40,40,10); ctx.fill(); ctx.strokeStyle=def.color; ctx.lineWidth=2.5; rr(t.x-20,t.y-20,40,40,10); ctx.stroke(); // glyph ctx.save(); ctx.translate(t.x,t.y); const evoScale=1+t.evo*0.12; ctx.scale(evoScale,evoScale); ctx.fillStyle=def.color; ctx.strokeStyle=def.color; if(t.type==='gun'||t.type==='cannon'||t.type==='sniper'){ ctx.rotate(t.angle); ctx.lineWidth=t.type==='cannon'?9:5; ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(t.type==='sniper'?26:20,0); ctx.stroke(); ctx.beginPath(); ctx.arc(0,0,t.type==='cannon'?11:9,0,7); ctx.fill(); ctx.fillStyle='#0a1330'; ctx.beginPath(); ctx.arc(0,0,4,0,7); ctx.fill(); } else if(t.type==='laser'){ ctx.rotate(time*1.5); ctx.beginPath(); for(let i=0;i<4;i++){ const a=i*Math.PI/2; ctx.moveTo(Math.cos(a)*4,Math.sin(a)*4); ctx.lineTo(Math.cos(a)*15,Math.sin(a)*15); } ctx.lineWidth=5; ctx.stroke(); ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(0,0,4,0,7); ctx.fill(); } else { // frost ctx.lineWidth=3.5; for(let i=0;i<3;i++){ ctx.rotate(Math.PI/3); ctx.beginPath(); ctx.moveTo(-12,0); ctx.lineTo(12,0); ctx.stroke(); ctx.beginPath(); ctx.moveTo(7,-5); ctx.lineTo(12,0); ctx.lineTo(7,5); ctx.stroke(); } } ctx.restore(); // evolution pips for(let i=0;i<4;i++){ ctx.fillStyle=i<=t.evo?def.color:'rgba(255,255,255,0.18)'; ctx.beginPath(); ctx.arc(t.x-15+i*10,t.y+26,3.4,0,7); ctx.fill(); } if(t.beam){ const fl=0.75+Math.random()*0.25; const g=ctx.createLinearGradient(t.x,t.y,t.beam.x,t.beam.y); g.addColorStop(0,'rgba(255,255,255,0.95)'); g.addColorStop(1,def.color); ctx.strokeStyle=g; ctx.lineWidth=(5+t.evo*2)*fl; ctx.beginPath(); ctx.moveTo(t.x,t.y); ctx.lineTo(t.beam.x,t.beam.y); ctx.stroke(); ctx.fillStyle='rgba(255,120,140,0.8)'; ctx.beginPath(); ctx.arc(t.beam.x,t.beam.y,6+t.evo*2,0,7); ctx.fill(); } } function drawEnemy(e){ const hue = e.boss? 278 : Math.max(0, 128 - wave*1.22); const wob = Math.sin(e.wob)*e.size*0.08; const r = e.size + wob; if(e.boss){ // spikes ctx.fillStyle=`hsl(${hue},70%,30%)`; ctx.beginPath(); for(let i=0;i<10;i++){ const a=i*Math.PI/5+time*0.8; const x1=e.x+Math.cos(a)*r*0.9, y1=e.y+Math.sin(a)*r*0.9; const x2=e.x+Math.cos(a+0.31)*r*1.5, y2=e.y+Math.sin(a+0.31)*r*1.5; ctx.moveTo(e.x,e.y); ctx.lineTo(x1,y1); ctx.lineTo(x2,y2); } ctx.fill(); } const g=ctx.createRadialGradient(e.x-r*0.3,e.y-r*0.3,r*0.2,e.x,e.y,r); g.addColorStop(0,`hsl(${hue},95%,${e.boss?60:68}%)`); g.addColorStop(1,`hsl(${hue},85%,${e.boss?32:38}%)`); ctx.fillStyle=g; ctx.beginPath(); ctx.arc(e.x,e.y,r,0,7); ctx.fill(); if(time0.5?'#5dff7a':p>0.25?'#ffd166':'#ff5252'; ctx.fillRect(e.x-w/2,e.y-r-12,w*p,6); } } function drawProjectiles(){ for(const p of projectiles){ ctx.fillStyle='#ffb347'; ctx.beginPath(); ctx.arc(p.x,p.y,6,0,7); ctx.fill(); ctx.fillStyle='rgba(255,180,70,0.35)'; ctx.beginPath(); ctx.arc(p.x,p.y,11,0,7); ctx.fill(); } } function drawEffects(){ for(const t of tracers){ ctx.globalAlpha=Math.max(0,t.ttl/0.09); ctx.strokeStyle=t.color; ctx.lineWidth=t.big?4:2.5; ctx.beginPath(); ctx.moveTo(t.x1,t.y1); ctx.lineTo(t.x2,t.y2); ctx.stroke(); } ctx.globalAlpha=1; for(const s of sparks){ ctx.globalAlpha=Math.max(0,s.ttl/0.15); ctx.fillStyle='#fff7c4'; ctx.beginPath(); ctx.arc(s.x,s.y,s.r*(s.ttl/0.15),0,7); ctx.fill(); } ctx.globalAlpha=1; for(const x of explosions){ const k=1-x.ttl/0.32, r=x.r+(x.max-x.r)*k; ctx.globalAlpha=Math.max(0,1-k); ctx.strokeStyle='#ffb347'; ctx.lineWidth=5; ctx.beginPath(); ctx.arc(x.x,x.y,r,0,7); ctx.stroke(); ctx.fillStyle='rgba(255,200,100,0.5)'; ctx.beginPath(); ctx.arc(x.x,x.y,r*0.6,0,7); ctx.fill(); } ctx.globalAlpha=1; for(const p of pulses){ const k=1-p.ttl/0.5, r=8+(p.max-8)*k; ctx.globalAlpha=Math.max(0,1-k)*0.9; ctx.strokeStyle=p.color; ctx.lineWidth=4; ctx.beginPath(); ctx.arc(p.x,p.y,r,0,7); ctx.stroke(); } ctx.globalAlpha=1; } function drawTexts(){ ctx.textAlign='center'; for(const t of texts){ ctx.globalAlpha=Math.min(1,t.ttl*2); ctx.font=`bold ${t.size}px sans-serif`; ctx.strokeStyle='rgba(0,0,0,0.7)'; ctx.lineWidth=3; ctx.strokeText(t.text,t.x,t.y); ctx.fillStyle=t.color; ctx.fillText(t.text,t.x,t.y); } ctx.globalAlpha=1; } function drawHUD(){ ctx.fillStyle='rgba(4,8,20,0.85)'; ctx.fillRect(0,0,W,HUD_H); ctx.strokeStyle='rgba(56,225,255,0.25)'; ctx.lineWidth=1; ctx.beginPath(); ctx.moveTo(0,HUD_H); ctx.lineTo(W,HUD_H); ctx.stroke(); ctx.textAlign='left'; ctx.fillStyle='#fff'; ctx.font='bold 20px sans-serif'; ctx.fillText(`WAVE ${Math.min(wave,100)}/100`, 16, 30); ctx.font='bold 19px sans-serif'; ctx.fillStyle='#ffe27a'; ctx.fillText(`πŸ’° ${fmt(gold)}`, 16, 54); ctx.fillStyle = lives<=5 ? '#ff5d5d' : '#ff8a8a'; ctx.fillText(`❀ ${lives}`, 150, 54); ctx.fillStyle='#9fb3ff'; ctx.font='16px sans-serif'; ctx.fillText(`SCORE ${fmt(score)}`, 230, 54); if(waveActive){ const left=queue.length+enemies.length; ctx.fillStyle='#ffd166'; ctx.font='bold 17px sans-serif'; ctx.textAlign='center'; ctx.fillText(`πŸ‘Ύ ${left} left`, W/2, 40); ctx.textAlign='left'; } else if(state==='play'&&wave<=100){ ctx.fillStyle='#7CFC9A'; ctx.font='bold 17px sans-serif'; ctx.textAlign='center'; ctx.fillText('Ready β€” start the wave when you are', W/2, 40); ctx.textAlign='left'; } ctx.fillStyle='rgba(160,180,220,0.5)'; ctx.font='11px sans-serif'; ctx.textAlign='right'; ctx.fillText('WIKIBUSINESS ARCADE', W-110, 56); ctx.textAlign='left'; // buttons hudBtn(UI.mute, muted?'πŸ”‡':'πŸ”Š'); hudBtn(UI.restart, '⟳'); } function hudBtn(r,icon){ ctx.fillStyle='rgba(56,225,255,0.10)'; rr(r.x,r.y,r.w,r.h,8); ctx.fill(); ctx.strokeStyle='rgba(56,225,255,0.4)'; ctx.lineWidth=1.5; rr(r.x,r.y,r.w,r.h,8); ctx.stroke(); ctx.fillStyle='#cfe9ff'; ctx.font='20px sans-serif'; ctx.textAlign='center'; ctx.fillText(icon, r.x+r.w/2, r.y+27); ctx.textAlign='left'; } function drawBottom(){ ctx.fillStyle='rgba(4,8,20,0.92)'; ctx.fillRect(0,SHOP_Y,W,H-SHOP_Y); ctx.strokeStyle='rgba(56,225,255,0.25)'; ctx.beginPath(); ctx.moveTo(0,SHOP_Y); ctx.lineTo(W,SHOP_Y); ctx.stroke(); if(towerSel) drawTowerPanel(); else drawShop(); } function drawShop(){ ctx.fillStyle='#9fb3ff'; ctx.font='bold 13px sans-serif'; ctx.textAlign='left'; ctx.fillText('BUILD (tap a tower, then tap the map)', 16, SHOP_Y+10); for(const c of UI.cards){ const def=TOWER_DEFS[c.type], afford=gold>=def.cost, sel=shopSel===c.type; ctx.fillStyle=sel?'rgba(56,225,255,0.16)':'rgba(20,30,60,0.9)'; rr(c.x,c.y,c.w,c.h,10); ctx.fill(); ctx.strokeStyle=sel?def.color:(afford?'rgba(120,160,255,0.35)':'rgba(120,120,140,0.2)'); ctx.lineWidth=sel?3:1.5; rr(c.x,c.y,c.w,c.h,10); ctx.stroke(); ctx.fillStyle=def.color; rr(c.x,c.y,c.w,6,3); ctx.fill(); ctx.globalAlpha=afford?1:0.45; ctx.fillStyle='#fff'; ctx.font='bold 16px sans-serif'; ctx.fillText(def.name, c.x+12, c.y+30); ctx.fillStyle='#ffe27a'; ctx.font='bold 15px sans-serif'; ctx.fillText('$'+def.cost, c.x+12, c.y+52); ctx.fillStyle='#9fb3ff'; ctx.font='12px sans-serif'; ctx.fillText(def.desc, c.x+12, c.y+70); ctx.fillStyle='rgba(200,220,255,0.55)'; ctx.font='bold 12px sans-serif'; ctx.fillText('['+(SHOP_ORDER.indexOf(c.type)+1)+']', c.x+c.w-30, c.y+30); ctx.globalAlpha=1; } // start / speed / pause const s=UI.start; ctx.fillStyle=waveActive?'rgba(90,90,110,0.5)':'rgba(60,220,120,0.25)'; rr(s.x,s.y,s.w,s.h,10); ctx.fill(); ctx.strokeStyle=waveActive?'#666':'#4be08a'; ctx.lineWidth=2; rr(s.x,s.y,s.w,s.h,10); ctx.stroke(); ctx.fillStyle=waveActive?'#999':'#c9ffd9'; ctx.font='bold 16px sans-serif'; ctx.textAlign='center'; ctx.fillText(waveActive?'FIGHTING…':'β–Ά START', s.x+s.w/2, s.y+23); ctx.font='bold 13px sans-serif'; ctx.fillText(waveActive?'wave '+wave:'wave '+Math.min(wave,100), s.x+s.w/2, s.y+42); ctx.textAlign='left'; bigBtn(UI.speed, speed+'Γ—'); bigBtn(UI.pause, paused?'β–Ά':'⏸'); } function bigBtn(r,label){ ctx.fillStyle='rgba(56,225,255,0.10)'; rr(r.x,r.y,r.w,r.h,10); ctx.fill(); ctx.strokeStyle='rgba(56,225,255,0.4)'; ctx.lineWidth=1.5; rr(r.x,r.y,r.w,r.h,10); ctx.stroke(); ctx.fillStyle='#cfe9ff'; ctx.font='bold 18px sans-serif'; ctx.textAlign='center'; ctx.fillText(label, r.x+r.w/2, r.y+29); ctx.textAlign='left'; } function statLine(type,st){ if(type==='laser') return `DPS ${st.dps} β€’ RANGE ${st.range.toFixed(1)}`; if(type==='frost') return `DMG ${st.dmg} β€’ SLOW ${Math.round(st.slow*100)}% β€’ RANGE ${st.range.toFixed(1)}`; const s=`DMG ${st.dmg} β€’ ${st.rate}/s β€’ RANGE ${st.range.toFixed(1)}`; return type==='cannon'? s+` β€’ SPLASH ${st.splash.toFixed(1)}` : s; } function drawTowerPanel(){ const t=towerSel, def=TOWER_DEFS[t.type], st=towerStats(t); ctx.fillStyle='rgba(20,30,60,0.95)'; rr(8,SHOP_Y+6,W-16,108,10); ctx.fill(); ctx.strokeStyle=def.color; ctx.lineWidth=2; rr(8,SHOP_Y+6,W-16,108,10); ctx.stroke(); ctx.fillStyle='#fff'; ctx.font='bold 19px sans-serif'; ctx.fillText(`${def.name} β€” Evolution ${EVO_ROMAN[t.evo]}`, 28, SHOP_Y+36); ctx.fillStyle='#9fb3ff'; ctx.font='14px sans-serif'; ctx.fillText(statLine(t.type,st), 28, SHOP_Y+60); ctx.fillStyle='rgba(200,220,255,0.6)'; ctx.font='12px sans-serif'; if(t.evo<3){ const nx=def.evo[t.evo+1]; ctx.fillText('Next ('+EVO_ROMAN[t.evo+1]+'): '+statLine(t.type,nx), 28, SHOP_Y+84); } else ctx.fillText('MAX evolution reached β€” a true monster.', 28, SHOP_Y+84); // evolve const e=UI.evolve; const canEvo=t.evo<3, cost=canEvo?def.evo[t.evo+1].cost:0, afford=gold>=cost; ctx.fillStyle=!canEvo?'rgba(90,90,110,0.5)':(afford?'rgba(60,220,120,0.25)':'rgba(120,60,60,0.25)'); rr(e.x,e.y,e.w,e.h,10); ctx.fill(); ctx.strokeStyle=!canEvo?'#666':(afford?'#4be08a':'#a05050'); ctx.lineWidth=2; rr(e.x,e.y,e.w,e.h,10); ctx.stroke(); ctx.fillStyle=!canEvo?'#999':(afford?'#c9ffd9':'#ffb0b0'); ctx.font='bold 16px sans-serif'; ctx.textAlign='center'; ctx.fillText(canEvo?`⬆ EVOLVE $${cost}`:'MAX', e.x+e.w/2, e.y+29); // sell const sl=UI.sell, val=Math.floor(t.invested*0.7); ctx.fillStyle='rgba(255,180,80,0.18)'; rr(sl.x,sl.y,sl.w,sl.h,10); ctx.fill(); ctx.strokeStyle='#ffb050'; ctx.lineWidth=2; rr(sl.x,sl.y,sl.w,sl.h,10); ctx.stroke(); ctx.fillStyle='#ffe0b0'; ctx.fillText(`SELL +$${val}`, sl.x+sl.w/2, sl.y+29); // close const c=UI.pclose; ctx.fillStyle='rgba(120,140,180,0.15)'; rr(c.x,c.y,c.w,c.h,10); ctx.fill(); ctx.strokeStyle='#8fa0c0'; ctx.lineWidth=2; rr(c.x,c.y,c.w,c.h,10); ctx.stroke(); ctx.fillStyle='#dfe8ff'; ctx.fillText('βœ•', c.x+c.w/2, c.y+29); ctx.textAlign='left'; } function drawMenu(){ ctx.fillStyle='rgba(2,4,12,0.82)'; ctx.fillRect(0,0,W,H); ctx.textAlign='center'; ctx.fillStyle='#38e1ff'; ctx.font='bold 58px sans-serif'; ctx.fillText('TOWER DEFENSE', W/2, 150); ctx.fillStyle='#ffd166'; ctx.font='bold 30px sans-serif'; ctx.fillText('100 WAVES', W/2, 195); ctx.fillStyle='#cfe0ff'; ctx.font='17px sans-serif'; const lines=[ 'Enemies march from the portal (left) to your base (right).', 'Tap a tower below, then tap the map to build it.', 'Tap a placed tower to EVOLVE it (4 stages) or sell it.', 'Kills earn gold. Leaked enemies cost ❀ β€” bosses cost 5.', 'Every 10th wave: a BOSS. Survive all 100 waves to win.', ]; lines.forEach((l,i)=>ctx.fillText(l, W/2, 260+i*30)); ctx.fillStyle='#9fb3ff'; ctx.font='14px sans-serif'; ctx.fillText('Keys: 1-5 build β€’ Space start wave β€’ F speed β€’ P pause', W/2, 440); menuBtn(UI.menuStart,'β–Ά START NEW GAME','#4be08a'); if(hasSave()) menuBtn(UI.menuContinue,'↻ CONTINUE β€” WAVE '+JSON.parse(localStorage.getItem(SAVE_KEY)).wave,'#38e1ff'); ctx.textAlign='left'; } function menuBtn(r,label,color){ ctx.fillStyle='rgba(10,16,36,0.95)'; rr(r.x,r.y,r.w,r.h,12); ctx.fill(); ctx.strokeStyle=color; ctx.lineWidth=3; rr(r.x,r.y,r.w,r.h,12); ctx.stroke(); ctx.fillStyle=color; ctx.font='bold 21px sans-serif'; ctx.textAlign='center'; ctx.fillText(label, r.x+r.w/2, r.y+37); ctx.textAlign='left'; } function drawEnd(win){ ctx.fillStyle='rgba(2,4,12,0.85)'; ctx.fillRect(0,0,W,H); ctx.textAlign='center'; ctx.fillStyle=win?'#7CFC9A':'#ff5d5d'; ctx.font='bold 54px sans-serif'; ctx.fillText(win?'πŸ† VICTORY!':'πŸ’€ BASE OVERRUN', W/2, 220); ctx.fillStyle='#cfe0ff'; ctx.font='20px sans-serif'; ctx.fillText(win?'You survived all 100 waves. Legendary commander.': `You fell on wave ${Math.min(wave,100)}. The blobs send their regards.`, W/2, 280); ctx.fillStyle='#ffe27a'; ctx.font='bold 22px sans-serif'; ctx.fillText(`SCORE ${fmt(score)} β€’ TOWERS ${towers.length}`, W/2, 330); ctx.fillStyle='#9fb3ff'; ctx.font='16px sans-serif'; ctx.fillText(win?'Your max-evolution army held the line. Play again?':'Evolve more, slow them down, try again.', W/2, 370); menuBtn(UI.again, win?'↻ PLAY AGAIN':'↻ TRY AGAIN', win?'#7CFC9A':'#ff8a8a'); ctx.textAlign='left'; } // ================= INPUT ================= function canvasPos(ev){ const r=cv.getBoundingClientRect(); const cx=(ev.touches&&ev.touches[0]?ev.touches[0].clientX:ev.clientX); const cy=(ev.touches&&ev.touches[0]?ev.touches[0].clientY:ev.clientY); return { x:(cx-r.left)*W/r.width, y:(cy-r.top)*H/r.height }; } cv.addEventListener('pointerdown', ev=>{ ev.preventDefault(); try{ AC=AC||new (window.AudioContext||window.webkitAudioContext)(); if(AC.state==='suspended')AC.resume(); }catch(e){} const p=canvasPos(ev); handleTap(p.x,p.y); }); cv.addEventListener('pointermove', ev=>{ const p=canvasPos(ev); if(p.y>=GRID_Y&&p.yev.preventDefault()); function handleTap(x,y){ if(state==='menu'){ if(inRect(x,y,UI.menuStart)){ newGame(); return; } if(hasSave()&&inRect(x,y,UI.menuContinue)){ if(loadGame()){ state='play'; } return; } return; } if(state==='victory'||state==='defeat'){ if(inRect(x,y,UI.again)){ state='menu'; } return; } // HUD if(inRect(x,y,UI.mute)){ muted=!muted; return; } if(inRect(x,y,UI.restart)){ saveGame(); state='menu'; return; } // bottom area if(y>=SHOP_Y){ if(towerSel){ if(inRect(x,y,UI.evolve)){ tryEvolve(); return; } if(inRect(x,y,UI.sell)){ trySell(); return; } if(inRect(x,y,UI.pclose)){ towerSel=null; return; } return; } for(const c of UI.cards){ if(inRect(x,y,c)){ shopSel=(shopSel===c.type?null:c.type); towerSel=null; sfx('coin'); return; } } if(inRect(x,y,UI.start)){ startWave(); return; } if(inRect(x,y,UI.speed)){ speed=speed>=3?1:speed+1; return; } if(inRect(x,y,UI.pause)){ paused=!paused; return; } return; } // grid if(y>=GRID_Y&&y{ if(state!=='play') return; const k=ev.key.toLowerCase(); const idx=['1','2','3','4','5'].indexOf(k); if(idx>=0){ shopSel=(shopSel===SHOP_ORDER[idx]?null:SHOP_ORDER[idx]); towerSel=null; } else if(k==='escape'){ shopSel=null; towerSel=null; } else if(k===' '){ ev.preventDefault(); startWave(); } else if(k==='f'){ speed=speed>=3?1:speed+1; } else if(k==='p'){ paused=!paused; } else if(k==='m'){ muted=!muted; } }); window.addEventListener('pagehide', saveGame); // ================= MAIN LOOP ================= let lastT=performance.now(); function loop(now){ const dt=(now-lastT)/1000; lastT=now; update(dt); render(); requestAnimationFrame(loop); } render(); requestAnimationFrame(loop);