diff --git a/src/main.js b/src/main.js index d1479c8..7e5db36 100644 --- a/src/main.js +++ b/src/main.js @@ -738,14 +738,17 @@ function tryEmitParticle(x, y, source) { if (canFlowDL || canFlowDR || canSpillL || canSpillR) return p; - const blocking = napalmParticles.find(np => np.alive && np.x === p.x && np.y === p.y); - if (blocking) { - for (let y = p.y; y >= 0; y--) { + const occupied = napalmParticles.some(np => np.alive && np.x === p.x && np.y === p.y) || hasFireAt(p.x, p.y); + if (occupied) { + for (let y = p.y - 1; y >= 0; y--) { const np = napalmParticles.find(n => n.alive && n.x === p.x && n.y === y); if (np) np.y--; else break; } - return p; + if (!isTerrain(terrain, p.x, p.y - 1) && !napalmParticles.some(np => np.alive && np.x === p.x && np.y === p.y - 1)) { + p.y--; + return p; + } } return null; @@ -799,16 +802,28 @@ function updateNapalm(dt) { fireCells = fireCells.filter(f => f.timeLeft > 0); for (let f of deadFireCells) { clipTerrain(terrain, (ctx) => drawRect(ctx, f.x, f.y + 1, 1, 1, ctx.color)); + for (let dx = -2; dx <= 2; dx++) { + for (let dy = 0; dy <= 2; dy++) { + if (dx === 0 && dy === 0) continue; + const sx = f.x + dx; + const sy = f.y + 1 + dy; + if (!isTerrain(terrain, sx, sy)) continue; + const dist = Math.max(Math.abs(dx), Math.abs(dy)); + const alpha = dist === 1 ? 0.4 : 0.2; + terrain.fillStyle = `rgba(0,0,0,${alpha})`; + terrain.fillRect(sx, sy, 1, 1); + } + } } for (let f of fireCells) { - if (Math.random() < dt * 6) smokeParticles.push({ + if (Math.random() < dt * 12) smokeParticles.push({ x: f.x + (Math.random() > 0.5 ? 1 : -1) * Math.random(), y: f.y, vx: (Math.random() - 0.5) * 5, vy: -(8 + Math.random() * 12), - alpha: 0.3 + Math.random() * 0.15, - lifetime: 1 + Math.random() * 0.8, + alpha: 0.35 + Math.random() * 0.2, + lifetime: 1.2 + Math.random() * 1.0, }); } @@ -918,7 +933,7 @@ function updateParticle(p) { // standing on another napalm particle - behave as if on fire terrain } - if (below && !hasFireAt(p.x, p.y) && !napalmBelow) { + if (below && !hasFireAt(p.x, p.y) && !napalmBelow && !hasFireAt(p.x, p.y + 1)) { createFire(p.x, p.y, p.source); p.alive = false; reservedPositions.delete(p.x + ',' + p.y); @@ -934,14 +949,16 @@ function updateParticle(p) { if (claimMove(p, p.x + dir, p.y + 1)) { p.dir = dir; p.hasDir = true; - } else if (flowDirs.length > 1) { + return; + } + if (flowDirs.length > 1) { const other = flowDirs.find(d => d !== dir); if (claimMove(p, p.x + other, p.y + 1)) { p.dir = other; p.hasDir = true; + return; } } - return; } const spillDirs = []; @@ -952,14 +969,16 @@ function updateParticle(p) { if (claimMove(p, p.x + dir, p.y)) { p.dir = dir; p.hasDir = true; - } else if (spillDirs.length > 1) { + return; + } + if (spillDirs.length > 1) { const other = spillDirs.find(d => d !== dir); if (claimMove(p, p.x + other, p.y)) { p.dir = other; p.hasDir = true; + return; } } - return; } const canPushLeft = !isTerrain(terrain, p.x - 1, p.y) && napalmParticles.some(np => np.alive && np.x === p.x - 1 && np.y === p.y) @@ -982,6 +1001,12 @@ function updateParticle(p) { if (tryFlow(p, p.dir) || trySpill(p, p.dir)) return; + const otherDir = p.dir === -1 ? 1 : -1; + if (tryFlow(p, otherDir)) { + p.dir = otherDir; + return; + } + if (p.x === p.emitX && p.y === p.emitY) { pushNapalmColumn(p.x, p.y - 1); } @@ -1001,8 +1026,8 @@ function applyFireDamage(dt) { function drawNapalm() { for (let s of smokeParticles) { - foreground.globalAlpha = s.alpha * 0.4; - drawRect(foreground, s.x, s.y - 1, 2, 2, '#666'); + foreground.globalAlpha = s.alpha * 0.7; + drawRect(foreground, s.x, s.y - 1, 3, 3, '#999'); foreground.globalAlpha = 1; } @@ -1191,7 +1216,7 @@ function drawParticles() { function drawStartMenuPanel() { drawPanelBg(framebuffer); - drawPanelTitle(framebuffer, 'RAVAGED PLANET'); + drawPanelTitle(framebuffer, 'RAVAGED PLANET', '#f60'); const playerCounts = [2, 3, 4, 5, 6]; const roundCounts = [1, 3, 5, 10]; @@ -1220,7 +1245,7 @@ function drawStartMenuPanel() { function drawMarketPanel() { drawPanelBg(framebuffer); - drawPanelTitle(framebuffer, 'MARKET'); + drawPanelTitle(framebuffer, 'MARKET', '#0c8'); const humanPlayer = players.find(p => !p.ai); drawPanelText(framebuffer, `Score: ${score}`, 160, 100, 'yellow'); @@ -1268,12 +1293,12 @@ function drawRoundEndPanel() { drawPanelBg(framebuffer); if (winner) { - drawPanelTitle(framebuffer, `Round ${round} Complete!`); + drawPanelTitle(framebuffer, `Round ${round} Complete!`, '#48f'); drawPanelText(framebuffer, `Winner: ${winner.name}`, 160, 100, winner.c); drawPanelText(framebuffer, `Kills: ${winner.kills}`, 160, 130, 'white'); drawPanelText(framebuffer, `Round Score: ${winner.kills * SCORE_PER_KILL + SCORE_FOR_WIN}`, 160, 160, 'white'); } else { - drawPanelTitle(framebuffer, `Round ${round} Complete!`); + drawPanelTitle(framebuffer, `Round ${round} Complete!`, '#48f'); drawPanelText(framebuffer, 'Nobody survived!', 160, 100, 'white'); } @@ -1289,7 +1314,7 @@ function drawRoundEndPanel() { function drawGameOverPanel() { drawPanelBg(framebuffer); - drawPanelTitle(framebuffer, 'GAME OVER'); + drawPanelTitle(framebuffer, 'GAME OVER', '#e22'); const y = 100; const sortedPlayers = [...players].sort((a, b) => b.wins - a.wins || b.kills - a.kills); diff --git a/src/panel.js b/src/panel.js index 36fae21..ea004ec 100644 --- a/src/panel.js +++ b/src/panel.js @@ -17,16 +17,41 @@ export function drawPanelBg(ctx) { ctx.strokeRect(PANEL_X + 3, PANEL_Y + 3, PANEL_WIDTH - 6, PANEL_HEIGHT - 6); } -export function drawPanelTitle(ctx, text) { - const x = PANEL_X + PANEL_WIDTH / 2; +export function drawBevelBar(ctx, x, y, w, h, color) { + if (w <= 0) return; + drawRect(ctx, x, y, w, h, color); + ctx.globalAlpha = 0.35; + drawRect(ctx, x, y, w, 1, '#fff'); + drawRect(ctx, x, y, 1, h, '#fff'); + drawRect(ctx, x, y + h - 1, w, 1, '#000'); + drawRect(ctx, x + w - 1, y, 1, h, '#000'); + ctx.globalAlpha = 1.0; +} + +export function drawPanelTitle(ctx, text, barColor) { + const cx = PANEL_X + PANEL_WIDTH / 2; const y = PANEL_Y + 20; + const gap = 8; + const margin = 20; + ctx.font = `${PANEL_TITLE_FONT_SIZE}px ibm-vga`; + const textWidth = ctx.measureText(text).width; + const barH = PANEL_TITLE_FONT_SIZE; + + const leftEnd = cx - textWidth / 2 - gap; + const rightStart = cx + textWidth / 2 + gap; + + if (barColor) { + drawBevelBar(ctx, PANEL_X + margin, y, leftEnd - (PANEL_X + margin), barH, barColor); + drawBevelBar(ctx, rightStart, y, (PANEL_X + PANEL_WIDTH - margin) - rightStart, barH, barColor); + } + ctx.textBaseline = 'top'; ctx.textAlign = 'center'; ctx.fillStyle = 'black'; - ctx.fillText(text, x + 1, y + 1); + ctx.fillText(text, cx + 1, y + 1); ctx.fillStyle = 'white'; - ctx.fillText(text, x, y); + ctx.fillText(text, cx, y); } export function drawPanelText(ctx, text, x, y, color = 'white', align = 'left') { diff --git a/src/weapons.js b/src/weapons.js index 2a2fec8..8f9d1fa 100644 --- a/src/weapons.js +++ b/src/weapons.js @@ -5,7 +5,7 @@ import {audio, createOsc} from './sound.js?v=15'; import {clipTerrain} from './terrain.js?v=15'; export function drawExplosion(ctx, x, y, r) { - const step = Math.max(1, Math.floor(r / 12)); + const step = Math.max(2, Math.floor(r / 6)); for (let i = r; i > 0; i -= step) { const t = 1 - (i / r); const base = 60 + Math.floor(t * 195); @@ -20,19 +20,33 @@ export function drawDirt(ctx, x, y, r, c) { drawCircle(ctx, x, y, r, c); } -export function drawLavaExplosion(ctx, x, y, r) { +export function drawLavaExplosion(ctx, x, y, r, blobs) { drawCircle(ctx, x, y, r, 'rgb(60, 0, 0)'); - const count = Math.floor(r * 1.2); - for (let i = 0; i < count; i++) { - const dist = Math.random() * r; - const angle = Math.random() * Math.PI * 2; - const sx = x + Math.cos(angle) * dist; - const sy = y + Math.sin(angle) * dist; - const spotR = Math.max(1, Math.random() * (2 + r * 0.03)); - const heat = 1 - dist / r; - const red = 180 + Math.floor(Math.random() * 75); - const green = Math.floor(Math.random() * heat * 150); - drawCircle(ctx, sx, sy, spotR, `rgb(${red}, ${green}, 0)`); + if (!blobs) return; + + for (const blob of blobs) { + const t = r * 0.1; + const drift = Math.sin(t + blob.phase) * 0.15; + const dist = r * blob.distFrac; + const angle = blob.angle + Math.sin(t * 0.2 + blob.phase * 0.5) * 0.3; + const sx = x + Math.cos(angle + drift) * dist; + const sy = y + Math.sin(angle + drift) * dist; + const pulse = 0.6 + 0.4 * Math.sin(t * 0.3 + blob.phase * 1.3); + const br = Math.max(1, blob.baseR * pulse); + const heat = 1 - blob.distFrac; + const red = Math.floor(blob.red + Math.sin(t * 0.5 + blob.phase) * 30); + const green = Math.floor(Math.abs(Math.sin(t * 0.4 + blob.phase * 0.7)) * heat * 150); + + ctx.globalAlpha = 0.15; + drawCircle(ctx, sx, sy, br * 3, `rgb(${red}, ${green}, 0)`); + + ctx.globalAlpha = 0.3; + drawCircle(ctx, sx, sy, br * 1.8, `rgb(${red}, ${green}, 0)`); + + ctx.globalAlpha = 0.7; + drawCircle(ctx, sx, sy, br, `rgb(${Math.min(255, red + 40)}, ${green}, 0)`); + + ctx.globalAlpha = 1.0; } } @@ -85,18 +99,29 @@ export const EXPLOSION_TYPES = { lava: { create(spec, x, y) { const {r} = spec; + const blobs = []; + const count = Math.max(3, Math.floor(r * 0.25)); + for (let i = 0; i < count; i++) { + blobs.push({ + phase: Math.random() * Math.PI * 2, + angle: Math.random() * Math.PI * 2, + distFrac: 0.1 + Math.random() * 0.9, + baseR: 3 + Math.random() * (4 + r * 0.06), + red: 150 + Math.random() * 100, + }); + } const osc = createOsc('sawtooth'); osc.start(); - return {type:'lava', x, y, r, cr:0, osc}; + return {type:'lava', x, y, r, cr:0, osc, blobs}; }, update(explosion, dt) { return (explosion.cr += dt * 30) < explosion.r; }, draw(explosion, foreground) { - const {x, y, cr, osc} = explosion; + const {x, y, cr, osc, blobs} = explosion; const f = cycle(explosion.cr, 6) % 2 === 0 ? 55 : 110; osc.frequency.setValueAtTime(f, audio.currentTime); - drawLavaExplosion(foreground, x, y, cr); + drawLavaExplosion(foreground, x, y, cr, blobs); }, stop(explosion) { const {osc} = explosion;