HardYards/prototype/game.js
m3ultra 06404d3505 Add side-systems to design doc + playable sail-storm prototype
Prototype: spring-mass sail, quadratic wind with gust telegraphs and a
mid-storm wind change, per-corner hardware ratings with weakest-link
cascade failures, garden coverage scoring, and a runnable repair loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:55:31 +10:00

581 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// SHADES prototype — one yard, one spring-mass sail, one storm.
// Thesis test: does watching a load meter climb on a corner you KNOW is
// under-shackled, while you sprint across the yard in the rain, feel great?
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const DEBUG = location.search.includes('debug');
const AUTO = location.search.includes('auto');
// ---------- world ----------
const HOUSE = { x: 0, y: 0, w: W, h: 64 };
const GARDEN = { x: 380, y: 290, w: 210, h: 130 };
const TREES = [
{ x: 130, y: 300, r: 42, phase: 0.7 },
{ x: 840, y: 240, r: 36, phase: 2.9 },
];
const ANCHORS = [
{ id: 'h1', x: 280, y: 72, type: 'house' },
{ id: 'h2', x: 480, y: 72, type: 'house' },
{ id: 'h3', x: 690, y: 72, type: 'house' },
{ id: 't1', x: TREES[0].x, y: TREES[0].y, type: 'tree', tree: TREES[0] },
{ id: 't2', x: TREES[1].x, y: TREES[1].y, type: 'tree', tree: TREES[1] },
{ id: 'p1', x: 240, y: 530, type: 'post' },
{ id: 'p2', x: 700, y: 545, type: 'post' },
];
const HARDWARE = [
{ name: 'carabiner', cost: 5, rating: 9, color: '#e2b04a' },
{ name: 'shackle', cost: 15, rating: 19, color: '#c8d2d8' },
{ name: 'rated shackle', cost: 30, rating: 40, color: '#7ee0ff' },
];
const START_BUDGET = 80;
const STORM_LEN = 90;
// ---------- state ----------
const state = {
phase: 'prep', // prep | storm | end
t: 0,
budget: START_BUDGET,
corners: [], // {anchor, hw, load, overload, broken}
tension: 1.0,
spare: 0,
gardenHP: 100,
events: [], // {t, text}
result: null,
repair: null, // {corner, progress}
};
// grass tufts for wind telegraph
const TUFTS = [];
for (let i = 0; i < 140; i++) {
const x = Math.random() * W, y = 80 + Math.random() * (H - 90);
TUFTS.push({ x, y, j: Math.random() * 6.28 });
}
// ---------- wind ----------
const wind = { speed: 0, dir: 0.9, gust: 0, nextGust: 3, gustT: -1, gustPow: 0, changed: false };
function windVec(t, dt) {
if (state.phase !== 'storm') { wind.speed = 4; return; }
const p = t / STORM_LEN;
let base = 8 + 26 * Math.min(1, p * 1.6);
// scheduled wind change ("southerly change") at 55s
if (t > 49 && !wind.changed) state.msg = 'WIND CHANGE INCOMING';
if (t > 55 && !wind.changed) { wind.changed = true; state.msg = ''; addEvent('the wind swings around'); }
const targetDir = wind.changed ? 2.6 : 0.9 + 0.25 * Math.sin(t * 0.13);
wind.dir += (targetDir - wind.dir) * Math.min(1, dt * 0.8);
// gust scheduler
if (t > wind.nextGust && wind.gustT < 0) {
wind.gustT = 0;
wind.gustPow = 12 + Math.random() * 16 + 10 * p;
wind.nextGust = t + 5 + Math.random() * 7;
}
let g = 0;
if (wind.gustT >= 0) {
wind.gustT += dt;
const gt = wind.gustT;
if (gt < 1.5) g = 0; // telegraph window
else if (gt < 2.3) g = wind.gustPow * (gt - 1.5) / 0.8; // ramp
else if (gt < 4.0) g = wind.gustPow; // hold
else if (gt < 5.0) g = wind.gustPow * (5.0 - gt); // fade
else wind.gustT = -1;
}
wind.gust = g;
wind.speed = base + g;
}
const windX = () => Math.cos(wind.dir) * wind.speed;
const windY = () => Math.sin(wind.dir) * wind.speed;
// ---------- sail (verlet spring-mass grid, nodes carry x,y,z) ----------
const N = 7;
let nodes = null, springs = null, cornerIdx = null;
function orderRing(anchors) {
const cx = anchors.reduce((s, a) => s + a.x, 0) / 4;
const cy = anchors.reduce((s, a) => s + a.y, 0) / 4;
return [...anchors].sort((a, b) => Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx));
}
function buildSail() {
const ring = state.corners.map(c => c.anchor);
const [c0, c1, c2, c3] = ring;
nodes = [];
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
const fu = u / (N - 1), fv = v / (N - 1);
const x = (1 - fv) * ((1 - fu) * c0.x + fu * c1.x) + fv * ((1 - fu) * c3.x + fu * c2.x);
const y = (1 - fv) * ((1 - fu) * c0.y + fu * c1.y) + fv * ((1 - fu) * c3.y + fu * c2.y);
nodes.push({ x, y, z: 0, px: x, py: y, pz: 0 });
}
}
springs = [];
const restScale = 1 / state.tension;
const idx = (u, v) => v * N + u;
const link = (a, b) => {
const A = nodes[a], B = nodes[b];
springs.push({ a, b, rest: Math.hypot(A.x - B.x, A.y - B.y) * restScale });
};
for (let v = 0; v < N; v++) for (let u = 0; u < N; u++) {
if (u < N - 1) link(idx(u, v), idx(u + 1, v));
if (v < N - 1) link(idx(u, v), idx(u, v + 1));
if (u < N - 1 && v < N - 1) { link(idx(u, v), idx(u + 1, v + 1)); link(idx(u + 1, v), idx(u, v + 1)); }
}
cornerIdx = [idx(0, 0), idx(N - 1, 0), idx(N - 1, N - 1), idx(0, N - 1)];
// corner i of grid pins to ring[i]; rebind state.corners to ring order
state.corners = ring.map(a => state.corners.find(c => c.anchor === a));
}
function anchorPos(a, t) {
if (a.type === 'tree') {
const amp = Math.min(10, wind.speed * 0.18);
return { x: a.x + Math.sin(t * 1.9 + a.tree.phase) * amp, y: a.y + Math.cos(t * 1.3 + a.tree.phase) * amp * 0.5 };
}
return { x: a.x, y: a.y };
}
function stepSail(t, dt) {
if (!nodes) return;
const ws = wind.speed;
const dirx = Math.cos(wind.dir), diry = Math.sin(wind.dir);
const push = ws * ws * 0.55; // wind pressure goes with speed² — gusts have teeth
const damp = 0.985;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const flut = 0.7 + 0.5 * Math.sin(t * 7 + i * 1.7) * Math.min(1, ws / 30);
const ax = dirx * push * flut, ay = diry * push * flut;
const az = ws * ws * 0.3 * flut - 120 - n.z * 8; // lift vs gravity vs restoring
const nx = n.x + (n.x - n.px) * damp + ax * dt * dt;
const ny = n.y + (n.y - n.py) * damp + ay * dt * dt;
const nz = n.z + (n.z - n.pz) * damp + az * dt * dt;
n.px = n.x; n.py = n.y; n.pz = n.z;
n.x = nx; n.y = ny; n.z = nz;
}
// corner load = total stretch in the springs meeting at that corner
for (let k = 0; k < 4; k++) {
const c = state.corners[k];
if (c.broken) continue;
const ci = cornerIdx[k];
let raw = 0;
for (const s of springs) {
if (s.a !== ci && s.b !== ci) continue;
const A = nodes[s.a], B = nodes[s.b];
const d = Math.hypot(B.x - A.x, B.y - A.y, B.z - A.z);
raw += Math.max(0, d - s.rest);
}
c.load = c.load * 0.85 + raw * 2.2 * 0.15;
}
for (let iter = 0; iter < 5; iter++) {
for (const s of springs) {
const A = nodes[s.a], B = nodes[s.b];
let dx = B.x - A.x, dy = B.y - A.y, dz = B.z - A.z;
const d = Math.hypot(dx, dy, dz) || 1e-6;
const f = (d - s.rest) / d * 0.5;
// fabric resists stretch strongly, compression barely (cloth, not rod)
const k = d > s.rest ? f : f * 0.08;
A.x += dx * k; A.y += dy * k; A.z += dz * k;
B.x -= dx * k; B.y -= dy * k; B.z -= dz * k;
}
for (let kk = 0; kk < 4; kk++) {
const c = state.corners[kk];
if (c.broken) continue;
const p = anchorPos(c.anchor, t);
const n = nodes[cornerIdx[kk]];
n.x = p.x; n.y = p.y; n.z = 0;
}
}
// failure check
if (state.phase === 'storm') {
for (const c of state.corners) {
if (c.broken) continue;
if (c.load > c.hw.rating) c.overload += dt; else c.overload = Math.max(0, c.overload - dt * 2);
if (c.overload > 0.4) {
c.broken = true; c.overload = 0;
addEvent(`${c.hw.name} BLOWS at ${c.anchor.id.toUpperCase()}!`);
}
}
}
}
function sailOutline() {
if (!nodes) return null;
const pts = [];
const idx = (u, v) => v * N + u;
for (let u = 0; u < N; u++) pts.push(nodes[idx(u, 0)]);
for (let v = 1; v < N; v++) pts.push(nodes[idx(N - 1, v)]);
for (let u = N - 2; u >= 0; u--) pts.push(nodes[idx(u, N - 1)]);
for (let v = N - 2; v >= 1; v--) pts.push(nodes[idx(0, v)]);
return pts;
}
function pointInPoly(x, y, pts) {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
if ((pts[i].y > y) !== (pts[j].y > y) &&
x < (pts[j].x - pts[i].x) * (y - pts[i].y) / (pts[j].y - pts[i].y) + pts[i].x) inside = !inside;
}
return inside;
}
function coveredFraction() {
const pts = sailOutline();
if (!pts) return 0;
let hit = 0, tot = 0;
for (let i = 0; i < 6; i++) for (let j = 0; j < 4; j++) {
const x = GARDEN.x + (i + 0.5) / 6 * GARDEN.w;
const y = GARDEN.y + (j + 0.5) / 4 * GARDEN.h;
tot++;
if (pointInPoly(x, y, pts)) hit++;
}
return hit / tot;
}
// ---------- player ----------
const player = { x: 480, y: 480, r: 9 };
const keys = {};
addEventListener('keydown', e => { keys[e.key.toLowerCase()] = true; if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(e.key.toLowerCase())) e.preventDefault(); });
addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
function stepPlayer(t, dt) {
if (state.phase !== 'storm') return;
let vx = 0, vy = 0;
if (keys['a'] || keys['arrowleft']) vx -= 1;
if (keys['d'] || keys['arrowright']) vx += 1;
if (keys['w'] || keys['arrowup']) vy -= 1;
if (keys['s'] || keys['arrowdown']) vy += 1;
const m = Math.hypot(vx, vy) || 1;
const slow = 1 - Math.min(0.35, wind.speed / 160); // rain + wind slow you
player.x += vx / m * 165 * slow * dt + windX() * 0.16 * dt * (wind.gust > 8 ? 1 : 0);
player.y += vy / m * 165 * slow * dt + windY() * 0.16 * dt * (wind.gust > 8 ? 1 : 0);
player.x = Math.max(10, Math.min(W - 10, player.x));
player.y = Math.max(HOUSE.h + 10, Math.min(H - 10, player.y));
// repair: hold E near a broken corner's anchor
let near = null;
for (const c of state.corners) {
if (c.broken && Math.hypot(player.x - c.anchor.x, player.y - c.anchor.y) < 30) near = c;
}
if (near && keys['e'] && state.spare > 0) {
if (!state.repair || state.repair.corner !== near) state.repair = { corner: near, progress: 0 };
state.repair.progress += dt / 2.5;
if (state.repair.progress >= 1) {
near.broken = false;
near.hw = HARDWARE[1];
near.load = 0; near.overload = 0;
state.spare--;
state.repair = null;
addEvent(`re-rigged ${near.anchor.id.toUpperCase()} with the spare shackle`);
}
} else state.repair = null;
}
// ---------- prep interactions ----------
const startBtn = document.getElementById('startBtn');
const tensionEl = document.getElementById('tension');
const spareEl = document.getElementById('spare');
cv.addEventListener('click', e => {
if (state.phase !== 'prep') return;
const r = cv.getBoundingClientRect();
const mx = (e.clientX - r.left) * (W / r.width), my = (e.clientY - r.top) * (H / r.height);
// cycle hardware on an already-rigged corner
for (const c of state.corners) {
if (Math.hypot(mx - c.anchor.x, my - c.anchor.y) < 16) {
const i = HARDWARE.indexOf(c.hw);
const next = HARDWARE[(i + 1) % HARDWARE.length];
const delta = next.cost - c.hw.cost;
if (spend(delta)) c.hw = next;
refreshPanel();
return;
}
}
// rig a new corner
for (const a of ANCHORS) {
if (Math.hypot(mx - a.x, my - a.y) < 16 && !state.corners.find(c => c.anchor === a)) {
if (state.corners.length >= 4) return;
if (!spend(HARDWARE[0].cost)) return;
state.corners.push({ anchor: a, hw: HARDWARE[0], load: 0, overload: 0, broken: false });
if (state.corners.length === 4) state.corners = orderRing(state.corners.map(c => c.anchor)).map(a2 => state.corners.find(c => c.anchor === a2));
refreshPanel();
return;
}
}
});
function spend(amount) {
if (state.budget - amount < 0) { flashMsg('not enough budget'); return false; }
state.budget -= amount;
return true;
}
let msgTimer = 0;
function flashMsg(s) { document.getElementById('msg').textContent = s; msgTimer = 2; }
function addEvent(text) { state.events.push({ t: state.t, text }); if (state.events.length > 4) state.events.shift(); }
spareEl.addEventListener('change', () => {
if (spareEl.checked) { if (spend(15)) state.spare = 1; else spareEl.checked = false; }
else { state.budget += 15; state.spare = 0; }
refreshPanel();
});
tensionEl.addEventListener('input', () => {
state.tension = parseFloat(tensionEl.value);
document.getElementById('tensionVal').textContent = state.tension.toFixed(2);
});
startBtn.addEventListener('click', () => {
if (state.corners.length !== 4) return;
buildSail();
state.phase = 'storm';
state.t = 0;
wind.nextGust = 3;
document.getElementById('phaseLabel').innerHTML = '<b>STORM</b>';
startBtn.disabled = true;
tensionEl.disabled = true;
spareEl.disabled = true;
});
function refreshPanel() {
document.getElementById('budget').textContent = `$${state.budget}`;
startBtn.disabled = !(state.phase === 'prep' && state.corners.length === 4);
}
// ---------- rendering ----------
const RAIN = [];
for (let i = 0; i < 130; i++) RAIN.push({ x: Math.random() * W, y: Math.random() * H });
function draw(t) {
ctx.clearRect(0, 0, W, H);
// lawn
ctx.fillStyle = '#4a7c3f';
ctx.fillRect(0, 0, W, H);
// grass tufts bend with wind
ctx.strokeStyle = 'rgba(30,70,25,.7)';
ctx.lineWidth = 1.5;
const bend = Math.min(14, wind.speed * 0.25);
for (const g of TUFTS) {
const b = bend * (0.7 + 0.3 * Math.sin(t * 3 + g.j));
ctx.beginPath();
ctx.moveTo(g.x, g.y);
ctx.lineTo(g.x + Math.cos(wind.dir) * b, g.y + Math.sin(wind.dir) * b - 3);
ctx.stroke();
}
// garden bed
ctx.fillStyle = '#6b4a2f';
ctx.fillRect(GARDEN.x, GARDEN.y, GARDEN.w, GARDEN.h);
ctx.fillStyle = '#7fce6a';
for (let i = 0; i < 6; i++) for (let j = 0; j < 4; j++) {
ctx.beginPath();
ctx.arc(GARDEN.x + (i + 0.5) / 6 * GARDEN.w, GARDEN.y + (j + 0.5) / 4 * GARDEN.h, 8, 0, 7);
ctx.fill();
}
// garden hp bar
ctx.fillStyle = '#00000066';
ctx.fillRect(GARDEN.x, GARDEN.y - 14, GARDEN.w, 8);
ctx.fillStyle = state.gardenHP > 40 ? '#5f5' : '#f55';
ctx.fillRect(GARDEN.x, GARDEN.y - 14, GARDEN.w * state.gardenHP / 100, 8);
// house
ctx.fillStyle = '#8a8f96';
ctx.fillRect(HOUSE.x, HOUSE.y, HOUSE.w, HOUSE.h);
ctx.fillStyle = '#6e737a';
ctx.fillRect(HOUSE.x, HOUSE.h - 10, HOUSE.w, 10);
ctx.fillStyle = '#dde5ea';
ctx.font = '12px ui-monospace';
ctx.fillText('HOUSE (fascia line — strong points marked)', 12, 24);
// trees (trunk marker; canopy after sail so it reads layered)
for (const tr of TREES) {
const sway = anchorPos(ANCHORS.find(a => a.tree === tr), t);
ctx.fillStyle = '#5a3d24';
ctx.beginPath(); ctx.arc(sway.x, sway.y, 7, 0, 7); ctx.fill();
tr._sway = sway;
}
// sail
if (nodes) {
const idx = (u, v) => v * N + u;
for (let v = 0; v < N - 1; v++) for (let u = 0; u < N - 1; u++) {
const a = nodes[idx(u, v)], b = nodes[idx(u + 1, v)], c = nodes[idx(u + 1, v + 1)], d = nodes[idx(u, v + 1)];
const z = (a.z + b.z + c.z + d.z) / 4;
const lite = Math.max(0, Math.min(60, z * 1.2 + 20));
ctx.fillStyle = `rgba(${200 + lite * 0.6},${170 + lite},${90 + lite},0.82)`;
ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y);
ctx.closePath(); ctx.fill();
}
const out = sailOutline();
ctx.strokeStyle = '#a8843b'; ctx.lineWidth = 2;
ctx.beginPath();
out.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
ctx.closePath(); ctx.stroke();
} else if (state.phase === 'prep' && state.corners.length >= 2) {
ctx.strokeStyle = 'rgba(255,220,120,.6)'; ctx.setLineDash([6, 5]); ctx.lineWidth = 2;
const ring = state.corners.length === 4 ? state.corners : state.corners;
ctx.beginPath();
ring.forEach((c, i) => i ? ctx.lineTo(c.anchor.x, c.anchor.y) : ctx.moveTo(c.anchor.x, c.anchor.y));
if (state.corners.length === 4) ctx.closePath();
ctx.stroke(); ctx.setLineDash([]);
}
// tree canopies
for (const tr of TREES) {
const s = tr._sway || tr;
ctx.fillStyle = 'rgba(40,95,35,.85)';
ctx.beginPath(); ctx.arc(s.x, s.y, tr.r, 0, 7); ctx.fill();
}
// anchors + corner hardware + load bars
for (const a of ANCHORS) {
const p = a.type === 'tree' ? (a.tree._sway || a) : a;
const c = state.corners.find(cc => cc.anchor === a);
ctx.beginPath();
ctx.arc(p.x, p.y, 6, 0, 7);
ctx.fillStyle = c ? c.hw.color : '#233';
ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
if (state.phase === 'prep') {
ctx.fillStyle = '#ffffffcc'; ctx.font = '10px ui-monospace';
ctx.fillText(a.id.toUpperCase() + (c ? ` ${c.hw.name} (${c.hw.rating})` : ''), p.x + 10, p.y - 6);
}
if (c && state.phase !== 'prep') {
// load bar
const frac = Math.min(1, c.load / c.hw.rating);
ctx.fillStyle = '#000a';
ctx.fillRect(p.x - 16, p.y + 10, 32, 6);
ctx.fillStyle = c.broken ? '#f33' : frac > 0.8 ? '#f66' : frac > 0.5 ? '#fc5' : '#6e6';
ctx.fillRect(p.x - 16, p.y + 10, 32 * (c.broken ? 1 : frac), 6);
if (c.broken) { ctx.fillStyle = '#f44'; ctx.font = 'bold 11px ui-monospace'; ctx.fillText('BLOWN! (E to re-rig)', p.x - 30, p.y + 30); }
if (DEBUG) { ctx.fillStyle = '#fff'; ctx.fillText(c.load.toFixed(0) + '/' + c.hw.rating, p.x - 14, p.y - 10); }
}
}
// player
if (state.phase !== 'prep') {
ctx.fillStyle = '#ffd27a';
ctx.beginPath(); ctx.arc(player.x, player.y, player.r, 0, 7); ctx.fill();
ctx.strokeStyle = '#5a3d24'; ctx.lineWidth = 2; ctx.stroke();
if (state.repair) {
ctx.strokeStyle = '#7ee0ff'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.arc(player.x, player.y, 15, -1.57, -1.57 + state.repair.progress * 6.28); ctx.stroke();
}
}
// rain + gust telegraph
if (state.phase === 'storm') {
ctx.strokeStyle = 'rgba(180,210,255,.35)'; ctx.lineWidth = 1;
const rl = 6 + wind.speed * 0.15;
for (const r of RAIN) {
r.x += windX() * 0.08 + 0.5; r.y += 6 + wind.speed * 0.12;
if (r.y > H) { r.y = 0; r.x = Math.random() * W; }
if (r.x > W) r.x = 0;
ctx.beginPath(); ctx.moveTo(r.x, r.y);
ctx.lineTo(r.x + Math.cos(wind.dir) * rl * 0.4, r.y + rl); ctx.stroke();
}
// telegraph: pale band sweeps across during the 1.5s before a gust ramps
if (wind.gustT >= 0 && wind.gustT < 1.5) {
const f = wind.gustT / 1.5;
const cx2 = W / 2 - Math.cos(wind.dir) * W * (0.8 - f * 1.3);
const cy2 = H / 2 - Math.sin(wind.dir) * H * (0.8 - f * 1.3);
ctx.save();
ctx.translate(cx2, cy2); ctx.rotate(wind.dir + Math.PI / 2);
ctx.fillStyle = 'rgba(255,255,255,.10)';
ctx.fillRect(-W, -30, W * 2, 60);
ctx.restore();
}
}
// HUD
ctx.fillStyle = '#000a';
ctx.fillRect(W - 210, H - 92, 200, 82);
ctx.fillStyle = '#dde5ea'; ctx.font = '12px ui-monospace';
if (state.phase === 'storm') {
ctx.fillText(`storm ${Math.max(0, STORM_LEN - state.t).toFixed(0)}s`, W - 198, H - 72);
ctx.fillText(`wind ${wind.speed.toFixed(0)}${wind.gust > 5 ? ' GUST!' : ''}`, W - 198, H - 54);
ctx.fillText(`garden ${state.gardenHP.toFixed(0)}% spare×${state.spare}`, W - 198, H - 36);
} else if (state.phase === 'prep') {
ctx.fillText('rig 4 corners, set tension,', W - 198, H - 66);
ctx.fillText('then START STORM', W - 198, H - 50);
}
// wind arrow
ctx.save();
ctx.translate(W - 40, H - 40);
ctx.rotate(wind.dir);
ctx.strokeStyle = '#ffd27a'; ctx.lineWidth = 3;
const al = 6 + Math.min(18, wind.speed * 0.3);
ctx.beginPath(); ctx.moveTo(-al, 0); ctx.lineTo(al, 0); ctx.lineTo(al - 6, -5); ctx.moveTo(al, 0); ctx.lineTo(al - 6, 5); ctx.stroke();
ctx.restore();
// event ticker
ctx.font = '12px ui-monospace';
state.events.forEach((ev, i) => {
ctx.fillStyle = `rgba(255,210,122,${1 - (state.t - ev.t) / 8})`;
ctx.fillText(ev.text, 12, H - 16 - i * 16);
});
if (state.msg) {
ctx.fillStyle = '#ff8'; ctx.font = 'bold 16px ui-monospace';
ctx.fillText(state.msg, W / 2 - 90, 90);
}
// end screen
if (state.phase === 'end') {
ctx.fillStyle = '#000c'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = state.result.win ? '#7fce6a' : '#f66';
ctx.font = 'bold 34px ui-monospace';
ctx.fillText(state.result.win ? 'THE GARDEN MADE IT' : 'THE GARDEN IS GONE', W / 2 - 200, H / 2 - 40);
ctx.fillStyle = '#dde5ea'; ctx.font = '15px ui-monospace';
ctx.fillText(`garden health ${state.gardenHP.toFixed(0)}%`, W / 2 - 200, H / 2);
ctx.fillText(`corners intact ${state.corners.filter(c => !c.broken).length}/4`, W / 2 - 200, H / 2 + 22);
ctx.fillText(`budget left $${state.budget}`, W / 2 - 200, H / 2 + 44);
ctx.fillText('refresh to run it again', W / 2 - 200, H / 2 + 80);
}
}
// ---------- main loop ----------
let last = performance.now();
function frame(now) {
const dt = Math.min(0.033, (now - last) / 1000);
last = now;
state.t += dt;
if (msgTimer > 0) { msgTimer -= dt; if (msgTimer <= 0) document.getElementById('msg').textContent = ''; }
windVec(state.t, dt);
stepSail(state.t, dt);
stepPlayer(state.t, dt);
if (state.phase === 'storm') {
const cover = coveredFraction();
state.gardenHP -= (1 - cover) * 5.5 * dt;
if (state.gardenHP <= 0) { state.gardenHP = 0; endStorm(false); }
else if (state.t >= STORM_LEN) endStorm(true);
}
draw(state.t);
requestAnimationFrame(frame);
}
function endStorm(win) {
state.phase = 'end';
state.result = { win };
document.getElementById('phaseLabel').innerHTML = '<b>AFTERMATH</b>';
}
// auto mode for quick testing: rig h1,h3,p1,p2 with mixed hardware and start
if (AUTO) {
for (const [id, hwi] of [['h1', 2], ['h3', 1], ['p1', 1], ['p2', 0]]) {
const a = ANCHORS.find(x => x.id === id);
state.corners.push({ anchor: a, hw: HARDWARE[hwi], load: 0, overload: 0, broken: false });
state.budget -= HARDWARE[hwi].cost;
}
state.corners = orderRing(state.corners.map(c => c.anchor)).map(a2 => state.corners.find(c => c.anchor === a2));
state.spare = 1; state.budget -= 15;
buildSail();
state.phase = 'storm';
state.t = 0;
document.getElementById('phaseLabel').innerHTML = '<b>STORM</b>';
}
refreshPanel();
requestAnimationFrame(frame);