What it proves:
- Hold Space to fire (or charge)
- Heat rises on a nonlinear curve (gets punishing fast)
- Cooling is slower than heating
- Overheat triggers a lockout + visual feedback
- No text UI needed (but included minimal readouts for debugging—delete later)
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>