mouse/touchscreen controls added

This commit is contained in:
catarrh committed 2026-07-27 01:07:04 +03:00
1 parent 8d5ab422cb
commit ee0911bcbe
4 files changed
+802 -165

No files matched your search

+2 -2
View File
@@ -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}},
+81 -1
View File
@@ -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;
}
+478 -161
View File
@@ -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<labels.length; i++) {
const rowY = y + i * 30;
const isSelected = menuState.selected === i;
const prefix = isSelected ? '> ' : ' ';
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<Math.min(maxVisible, marketItems.length); i++) {
// Up/down arrow buttons (left side) — 3× taller
const arrowX = PANEL_X + 15;
const arrowW = 22;
const arrowH = 54;
drawButton(framebuffer, arrowX, y, arrowW, arrowH, '\u25B2', false);
drawButton(framebuffer, arrowX, y + (maxVisible - 1) * itemH - arrowH + itemH, arrowW, arrowH, '\u25BC', false);
const listX = PANEL_X + 40;
const listW = 340;
for (let i = 0; i < Math.min(maxVisible, marketItems.length); i++) {
const itemIndex = i + (menuState ? menuState.scrollOffset : 0);
if (itemIndex >= 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<sortedPlayers.length; i++) {
const p = sortedPlayers[i];
const rowY = y + 20 + i * 16;
const color = p === winner ? 'yellow' : 'white';
const roundScore = p.kills * SCORE_PER_KILL + (p === winner ? SCORE_FOR_WIN : 0);
drawPanelText(framebuffer, p.name, 155, rowY, color, 'left');
drawPanelText(framebuffer, ''+p.kills, 300, rowY, color, 'center');
drawPanelText(framebuffer, ''+roundScore, 435, rowY, color, 'center');
}
drawPanelText(framebuffer, `Total Score: ${score}`, 160, 200, 'yellow');
drawPanelDivider(framebuffer, 240);
if (round < totalRounds) {
drawPanelText(framebuffer, 'Press ENTER for next round', 160, 260, 'white');
} else {
drawPanelText(framebuffer, 'Press ENTER for final results', 160, 260, 'white');
}
drawPanelDivider(framebuffer, 308);
drawButton(framebuffer, PANEL_X+100, 318, 200, 22, 'CONTINUE', false);
}
function drawGameOverPanel() {
drawPanelBg(framebuffer);
drawPanelTitle(framebuffer, 'GAME OVER', '#e22');
const y = 100;
const sortedPlayers = [...players].sort((a, b) => 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<sortedPlayers.length; i++) {
const p = sortedPlayers[i];
const text = `${p.name}: W:${p.wins} K:${p.kills} D:${p.deaths} S:${p.shotsFired}`;
const rowY = y + 20 + i * 16;
const color = i === 0 ? 'yellow' : 'white';
drawPanelText(framebuffer, text, 160, y + i * 16, color);
drawPanelText(framebuffer, p.name, 155, rowY, color, 'left');
drawPanelText(framebuffer, ''+p.wins, 240, rowY, color, 'center');
drawPanelText(framebuffer, ''+p.kills, 310, rowY, color, 'center');
drawPanelText(framebuffer, ''+p.shotsFired, 380, rowY, color, 'center');
drawPanelText(framebuffer, ''+p.totalEarned, 450, rowY, color, 'center');
}
drawPanelDivider(framebuffer, 280);
drawPanelText(framebuffer, 'Press ENTER to play again', 160, 290, 'white');
drawButton(framebuffer, PANEL_X+100, 290, 200, 22, 'START MENU', false);
}
function drawScreenShake() {
@@ -1345,7 +1662,7 @@ function drawStatus() {
const {currentWeapon} = player;
const weapon = player.weapons[currentWeapon];
const weaponType = WEAPON_TYPES[weapon.type];
drawText(foreground, `${player.name} NRG:${player.energy} AIM:${player.a} PWR:${player.p} SHD:${player.shield?player.shield.energy:0} ${clamp(0, weapon.ammo, 99)} ${weaponType.name}`, 8, 8, player.c, 'left');
drawText(foreground, `${player.name} NRG:${Math.round(player.energy)} AIM:${player.a} PWR:${player.p} SHD:${player.shield?Math.round(player.shield.energy):0} ${clamp(0, weapon.ammo, 99)} ${weaponType.name}`, 8, 8, player.c, 'left');
drawText(foreground, `WIND: ${wind<=0?'<':''}${Math.abs(wind)}${wind>=0?'>':''}`, W-8, 8, 'white', 'right');
drawText(foreground, `Round: ${round}/${totalRounds} Score: ${score}`, W-8, 18, 'white', 'right');
}
+241 -1
View File
@@ -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';