(function(){ 'use strict'; /* ============================================================ TRENCH DEFENSE — 50 WAVES Hold the line: infantry, tanks and landships cross the snow. ============================================================ */ const cv = document.getElementById('trenchgame'); const ctx = cv.getContext('2d'); // ---------- Layout ---------- const W = 960, H = 800, HUD_H = 64; const FIELD_Y = HUD_H; // 64 const SHOP_Y = 680; const FIELD_H = SHOP_Y - FIELD_Y; // 616 const ENEMY_TRENCH_Y = FIELD_Y + 30; // 94 (center line of enemy trench) const PLAYER_TRENCH_Y = SHOP_Y - 30; // 650 const LEAK_Y = SHOP_Y - 28; // 652 — enemies crossing this line hit your trench const PLACE_MIN_Y = FIELD_Y + 56; // no building inside the enemy trench band // ---------- Defense definitions ---------- const DEF_DEFS = { mg: { name:'MG Nest', cost:60, color:'#7a7f6a', desc:'Rapid fire', lv:[ {dmg:4, rate:6, range:135, cost:0}, {dmg:6, rate:7, range:145, cost:55}, {dmg:10, rate:8.5, range:155, cost:100} ] }, sniper: { name:'Sniper', cost:110, color:'#8a7a5a', desc:'Long range marksman', lv:[ {dmg:45, rate:0.55, range:265, cost:0}, {dmg:85, rate:0.60, range:290, cost:90}, {dmg:150, rate:0.65, range:315, cost:160} ] }, mortar: { name:'Mortar', cost:130, color:'#6a7078', desc:'Splash shells', lv:[ {dmg:32, rate:0.50, range:245, splash:58, cost:0}, {dmg:55, rate:0.55, range:255, splash:68, cost:110}, {dmg:95, rate:0.60, range:270, splash:80, cost:185} ] }, wire: { name:'Barbed Wire', cost:40, color:'#9aa0a6', desc:'Slows 45% - wears out', radius:36, slow:0.45, dur:40 }, mine: { name:'Mine', cost:50, color:'#5a4a3a', desc:'One big boom', dmg:130, splash:62, trigger:26 }, }; const SHOP_ORDER = ['mg','sniper','mortar','wire','mine']; // ---------- Enemy kinds ---------- const KIND = { infantry:{ hp:1, sp:1, r:7, rw:1, leak:1 }, runner: { hp:0.55, sp:1.55, r:6, rw:1, leak:1 }, officer: { hp:1.7, sp:1.12, r:7, rw:2, leak:1 }, mgteam: { hp:2.6, sp:0.70, r:9, rw:2, leak:1 }, tank: { hp:9, sp:0.45, r:20, rw:6, leak:3 }, mini: { hp:6, sp:0.60, r:16, rw:10, leak:3 }, boss: { hp:14, sp:0.40, r:27, rw:25, leak:8 }, }; const FOOT_KINDS = { infantry:1, runner:1, officer:1, mgteam:1 }; // ---------- Game state ---------- let state = 'menu'; // menu | play | victory | defeat let wave = 1, gold = 250, lives = 20, score = 0; let defs = [], enemies = [], queue = []; let shells = [], artShells = [], tracers = [], muzzle = []; let smoke = [], flashes = [], texts = [], decals = [], scorch = []; let waveActive = false, spawnT = 0, spawnInterval = 0.8; let speed = 1, paused = false, time = 0; let shopSel = null, defSel = null, mouse = { x:0, y:0, in:false }; let artCD = 0, artArmed = false; let leakFlash = 0, shakeT = 0, denyT = 0, denyMsg = ''; const SAVE_KEY = 'trench_save_v1'; // Wikibusiness logo (menu + HUD) const LOGO_URL = 'https://wikibusiness.biz/wp-content/uploads/2026/10/wikibusiness-logo-game.png'; const logoImg = new Image(); let logoReady = false; logoImg.onload = function(){ logoReady = true; }; logoImg.src = LOGO_URL; // ---------- Audio (tiny synth, no assets) ---------- let AC = null, muted = false; const lastSfx = {}; function sfx(kind){ if(muted) return; const now = performance.now(); if(lastSfx[kind] && now - lastSfx[kind] < 70) return; lastSfx[kind] = now; try{ AC = AC || new (window.AudioContext || window.webkitAudioContext)(); if(AC.state === 'suspended') AC.resume(); const o = AC.createOscillator(), g = AC.createGain(); const P = { gun:[720,0.05,'square',0.014], sniper:[190,0.20,'sawtooth',0.030], mortar:[95,0.30,'sine',0.050], boom:[65,0.50,'sine',0.085], whistle:[1500,0.55,'sine',0.020], kill:[420,0.08,'triangle',0.028], place:[560,0.08,'square',0.026], evolve:[440,0.30,'square',0.032], leak:[130,0.40,'sawtooth',0.055], coin:[1250,0.07,'sine',0.026], click:[880,0.05,'square',0.020], start:[300,0.25,'square',0.036] }[kind] || [440,0.1,'square',0.03]; o.type = P[2]; o.frequency.setValueAtTime(P[0], AC.currentTime); if(kind === 'whistle') o.frequency.exponentialRampToValueAtTime(240, AC.currentTime + P[1]); if(kind === 'kill' || kind === 'leak' || kind === 'boom') o.frequency.exponentialRampToValueAtTime(55, AC.currentTime + P[1]); if(kind === 'evolve') o.frequency.exponentialRampToValueAtTime(880, AC.currentTime + P[1]); g.gain.setValueAtTime(P[3], AC.currentTime); g.gain.exponentialRampToValueAtTime(0.0001, AC.currentTime + P[1]); o.connect(g); g.connect(AC.destination); o.start(); o.stop(AC.currentTime + P[1] + 0.02); }catch(e){} } // ---------- Save / load ---------- function saveGame(){ if(state !== 'play' || waveActive) return; try{ localStorage.setItem(SAVE_KEY, JSON.stringify({ wave, gold, lives, score, defs: defs.map(d => ({ type:d.type, x:d.x, y:d.y, lvl:d.lvl, invested:d.invested, integrity: d.type === 'wire' ? d.integrity : undefined })) })); }catch(e){} } function loadGame(){ try{ const s = JSON.parse(localStorage.getItem(SAVE_KEY)); if(!s || !s.wave || s.wave > 50) return false; wave = s.wave; gold = s.gold; lives = s.lives; score = s.score || 0; defs = []; for(const sd of (s.defs || [])){ if(!DEF_DEFS[sd.type]) continue; defs.push(mkDef(sd.type, sd.x, sd.y, sd.lvl || 0, sd.invested || DEF_DEFS[sd.type].cost, sd.integrity != null ? sd.integrity : DEF_DEFS.wire.dur)); } return true; }catch(e){ return false; } } function hasSave(){ try{ const s = JSON.parse(localStorage.getItem(SAVE_KEY)); return !!(s && s.wave && s.wave <= 50); } catch(e){ return false; } } function clearSave(){ try{ localStorage.removeItem(SAVE_KEY); }catch(e){} } // ---------- Helpers ---------- 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 deny(msg){ denyT = 1.1; denyMsg = msg; shakeT = Math.max(shakeT, 0.18); sfx('leak'); } function mkSmoke(x,y,r,life){ return { x, y, r, vx:(Math.random()-0.5)*7, vy:-9-Math.random()*9, ttl:life, max:life }; } function defStats(d){ return DEF_DEFS[d.type].lv[d.lvl]; } function mkDef(type,x,y,lvl,invested,integrity){ const d = { type, x, y, lvl: lvl || 0, invested: invested != null ? invested : DEF_DEFS[type].cost, cd:0, angle:-Math.PI/2, target:null }; if(type === 'wire'){ d.integrity = integrity != null ? integrity : DEF_DEFS.wire.dur; d.inside = new Set(); } return d; } // ---------- Static battlefield decoration ---------- const speckles = [], craters = [], trees = [], wrecks = [], flakes = []; (function genDecor(){ for(let i=0;i<340;i++) speckles.push({ x:Math.random()*W, y:FIELD_Y+Math.random()*FIELD_H, r:0.6+Math.random()*1.6, a:0.05+Math.random()*0.10 }); for(let i=0;i<26;i++) craters.push({ x:30+Math.random()*(W-60), y:FIELD_Y+76+Math.random()*(FIELD_H-150), r:10+Math.random()*20 }); for(let i=0;i<6;i++) trees.push({ x:40+Math.random()*(W-80), y:FIELD_Y+90+Math.random()*(FIELD_H-170), s:0.8+Math.random()*0.7, lean:(Math.random()-0.5)*0.5 }); for(let i=0;i<3;i++) wrecks.push({ x:70+Math.random()*(W-140), y:FIELD_Y+100+Math.random()*(FIELD_H-190), rot:(Math.random()-0.5)*1.1 }); for(let i=0;i<130;i++) flakes.push({ x:Math.random()*W, y:Math.random()*H, r:0.8+Math.random()*1.9, sp:14+Math.random()*26, ph:Math.random()*6.28 }); })(); function trenchPts(baseY){ const pts = []; for(let x=-40,i=0; x<=W+40; x+=74,i++) pts.push({ x, y: baseY + (i%2 ? 11 : -7) }); return pts; } // ================= WAVES ================= function waveSpec(n){ return { n, count: Math.min(8 + Math.floor(n*1.4), 110), hp: Math.round(20*Math.pow(1.08,n) + 5*n), speed: Math.min(36 + n*0.9, 78), reward: 5 + n, interval: Math.max(0.26, 0.85 - n*0.007) }; } function pickKind(n){ const r = Math.random(); if(n >= 6 && r < 0.10) return 'tank'; if(n >= 8 && r < 0.25) return 'mgteam'; if(n >= 12 && r < 0.33) return 'officer'; if(n >= 3 && r < 0.55) return 'runner'; return 'infantry'; } function buildWave(n){ const s = waveSpec(n), q = []; const push = (kind) => { const k = KIND[kind]; q.push({ kind, hp: Math.max(1, Math.round(s.hp * k.hp)), maxHp: Math.max(1, Math.round(s.hp * k.hp)), speed: s.speed * k.sp * (0.9 + Math.random()*0.2), reward: Math.max(1, Math.round(s.reward * k.rw)), r: k.r, leak: k.leak }); }; if(n === 50){ push('boss'); push('mini'); push('mini'); for(let i=0;i 50) return; const b = buildWave(wave); queue = b.queue; spawnInterval = b.interval; spawnT = 0.3; waveActive = true; defSel = null; shopSel = null; sfx('start'); if(wave === 50) addText(W/2, FIELD_Y+130, '⚠ THE FINAL ASSAULT ⚠', '#ff5d5d', 34); else if(wave % 10 === 0) addText(W/2, FIELD_Y+130, '⚠ LANDSHIP — WAVE ' + wave + ' ⚠', '#ff8a5d', 32); else if(wave % 5 === 0) addText(W/2, FIELD_Y+130, 'ARMORED CAR — WAVE ' + wave, '#ffd166', 28); else addText(W/2, FIELD_Y+130, 'WAVE ' + wave, '#e8ecf0', 30); } function spawnEnemy(q){ enemies.push({ x: 26 + Math.random()*(W-52), y: FIELD_Y + 12 + Math.random()*22, hp: q.hp, maxHp: q.maxHp, speed: q.speed, reward: q.reward, r: q.r, leak: q.leak, kind: q.kind, phase: Math.random()*6.28, wob: Math.random()*6.28, smokeT: 0, dead: false }); } function killEnemy(e){ if(e.dead) return; e.dead = true; gold += e.reward; score += e.reward*10 + wave*5; addText(e.x, e.y - 14, '+$' + e.reward, '#ffe27a', (e.kind === 'boss' || e.kind === 'mini') ? 19 : 13); if(FOOT_KINDS[e.kind]){ decals.push({ x: e.x, y: e.y, r: 4 + Math.random()*5, rot: Math.random()*3.14 }); if(decals.length > 150) decals.shift(); sfx('kill'); } else { smoke.push(mkSmoke(e.x, e.y, 9 + Math.random()*6, 1.8)); flashes.push({ x: e.x, y: e.y, r: e.r*1.1, ttl: 0.16, max: 0.16 }); sfx('boom'); } } function explode(x,y,dmg,splash,big){ for(const e of enemies){ if(e.dead) continue; const dx = e.x - x, dy = e.y - y, rr2 = splash + e.r*0.5; if(dx*dx + dy*dy <= rr2*rr2){ e.hp -= dmg; if(e.hp <= 0) killEnemy(e); } } flashes.push({ x, y, r: splash*0.72, ttl: 0.18, max: 0.18 }); const n = big ? 9 : 6; for(let i=0;i 46) scorch.shift(); if(big) shakeT = Math.max(shakeT, 0.35); sfx('boom'); } function fireArtillery(x,y){ if(artCD > 0 || state !== 'play') return; artCD = 25; artArmed = false; for(let i=0;i<3;i++) artShells.push({ x: x + (Math.random()-0.5)*84, y: y + (Math.random()-0.5)*84, t: -(0.5 + i*0.4) }); sfx('whistle'); addText(x, y - 30, 'ARTILLERY INBOUND', '#ff8a5d', 17); } // ================= ACTIONS ================= function tryPlace(x,y){ const def = DEF_DEFS[shopSel]; if(!def) return; if(y < PLACE_MIN_Y){ deny('Too close to the enemy trench'); return; } if(y > SHOP_Y - 8){ deny('Off the field'); return; } for(const d of defs){ const dx = d.x - x, dy = d.y - y; if(dx*dx + dy*dy < 30*30){ deny('Too crowded'); return; } } if(gold < def.cost){ deny('Not enough gold'); return; } gold -= def.cost; defs.push(mkDef(shopSel, x, y)); sfx('place'); if(!waveActive) saveGame(); } function tryUpgrade(){ const d = defSel; if(!d) return; const def = DEF_DEFS[d.type]; if(!def.lv || d.lvl >= 2) return; const cost = def.lv[d.lvl + 1].cost; if(gold < cost){ deny('Not enough gold'); return; } gold -= cost; d.invested += cost; d.lvl++; sfx('evolve'); addText(d.x, d.y - 26, 'LEVEL ' + (d.lvl + 1), '#7CFC9A', 15); if(!waveActive) saveGame(); } function trySell(){ const d = defSel; if(!d) return; const val = Math.floor(d.invested * 0.7); gold += val; addText(d.x, d.y - 20, '+$' + val, '#ffe27a', 14); defs = defs.filter(x => x !== d); defSel = null; sfx('coin'); if(!waveActive) saveGame(); } function gameOver(){ state = 'defeat'; waveActive = false; clearSave(); sfx('leak'); } function victory(){ state = 'victory'; waveActive = false; clearSave(); sfx('evolve'); } function newGame(){ wave = 1; gold = 250; lives = 20; score = 0; defs = []; enemies = []; queue = []; shells = []; artShells = []; tracers = []; muzzle = []; smoke = []; flashes = []; texts = []; decals = []; scorch = []; waveActive = false; speed = 1; paused = false; time = 0; shopSel = null; defSel = null; leakFlash = 0; shakeT = 0; artCD = 0; artArmed = false; clearSave(); state = 'play'; } // ================= UPDATE ================= function update(dt){ if(state !== 'play' || paused) return; const sdt = Math.min(dt, 0.05) * speed; time += sdt; if(artCD > 0) artCD -= sdt; if(leakFlash > 0) leakFlash -= sdt; if(shakeT > 0) shakeT -= sdt; if(denyT > 0) denyT -= sdt; // spawning if(waveActive && queue.length){ spawnT -= sdt; while(spawnT <= 0 && queue.length){ spawnEnemy(queue.shift()); spawnT += spawnInterval; } } // artillery shells landing for(let i = artShells.length - 1; i >= 0; i--){ const sh = artShells[i]; sh.t += sdt; if(sh.t >= 0){ artShells.splice(i, 1); explode(sh.x, sh.y, 150, 88, true); } } // mortar shells in flight for(let i = shells.length - 1; i >= 0; i--){ const sh = shells[i]; sh.t += sdt / sh.dur; if(sh.t >= 1){ shells.splice(i, 1); explode(sh.tx, sh.ty, sh.dmg, sh.splash, false); } } // enemies advance for(const e of enemies){ if(e.dead) continue; let sf = 1; for(const d of defs){ if(d.type !== 'wire') continue; const dx = e.x - d.x, dy = e.y - d.y, wr = DEF_DEFS.wire.radius + e.r; if(dx*dx + dy*dy < wr*wr) sf = Math.min(sf, 1 - DEF_DEFS.wire.slow); } e.y += e.speed * sf * sdt; e.x += Math.sin(e.phase + time * 1.4) * 13 * sdt; if(e.x < 16) e.x = 16; if(e.x > W - 16) e.x = W - 16; e.wob += sdt * 7; if(e.kind === 'tank' || e.kind === 'boss' || e.kind === 'mini'){ e.smokeT -= sdt; if(e.smokeT <= 0){ e.smokeT = 0.3; smoke.push(mkSmoke(e.x + (Math.random()-0.5)*10, e.y - e.r, 5 + Math.random()*5, 1.5)); } } if(e.y >= LEAK_Y){ e.dead = true; lives -= e.leak; leakFlash = 0.4; addText(W - 90, SHOP_Y - 76, '-' + e.leak + ' ❤', '#ff5d5d', 22); sfx('leak'); if(lives <= 0){ lives = 0; gameOver(); return; } } } // barbed wire wears out for(let i = defs.length - 1; i >= 0; i--){ const d = defs[i]; if(d.type !== 'wire') continue; const nowIn = new Set(), wr = DEF_DEFS.wire.radius; for(const e of enemies){ if(e.dead) continue; const dx = e.x - d.x, dy = e.y - d.y; if(dx*dx + dy*dy < (wr + e.r) * (wr + e.r)) nowIn.add(e); } for(const e of d.inside){ if(!nowIn.has(e)) d.integrity--; } d.inside = nowIn; if(d.integrity <= 0){ defs.splice(i, 1); addText(d.x, d.y, 'WIRE WORN OUT', '#ffb0b0', 13); } } // mines trigger for(let i = defs.length - 1; i >= 0; i--){ const d = defs[i]; if(d.type !== 'mine') continue; let boom = false; for(const e of enemies){ if(e.dead) continue; const dx = e.x - d.x, dy = e.y - d.y, tr = DEF_DEFS.mine.trigger + e.r * 0.5; if(dx*dx + dy*dy < tr * tr){ boom = true; break; } } if(boom){ defs.splice(i, 1); explode(d.x, d.y, DEF_DEFS.mine.dmg, DEF_DEFS.mine.splash, true); } } // defenses fire for(const d of defs){ if(d.type === 'wire' || d.type === 'mine') continue; const st = defStats(d); d.cd -= sdt; let best = null, bd = -1; for(const e of enemies){ if(e.dead) continue; const dx = e.x - d.x, dy = e.y - d.y, R = st.range + e.r * 0.4; if(dx*dx + dy*dy <= R * R && e.y > bd){ bd = e.y; best = e; } } d.target = best; if(best) d.angle = Math.atan2(best.y - d.y, best.x - d.x); if(!best || d.cd > 0) continue; d.cd = 1 / st.rate; if(d.type === 'mortar'){ const dist = Math.hypot(best.x - d.x, best.y - d.y); shells.push({ sx: d.x, sy: d.y, tx: best.x, ty: best.y, t: 0, dur: Math.min(1.3, Math.max(0.45, dist / 300)), dmg: st.dmg, splash: st.splash }); sfx('mortar'); } else { best.hp -= st.dmg; tracers.push({ x1: d.x, y1: d.y - 4, x2: best.x, y2: best.y, ttl: 0.09, color: d.type === 'sniper' ? '#ffe9a8' : '#e8c97a', big: d.type === 'sniper' }); muzzle.push({ x: d.x + Math.cos(d.angle) * 16, y: d.y + Math.sin(d.angle) * 16, ttl: 0.05 }); sfx(d.type === 'sniper' ? 'sniper' : 'gun'); if(best.hp <= 0) killEnemy(best); } } if(enemies.some(e => e.dead)) enemies = enemies.filter(e => !e.dead); // wave complete? if(waveActive && !queue.length && !enemies.length){ waveActive = false; const bonus = 25 + wave * 3; gold += bonus; score += 100 + wave * 10; addText(W/2, FIELD_Y + 180, 'WAVE ' + wave + ' CLEAR +$' + bonus, '#7CFC9A', 24); sfx('coin'); wave++; if(wave > 50){ victory(); } else saveGame(); } } // visual-only effects (run even when paused / in menu) function updateFx(dt){ for(const f of flakes){ f.y += f.sp * dt; f.x += Math.sin(f.ph + time * 0.7 + f.y * 0.01) * 9 * dt; if(f.y > H + 3){ f.y = -3; f.x = Math.random() * W; } if(f.x < -3) f.x = W + 3; else if(f.x > W + 3) f.x = -3; } for(let i = smoke.length - 1; i >= 0; i--){ const s = smoke[i]; s.ttl -= dt; s.x += s.vx * dt; s.y += s.vy * dt; s.r += 5.5 * dt; if(s.ttl <= 0) smoke.splice(i, 1); } for(let i = flashes.length - 1; i >= 0; i--){ flashes[i].ttl -= dt; if(flashes[i].ttl <= 0) flashes.splice(i, 1); } for(let i = tracers.length - 1; i >= 0; i--){ tracers[i].ttl -= dt; if(tracers[i].ttl <= 0) tracers.splice(i, 1); } for(let i = muzzle.length - 1; i >= 0; i--){ muzzle[i].ttl -= dt; if(muzzle[i].ttl <= 0) muzzle.splice(i, 1); } for(let i = texts.length - 1; i >= 0; i--){ const t = texts[i]; t.ttl -= dt; t.y -= 30 * dt; if(t.ttl <= 0) texts.splice(i, 1); } } // ================= 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 }, art: { x: 622, y: 12, w: 138, 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 }, upgrade:{ x: 520, y: SHOP_Y + 16, w: 190, h: 46 }, sell: { x: 720, y: SHOP_Y + 16, w: 110, h: 46 }, pclose: { x: 850, y: SHOP_Y + 16, w: 46, h: 46 }, menuStart: { x: W/2 - 150, y: 528, w: 300, h: 60 }, menuContinue:{ x: W/2 - 150, y: 602, 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) * 14 * shakeT, (Math.random()-0.5) * 14 * shakeT); drawField(); drawGhost(); for(const d of defs) drawDef(d); drawShells(); for(const e of enemies) drawEnemy(e); drawFx(); drawTexts(); drawHUD(); drawBottom(); drawSnow(); drawVignette(); if(leakFlash > 0){ ctx.fillStyle = 'rgba(160,20,20,' + (leakFlash * 0.45) + ')'; ctx.fillRect(0, 0, W, H); } if(denyT > 0 && denyMsg){ ctx.fillStyle = '#ff9a9a'; ctx.font = 'bold 18px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(denyMsg, W/2, FIELD_Y + 44); ctx.textAlign = 'left'; } if(state === 'menu') drawMenu(); if(state === 'victory') drawEnd(true); if(state === 'defeat') drawEnd(false); ctx.restore(); } function drawField(){ // snow base ctx.fillStyle = '#dfe3e6'; ctx.fillRect(0, FIELD_Y, W, FIELD_H); const g = ctx.createLinearGradient(0, FIELD_Y, 0, SHOP_Y); g.addColorStop(0, 'rgba(120,130,140,0.20)'); g.addColorStop(0.5, 'rgba(255,255,255,0)'); g.addColorStop(1, 'rgba(90,80,60,0.14)'); ctx.fillStyle = g; ctx.fillRect(0, FIELD_Y, W, FIELD_H); // speckles for(const s of speckles){ ctx.fillStyle = 'rgba(105,112,120,' + s.a + ')'; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, 7); ctx.fill(); } // explosion scorch marks for(const s of scorch){ ctx.fillStyle = 'rgba(52,46,40,0.5)'; ctx.beginPath(); ctx.ellipse(s.x, s.y, s.r, s.r * 0.72, 0, 0, 7); ctx.fill(); ctx.fillStyle = 'rgba(30,26,22,0.5)'; ctx.beginPath(); ctx.ellipse(s.x, s.y, s.r * 0.55, s.r * 0.4, 0, 0, 7); ctx.fill(); } // static craters for(const c of craters){ ctx.fillStyle = 'rgba(110,100,88,0.55)'; ctx.beginPath(); ctx.ellipse(c.x, c.y, c.r, c.r * 0.7, 0, 0, 7); ctx.fill(); ctx.fillStyle = 'rgba(88,78,64,0.6)'; ctx.beginPath(); ctx.ellipse(c.x, c.y, c.r * 0.62, c.r * 0.42, 0, 0, 7); ctx.fill(); } // blood decals where infantry fell for(const d of decals){ ctx.save(); ctx.translate(d.x, d.y); ctx.rotate(d.rot); ctx.fillStyle = 'rgba(96,14,14,0.5)'; ctx.beginPath(); ctx.ellipse(0, 0, d.r, d.r * 0.62, 0, 0, 7); ctx.fill(); ctx.restore(); } // dead trees for(const t of trees) drawTree(t); // wrecked tank husks for(const w of wrecks) drawWreck(w); // trenches drawTrench(ENEMY_TRENCH_Y, '#7a5f40'); drawTrench(PLAYER_TRENCH_Y, '#7a5f40'); // base line marker on player trench ctx.strokeStyle = 'rgba(150,30,30,0.5)'; ctx.lineWidth = 2; ctx.setLineDash([10, 8]); ctx.beginPath(); ctx.moveTo(0, LEAK_Y + 8); ctx.lineTo(W, LEAK_Y + 8); ctx.stroke(); ctx.setLineDash([]); // artillery target markers for(const sh of artShells){ const p = 0.6 + 0.4 * Math.sin(time * 10); ctx.strokeStyle = 'rgba(200,40,30,' + p + ')'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(sh.x, sh.y, 15 + 3 * Math.sin(time * 10), 0, 7); ctx.stroke(); ctx.beginPath(); ctx.moveTo(sh.x - 20, sh.y); ctx.lineTo(sh.x - 7, sh.y); ctx.moveTo(sh.x + 7, sh.y); ctx.lineTo(sh.x + 20, sh.y); ctx.moveTo(sh.x, sh.y - 20); ctx.lineTo(sh.x, sh.y - 7); ctx.moveTo(sh.x, sh.y + 7); ctx.lineTo(sh.x, sh.y + 20); ctx.stroke(); } if(artArmed && state === 'play'){ ctx.fillStyle = 'rgba(200,40,30,0.9)'; ctx.font = 'bold 16px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('ARTILLERY ARMED — tap the field to fire', W/2, FIELD_Y + 24); ctx.textAlign = 'left'; } } function strokePts(pts){ ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for(let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); ctx.stroke(); } function drawTrench(baseY){ const pts = trenchPts(baseY); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.strokeStyle = '#77572f'; ctx.lineWidth = 47; strokePts(pts); // muddy rim / sandbags base ctx.strokeStyle = '#6b543a'; ctx.lineWidth = 42; strokePts(pts); // mud ctx.strokeStyle = '#43331f'; ctx.lineWidth = 27; strokePts(pts); // trench interior ctx.strokeStyle = 'rgba(20,14,8,0.55)'; ctx.lineWidth = 13; ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y + 3); for(let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y + 3); ctx.stroke(); // sandbags along both edges ctx.fillStyle = '#8a7350'; for(let s = 0; s < pts.length - 1; s++){ const a = pts[s], b = pts[s + 1]; for(let k = 0; k <= 4; k++){ const x = a.x + (b.x - a.x) * k / 4, y = a.y + (b.y - a.y) * k / 4; ctx.beginPath(); ctx.ellipse(x, y - 22, 7, 4.4, 0, 0, 7); ctx.fill(); ctx.beginPath(); ctx.ellipse(x + 9, y + 22, 7, 4.4, 0, 0, 7); ctx.fill(); } } // duckboards ctx.fillStyle = 'rgba(146,116,74,0.5)'; for(let s = 0; s < pts.length - 1; s++){ const a = pts[s], b = pts[s + 1]; for(let k = 1; k <= 2; k++){ const x = a.x + (b.x - a.x) * k / 3, y = a.y + (b.y - a.y) * k / 3; ctx.fillRect(x - 9, y - 2, 18, 4); } } } function drawTree(t){ ctx.save(); ctx.translate(t.x, t.y); ctx.rotate(t.lean); ctx.scale(t.s, t.s); ctx.strokeStyle = '#3d3830'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(0, 10); ctx.lineTo(0, -14); ctx.stroke(); ctx.lineWidth = 2.4; ctx.beginPath(); ctx.moveTo(0, -6); ctx.lineTo(-11, -18); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, -10); ctx.lineTo(10, -22); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, -14); ctx.lineTo(-3, -26); ctx.stroke(); ctx.restore(); } function drawWreck(w){ ctx.save(); ctx.translate(w.x, w.y); ctx.rotate(w.rot); ctx.fillStyle = 'rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.ellipse(2, 5, 30, 15, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#4a443c'; rr(-26, -13, 52, 26, 4); ctx.fill(); ctx.fillStyle = '#38332c'; rr(-26, -16, 52, 6, 3); ctx.fill(); rr(-26, 10, 52, 6, 3); ctx.fill(); ctx.fillStyle = '#554e42'; ctx.beginPath(); ctx.arc(-2, 0, 10, 0, 7); ctx.fill(); ctx.strokeStyle = '#2e2a24'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(-2, 0); ctx.lineTo(24, -7); ctx.stroke(); ctx.restore(); } function drawGhost(){ if(!shopSel || !mouse.in || state !== 'play') return; if(mouse.y < FIELD_Y || mouse.y >= SHOP_Y) return; const def = DEF_DEFS[shopSel]; const range = def.lv ? def.lv[0].range : (def.radius || def.splash || 60); const bad = mouse.y < PLACE_MIN_Y; ctx.globalAlpha = 0.30; ctx.fillStyle = bad ? '#c03a30' : def.color; ctx.beginPath(); ctx.arc(mouse.x, mouse.y, range, 0, 7); ctx.fill(); ctx.globalAlpha = 0.85; ctx.beginPath(); ctx.arc(mouse.x, mouse.y, 13, 0, 7); ctx.fill(); ctx.globalAlpha = 1; } function drawDef(d){ const def = DEF_DEFS[d.type]; if(defSel === d){ const st = def.lv ? defStats(d) : null; const rad = st ? st.range : (def.radius || def.splash || 40); ctx.fillStyle = 'rgba(255,255,255,0.13)'; ctx.beginPath(); ctx.arc(d.x, d.y, rad, 0, 7); ctx.fill(); ctx.strokeStyle = 'rgba(60,60,60,0.6)'; ctx.lineWidth = 1.5; ctx.setLineDash([7, 6]); ctx.beginPath(); ctx.arc(d.x, d.y, rad, 0, 7); ctx.stroke(); ctx.setLineDash([]); } ctx.fillStyle = 'rgba(40,36,30,0.25)'; ctx.beginPath(); ctx.ellipse(d.x, d.y + 8, 16, 6, 0, 0, 7); ctx.fill(); const s = 1 + d.lvl * 0.13; if(d.type === 'wire'){ ctx.strokeStyle = '#7d838a'; ctx.lineWidth = 2.2; for(const o of [[-11, 0], [0, -4], [11, 0], [-5, 5], [6, 5]]){ ctx.beginPath(); ctx.arc(d.x + o[0], d.y + o[1], 8, 0, 7); ctx.stroke(); } ctx.strokeStyle = '#5c5348'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(d.x - 16, d.y - 10); ctx.lineTo(d.x - 16, d.y + 9); ctx.stroke(); ctx.beginPath(); ctx.moveTo(d.x + 16, d.y - 10); ctx.lineTo(d.x + 16, d.y + 9); ctx.stroke(); return; } if(d.type === 'mine'){ ctx.fillStyle = '#37312a'; ctx.beginPath(); ctx.arc(d.x, d.y, 8, 0, 7); ctx.fill(); ctx.strokeStyle = '#241f19'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(d.x, d.y, 8, 0, 7); ctx.stroke(); ctx.fillStyle = '#a33327'; ctx.beginPath(); ctx.arc(d.x, d.y, 2.6, 0, 7); ctx.fill(); return; } ctx.save(); ctx.translate(d.x, d.y); ctx.scale(s, s); if(d.type === 'mg'){ ctx.fillStyle = '#8a7350'; for(const o of [[-14, 8], [-7, 11], [0, 12], [7, 11], [14, 8]]) { ctx.beginPath(); ctx.arc(o[0], o[1], 5.4, 0, 7); ctx.fill(); } ctx.fillStyle = '#596052'; ctx.beginPath(); ctx.arc(0, 2, 7.5, 0, 7); ctx.fill(); ctx.fillStyle = '#3a4030'; ctx.beginPath(); ctx.arc(0, 0, 5.4, 0, 7); ctx.fill(); ctx.save(); ctx.rotate(d.angle); ctx.strokeStyle = '#22252a'; ctx.lineWidth = 4.4; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(17, 0); ctx.stroke(); ctx.strokeStyle = '#3c4046'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(13, -3); ctx.lineTo(13, 3); ctx.stroke(); ctx.restore(); } else if(d.type === 'sniper'){ ctx.fillStyle = '#6a6a58'; ctx.beginPath(); ctx.ellipse(0, 3, 11, 5.4, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#4c4c3e'; ctx.beginPath(); ctx.arc(-2, 0, 5, 0, 7); ctx.fill(); ctx.save(); ctx.rotate(d.angle); ctx.strokeStyle = '#26221c'; ctx.lineWidth = 2.6; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(27, 0); ctx.stroke(); ctx.fillStyle = '#c8cdd2'; ctx.beginPath(); ctx.arc(9, -2.4, 1.5, 0, 7); ctx.fill(); ctx.restore(); ctx.fillStyle = 'rgba(90,96,74,0.8)'; ctx.beginPath(); ctx.arc(-9, -6, 3.4, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(8, 7, 3, 0, 7); ctx.fill(); } else if(d.type === 'mortar'){ ctx.fillStyle = '#494540'; ctx.beginPath(); ctx.arc(0, 0, 13, 0, 7); ctx.fill(); ctx.strokeStyle = '#2e2b27'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, 13, 0, 7); ctx.stroke(); ctx.strokeStyle = '#22252a'; ctx.lineWidth = 6; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(2, 3); ctx.lineTo(-6, -13); ctx.stroke(); ctx.fillStyle = '#5d6167'; ctx.beginPath(); ctx.arc(8, 7, 3, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(12, 4, 3, 0, 7); ctx.fill(); } ctx.restore(); // level pips for(let i = 0; i < 3; i++){ ctx.fillStyle = i <= d.lvl ? '#e8c97a' : 'rgba(70,70,70,0.35)'; ctx.beginPath(); ctx.arc(d.x - 10 + i * 10, d.y + 20, 3.2, 0, 7); ctx.fill(); } } function drawShells(){ for(const sh of shells){ const x = sh.sx + (sh.tx - sh.sx) * sh.t, y = sh.sy + (sh.ty - sh.sy) * sh.t; const h = Math.sin(Math.PI * Math.min(1, sh.t)) * 48; ctx.fillStyle = 'rgba(40,40,40,0.28)'; ctx.beginPath(); ctx.ellipse(x, y, 6, 2.6, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#2c2e30'; ctx.beginPath(); ctx.arc(x, y - h, 3.6, 0, 7); ctx.fill(); } } function drawEnemy(e){ const wob = Math.sin(e.wob) * 0.7; ctx.fillStyle = 'rgba(40,42,46,0.25)'; ctx.beginPath(); ctx.ellipse(e.x, e.y + e.r * 0.75, e.r * 0.95, e.r * 0.4, 0, 0, 7); ctx.fill(); if(e.kind === 'tank' || e.kind === 'boss' || e.kind === 'mini'){ drawTank(e, wob); } else { const uni = e.kind === 'officer' ? '#4c5544' : (e.kind === 'mgteam' ? '#565d49' : (e.kind === 'runner' ? '#6b7055' : '#5f6650')); ctx.fillStyle = uni; ctx.beginPath(); ctx.arc(e.x, e.y, e.r, 0, 7); ctx.fill(); ctx.fillStyle = '#3a4030'; // helmet ctx.beginPath(); ctx.arc(e.x, e.y - e.r * 0.22, e.r * 0.78, Math.PI, 0); ctx.fill(); ctx.strokeStyle = '#2c2e26'; ctx.lineWidth = 2; // rifle ctx.beginPath(); ctx.moveTo(e.x + e.r * 0.5, e.y - 2); ctx.lineTo(e.x + e.r * 0.9, e.y + e.r * 0.8); ctx.stroke(); if(e.kind === 'officer'){ ctx.fillStyle = '#a33327'; ctx.fillRect(e.x - e.r * 0.8, e.y - e.r * 0.42, e.r * 1.6, 2.4); } if(e.kind === 'mgteam'){ ctx.fillStyle = '#33363b'; ctx.fillRect(e.x - 7, e.y + 2, 14, 5); ctx.fillStyle = uni; ctx.beginPath(); ctx.arc(e.x - 8, e.y + 6, 4.4, 0, 7); ctx.fill(); } } if(e.hp < e.maxHp){ const w = Math.max(26, e.r * 2), p = Math.max(0, e.hp / e.maxHp); ctx.fillStyle = 'rgba(20,20,20,0.6)'; ctx.fillRect(e.x - w/2, e.y - e.r - 11, w, 5); ctx.fillStyle = p > 0.5 ? '#7fae4c' : (p > 0.25 ? '#d8a03c' : '#c03a30'); ctx.fillRect(e.x - w/2, e.y - e.r - 11, w * p, 5); } } function drawTank(e, wob){ const boss = e.kind === 'boss', mini = e.kind === 'mini'; const bw = boss ? 62 : (mini ? 36 : 46), bh = boss ? 42 : (mini ? 24 : 31); ctx.save(); ctx.translate(e.x, e.y + wob * 0.3); // tracks ctx.fillStyle = '#2b2b2d'; rr(-bw/2, -bh/2, 9, bh, 3); ctx.fill(); rr(bw/2 - 9, -bh/2, 9, bh, 3); ctx.fill(); ctx.fillStyle = '#45464a'; for(let i = 0; i < 4; i++){ ctx.beginPath(); ctx.arc(-bw/2 + 4.5, -bh/2 + 6 + i * (bh - 12) / 3, 2.4, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(bw/2 - 4.5, -bh/2 + 6 + i * (bh - 12) / 3, 2.4, 0, 7); ctx.fill(); } if(mini){ // armored car wheels ctx.fillStyle = '#1f1f21'; for(const o of [[-13, -11], [13, -11], [-13, 11], [13, 11]]) { ctx.beginPath(); ctx.arc(o[0], o[1], 4.6, 0, 7); ctx.fill(); } } // hull ctx.fillStyle = boss ? '#4c5054' : '#5d6167'; rr(-bw/2 + 7, -bh/2 + 2, bw - 14, bh - 4, 4); ctx.fill(); ctx.strokeStyle = '#33363a'; ctx.lineWidth = 1.6; rr(-bw/2 + 7, -bh/2 + 2, bw - 14, bh - 4, 4); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.10)'; ctx.fillRect(-bw/2 + 10, -bh/2 + 4, bw - 20, 4); // turret + barrel (pointing down toward player) ctx.fillStyle = boss ? '#3f4347' : '#4c5054'; ctx.beginPath(); ctx.arc(0, -2, boss ? 12 : 8.5, 0, 7); ctx.fill(); ctx.strokeStyle = '#26282c'; ctx.lineWidth = boss ? 5 : 3.6; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, bh/2 + (boss ? 15 : 11)); ctx.stroke(); if(boss){ ctx.beginPath(); ctx.arc(-14, 6, 6, 0, 7); ctx.fill(); ctx.beginPath(); ctx.moveTo(-14, 6); ctx.lineTo(-14, bh/2 + 9); ctx.stroke(); } ctx.restore(); } function drawFx(){ for(const t of tracers){ ctx.globalAlpha = Math.max(0, t.ttl / 0.09); ctx.strokeStyle = t.color; ctx.lineWidth = t.big ? 2.6 : 1.6; ctx.beginPath(); ctx.moveTo(t.x1, t.y1); ctx.lineTo(t.x2, t.y2); ctx.stroke(); } ctx.globalAlpha = 1; for(const m of muzzle){ ctx.fillStyle = 'rgba(255,205,90,0.9)'; ctx.beginPath(); ctx.arc(m.x, m.y, 4.4, 0, 7); ctx.fill(); } for(const f of flashes){ const k = Math.max(0, f.ttl / f.max); ctx.globalAlpha = k; const g = ctx.createRadialGradient(f.x, f.y, 1, f.x, f.y, f.r); g.addColorStop(0, 'rgba(255,230,170,0.95)'); g.addColorStop(0.35, 'rgba(240,130,40,0.8)'); g.addColorStop(1, 'rgba(120,60,30,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, 7); ctx.fill(); } ctx.globalAlpha = 1; for(const s of smoke){ const a = Math.max(0, s.ttl / s.max) * 0.42; ctx.fillStyle = 'rgba(96,98,102,' + a + ')'; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, 7); ctx.fill(); } } 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(20,18,14,0.65)'; 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; ctx.textAlign = 'left'; } function drawSnow(){ ctx.fillStyle = 'rgba(255,255,255,0.85)'; for(const f of flakes){ ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, 7); ctx.fill(); } } function drawVignette(){ const g = ctx.createRadialGradient(W/2, H/2, H * 0.36, W/2, H/2, H * 0.78); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(10,10,12,0.30)'); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); } function drawHUD(){ ctx.fillStyle = '#1b1e23'; ctx.fillRect(0, 0, W, HUD_H); ctx.strokeStyle = 'rgba(200,205,210,0.25)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, HUD_H); ctx.lineTo(W, HUD_H); ctx.stroke(); ctx.textAlign = 'left'; ctx.fillStyle = '#e8ecf0'; ctx.font = 'bold 20px sans-serif'; ctx.fillText('WAVE ' + Math.min(wave, 50) + '/50', 16, 30); ctx.font = 'bold 19px sans-serif'; ctx.fillStyle = '#e8c97a'; ctx.fillText('💰 ' + fmt(gold), 16, 55); ctx.fillStyle = lives <= 5 ? '#ff5d5d' : '#e08a8a'; ctx.fillText('❤ ' + lives, 150, 55); ctx.fillStyle = '#a8b0ba'; ctx.font = '16px sans-serif'; ctx.fillText('SCORE ' + fmt(score), 235, 55); if(waveActive){ ctx.fillStyle = '#ffd166'; ctx.font = 'bold 17px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('👾 ' + (queue.length + enemies.length) + ' left', 480, 40); ctx.textAlign = 'left'; } else if(state === 'play' && wave <= 50){ ctx.fillStyle = '#8fce6e'; ctx.font = 'bold 16px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Ready — start the wave when you are', 470, 40); ctx.textAlign = 'left'; } // artillery button const a = UI.art; const ready = artCD <= 0; ctx.fillStyle = artArmed ? 'rgba(190,50,35,0.45)' : (ready ? 'rgba(190,120,50,0.28)' : 'rgba(90,90,100,0.30)'); rr(a.x, a.y, a.w, a.h, 8); ctx.fill(); ctx.strokeStyle = artArmed ? '#e05a3a' : (ready ? '#d8923c' : '#5a5e64'); ctx.lineWidth = 1.6; rr(a.x, a.y, a.w, a.h, 8); ctx.stroke(); ctx.fillStyle = artArmed ? '#ffd9c9' : (ready ? '#f0d9b8' : '#9aa0a6'); ctx.font = 'bold 13px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(artArmed ? '🎯 TAP TARGET' : (ready ? '🎯 ARTILLERY' : '🎯 ' + Math.ceil(artCD) + 's'), a.x + a.w/2, a.y + 26); ctx.textAlign = 'left'; if(logoReady){ const lhh = 30, lww = lhh * logoImg.width / logoImg.height; ctx.drawImage(logoImg, W - 172 - lww, 17, lww, lhh); } else { ctx.fillStyle = 'rgba(180,188,198,0.5)'; ctx.font = '11px sans-serif'; ctx.textAlign = 'right'; ctx.fillText('WIKIBUSINESS', W - 176, 56); ctx.textAlign = 'left'; } hudBtn(UI.mute, muted ? '🔇' : '🔊'); hudBtn(UI.restart, '⟳'); } function hudBtn(r, icon){ ctx.fillStyle = 'rgba(220,225,230,0.10)'; rr(r.x, r.y, r.w, r.h, 8); ctx.fill(); ctx.strokeStyle = 'rgba(220,225,230,0.35)'; ctx.lineWidth = 1.5; rr(r.x, r.y, r.w, r.h, 8); ctx.stroke(); ctx.fillStyle = '#dfe4ea'; 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 = '#191c20'; ctx.fillRect(0, SHOP_Y, W, H - SHOP_Y); ctx.strokeStyle = 'rgba(200,205,210,0.22)'; ctx.beginPath(); ctx.moveTo(0, SHOP_Y); ctx.lineTo(W, SHOP_Y); ctx.stroke(); if(defSel) drawDefPanel(); else drawShop(); } function drawShop(){ ctx.fillStyle = '#a8b0ba'; ctx.font = 'bold 13px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('DEPLOY (tap a defense, then tap the field)', 16, SHOP_Y + 10); for(let i = 0; i < UI.cards.length; i++){ const c = UI.cards[i], def = DEF_DEFS[c.type]; const sel = shopSel === c.type, afford = gold >= def.cost; ctx.fillStyle = sel ? 'rgba(200,170,90,0.20)' : 'rgba(38,42,48,0.95)'; rr(c.x, c.y, c.w, c.h, 10); ctx.fill(); ctx.strokeStyle = sel ? '#d8b25c' : (afford ? 'rgba(170,178,188,0.4)' : 'rgba(120,120,130,0.22)'); 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 = '#e8ecf0'; ctx.font = 'bold 15px sans-serif'; ctx.fillText(def.name, c.x + 11, c.y + 30); ctx.fillStyle = '#e8c97a'; ctx.font = 'bold 15px sans-serif'; ctx.fillText('$' + def.cost, c.x + 11, c.y + 52); ctx.fillStyle = '#9aa4ae'; ctx.font = '11.5px sans-serif'; ctx.fillText(def.desc, c.x + 11, c.y + 72); ctx.fillStyle = 'rgba(200,208,216,0.55)'; ctx.font = 'bold 12px sans-serif'; ctx.fillText('[' + (i + 1) + ']', c.x + c.w - 28, c.y + 30); ctx.globalAlpha = 1; } const s = UI.start; ctx.fillStyle = waveActive ? 'rgba(90,90,100,0.4)' : 'rgba(110,170,80,0.28)'; rr(s.x, s.y, s.w, s.h, 10); ctx.fill(); ctx.strokeStyle = waveActive ? '#666' : '#8fce6e'; ctx.lineWidth = 2; rr(s.x, s.y, s.w, s.h, 10); ctx.stroke(); ctx.fillStyle = waveActive ? '#999' : '#dcf2cd'; 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('wave ' + Math.min(wave, 50), 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(220,225,230,0.10)'; rr(r.x, r.y, r.w, r.h, 10); ctx.fill(); ctx.strokeStyle = 'rgba(220,225,230,0.35)'; ctx.lineWidth = 1.5; rr(r.x, r.y, r.w, r.h, 10); ctx.stroke(); ctx.fillStyle = '#dfe4ea'; 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 === 'mortar') return 'DMG ' + st.dmg + ' • ' + st.rate + '/s • RANGE ' + st.range + ' • SPLASH ' + st.splash; return 'DMG ' + st.dmg + ' • ' + st.rate + '/s • RANGE ' + st.range; } function drawDefPanel(){ const d = defSel, def = DEF_DEFS[d.type]; ctx.fillStyle = 'rgba(30,34,40,0.97)'; rr(8, SHOP_Y + 6, W - 16, 108, 10); ctx.fill(); ctx.strokeStyle = '#8a8f96'; ctx.lineWidth = 2; rr(8, SHOP_Y + 6, W - 16, 108, 10); ctx.stroke(); ctx.fillStyle = '#e8ecf0'; ctx.font = 'bold 19px sans-serif'; ctx.fillText(def.name + (def.lv ? ' — Level ' + (d.lvl + 1) : ''), 28, SHOP_Y + 36); ctx.fillStyle = '#a8b0ba'; ctx.font = '14px sans-serif'; if(def.lv){ ctx.fillText(statLine(d.type, defStats(d)), 28, SHOP_Y + 60); ctx.fillStyle = 'rgba(200,208,216,0.6)'; ctx.font = '12px sans-serif'; if(d.lvl < 2) ctx.fillText('Next: ' + statLine(d.type, def.lv[d.lvl + 1]), 28, SHOP_Y + 84); else ctx.fillText('MAX level reached.', 28, SHOP_Y + 84); } else if(d.type === 'wire'){ ctx.fillText('SLOW 45% • RADIUS ' + def.radius + ' • INTEGRITY ' + Math.max(0, d.integrity) + '/' + def.dur, 28, SHOP_Y + 60); } else { ctx.fillText('DMG ' + def.dmg + ' • SPLASH ' + def.splash + ' • ARMED — one shot', 28, SHOP_Y + 60); } // upgrade button (leveled defenses only) if(def.lv){ const e = UI.upgrade, can = d.lvl < 2; const cost = can ? def.lv[d.lvl + 1].cost : 0, afford = gold >= cost; ctx.fillStyle = !can ? 'rgba(90,90,100,0.4)' : (afford ? 'rgba(110,170,80,0.28)' : 'rgba(150,70,60,0.25)'); rr(e.x, e.y, e.w, e.h, 10); ctx.fill(); ctx.strokeStyle = !can ? '#666' : (afford ? '#8fce6e' : '#a05050'); ctx.lineWidth = 2; rr(e.x, e.y, e.w, e.h, 10); ctx.stroke(); ctx.fillStyle = !can ? '#999' : (afford ? '#dcf2cd' : '#ffb0b0'); ctx.font = 'bold 15px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(can ? '⬆ UPGRADE $' + cost : 'MAX', e.x + e.w/2, e.y + 29); } else { ctx.fillStyle = 'rgba(200,208,216,0.55)'; ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('(no upgrades)', UI.upgrade.x + UI.upgrade.w/2, UI.upgrade.y + 29); } ctx.textAlign = 'center'; const sl = UI.sell, val = Math.floor(d.invested * 0.7); ctx.fillStyle = 'rgba(216,178,92,0.18)'; rr(sl.x, sl.y, sl.w, sl.h, 10); ctx.fill(); ctx.strokeStyle = '#d8b25c'; ctx.lineWidth = 2; rr(sl.x, sl.y, sl.w, sl.h, 10); ctx.stroke(); ctx.fillStyle = '#f0dfae'; ctx.font = 'bold 15px sans-serif'; ctx.fillText('SELL +$' + val, sl.x + sl.w/2, sl.y + 29); const c = UI.pclose; ctx.fillStyle = 'rgba(140,150,160,0.15)'; rr(c.x, c.y, c.w, c.h, 10); ctx.fill(); ctx.strokeStyle = '#8fa0a8'; ctx.lineWidth = 2; rr(c.x, c.y, c.w, c.h, 10); ctx.stroke(); ctx.fillStyle = '#dfe8ea'; ctx.fillText('✕', c.x + c.w/2, c.y + 29); ctx.textAlign = 'left'; } function drawMenu(){ ctx.fillStyle = 'rgba(16,18,22,0.80)'; ctx.fillRect(0, 0, W, H); ctx.textAlign = 'center'; if(logoReady){ const lw = 280, lh = lw * logoImg.height / logoImg.width; ctx.drawImage(logoImg, W/2 - lw/2, 46, lw, lh); } ctx.fillStyle = '#e8ecf0'; ctx.font = 'bold 52px sans-serif'; ctx.fillText('TRENCH DEFENSE', W/2, 288); ctx.fillStyle = '#d8b25c'; ctx.font = 'bold 24px sans-serif'; ctx.fillText('Hold the line — 50 waves', W/2, 326); ctx.fillStyle = '#c3cad2'; ctx.font = '16px sans-serif'; const lines = [ 'The enemy pours out of the far trench and crosses the snow.', 'Tap a defense below, then tap the field to deploy it.', 'Tap a placed defense to upgrade it (3 levels) or sell it.', 'Barbed wire slows them. Mines end arguments. 🎯 Artillery (A)', 'clears a whole patch of no-man\'s land — 25s to reload.', 'Mini-boss every 5 waves, a LANDSHIP every 10… and wave 50.', 'Lives lost: infantry 1 • tank 3 • bosses up to 8. Hold the line!', ]; lines.forEach((l, i) => ctx.fillText(l, W/2, 372 + i * 27)); menuBtn(UI.menuStart, '▶ START NEW GAME', '#8fce6e'); if(hasSave()) menuBtn(UI.menuContinue, '↻ CONTINUE — WAVE ' + JSON.parse(localStorage.getItem(SAVE_KEY)).wave, '#7ab5d9'); ctx.textAlign = 'left'; } function menuBtn(r, label, color){ ctx.fillStyle = 'rgba(24,28,34,0.96)'; 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(14,16,20,0.86)'; ctx.fillRect(0, 0, W, H); ctx.textAlign = 'center'; if(logoReady){ const lw = 200, lh = lw * logoImg.height / logoImg.width; ctx.drawImage(logoImg, W/2 - lw/2, 92, lw, lh); } ctx.fillStyle = win ? '#8fce6e' : '#e05a4a'; ctx.font = 'bold 54px sans-serif'; ctx.fillText(win ? '🏆 THE LINE HELD!' : '💀 TRENCH OVERRUN', W/2, 330); ctx.fillStyle = '#c3cad2'; ctx.font = '20px sans-serif'; ctx.fillText(win ? 'Fifty waves repelled. The snow is quiet again.' : 'You fell on wave ' + Math.min(wave, 50) + '. The enemy takes the trench.', W/2, 386); ctx.fillStyle = '#e8c97a'; ctx.font = 'bold 22px sans-serif'; ctx.fillText('SCORE ' + fmt(score) + ' • DEFENSES ' + defs.length, W/2, 428); menuBtn(UI.again, win ? '↻ PLAY AGAIN' : '↻ TRY AGAIN', win ? '#8fce6e' : '#e08a7a'); ctx.textAlign = 'left'; } // ================= INPUT ================= function canvasPos(ev){ const r = cv.getBoundingClientRect(); return { x: (ev.clientX - r.left) * W / r.width, y: (ev.clientY - 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); mouse.x = p.x; mouse.y = p.y; mouse.in = true; }); cv.addEventListener('pointerleave', () => { mouse.in = false; }); cv.addEventListener('contextmenu', ev => ev.preventDefault()); function defAt(x,y){ let best = null, bd = 32 * 32; for(const d of defs){ const dx = d.x - x, dy = d.y - y, dd = dx*dx + dy*dy; if(dd < bd){ bd = dd; best = d; } } return best; } 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(y < HUD_H){ if(inRect(x, y, UI.mute)){ muted = !muted; return; } if(inRect(x, y, UI.restart)){ saveGame(); state = 'menu'; return; } if(inRect(x, y, UI.art)){ if(artCD <= 0){ artArmed = !artArmed; sfx('click'); } else deny('Artillery reloading'); return; } return; } // bottom area if(y >= SHOP_Y){ if(defSel){ if(DEF_DEFS[defSel.type].lv && inRect(x, y, UI.upgrade)){ tryUpgrade(); return; } if(inRect(x, y, UI.sell)){ trySell(); return; } if(inRect(x, y, UI.pclose)){ defSel = null; return; } return; } for(const c of UI.cards){ if(inRect(x, y, c)){ shopSel = (shopSel === c.type ? null : c.type); defSel = null; sfx('click'); 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; } // field if(artArmed){ fireArtillery(x, y); return; } if(shopSel){ tryPlace(x, y); return; } const d = defAt(x, y); defSel = d || null; if(d) sfx('click'); } window.addEventListener('keydown', ev => { 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]); defSel = null; sfx('click'); } else if(k === 'escape'){ shopSel = null; defSel = null; artArmed = false; } else if(k === ' '){ ev.preventDefault(); startWave(); } else if(k === 'a'){ if(artCD <= 0){ artArmed = !artArmed; sfx('click'); } else deny('Artillery reloading'); } 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; updateFx(Math.min(dt, 0.05)); update(dt); render(); requestAnimationFrame(loop); } render(); requestAnimationFrame(loop); })();