What it proves:

Weapon Charge / Overheat Curve POC (Color)


Code for Above

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Weapon Charge / Overheat Curve POC (Color)</title>
<style>
  html, body { margin:0; background:#000; overflow:hidden; }
  canvas { display:block; margin:0 auto; background: radial-gradient(#0a0a0a, #000); }
</style>
</head>
<body>
<canvas id="c" width="900" height="900"></canvas>

<script>
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");

const keys = new Set();
addEventListener("keydown", e => keys.add(e.code));
addEventListener("keyup", e => keys.delete(e.code));

const ship = { x: 450, y: 650 };
const shots = [];

const heat = {
  value: 0,          // 0..1
  overheated: false,
  lockout: 0,        // seconds remaining
};

// Tunables
const TUNE = {
  fireHeatRate: 0.55,        // baseline heat/sec when firing
  heatExponent: 2.2,         // nonlinearity: higher = harsher near max
  coolRate: 0.22,            // heat/sec when not firing
  coolRateOverheated: 0.12,  // slower cool when overheated
  overheatLockout: 1.2,      // seconds
  fireInterval: 0.06,        // seconds between shots while holding
  shotSpeed: 900,            // px/sec
};

let last = performance.now();
let fireTimer = 0;

function clamp01(v){ return Math.max(0, Math.min(1, v)); }
function lerp(a,b,t){ return a + (b-a)*t; }

function heatToAlpha(h){
  const near = Math.pow(h, 3);
  return lerp(0.05, 0.6, near);
}

// Color is state-driven: cold->cyan, mid->amber, hot->red
function heatColor(h){
  let r, g, b;

  if (h < 0.6) {
    const t = h / 0.6;
    r = lerp(80, 255, t);
    g = lerp(220, 180, t);
    b = lerp(255, 40, t);
  } else {
    const t = (h - 0.6) / 0.4;
    r = 255;
    g = lerp(180, 40, t);
    b = 40;
  }

  return `rgb(${r|0},${g|0},${b|0})`;
}

function spawnShot(){
  shots.push({ x: ship.x, y: ship.y - 20, vy: -TUNE.shotSpeed });
}

function update(dt){
  if (heat.lockout > 0) {
    heat.lockout = Math.max(0, heat.lockout - dt);
    if (heat.lockout === 0) heat.overheated = false;
  }

  const wantFire = keys.has("Space");
  const canFire = !heat.overheated && heat.lockout === 0;

  if (wantFire && canFire) {
    const curve = 1 + 2.5 * Math.pow(heat.value, TUNE.heatExponent);
    heat.value = clamp01(heat.value + TUNE.fireHeatRate * curve * dt);
  } else {
    const cool = heat.overheated ? TUNE.coolRateOverheated : TUNE.coolRate;
    heat.value = clamp01(heat.value - cool * dt);
  }

  if (!heat.overheated && heat.value >= 1) {
    heat.overheated = true;
    heat.lockout = TUNE.overheatLockout;
  }

  fireTimer -= dt;
  if (wantFire && canFire && fireTimer <= 0) {
    spawnShot();
    fireTimer = TUNE.fireInterval;
  }

  for (let i = shots.length - 1; i >= 0; i--) {
    shots[i].y += shots[i].vy * dt;
    if (shots[i].y < -50) shots.splice(i, 1);
  }
}

function draw(){
  ctx.clearRect(0,0,canvas.width,canvas.height);

  const h = heat.value;
  const glowA = heatToAlpha(h);
  const hCol = heatColor(h);

  // Ship
  ctx.save();
  ctx.translate(ship.x, ship.y);

  // Heat envelope (geometry first, color reinforces state)
  if (h > 0.02) {
    ctx.beginPath();
    ctx.arc(0, 0, 28 + 18*h, 0, Math.PI*2);
    ctx.strokeStyle = hCol;
    ctx.globalAlpha = glowA;
    ctx.lineWidth = 2 + 10*h;
    ctx.stroke();
    ctx.globalAlpha = 1;
  }

  // Overheat jitter
  const jitter = heat.overheated ? (Math.sin(performance.now()*0.05) * 2.5) : 0;
  ctx.translate(jitter, 0);

  // Ship body
  ctx.beginPath();
  ctx.moveTo(0, -18);
  ctx.lineTo(16, 18);
  ctx.lineTo(-16, 18);
  ctx.closePath();
  ctx.fillStyle = "#fff";
  ctx.fill();

  // Barrel indicator inherits heat color
  ctx.beginPath();
  ctx.rect(-2, -32, 4, 16);
  ctx.fillStyle = hCol;
  ctx.fill();

  ctx.restore();

  // Shots inherit heat color (optional, but strong signal)
  ctx.save();
  ctx.strokeStyle = hCol;
  ctx.lineWidth = 2;
  for (const s of shots) {
    ctx.beginPath();
    ctx.moveTo(s.x, s.y);
    ctx.lineTo(s.x, s.y + 18);
    ctx.stroke();
  }
  ctx.restore();

  // Heat meter (debug — delete later if you want pure diegetic feedback)
  ctx.save();
  ctx.globalAlpha = 0.85;

  const x = 40, y = 830, w = 820, hh = 10;
  ctx.strokeStyle = "rgba(255,255,255,0.25)";
  ctx.strokeRect(x, y, w, hh);

  ctx.fillStyle = hCol;
  ctx.globalAlpha = 0.85;
  ctx.fillRect(x, y, w * h, hh);

  // Overheat indicator: pulsing red strip
  if (heat.overheated || heat.lockout > 0) {
    const pulse = 0.35 + 0.5*Math.sin(performance.now()*0.02);
    ctx.globalAlpha = pulse;
    ctx.fillStyle = "rgb(255,60,60)";
    ctx.fillRect(x, y - 18, 160, 6);
  }

  ctx.restore();
}

function loop(now){
  const dt = Math.min(0.033, (now - last) / 1000);
  last = now;
  update(dt);
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>