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>
This commit is contained in:
parent
430a58061b
commit
06404d3505
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "shades-proto",
|
||||||
|
"runtimeExecutable": "python3",
|
||||||
|
"runtimeArgs": ["-m", "http.server", "8642", "--directory", "prototype"],
|
||||||
|
"port": 8642
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
22
DESIGN.md
22
DESIGN.md
@ -166,6 +166,28 @@ Weather affects *you*: gusts shove you, rain slows you, heat exhausts you, ladde
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Site features & side-systems — the yard is a puzzle box
|
||||||
|
|
||||||
|
Every callout site is assembled from features that each carry a mechanic. The more of these on one site, the more the systems collide:
|
||||||
|
|
||||||
|
**Lawn mowing** — filler income between big jobs, and a reveal mechanic: long grass hides sprinkler heads, buried pipe, stakes, and rocks. Mow blind and you eat blade damage — or worse, **the mower throws a stone**, and if there's a glasshouse in range you just bought new panels. Mowed ground also shows the terrain's true fall lines (drainage intel), and stripes are their own reward.
|
||||||
|
|
||||||
|
**Leaf blowing** — the autumn side-game, and secretly a wind-geometry tutorial: blowing against the breeze is comedy, working *with* it is technique. Leaves clog drain inlets, gutters, and pools; piles you don't bag in time get re-scattered by the next gust. Clearing the inlets before the rain arrives is a real defense action, not chores.
|
||||||
|
|
||||||
|
**Tree pruning — the order puzzle.** Branches have weight, lean, and a fall arc you can read. Every cut re-balances the limb and the tree, so sequence matters: right order drops limbs into the clear zone (or exactly where you wanted a log); wrong order puts one through the glasshouse, the fence, or your own sail. Rope-assisted directional felling for the big stuff. Strategic layer: pruning reduces the tree's wind load and storm-debris risk, **but a removed branch is an anchor point lost forever** — and sometimes the *right* cut unlocks something: a new anchor line, morning sun onto a shaded bed, clearance for the sail you couldn't fit before.
|
||||||
|
|
||||||
|
**Glasshouses** — the maximum-fragility asset. Hail apocalypse, mower ejecta, branch drops, debris. Jobs: hail nets and mesh sails rigged *over* glass, where your own failure is the worst debris of all. Highest pay on the board, highest collateral. Achievement: *Don't Throw Stones.*
|
||||||
|
|
||||||
|
**Wet concrete** — pours are timed commitments. Fresh slab must be kept clear of rain, hail, leaves, the client's dog, and **your own feet** until cured — rig a sail over the pour, plan your walking routes, and check the forecast before you commit, because a ruined slab is a jackhammer day. Pour-deadline vs incoming-weather is a beautiful pressure decision.
|
||||||
|
|
||||||
|
**Trampolines** — secure the client's (tie-down kit, sandbags) or it *will* be the storm's opening projectile. Fun-over-realism option: a bounce route to a high anchor you can't reach by ladder.
|
||||||
|
|
||||||
|
**Swimming pools** — a constraint bundle: pool-fence compliance restricts where posts can legally go, chlorinated air corrodes cheap hardware faster, drainage must never discharge into the pool, leaves in the water make the client cold-call you, and falling in mid-storm dumps your carried tools.
|
||||||
|
|
||||||
|
**Granny flat** — a second roofline changes everything: more building anchor edges, shade-walkway jobs between the buildings, and the alley between house and flat **funnels wind** (venturi acceleration — locally faster wind in the gap, real and mechanically juicy). Plus an occupant NPC: granny brings lemonade during heatwaves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Four seasons — the campaign clock
|
## Four seasons — the campaign clock
|
||||||
|
|
||||||
The year is the campaign structure. Each season biases the callout mix AND changes the physics:
|
The year is the campaign structure. Each season biases the callout mix AND changes the physics:
|
||||||
|
|||||||
580
prototype/game.js
vendored
Normal file
580
prototype/game.js
vendored
Normal file
@ -0,0 +1,580 @@
|
|||||||
|
'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);
|
||||||
36
prototype/index.html
Normal file
36
prototype/index.html
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>SHADES — sail storm prototype</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; background: #1a2229; color: #dde5ea; font-family: ui-monospace, Menlo, monospace; }
|
||||||
|
#wrap { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 10px; }
|
||||||
|
canvas { background: #4a7c3f; border-radius: 6px; box-shadow: 0 4px 24px rgba(0,0,0,.5); }
|
||||||
|
#panel { width: 960px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; font-size: 13px; }
|
||||||
|
#panel button { font: inherit; padding: 6px 14px; border-radius: 4px; border: 1px solid #5a6; background: #2c4; color: #062; font-weight: bold; cursor: pointer; }
|
||||||
|
#panel button:disabled { background: #444; color: #888; border-color: #555; cursor: default; }
|
||||||
|
#msg { color: #ffd27a; min-height: 1.2em; }
|
||||||
|
label { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.dim { color: #8fa3ad; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="wrap">
|
||||||
|
<div id="panel">
|
||||||
|
<span id="phaseLabel"><b>PREP</b></span>
|
||||||
|
<span id="budget"></span>
|
||||||
|
<label>tension <input type="range" id="tension" min="0.6" max="1.4" step="0.05" value="1.0"><span id="tensionVal">1.00</span></label>
|
||||||
|
<label><input type="checkbox" id="spare"> spare shackle ($15)</label>
|
||||||
|
<button id="startBtn" disabled>START STORM</button>
|
||||||
|
<span id="msg"></span>
|
||||||
|
</div>
|
||||||
|
<canvas id="cv" width="960" height="640"></canvas>
|
||||||
|
<div id="panel" class="dim">
|
||||||
|
PREP: click 4 anchor points to rig the sail · click a rigged corner to cycle its hardware ·
|
||||||
|
STORM: WASD/arrows move · hold E at a blown corner to re-rig (needs spare) · protect the garden bed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user