The weather: evening sun, rain and leaves over any court, coming and going (v390)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-16 15:52:52 +02:00
1 parent f18680d497
commit af8cd7a513
7 files changed
+202 -1

No files matched your search

+22
View File
@@ -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):
+1 -1
View File
@@ -1211,6 +1211,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=389"></script>
<script type="module" src="src/main.js?v=390"></script>
</body>
</html>
+5
View File
@@ -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)
+2
View File
@@ -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());
+8
View File
@@ -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;
+140
View File
@@ -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);
}
}
+24
View File
@@ -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;