From ee0911bcbe8d510d2dc2169107dcca6616c555be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 27 Jul 2026 01:07:04 +0300 Subject: [PATCH] mouse/touchscreen controls added --- src/constants.js | 4 +- src/input.js | 82 +++++- src/main.js | 639 +++++++++++++++++++++++++++++++++++------------ src/panel.js | 242 +++++++++++++++++- 4 files changed, 802 insertions(+), 165 deletions(-) diff --git a/src/constants.js b/src/constants.js index f1f70cf..383834d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -32,8 +32,8 @@ export const WEAPON_TYPES = { missile: {id:'missile', name:'Missile', projectile:{type:'normal'}, explosion:{type:'blast', r:20}}, babyNuke: {id:'babyNuke', name:'Baby Nuke', projectile:{type:'normal'}, explosion:{type:'lava', r:50}}, nuke: {id:'nuke', name:'Nuke', projectile:{type:'normal'}, explosion:{type:'lava', r:100}}, - babyNapalm: {id:'babyNapalm', name:'Baby Napalm', projectile:{type:'normal'}, explosion:{type:'napalm', particles:32}}, - napalm: {id:'napalm', name:'Napalm', projectile:{type:'normal'}, explosion:{type:'napalm', particles:64}}, + babyNapalm: {id:'babyNapalm', name:'Napalm', projectile:{type:'normal'}, explosion:{type:'napalm', particles:32}}, + napalm: {id:'napalm', name:'Hot Napalm', projectile:{type:'normal'}, explosion:{type:'napalm', particles:64}}, babyRoller: {id:'babyRoller', name:'Baby Roller', projectile:{type:'roller'}, explosion:{type:'blast', r:15}}, roller: {id:'roller', name:'Roller', projectile:{type:'roller'}, explosion:{type:'blast', r:35}}, superRoller: {id:'superRoller', name:'Super Roller', projectile:{type:'roller'}, explosion:{type:'blast', r:60}}, diff --git a/src/input.js b/src/input.js index 3248747..8fb54d6 100644 --- a/src/input.js +++ b/src/input.js @@ -1,7 +1,11 @@ -import {DEFAULT_KEYPRESS_DELAY} from './constants.js?v=15'; +import {DEFAULT_KEYPRESS_DELAY, Z} from './constants.js?v=15'; const input = {}; let lastKeypressTime = 0; +let clickQueue = []; +let canvasEl = null; +let pointerX = 0, pointerY = 0; +let isPointerDown = false; document.addEventListener('keydown', (e) => {input[e.key] = true; e.preventDefault()}); document.addEventListener('keyup', (e) => {input[e.key] = false; e.preventDefault()}); @@ -17,3 +21,79 @@ export function afterKeyDelay(amount=DEFAULT_KEYPRESS_DELAY) { return true; } } + +export function getPointer() { + return {x: pointerX, y: pointerY}; +} + +export function isPointerPressed() { + return isPointerDown; +} + +export function initClickCanvas(canvas) { + canvasEl = canvas; + canvas.addEventListener('click', handleClick); + canvas.addEventListener('touchstart', handleTouchStart, {passive: false}); + canvas.addEventListener('touchmove', handleTouchMove, {passive: false}); + canvas.addEventListener('touchend', handleTouchEnd, {passive: false}); + canvas.addEventListener('mousemove', handleMouseMove); + canvas.addEventListener('mousedown', handleMouseDown); + canvas.addEventListener('mouseup', handleMouseUp); +} + +function getCanvasPos(clientX, clientY) { + const rect = canvasEl.getBoundingClientRect(); + return { + x: (clientX - rect.left) / Z, + y: (clientY - rect.top) / Z, + }; +} + +function handleClick(e) { + const {x, y} = getCanvasPos(e.clientX, e.clientY); + clickQueue.push({x, y}); +} + +function handleTouchStart(e) { + e.preventDefault(); + const touch = e.changedTouches[0]; + const {x, y} = getCanvasPos(touch.clientX, touch.clientY); + clickQueue.push({x, y}); + pointerX = x; + pointerY = y; + isPointerDown = true; +} + +function handleTouchMove(e) { + e.preventDefault(); + const touch = e.changedTouches[0]; + const {x, y} = getCanvasPos(touch.clientX, touch.clientY); + pointerX = x; + pointerY = y; +} + +function handleTouchEnd(e) { + e.preventDefault(); + isPointerDown = false; +} + +function handleMouseMove(e) { + const {x, y} = getCanvasPos(e.clientX, e.clientY); + pointerX = x; + pointerY = y; +} + +function handleMouseDown(e) { + const {x, y} = getCanvasPos(e.clientX, e.clientY); + pointerX = x; + pointerY = y; + isPointerDown = true; +} + +function handleMouseUp(e) { + isPointerDown = false; +} + +export function popClick() { + return clickQueue.shift() || null; +} diff --git a/src/main.js b/src/main.js index 7e5db36..44f7c1d 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,7 @@ import {AI_TYPES} from './ai.js?v=15'; import {DEATH_SPECS, EXPLOSION_SHAKE_REDUCTION_FACTOR, H, MAX_EXPLOSION_SHAKE_FACTOR, MAX_WIND, PARTICLE_AMOUNT, PARTICLE_FADE_AMOUNT, PARTICLE_MAX_POWER_FACTOR, PARTICLE_MIN_LIFETIME, PARTICLE_MIN_POWER_FACTOR, PARTICLE_POWER_REDUCTION_FACTOR, PARTICLE_TIME_FACTOR, PARTICLE_WIND_REDUCTION_FACTOR, PLAYER_ANGLE_FAST_INCREMENT, PLAYER_ANGLE_INCREMENT, PLAYER_ANGLE_TICK_SOUND_INTERVAL, PLAYER_COLORS, PLAYER_ENERGY_POWER_MULTIPLIER, PLAYER_EXPLOSION_PARTICLE_POWER, PLAYER_FALL_DAMAGE_FACTOR, PLAYER_FALL_DAMAGE_HEIGHT, PLAYER_INITIAL_POWER, PLAYER_MAX_ENERGY, PLAYER_POWER_FAST_INCREMENT, PLAYER_POWER_INCREMENT, PLAYER_POWER_TICK_SOUND_INTERVAL, PLAYER_STARTING_TOOLS, PLAYER_STARTING_WEAPONS, PLAYER_TANK_BOUNDING_RADIUS, PLAYER_TANK_Y_FOOTPRINT, SHIELD_TYPES, TRAJECTORY_FADE_SPEED, TRAJECTORY_FLOAT_SPEED, W, WEAPON_TYPES, Z, STARTING_SCORE, SCORE_PER_KILL, SCORE_FOR_WIN, MARKET_ITEMS, NAPALM_SPAWN_RATE, FIRE_DURATION, FIRE_DAMAGE} from './constants.js?v=15'; import {createCanvas, drawLine, drawRect, drawSemiCircle, drawText, loop, plot, strokeCircle} from './gfx.js?v=15'; -import {afterKeyDelay, key} from './input.js?v=15'; +import {afterKeyDelay, key, initClickCanvas, popClick, getPointer} from './input.js?v=15'; import {clamp, deg2rad, distance, parable, random, randomInt, vec, wrap} from './math.js?v=15'; import {PROJECTILE_TYPES} from './projectiles.js?v=15'; import {generateSky} from './sky.js?v=15'; @@ -9,7 +9,7 @@ import {playTickSound} from './sound.js?v=15'; import {clipTerrain, closestLand, collapseTerrain, generateTerrain, isTerrain, landHeight, startCollapseTerrain, collapseTerrainStep} from './terrain.js?v=15'; import {sample, shuffle} from './utils.js?v=15'; import {EXPLOSION_TYPES} from './weapons.js?v=15'; -import {drawPanelBg, drawPanelTitle, drawPanelText, drawPanelDivider, drawPanelMenu, getPanelBounds} from './panel.js?v=15'; +import {drawPanelBg, drawPanelTitle, drawPanelTitleFancy, drawPanelText, drawPanelDivider, drawPanelMenu, getPanelBounds, drawButton, checkHit, PANEL_X} from './panel.js?v=15'; let state = 'start-game'; @@ -24,6 +24,7 @@ let trajectories = []; let idle = false; let dt = 0; let collapseState = null; +let deathOrderCounter = 0; let napalmParticles = []; let fireCells = []; let smokeParticles = []; @@ -34,7 +35,7 @@ let winner; let score = 0; let round = 0; let totalRounds = 1; -let selectedPlayers = 6; +let selectedPlayers = 5; let selectedTerrain = null; let menuState = null; let tracerMode = true; @@ -54,6 +55,7 @@ const framebuffer = createCanvas(W, H); framebuffer.canvas.style.width = `${W * Z}px`; framebuffer.canvas.style.height = `${H * Z}px`; document.body.appendChild(framebuffer.canvas); +initClickCanvas(framebuffer.canvas); function init() { currentPlayer = 0; @@ -74,7 +76,7 @@ function initNewGame() { score = STARTING_SCORE; round = 0; totalRounds = 1; - selectedPlayers = 6; + selectedPlayers = 5; selectedTerrain = null; napalmParticles = []; fireCells = []; @@ -87,20 +89,36 @@ function initNewGame() { state = 'start-menu'; } -function updateStartMenu() { - const options = ['Players', 'Rounds', 'Terrain', 'Tracer']; - const playerCounts = [2, 3, 4, 5, 6]; - const roundCounts = [1, 3, 5, 10]; +function commitStartMenu() { + const playerCounts = [3, 4, 5, 6, 7, 8, 9, 10]; + const roundCounts = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const terrains = ['Random', 'Mountain', 'Sand']; - const tracerOptions = ['On', 'Off']; + selectedPlayers = playerCounts[menuState.values[0]]; + totalRounds = roundCounts[menuState.values[1]]; + selectedTerrain = menuState.values[2] === 0 ? null : terrains[menuState.values[2]].toLowerCase(); + tracerMode = menuState.values[3] === 0; + menuState = null; + score = STARTING_SCORE; + initAllPlayerStats(); + state = 'market'; +} + +function updateStartMenu() { + const options = ['Players', 'Rounds', 'Terrain', 'Permanent tracer mode']; + const optionValues = [[3,4,5,6,7,8,9,10], [1,2,3,4,5,6,7,8,9,10], ['Random','Mountain','Sand'], ['On','Off']]; if (!menuState) { menuState = { selected: 0, - values: [selectedPlayers, totalRounds, selectedTerrain || 0, tracerMode ? 0 : 1], + values: [optionValues[0].indexOf(selectedPlayers), optionValues[1].indexOf(totalRounds), selectedTerrain === null ? 0 : selectedTerrain === 'mountain' ? 1 : selectedTerrain === 'sand' ? 2 : 0, tracerMode ? 0 : 1], }; } + const cycleValue = (opt, dir) => { + const vals = optionValues[opt]; + menuState.values[opt] = (menuState.values[opt] + dir + vals.length) % vals.length; + }; + if (key('ArrowUp')) { if (afterKeyDelay()) { menuState.selected = (menuState.selected - 1 + options.length) % options.length; @@ -115,44 +133,43 @@ function updateStartMenu() { } else if (key('ArrowLeft')) { if (afterKeyDelay()) { - const opt = menuState.selected; - if (opt === 0) { - menuState.values[0] = (menuState.values[0] - 2 + playerCounts.length) % playerCounts.length; - } else if (opt === 1) { - menuState.values[1] = (menuState.values[1] - 1 + roundCounts.length) % roundCounts.length; - } else if (opt === 2) { - menuState.values[2] = (menuState.values[2] - 1 + terrains.length) % terrains.length; - } else if (opt === 3) { - menuState.values[3] = menuState.values[3] === 0 ? 1 : 0; - } + cycleValue(menuState.selected, -1); playTickSound(); } } else if (key('ArrowRight')) { if (afterKeyDelay()) { - const opt = menuState.selected; - if (opt === 0) { - menuState.values[0] = (menuState.values[0] + 1) % playerCounts.length; - } else if (opt === 1) { - menuState.values[1] = (menuState.values[1] + 1) % roundCounts.length; - } else if (opt === 2) { - menuState.values[2] = (menuState.values[2] + 1) % terrains.length; - } else if (opt === 3) { - menuState.values[3] = menuState.values[3] === 0 ? 1 : 0; - } + cycleValue(menuState.selected, 1); playTickSound(); } } else if (key('Enter')) { if (afterKeyDelay()) { - selectedPlayers = playerCounts[menuState.values[0]]; - totalRounds = roundCounts[menuState.values[1]]; - selectedTerrain = menuState.values[2] === 0 ? null : terrains[menuState.values[2]].toLowerCase(); - tracerMode = menuState.values[3] === 0; - menuState = null; - score = STARTING_SCORE; - initAllPlayerStats(); - state = 'market'; + commitStartMenu(); + } + } + + const click = popClick(); + if (click) { + for (let i = 0; i < 4; i++) { + const rowY = 120 + i * 30; + if (checkHit(click.x, click.y, PANEL_X+20, rowY, 168, 20)) { + menuState.selected = i; + playTickSound(); + } + if (checkHit(click.x, click.y, PANEL_X+262, rowY, 24, 18)) { + menuState.selected = i; + cycleValue(i, -1); + playTickSound(); + } + if (checkHit(click.x, click.y, PANEL_X+355, rowY, 24, 18)) { + menuState.selected = i; + cycleValue(i, 1); + playTickSound(); + } + } + if (checkHit(click.x, click.y, PANEL_X+100, 318, 200, 22)) { + commitStartMenu(); } } } @@ -190,6 +207,79 @@ function initAllPlayerStats() { } } +function buySelectedItem(item) { + const itemData = MARKET_ITEMS[item]; + const humanPlayer = players.find(p => !p.ai); + if (!humanPlayer || score < itemData.price) return; + if (item === 'babyMissile') return; + if (item === 'parachute') { + score -= itemData.price; + let t = humanPlayer.tools.find(x => x.type === 'parachute'); + if (t) t.ammo += itemData.ammo; + else humanPlayer.tools.push({type: 'parachute', ammo: itemData.ammo}); + playTickSound(); + } else if (item === 'shield') { + if (humanPlayer.shield) return; + score -= itemData.price; + humanPlayer.shield = {type:'shield', energy:SHIELD_TYPES.shield.energy}; + playTickSound(); + } else if (WEAPON_TYPES[item]) { + score -= itemData.price; + let existingWeapon = humanPlayer.weapons.find(w => w.type === item); + if (existingWeapon) { + existingWeapon.ammo += itemData.ammo; + } else { + humanPlayer.weapons.push({type: item, ammo: itemData.ammo}); + } + playTickSound(); + } +} + +function sellSelectedItem(item) { + const itemData = MARKET_ITEMS[item]; + const humanPlayer = players.find(p => !p.ai); + if (!humanPlayer) return; + if (item === 'babyMissile') return; + if (item === 'parachute') { + const t = humanPlayer.tools.find(x => x.type === 'parachute'); + if (t && t.ammo > 0) { + const soldUnits = itemData.ammo - t.ammo; + const totalRefund = Math.floor((soldUnits + 1) * itemData.price / itemData.ammo); + const refund = totalRefund - (t._refunded || 0); + t._refunded = totalRefund; + score += refund; + t.ammo--; + if (t.ammo <= 0) humanPlayer.tools = humanPlayer.tools.filter(x => x.type !== 'parachute'); + playTickSound(); + } + } else if (item === 'shield') { + if (humanPlayer.shield) { + score += itemData.price; + humanPlayer.shield = null; + playTickSound(); + } + } else { + const weapon = humanPlayer.weapons.find(w => w.type === item); + if (weapon && weapon.ammo > 0) { + const soldUnits = itemData.ammo - weapon.ammo; + const totalRefund = Math.floor((soldUnits + 1) * itemData.price / itemData.ammo); + const refund = totalRefund - (weapon._refunded || 0); + weapon._refunded = totalRefund; + score += refund; + weapon.ammo--; + if (weapon.ammo <= 0) { + humanPlayer.weapons = humanPlayer.weapons.filter(w => w.type !== item); + } + playTickSound(); + } + } +} + +function startRound() { + menuState = null; + state = 'round-start'; +} + function updateMarket() { const marketItems = Object.keys(MARKET_ITEMS).filter(item => !(tracerMode && item === 'tracer')); @@ -221,74 +311,7 @@ function updateMarket() { } else if (key('ArrowRight')) { if (afterKeyDelay()) { - const item = marketItems[menuState.selected]; - const itemData = MARKET_ITEMS[item]; - const humanPlayer = players.find(p => !p.ai); - if (!humanPlayer || score < itemData.price) return; - if (item === 'babyMissile') return; - if (item === 'parachute') { - score -= itemData.price; - let t = humanPlayer.tools.find(x => x.type === 'parachute'); - if (t) t.ammo += itemData.ammo; - else humanPlayer.tools.push({type: 'parachute', ammo: itemData.ammo}); - playTickSound(); - } else if (item === 'shield') { - if (humanPlayer.shield) return; - score -= itemData.price; - humanPlayer.shield = {type:'shield', energy:SHIELD_TYPES.shield.energy}; - playTickSound(); - } else if (WEAPON_TYPES[item]) { - score -= itemData.price; - let existingWeapon = humanPlayer.weapons.find(w => w.type === item); - if (existingWeapon) { - existingWeapon.ammo += itemData.ammo; - } else { - humanPlayer.weapons.push({type: item, ammo: itemData.ammo}); - } - playTickSound(); - } - } - } - else if (key('ArrowLeft')) { - if (afterKeyDelay()) { - const item = marketItems[menuState.selected]; - const itemData = MARKET_ITEMS[item]; - const humanPlayer = players.find(p => !p.ai); - if (!humanPlayer) return; - if (item === 'babyMissile') return; - if (item === 'parachute') { - const t = humanPlayer.tools.find(x => x.type === 'parachute'); - if (t.ammo > 0) { - const soldUnits = itemData.ammo - t.ammo; - const totalRefund = Math.floor((soldUnits + 1) * itemData.price / itemData.ammo); - const refund = totalRefund - (t._refunded || 0); - t._refunded = totalRefund; - score += refund; - t.ammo--; - if (t.ammo <= 0) humanPlayer.tools = humanPlayer.tools.filter(x => x.type !== 'parachute'); - playTickSound(); - } - } else if (item === 'shield') { - if (humanPlayer.shield) { - score += itemData.price; - humanPlayer.shield = null; - playTickSound(); - } - } else { - const weapon = humanPlayer.weapons.find(w => w.type === item); - if (weapon && weapon.ammo > 0) { - const soldUnits = itemData.ammo - weapon.ammo; - const totalRefund = Math.floor((soldUnits + 1) * itemData.price / itemData.ammo); - const refund = totalRefund - (weapon._refunded || 0); - weapon._refunded = totalRefund; - score += refund; - weapon.ammo--; - if (weapon.ammo <= 0) { - humanPlayer.weapons = humanPlayer.weapons.filter(w => w.type !== item); - } - playTickSound(); - } - } + buySelectedItem(marketItems[menuState.selected]); } } else if (key(' ')) { @@ -296,13 +319,68 @@ function updateMarket() { } else if (key('Enter')) { if (afterKeyDelay()) { - menuState = null; - state = 'round-start'; + startRound(); } } else { idle = true; } + + const click = popClick(); + if (click) { + const arrowX = PANEL_X + 15; + const arrowW = 22; + const arrowH = 54; + const listX = PANEL_X + 40; + const listW = 340; + const itemH = 18; + const y = 94; + const maxVisible = 8; + + // Up/down arrow buttons (left side) — 3× taller + if (checkHit(click.x, click.y, arrowX, y, arrowW, arrowH)) { + const prev = menuState.selected; + menuState.selected = (menuState.selected - 1 + marketItems.length) % marketItems.length; + if (menuState.selected < menuState.scrollOffset) menuState.scrollOffset = menuState.selected; + if (menuState.selected >= menuState.scrollOffset + maxVisible) menuState.scrollOffset = menuState.selected - (maxVisible - 1); + if (menuState.selected !== prev) playTickSound(); + return; + } + if (checkHit(click.x, click.y, arrowX, y + (maxVisible - 1) * itemH - arrowH + itemH, arrowW, arrowH)) { + const prev = menuState.selected; + menuState.selected = (menuState.selected + 1) % marketItems.length; + if (menuState.selected >= menuState.scrollOffset + maxVisible) menuState.scrollOffset = menuState.selected - (maxVisible - 1); + if (menuState.selected < menuState.scrollOffset) menuState.scrollOffset = menuState.selected; + if (menuState.selected !== prev) playTickSound(); + return; + } + + for (let i = 0; i < Math.min(maxVisible, marketItems.length); i++) { + const itemY = y + i * itemH; + if (checkHit(click.x, click.y, listX, itemY, listW, itemH)) { + const itemIndex = i + menuState.scrollOffset; + if (menuState.selected === itemIndex) { + buySelectedItem(marketItems[itemIndex]); + } else { + menuState.selected = itemIndex; + playTickSound(); + } + return; + } + } + + // Buy button + const btnY = 246; + const btnH = 22; + if (checkHit(click.x, click.y, listX + 20, btnY, 90, btnH)) { + buySelectedItem(marketItems[menuState.selected]); + return; + } + + if (checkHit(click.x, click.y, PANEL_X+100, 318, 200, 22)) { + startRound(); + } + } } function aiBuy(player) { @@ -352,7 +430,9 @@ function initPlayers() { player.fallHeight = 0; player.kills = 0; player.deaths = 0; + player.deathOrder = -1; player.shotsFired = 0; + player.totalEarned = player.totalEarned || 0; player.lastDamageSource = null; } players = existingPlayers; @@ -378,9 +458,11 @@ function initPlayers() { ai: i !== 0 ? sample(Object.keys(AI_TYPES)) : undefined, parachute: null, fallHeight: 0, - score: 0, + score: 0, + totalEarned: 0, kills: 0, deaths: 0, + deathOrder: -1, shotsFired: 0, wins: 0, lastDamageSource: null, @@ -442,6 +524,66 @@ function update() { const isReverse = key('Shift'); let shoot; + // Check for clicks on controls + const click = popClick(); + if (click && !player.ai) { + const maxPower = player.energy * PLAYER_ENERGY_POWER_MULTIPLIER; + + // Angle gauge (cx=90, cy=312, r=60) + const gaugeCx = 80, gaugeCy = 357, gaugeR = 60; + const gdx = click.x - gaugeCx, gdy = click.y - gaugeCy; + if (gdx*gdx + gdy*gdy < gaugeR*gaugeR*1.3 && gdy < 0) { + let aDeg = (-Math.atan2(gdy, gdx) + Math.PI) * 180 / Math.PI; + if (aDeg < 0) aDeg += 360; + if (aDeg > 180) aDeg = 360 - aDeg; + player.a = Math.round(clamp(0, aDeg, 180)); + return; + } + + // Angle < button (left half, 1px gap from center) + if (click.x >= 20 && click.x < 80 && click.y >= 369 && click.y < 395) { + player.a = wrap(0, player.a - 1, 180); + return; + } + + // Angle > button (right half, 1px gap from center) + if (click.x >= 81 && click.x < 141 && click.y >= 369 && click.y < 395) { + player.a = wrap(0, player.a + 1, 180); + return; + } + + // Power bar (610, 286, 25×80) + if (click.x >= 610 && click.x < 635 && click.y >= 286 && click.y < 366) { + const pRatio = 1 - (click.y - 286) / 80; + player.p = Math.round(clamp(0, pRatio, 1) * maxPower); + return; + } + + // Power ▲ (1px gap above bar) + if (click.x >= 610 && click.x < 635 && click.y >= 257 && click.y < 285) { + player.p = clamp(0, player.p + 1, maxPower); + return; + } + + // Power ▼ (1px gap below bar) + if (click.x >= 610 && click.x < 635 && click.y >= 367 && click.y < 395) { + player.p = clamp(0, player.p - 1, maxPower); + return; + } + + // Weapon switch (385, 360, 140×28) + if (click.x >= 385 && click.x < 525 && click.y >= 360 && click.y < 388) { + player.currentWeapon = wrap(0, player.currentWeapon + 1, player.weapons.length - 1); + playTickSound(); + return; + } + + // FIRE button (280, 355, 80×40) + if (click.x >= 280 && click.x < 360 && click.y >= 355 && click.y < 395) { + shoot = {a: player.a, p: player.p}; + } + } + if (player.ai) { let ai = AI_TYPES[player.ai]; const plan = ai.decide(player); @@ -556,9 +698,14 @@ function update() { else if (state === 'land-collapse') { if (!collapseState) collapseState = startCollapseTerrain(terrain); - if (collapseTerrainStep(terrain, collapseState)) { - collapseState = null; - state = 'land-players'; + let done = false; + for (let i = 0; i < 2; i++) { + if (collapseTerrainStep(terrain, collapseState)) { + collapseState = null; + state = 'land-players'; + done = true; + break; + } } } @@ -588,8 +735,9 @@ function update() { } else if (state === 'destroy-players') { + if (typeof deathOrderCounter === 'undefined') deathOrderCounter = 0; const dyingPlayer = players.find(x => x.energy<=0 && !x.dead); - if (!dyingPlayer) {state = 'end-turn'; return} + if (!dyingPlayer) {deathOrderCounter = undefined; state = 'end-turn'; return} const {x, y, c} = dyingPlayer; const explosionSpec = sample(DEATH_SPECS); @@ -598,6 +746,7 @@ function update() { createParticles(x, y, PLAYER_EXPLOSION_PARTICLE_POWER, c); dyingPlayer.dead = true; dyingPlayer.deaths++; + dyingPlayer.deathOrder = deathOrderCounter++; if (dyingPlayer.lastDamageSource && dyingPlayer.lastDamageSource !== dyingPlayer) { dyingPlayer.lastDamageSource.kills++; } @@ -637,9 +786,11 @@ function update() { menuState = {scoreAwarded: false}; for (let player of players) { player.score += player.kills * SCORE_PER_KILL; + player.totalEarned += player.kills * SCORE_PER_KILL; } if (winner) { winner.score += SCORE_FOR_WIN; + winner.totalEarned += SCORE_FOR_WIN; } const humanPlayer = players.find(p => !p.ai); if (humanPlayer) { @@ -658,6 +809,15 @@ function update() { } } } + const click = popClick(); + if (click && checkHit(click.x, click.y, PANEL_X+100, 318, 200, 22)) { + menuState = null; + if (round < totalRounds) { + state = 'market'; + } else { + state = 'game-over'; + } + } idle = true; } @@ -667,6 +827,10 @@ function update() { state = 'start-game'; } } + const click = popClick(); + if (click && checkHit(click.x, click.y, PANEL_X+100, 290, 200, 22)) { + state = 'start-game'; + } idle = true; } @@ -1080,7 +1244,7 @@ export function isTankShield(x, y) { } function draw() { - if (idle && particles.length===0 && fireCells.length===0 && smokeParticles.length===0 && napalmParticles.length===0 && state !== 'start-menu' && state !== 'market' && state !== 'round-end' && state !== 'game-over') return; + if (idle && particles.length===0 && fireCells.length===0 && smokeParticles.length===0 && napalmParticles.length===0 && state !== 'start-menu' && state !== 'market' && state !== 'round-end' && state !== 'game-over' && state !== 'aim') return; foreground.clearRect(0, 0, W, H); drawTrajectories(); @@ -1105,9 +1269,123 @@ function draw() { drawGameOverPanel(); } + drawGameControls(); drawScreenShake(); } +function drawGameControls() { + if (state !== 'aim') return; + const player = players[currentPlayer]; + if (player.ai) return; + + const maxPower = player.energy * PLAYER_ENERGY_POWER_MULTIPLIER; + + // ---- Angle gauge (bottom-left) ---- + const cx = 80, cy = 357, r = 60; + framebuffer.globalAlpha = 0.65; + framebuffer.beginPath(); + framebuffer.arc(cx, cy, r, Math.PI, 0); + framebuffer.strokeStyle = '#888'; + framebuffer.lineWidth = 2; + framebuffer.stroke(); + framebuffer.globalAlpha = 1; + + // Tick marks every 30 degrees + for (let a = 0; a <= 180; a += 30) { + const ta = Math.PI + a * Math.PI / 180; + const inner = r - 8, outer = r; + drawLine(framebuffer, + cx + Math.cos(ta) * inner, cy + Math.sin(ta) * inner, + cx + Math.cos(ta) * outer, cy + Math.sin(ta) * outer, + '#aaa'); + } + + // Arrow pointer + const pa = Math.PI + player.a * Math.PI / 180; + const px = cx + Math.cos(pa) * r; + const py = cy + Math.sin(pa) * r; + drawLine(framebuffer, cx, cy, px, py, '#f80'); + const arrowAngle = 0.5; + const arrowLen = 12; + const ax1 = px - Math.cos(pa - arrowAngle) * arrowLen; + const ay1 = py - Math.sin(pa - arrowAngle) * arrowLen; + const ax2 = px - Math.cos(pa + arrowAngle) * arrowLen; + const ay2 = py - Math.sin(pa + arrowAngle) * arrowLen; + drawLine(framebuffer, px, py, ax1, ay1, '#f80'); + drawLine(framebuffer, px, py, ax2, ay2, '#f80'); + framebuffer.beginPath(); + framebuffer.arc(cx, cy, 4, 0, Math.PI * 2); + framebuffer.fillStyle = '#fa0'; + framebuffer.fill(); + + // Angle fine-tune buttons (separated by 1px gap) + const btnY = 369; + const btnH = 26; + framebuffer.globalAlpha = 0.5; + drawRect(framebuffer, cx - r, btnY, r, btnH, '#555'); + drawRect(framebuffer, cx + 1, btnY, r, btnH, '#555'); + framebuffer.globalAlpha = 0.3; + drawRect(framebuffer, cx - r, btnY, r, 1, '#fff'); + drawRect(framebuffer, cx + 1, btnY, r, 1, '#fff'); + framebuffer.globalAlpha = 1; + framebuffer.font = '14px ibm-vga'; + framebuffer.textAlign = 'center'; + framebuffer.textBaseline = 'middle'; + framebuffer.fillStyle = '#fff'; + framebuffer.fillText('<', cx - r/2, btnY + btnH/2); + framebuffer.fillText('>', cx + 1 + r/2, btnY + btnH/2); + + // ---- Power gauge (bottom-right corner) ---- + const barX = 610, barY = 286, barW = 25, barH = 80; + const powerRatio = maxPower > 0 ? player.p / maxPower : 0; + framebuffer.globalAlpha = 0.4; + drawRect(framebuffer, barX, barY, barW, barH, '#333'); + framebuffer.globalAlpha = 0.7; + const fillH = Math.round(barH * powerRatio); + drawRect(framebuffer, barX, barY + barH - fillH, barW, fillH, '#f80'); + framebuffer.globalAlpha = 1; + + // Power ▲ ▼ buttons (1px gap from gauge) + const btnW = barW; + framebuffer.globalAlpha = 0.5; + drawRect(framebuffer, barX, barY - 28 - 1, btnW, 28, '#555'); + drawRect(framebuffer, barX, barY + barH + 1, btnW, 28, '#555'); + framebuffer.globalAlpha = 1; + framebuffer.font = '10px ibm-bios'; + framebuffer.textAlign = 'center'; + framebuffer.textBaseline = 'middle'; + framebuffer.fillStyle = '#fff'; + framebuffer.fillText('▲', barX + btnW / 2, barY - 14); + framebuffer.fillText('▼', barX + btnW / 2, barY + barH + 14); + + // ---- FIRE button (center bottom) ---- + framebuffer.globalAlpha = 0.55; + drawRect(framebuffer, 280, 355, 80, 40, '#c00'); + framebuffer.globalAlpha = 0.3; + drawRect(framebuffer, 280, 355, 80, 2, '#fff'); + drawRect(framebuffer, 280, 355, 2, 40, '#fff'); + drawRect(framebuffer, 280, 393, 80, 2, '#600'); + drawRect(framebuffer, 358, 355, 2, 40, '#600'); + framebuffer.globalAlpha = 1; + framebuffer.font = 'bold 16px ibm-vga'; + framebuffer.textAlign = 'center'; + framebuffer.textBaseline = 'middle'; + framebuffer.fillStyle = '#fff'; + framebuffer.fillText('FIRE', 320, 375); + + // ---- Weapon switch (between FIRE and power gauge) ---- + framebuffer.globalAlpha = 0.5; + drawRect(framebuffer, 385, 360, 140, 28, '#448'); + framebuffer.globalAlpha = 1; + const weapon = player.weapons[player.currentWeapon]; + const weaponType = WEAPON_TYPES[weapon.type]; + framebuffer.font = '8px ibm-bios'; + framebuffer.textAlign = 'center'; + framebuffer.textBaseline = 'middle'; + framebuffer.fillStyle = '#fff'; + framebuffer.fillText(weaponType.name, 455, 374); +} + function drawPlayers() { for (let player of players) { const {x, y, a, c, cb, energy, shield, dead} = player; @@ -1216,31 +1494,28 @@ function drawParticles() { function drawStartMenuPanel() { drawPanelBg(framebuffer); - drawPanelTitle(framebuffer, 'RAVAGED PLANET', '#f60'); + drawPanelTitleFancy(framebuffer, 'RAVAGED PLANET', performance.now() / 1000); - const playerCounts = [2, 3, 4, 5, 6]; - const roundCounts = [1, 3, 5, 10]; - const terrains = ['Random', 'Mountain', 'Sand']; - const tracerOptions = ['On', 'Off']; - - const y = 100; - const labels = ['Players', 'Rounds', 'Terrain', 'Tracer']; - const values = [ - playerCounts[menuState.values[0]], - roundCounts[menuState.values[1]], - terrains[menuState.values[2]], - tracerOptions[menuState.values[3]], - ]; + const optionValues = [[3,4,5,6,7,8,9,10], [1,2,3,4,5,6,7,8,9,10], ['Random','Mountain','Sand'], ['On','Off']]; + const labels = ['Players', 'Rounds', 'Terrain', 'Permanent tracer mode']; + const y = 120; + const labelW = 168; + const arrowW = 24, arrowH = 18; for (let i=0; i ' : ' '; - const color = isSelected ? 'yellow' : 'white'; - drawPanelText(framebuffer, `${prefix}${labels[i]}: ${values[i]}`, 160, y + i * 30, color); + + drawPanelText(framebuffer, labels[i], PANEL_X + 30, rowY + 5, isSelected ? 'yellow' : 'white'); + + const v = optionValues[i][menuState.values[i]]; + drawButton(framebuffer, PANEL_X+262, rowY, arrowW, arrowH, '\u25C0', isSelected); + drawPanelText(framebuffer, '' + v, PANEL_X+322, rowY + 5, isSelected ? 'yellow' : 'white', 'center'); + drawButton(framebuffer, PANEL_X+355, rowY, arrowW, arrowH, '\u25B6', isSelected); } - drawPanelDivider(framebuffer, 230); - drawPanelText(framebuffer, 'Press ENTER to start', 160, 250, 'white'); + drawPanelDivider(framebuffer, 308); + drawButton(framebuffer, PANEL_X+100, 318, 200, 22, 'START GAME', false); } function drawMarketPanel() { @@ -1248,22 +1523,33 @@ function drawMarketPanel() { drawPanelTitle(framebuffer, 'MARKET', '#0c8'); const humanPlayer = players.find(p => !p.ai); - drawPanelText(framebuffer, `Score: ${score}`, 160, 100, 'yellow'); const marketItems = Object.keys(MARKET_ITEMS).filter(item => !(tracerMode && item === 'tracer')); - const y = 120; + const y = 94; const maxVisible = 8; + const itemH = 18; - for (let i=0; i= marketItems.length) break; const item = marketItems[itemIndex]; const itemData = MARKET_ITEMS[item]; const isSelected = menuState && menuState.selected === itemIndex; - const prefix = isSelected ? '> ' : ' '; const color = isSelected ? 'yellow' : 'white'; + const rowY = y + i * itemH; + let ammoText = ''; if (humanPlayer) { if (item === 'parachute') { @@ -1282,52 +1568,83 @@ function drawMarketPanel() { } const priceText = itemData.price === 0 ? 'FREE' : `$${itemData.price}`; - drawPanelText(framebuffer, `${prefix}${item}: ${priceText} ${ammoText}`, 140, y + i * 20, color); + if (isSelected) { + drawRect(framebuffer, listX, rowY, listW, itemH, '#558'); + } + drawPanelText(framebuffer, item, listX + 12, rowY + 5, color); + drawPanelText(framebuffer, priceText, listX + 150, rowY + 5, isSelected ? 'yellow' : '#aaa'); + drawPanelText(framebuffer, ammoText, listX + 230, rowY + 5, isSelected ? 'yellow' : '#aaa'); } - drawPanelDivider(framebuffer, 280); - drawPanelText(framebuffer, 'RIGHT: Buy LEFT: Sell ENTER: Start Round', 160, 290, 'white'); + // Buy button + const btnY = 246; + const btnH = 22; + drawButton(framebuffer, listX + 20, btnY, 90, btnH, 'BUY', false); + drawPanelText(framebuffer, `Score: ${score}`, listX + 212, btnY + 1, 'yellow'); + + drawPanelDivider(framebuffer, 278); + drawButton(framebuffer, PANEL_X+100, 318, 200, 22, 'GO TO BATTLE', false); } function drawRoundEndPanel() { drawPanelBg(framebuffer); + drawPanelTitle(framebuffer, `Round ${round} Complete!`, '#48f'); - if (winner) { - 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!`, '#48f'); - drawPanelText(framebuffer, 'Nobody survived!', 160, 100, 'white'); + const y = 100; + const sortedPlayers = [...players].sort((a, b) => { + if (a === winner) return -1; + if (b === winner) return 1; + return b.deathOrder - a.deathOrder; + }); + + // Column headers + drawPanelText(framebuffer, 'Player', 155, y, '#aaa', 'left'); + drawPanelText(framebuffer, 'Kills', 300, y, '#aaa', 'center'); + drawPanelText(framebuffer, 'Score', 435, y, '#aaa', 'center'); + + // Player rows + for (let i=0; i b.wins - a.wins || b.kills - a.kills); + const y = 100; + // Column headers + drawPanelText(framebuffer, 'Player', 155, y, '#aaa', 'left'); + drawPanelText(framebuffer, 'Wins', 240, y, '#aaa', 'center'); + drawPanelText(framebuffer, 'Kills', 310, y, '#aaa', 'center'); + drawPanelText(framebuffer, 'Shots', 380, y, '#aaa', 'center'); + drawPanelText(framebuffer, 'Score', 450, y, '#aaa', 'center'); + + // Player rows for (let i=0; i=0?'>':''}`, W-8, 8, 'white', 'right'); drawText(foreground, `Round: ${round}/${totalRounds} Score: ${score}`, W-8, 18, 'white', 'right'); } diff --git a/src/panel.js b/src/panel.js index ea004ec..8dd0a28 100644 --- a/src/panel.js +++ b/src/panel.js @@ -1,9 +1,30 @@ import {PANEL_WIDTH, PANEL_HEIGHT, PANEL_FONT_SIZE, PANEL_TITLE_FONT_SIZE, PANEL_BG_COLOR, PANEL_BORDER_COLOR, W, H, FONT_WIDTH} from './constants.js?v=15'; import {drawRect, drawLine} from './gfx.js?v=15'; -const PANEL_X = (W - PANEL_WIDTH) / 2; +export const PANEL_X = (W - PANEL_WIDTH) / 2; const PANEL_Y = (H - PANEL_HEIGHT) / 2; +export function drawButton(ctx, x, y, w, h, label, active) { + drawRect(ctx, x, y, w, h, active ? '#558' : '#444'); + 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; + ctx.font = `${PANEL_FONT_SIZE}px ibm-bios`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'black'; + ctx.fillText(label, x + w/2 + 1, y + h/2 + 1); + ctx.fillStyle = '#fff'; + ctx.fillText(label, x + w/2, y + h/2); +} + +export function checkHit(cx, cy, x, y, w, h) { + return cx >= x && cx <= x + w && cy >= y && cy <= y + h; +} + export function drawPanelBg(ctx) { ctx.fillStyle = PANEL_BG_COLOR; ctx.fillRect(PANEL_X, PANEL_Y, PANEL_WIDTH, PANEL_HEIGHT); @@ -54,6 +75,225 @@ export function drawPanelTitle(ctx, text, barColor) { ctx.fillText(text, cx, y); } +const fireSparks = []; +const flameTongues = []; +const tongueHotspots = []; +for (let i = 0; i < 3; i++) { + tongueHotspots.push({ x: Math.random(), speed: (Math.random() - 0.5) * 0.002 }); +} + +function drawFireBar(ctx, x, y, w, h, time) { + if (w <= 0) return; + + const hash = (v) => { v = (v ^ 61) ^ (v >>> 16); v = v + (v << 3); v = v ^ (v >>> 4); return (v * 0x27d4eb2d) | 0; }; + const seed = (sx) => ((hash(sx) & 0xffff) / 0xffff) * Math.PI * 2; + + // Pre-compute raw flame heights per column + const raw = new Float32Array(w); + const drift = Math.sin(time * 0.4) * 4 + Math.sin(time * 0.7 + 1.2) * 2; + for (let i = 0; i < w; i++) { + const sx = x + i; + const dsx = sx + drift; + const s = seed(sx); + const wave = Math.sin(time * 2.2 + dsx * 0.025 + s) * 0.28 + + Math.sin(time * 3.1 + dsx * 0.04 + s * 0.7) * 0.18 + + Math.sin(time * 1.5 + dsx * 0.012 + s * 1.3) * 0.14; + const wander = Math.sin(sx * 0.08 + time * 0.5 + s * 2) * 0.03; + raw[i] = 0.45 + wave + wander; + } + + // Smooth with sliding window average (±5px) + const smooth = new Float32Array(w); + const windowR = 5; + for (let i = 0; i < w; i++) { + let sum = 0, count = 0; + for (let j = Math.max(0, i - windowR); j <= Math.min(w - 1, i + windowR); j++) { + sum += raw[j]; + count++; + } + smooth[i] = Math.max(0.1, sum / count); + } + + // Blend 80/20 smooth + raw for final heights + const finalH = new Float32Array(w); + for (let i = 0; i < w; i++) { + finalH[i] = smooth[i] * 0.8 + raw[i] * 0.2; + } + + // Modulate finalH by hotspot proximity (scarce base flame) + for (let i = 0; i < w; i++) { + const colNorm = i / w; + let maxProx = 0; + for (const hs of tongueHotspots) { + const dist = Math.abs(colNorm - hs.x); + const prox = 1 - Math.min(1, dist / 0.25); + if (prox > maxProx) maxProx = prox; + } + finalH[i] *= 0.2 + 0.8 * maxProx; + } + + // 1st pass: main flame body + for (let i = 0; i < w; i++) { + const sx = x + i; + const bendBase = Math.sin(sx * 0.15 + time * 0.7 + seed(sx + 5000)); + const fh = Math.max(1, Math.floor(h * finalH[i])); + const topY = y + h - fh; + + ctx.globalAlpha = 0.4; + drawRect(ctx, sx, topY - 2, 1, 4, `rgb(255,120,0)`); + + for (let j = 0; j < fh; j++) { + const t = j / fh; + const g = Math.floor(255 - t * 225); + const b = Math.floor(Math.max(0, 40 - t * 40)); + ctx.globalAlpha = 1 - t * 0.3; + const bend = bendBase * (1 - t) * 1.5; + const bx = Math.round(sx + bend); + drawRect(ctx, bx, topY + j, 1, 1, `rgb(255,${g},${b})`); + } + } + + // 2nd pass: glow overlay (built on blended heights, slightly shorter) + for (let i = 0; i < w; i++) { + const sx = x + i; + const s = seed(sx + 1000); + const extra = Math.sin(time * 2.5 + sx * 0.03 + s) * 0.12; + const glowH = Math.max(1, Math.floor(h * (finalH[i] * 0.7 + extra))); + const glowY = y + h - glowH; + ctx.globalAlpha = 0.25; + drawRect(ctx, sx - 1, glowY - 1, 3, glowH + 2, `rgb(255,80,0)`); + } + + // 3rd pass: soft haze (shorter, wider strips) + for (let i = 0; i < w; i++) { + const sx = x + i; + const s = seed(sx + 2000); + const haze = Math.sin(time * 1.8 + sx * 0.02 + 4.0 + s) * 0.12 + 0.1; + const hazeH = Math.max(1, Math.floor(h * haze)); + ctx.globalAlpha = 0.12; + drawRect(ctx, sx - 2, y + h - hazeH, 5, hazeH, `rgb(255,160,0)`); + } + + // 4th pass: 2nd flame overlay (different phase, broad) + for (let i = 0; i < w; i++) { + const sx = x + i; + const s = seed(sx + 3000); + const extra = Math.sin(time * 1.9 + sx * 0.02 + s * 1.3) * 0.15 + + Math.sin(time * 2.8 + sx * 0.035 + s * 0.5) * 0.1; + const flameH = Math.max(1, Math.floor(h * (finalH[i] * 0.55 + extra))); + const flameY = y + h - flameH; + ctx.globalAlpha = 0.15; + drawRect(ctx, sx - 2, flameY - 1, 5, flameH + 2, `rgb(255,100,0)`); + } + + // 5th pass: hotspot-based flame tongues (cluster, flicker, fade) + for (const hs of tongueHotspots) { + hs.x += hs.speed + Math.sin(time * 0.3 + hs.speed * 100) * 0.001; + if (hs.x < 0) { hs.x = 0; hs.speed *= -1; } + if (hs.x > 1) { hs.x = 1; hs.speed *= -1; } + } + + const spawnChance = 0.025; + if (Math.random() < spawnChance) { + const hs = tongueHotspots[Math.floor(Math.random() * tongueHotspots.length)]; + flameTongues.push({ + x: x + (hs.x + (Math.random() - 0.5) * 0.05) * w, + phase: 0, + maxH: 0.5 + Math.random() * 0.3, + speed: 0.008 + Math.random() * 0.007, + width: 5 + Math.random() * 5, + seed: Math.random() * 100, + }); + } + + for (let i = flameTongues.length - 1; i >= 0; i--) { + const t = flameTongues[i]; + t.phase += t.speed; + if (t.phase >= 1) { flameTongues.splice(i, 1); continue; } + + const heightFactor = Math.sin(t.phase * Math.PI); + const flicker = 0.85 + Math.sin(time * 15 + t.x * 3 + t.seed) * 0.15; + const totalH = h * t.maxH * heightFactor * flicker; + if (totalH < 1) continue; + + const halfW = Math.floor(t.width / 2); + const bend = Math.sin(t.x * 0.15 + time * 0.7 + t.seed) * 1.5; + + for (let dx = -halfW; dx <= halfW; dx++) { + const colX = Math.round(t.x + dx + bend); + if (colX < x || colX >= x + w) continue; + const dist = Math.abs(dx) / halfW; + const localH = Math.max(1, Math.floor(totalH * (1 - dist * 0.4))); + const localTop = y + h - localH; + const colAlpha = (1 - dist) * 0.35 * heightFactor; + for (let j = 0; j < localH; j++) { + const p = j / localH; + const g = Math.floor(200 - p * 180); + const b = Math.floor(Math.max(0, 30 - p * 30)); + ctx.globalAlpha = colAlpha * (1 - p * 0.4); + drawRect(ctx, colX, localTop + j, 1, 1, `rgb(255,${g},${b})`); + } + } + } + + // 6th pass: spark particles + if (Math.random() < 0.4) { + fireSparks.push({ + x: x + Math.random() * w, + y: y + h * (0.3 + Math.random() * 0.5), + vx: (Math.random() - 0.5) * 0.8, + vy: -(1 + Math.random() * 1.5), + life: 1.0, + }); + } + + for (let i = fireSparks.length - 1; i >= 0; i--) { + const sp = fireSparks[i]; + sp.x += sp.vx; + sp.y += sp.vy; + sp.life -= 0.06; + if (sp.life <= 0 || sp.y < y - 5 || sp.x < x - 5 || sp.x > x + w + 5) { + fireSparks.splice(i, 1); + continue; + } + const alpha = sp.life * 0.8; + const size = sp.life > 0.5 ? 2 : 1; + ctx.globalAlpha = alpha; + const bright = sp.life > 0.6 ? '255,255,200' : '255,180,50'; + drawRect(ctx, Math.round(sp.x), Math.round(sp.y), size, size, `rgb(${bright})`); + } + + ctx.globalAlpha = 1.0; +} + +export function drawPanelTitleFancy(ctx, text, time) { + const y = PANEL_Y + 20; + const fontSize = PANEL_TITLE_FONT_SIZE * 2; + const margin = 20; + + // Fire covers full title width (background) + const fireW = PANEL_WIDTH - margin * 2; + drawFireBar(ctx, PANEL_X + margin, y, fireW, fontSize, time); + + // Text on top (foreground) + const cx = PANEL_X + PANEL_WIDTH / 2; + ctx.font = `${fontSize}px ibm-vga`; + ctx.textBaseline = 'top'; + ctx.textAlign = 'left'; + let dx = cx - ctx.measureText(text).width / 2; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const cw = ctx.measureText(char).width; + const hue = Math.sin(time * 8 + i * 1.5) * 25 + 15; + const lightness = 55 + Math.sin(time * 6 + i * 2) * 10; + ctx.fillStyle = 'rgba(0,0,0,0.7)'; + ctx.fillText(char, dx + 1, y + 1); + ctx.fillStyle = `hsl(${hue}, 100%, ${lightness}%)`; + ctx.fillText(char, dx, y); + dx += cw; + } +} + export function drawPanelText(ctx, text, x, y, color = 'white', align = 'left') { ctx.font = `${PANEL_FONT_SIZE}px ibm-bios`; ctx.textBaseline = 'top';