diff --git a/PROJECT.md b/PROJECT.md index 1d2bdb9..232066f 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -636,6 +636,28 @@ Each phase ships to staging and gets a phone verdict, and tests ride along with 5. **LATER.** The metal bed (when Oliver has a track: start at the transformation, gore only) and Members' Night (option C). +## Session log: the weather (2026-10-16, v390) + +Shown first as a mock-up over real v389 frames (artifact "Splatter Tennis Wetter", five kinds, one per +act court). Oliver kept **evening sun, rain, leaves** (heat/gusts and fog/floodlight dropped), asked for +rain and leaves less extreme, for weather that is sometimes simply absent and comes back ("irgendeine +softe Range"), and for it to be court-independent — except no evening sun on the night court. + +`src/weather.js`, render-owned and purely visual (render's own Math.random, nothing gameplay reads): +- **The sky** (`tickSky`): one kind or none, intensity `k` eased linearly over `TUNE.skyFade` 10 s toward a + target redrawn every `skyHold` 25-60 s — 0 with `skyLull` 0.35, else in `skyRange` 0.35-1. The KIND only + changes from a clear sky (k < 0.02; `skyClear` 0.3 keeps it clear), so nothing snaps from one to another. + Sun is never picked on `gloom`, and fades out if you walk onto it. It runs on across runs and modes. +- **Drawing** (`drawWeather`), in render right after the un-shake: over the world, UNDER the gore grade (so + the gore sky is red too) and under the HUD. Logical-pixel particles; rain splashes and lying leaves are + world points through `proj`, so they sit on the court in perspective. Lying leaves have per-spot + thresholds, so the court fills as k rises and clears as it falls. Full-intensity counts in TUNE + (`rainDrops` 70, `rainSplash` 30/s, `leafFall` 22, `leafLie` 36) — about half the preview's density, with + the colour casts at roughly 0.55x. +- `TUNE.weather` 'auto' | 'off' | 'sun' | 'rain' | 'leaves'; the URL door `?weather=rain` sets it. +Test: two simulated hours — never a snap, kind changes only under a clear sky, all three come round, clear +25-80% of the time, no sun on the night court, the forced door works. + ## Session log: the burning ball and the gore grade (2026-10-16, v389) Oliver picked two of the polish ideas (1 and 5; weather only after he has seen a preview): diff --git a/index.html b/index.html index cc3f685..a338ced 100644 --- a/index.html +++ b/index.html @@ -1211,6 +1211,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index 307696d..1798152 100644 --- a/src/config.js +++ b/src/config.js @@ -64,6 +64,11 @@ export const TUNE = { // The rolled cycles hold a real racket, and an un-earned flip teleports it // across the body — the "racket flashing left and right" report hotGlowT: 0.45, // s: the halo a PERFECT strike's ball wears off the racket (render, the burning ball) + // THE WEATHER (src/weather.js): one slow sky over every court. Every skyHold s a new target + // intensity is drawn — 0 with skyLull, else inside skyRange — and eased over skyFade s; the kind only + // changes from a clear sky (skyClear: the chance the next one stays clear). 'auto' | 'off' | a kind. + weather: 'auto', skyHold: [25, 60], skyFade: 10, skyLull: 0.35, skyClear: 0.3, skyRange: [0.35, 1], + rainDrops: 70, rainSplash: 30, leafFall: 22, leafLie: 36, // at full intensity (k 1) — softer than the preview goreGrade: 0.5, // 0-1: how far the gore modes' colour grade goes (render: desaturate + blood cast) trailSpeed: 13, // m/s of horizontal pace at which a rally ball earns the ghost streak in every // mode (the pow>0.8 gate alone was only ever reached by the gore slingshot) diff --git a/src/main.js b/src/main.js index 4d205f3..421c334 100644 --- a/src/main.js +++ b/src/main.js @@ -740,6 +740,8 @@ document.getElementById('recExport').addEventListener('click', async () => { document.getElementById('recStatus').textContent = 'Export could not open. Your recording is kept; please try again.'; } }); +// ?weather=rain|sun|leaves|off forces the sky (TUNE.weather) — to look at one without waiting for it +{ const w = new URLSearchParams(location.search).get('weather'); if (w) TUNE.weather = w; } if (new URLSearchParams(location.search).get('record') === '1') { if (recorder.recordingInfo().exists) recorder.setRecording(true, recordingMeta()); else recorder.beginRecording(recordingMeta()); diff --git a/src/render.js b/src/render.js index 86159a1..131196b 100644 --- a/src/render.js +++ b/src/render.js @@ -3,6 +3,7 @@ import { cv, ctx } from './dom.js'; import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, TINT, SHOT_DEPTH, matchFoe, cupFixture, cupRoundName , STORY, ROMAN, storyFixture, storyBossArt, STORY_HELP } from './config.js'; import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js'; +import { tickSky, drawWeather } from './weather.js'; import { RAISERS, FRAMES } from './crowd.js'; import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js'; import { drillQueue, drillLegal, drillMult } from './drill.js'; @@ -2916,6 +2917,13 @@ export function render() { // holding his own racket is the picture. ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady + // THE WEATHER (src/weather.js): over the world, under the grade (a red sky in gore) and the HUD + { + const now = performance.now(), dt = Math.min(0.05, (now - (render.skyT || now)) / 1000); + render.skyT = now; + tickSky(dt, court.id); + drawWeather(ctx, W, H, proj, dt, 0); + } // THE GORE GRADE (2026-10-16): when the slaughter owns the court, the world drains toward grey and // takes a blood cast — the picture's half of the metal coming in. It eases in over ~1.5 s, so the // story's turn (the boss growing, m.boss set) bleeds into it instead of cutting. The world only; diff --git a/src/weather.js b/src/weather.js new file mode 100644 index 0000000..5c43a1b --- /dev/null +++ b/src/weather.js @@ -0,0 +1,140 @@ +// THE WEATHER (2026-10-16, PROJECT.md "the weather"). Pure picture: nothing here touches the ball, the +// bodies or gameplay's RNG — it is render-owned state, like the crowd dots. Oliver picked three of the +// five previewed (evening sun, rain, leaves), asked for rain and leaves softer, for weather that comes +// and goes on its own ("random mal gar nicht und dann mal wieder, irgendeine softe Range"), and for it +// to happen on ANY court — except the evening sun on the night court. +// +// THE SKY is one slow process: a kind (or none) and an intensity k that eases toward a target. Every +// `TUNE.skyHold` seconds a new target is drawn — often 0, otherwise somewhere in a soft range — and the +// KIND only changes while the sky is clear, so rain never snaps into leaves; it fades out, and maybe +// something else fades in later. TUNE.weather forces one kind ('off' clears it) — the ?weather= door. +import { TUNE } from './config.js'; + +export const SKY = { kind: null, k: 0, target: 0, nextT: 0 }; +const KINDS = ['sun', 'rain', 'leaves']; +const R = (a, b) => a + Math.random() * (b - a); +const allowed = (kind, courtId) => !(kind === 'sun' && courtId === 'gloom'); // no sunset at night + +export function tickSky(dt, courtId) { + const forced = TUNE.weather && TUNE.weather !== 'auto' ? TUNE.weather : null; + if (forced) { + if (forced === 'off' || !allowed(forced, courtId)) SKY.target = 0; + else if (SKY.kind === forced) SKY.target = 1; + else if (SKY.k < 0.02) { SKY.kind = forced; SKY.target = 1; } + else SKY.target = 0; // fade the old kind out first + } else { + SKY.nextT -= dt; + if (SKY.kind && !allowed(SKY.kind, courtId)) SKY.target = 0; // walked onto the night court + else if (SKY.nextT <= 0) { + SKY.nextT = R(TUNE.skyHold[0], TUNE.skyHold[1]); + if (SKY.k < 0.02) { // clear sky: the only time the kind may change + const pool = KINDS.filter(k => allowed(k, courtId)); + SKY.kind = Math.random() < TUNE.skyClear ? null : pool[(Math.random() * pool.length) | 0]; + } + SKY.target = !SKY.kind || Math.random() < TUNE.skyLull ? 0 : R(TUNE.skyRange[0], TUNE.skyRange[1]); + } + } + const step = dt / TUNE.skyFade; // linear ease: a full swing takes skyFade s + SKY.k += Math.max(-step, Math.min(step, SKY.target - SKY.k)); + if (SKY.k <= 0 && SKY.target === 0 && !forced) SKY.k = 0; + return SKY; +} + +// ---- drawing. Everything in logical game pixels (the canvas is 270 wide), snapped to that grid. ---- +const sun = { motes: [] }, rain = { drops: [], spl: [], acc: 0 }, leaves = { fall: [], lie: [], kind: null }; +const LEAF = ['#b5651d', '#8b4a1f', '#d2a04a', '#6b3a1a']; +const px = v => Math.round(v); + +export function drawWeather(ctx, W, H, proj, dt, top) { + const k = SKY.k; + if (k <= 0 || !SKY.kind) { rain.spl.length = 0; return; } + ctx.save(); + if (SKY.kind === 'sun') drawSun(ctx, W, H, k, dt, top); + else if (SKY.kind === 'rain') drawRain(ctx, W, H, k, dt, proj, top); + else if (SKY.kind === 'leaves') drawLeaves(ctx, W, H, k, dt, proj, top); + ctx.restore(); +} + +function drawSun(c, W, H, k, dt, top) { + const t = performance.now() / 1000; + c.globalCompositeOperation = 'multiply'; + let g = c.createLinearGradient(0, 0, 0, H); + g.addColorStop(0, `rgba(255,176,120,${0.34 * k})`); g.addColorStop(1, `rgba(214,120,96,${0.3 * k})`); + c.fillStyle = g; c.fillRect(0, 0, W, H); + c.globalCompositeOperation = 'screen'; + g = c.createRadialGradient(W * 1.02, H * 0.2, 0, W * 1.02, H * 0.2, W * 0.95); + g.addColorStop(0, `rgba(255,196,120,${0.5 * k})`); g.addColorStop(1, 'rgba(255,196,120,0)'); + c.fillStyle = g; c.fillRect(0, 0, W, H); + const ox = W * 1.04, oy = H * 0.16, L = H * 1.2; + for (let i = 0; i < 5; i++) { // god rays fanning down-left from a low sun + const a0 = 2.35 + i * 0.13, a1 = a0 + 0.05 + 0.02 * (i % 2); + const al = (0.045 + 0.03 * Math.sin(t * 0.5 + i * 1.7)) * k; + g = c.createLinearGradient(ox, oy, ox + Math.cos(a0) * L, oy + Math.sin(a0) * L); + g.addColorStop(0, `rgba(255,210,150,${al * 2})`); g.addColorStop(1, 'rgba(255,210,150,0)'); + c.fillStyle = g; c.beginPath(); c.moveTo(ox, oy); + c.lineTo(ox + Math.cos(a0) * L, oy + Math.sin(a0) * L); c.lineTo(ox + Math.cos(a1) * L, oy + Math.sin(a1) * L); c.fill(); + } + if (!sun.motes.length) for (let i = 0; i < 40; i++) sun.motes.push({ x: R(0.1, 1), y: R(0.15, 0.8), p: R(0, 7), v: R(0.004, 0.012) }); + for (const m of sun.motes) { // dust hanging in the light + m.y -= m.v * dt; m.x += Math.sin(t * 0.6 + m.p) * 0.02 * dt; + if (m.y < 0.12) { m.y = 0.8; m.x = R(0.1, 1); } + const a = (0.3 + 0.3 * Math.sin(t * 2 + m.p)) * Math.min(1, m.x * 1.6) * k; + c.fillStyle = `rgba(255,232,170,${a})`; c.fillRect(px(m.x * W), px(Math.max(top, m.y * H)), 1, 1); + } +} + +function drawRain(c, W, H, k, dt, proj, top) { + c.globalCompositeOperation = 'multiply'; + c.fillStyle = `rgba(118,132,162,${0.3 * k})`; c.fillRect(0, 0, W, H); // cooler and darker, not black + c.globalCompositeOperation = 'source-over'; + const n = Math.round(TUNE.rainDrops * k); + while (rain.drops.length < n) rain.drops.push({ x: R(-0.1, 1.2), y: R(-0.2, 1), z: R(0.3, 1) }); + rain.drops.length = n; + for (const d of rain.drops) { + d.y += 1.8 * d.z * dt; d.x -= 0.42 * d.z * dt; + if (d.y > 1.05) { d.y = R(-0.15, 0); d.x = R(0, 1.25); } + const L = 15 * d.z, x = d.x * W, y = d.y * H; + if (y < top) continue; + c.strokeStyle = `rgba(206,218,240,${(0.12 + 0.22 * d.z) * (0.6 + 0.4 * k)})`; c.lineWidth = Math.max(0.5, 0.6 * d.z); + c.beginPath(); c.moveTo(x, y); c.lineTo(x + L * 0.24, y - L); c.stroke(); + } + // splashes stand ON the court: world points through the camera, so they shrink into the far end + rain.acc += TUNE.rainSplash * k * dt; + while (rain.acc >= 1) { rain.acc--; rain.spl.push({ x: R(-6.5, 6.5), z: R(-1, 25.5), t: 0 }); } + rain.spl = rain.spl.filter(p => (p.t += dt) < 0.3); + for (const p of rain.spl) { + const q = proj(p.x, 0, p.z), f = p.t / 0.3; + if (q.s <= 0 || q.y < top) continue; + c.strokeStyle = `rgba(225,232,250,${0.45 * (1 - f)})`; c.lineWidth = 0.5; + c.beginPath(); c.ellipse(q.x, q.y, Math.max(0.6, q.s * 0.05 * (1 + f * 3)), Math.max(0.3, q.s * 0.016 * (1 + f * 3)), 0, 0, 7); c.stroke(); + } +} + +function drawLeaves(c, W, H, k, dt, proj, top) { + c.globalCompositeOperation = 'multiply'; + c.fillStyle = `rgba(226,196,156,${0.18 * k})`; c.fillRect(0, 0, W, H); + c.globalCompositeOperation = 'source-over'; + // what has come down stays down: fixed spots, each with its own threshold, so the court fills as the + // wind picks up and clears again as it drops — never a pop, never a sweep + if (!leaves.lie.length) for (let i = 0; i < TUNE.leafLie; i++) + leaves.lie.push({ x: R(-6.2, 6.2), z: R(-0.5, 25), th: Math.random(), c: (Math.random() * 4) | 0, f: Math.random() < 0.5 }); + for (const l of leaves.lie) { + const a = Math.min(1, (k - l.th) * 4); if (a <= 0) continue; + const q = proj(l.x, 0, l.z); if (q.s <= 0 || q.y < top) continue; + const u = Math.max(1, Math.round(q.s * 0.07)); + c.globalAlpha = 0.85 * a; c.fillStyle = LEAF[l.c]; + c.fillRect(px(q.x), px(q.y), l.f ? u * 2 : u, u); + } + c.globalAlpha = 1; + const n = Math.round(TUNE.leafFall * k); + while (leaves.fall.length < n) leaves.fall.push({ x: R(0, 1), y: R(-0.1, 1), z: R(0.45, 1), p: R(0, 7), c: (Math.random() * 4) | 0 }); + leaves.fall.length = n; + for (const l of leaves.fall) { // tumbling down, swaying, a little downwind + l.y += 0.09 * l.z * dt; l.p += 4 * dt; l.x += (Math.sin(l.p) * 0.13 + 0.035) * l.z * dt; + if (l.y > 1.02) { l.y = R(-0.1, 0); l.x = R(-0.05, 1); } + const y = l.y * H; if (y < top) continue; + const u = Math.max(1, Math.round(2 * l.z)), x = px(l.x * W), ph = Math.sin(l.p * 1.6); + c.fillStyle = LEAF[l.c]; + if (ph > 0.35) c.fillRect(x, px(y), u * 2, u); else if (ph < -0.35) c.fillRect(x, px(y), u, u * 2); else c.fillRect(x, px(y), u, u); + } +} diff --git a/test.js b/test.js index 95f95c9..fffb419 100644 --- a/test.js +++ b/test.js @@ -90,6 +90,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); const state = await import('./src/state.js'); const { G, P, O, score, ball, setupPoint } = state; const { ringCue, predictLanding, solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); +const weather = await import('./src/weather.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs, startStory, startStoryChapter, startHorde, storyUI } = await import('./src/beats.js'); const { mirror, netMsg, netTest, netTick, netAgain } = await import('./src/net.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a @@ -1310,6 +1311,29 @@ test('a groundstroke a little off PERFECT is a little worse, not a weak ball (no } finally { TUNE.autoAssist = assist; } }); +test('THE WEATHER: one soft sky that comes and goes, never snaps, and never sets the sun on the night court', () => { + const { SKY, tickSky } = weather; + Object.assign(SKY, { kind: null, k: 0, target: 0, nextT: 0 }); + const dt = 0.1, maxStep = dt / TUNE.skyFade + 1e-9; + let clear = 0, total = 0, seen = new Set(), prev = { ...SKY }; + for (let i = 0; i < 2 * 3600 / dt; i++) { + const court = i < 3600 / dt ? 'clay' : 'gloom'; + tickSky(dt, court); + assert.ok(SKY.k >= 0 && SKY.k <= 1, 'intensity stays in range'); + assert.ok(Math.abs(SKY.k - prev.k) <= maxStep, 'no snap: ' + prev.k + ' -> ' + SKY.k); + if (SKY.kind !== prev.kind) assert.ok(prev.k < 0.02, 'the kind only changes under a clear sky'); + if (court === 'gloom' && i > 3600 / dt + 200) assert.ok(!(SKY.kind === 'sun' && SKY.k > 0), 'no sunset at night'); + if (SKY.k > 0.05) seen.add(SKY.kind); else clear++; + total++; prev = { ...SKY }; + } + assert.deepEqual([...seen].sort(), ['leaves', 'rain', 'sun'], 'all three come round'); + assert.ok(clear / total > 0.25 && clear / total < 0.8, 'and sometimes nothing at all: clear ' + (clear / total).toFixed(2)); + TUNE.weather = 'rain'; + for (let i = 0; i < 40 / dt; i++) tickSky(dt, 'clay'); + assert.equal(SKY.kind, 'rain'); assert.equal(SKY.k, 1, '?weather=rain forces it in'); + TUNE.weather = 'auto'; +}); + test('THE BURNING BALL: a perfect strike marks its ball for the flight, and the reply clears it', () => { // render reads ball.hotT (the white-hot impact frame, the halo, the gold streak); the state is here const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;