diff --git a/config.js b/config.js index 4011ceb..28df04c 100644 --- a/config.js +++ b/config.js @@ -168,12 +168,6 @@ export const TUNE = { // court — so every downward pull picked the same body and the angle felt inert. // 0.10 gives ±2.9 m out there: you have to actually point at a lane. pullHintAfter: 1.5, // s of doing nothing in the serve stance before the pull gesture demos itself - shotTail: 1, // HOW THE AIM BAND BEHAVES ONCE FIRED. 0 = a short WAKE ahead of the ball, - // dissolving; 1 = a short TAIL behind it and nothing ahead at all. Both keep - // the aiming band itself untouched — full arc, full colour, that part is the - // information you act on. Read per frame, so the T panel flips it live — but - // the panel is keyboard-only ('T'), so on a phone this default is the setting. - // 1 is Oliver's pick; the losing branch stays until a real playtest confirms it. aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the // cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the // lane always wins", which is what made the magnet grab the wrong shambler. diff --git a/render.js b/render.js index a643c7b..d01887b 100644 --- a/render.js +++ b/render.js @@ -460,12 +460,10 @@ function drawFlames(zb, hMeters) { // information you are deciding on. Carrying that picture unchanged into the flight is what made // the shot read as a laser — a bright, saturated, continuous line running from the projectile to // the thing it is about to hit is the visual definition of a targeting beam, and no real ball ever -// shows its own future. So once fired the band is spent: it desaturates toward dust, collapses -// fast, and shrinks to a short streak. TUNE.shotTail picks which side of the ball that streak is on -// 0 a WAKE ahead of it, dissolving — the erase idea, with the beam cut down to a wisp -// 1 a TAIL behind it, nothing ahead at all — conventional projectile language, cannot beam -const WAKE = 0.16; // fraction of the arc drawn ahead of the ball (shotTail 0) -const TAIL = 0.15; // ...and behind it (shotTail 1) +// shows its own future. So once fired the band is spent: it desaturates toward dust, collapses, +// and shrinks to a short streak BEHIND the ball, with nothing at all drawn ahead of it. +// (A variant that put that streak ahead of the ball instead was built, compared and cut.) +const TAIL = 0.15; // fraction of the arc drawn behind the ball once it is flying const DUST = [206, 200, 184]; const RGB = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t)); @@ -476,10 +474,9 @@ function drawShotBand(a, u0, flying) { // cool blue-white = this only staggers. It stays READABLE on the dark lawn: you still need to // see where you're pointing, you just need to know it won't kill. const hex = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; - const tailMode = flying && TUNE.shotTail >= 0.5; - // the slice of arc to draw, and how hard the whole thing is already gone - const lo = !flying ? 0 : tailMode ? Math.max(0, u0 - TAIL) : u0; - const hi = !flying ? 1 : tailMode ? u0 : Math.min(1, u0 + WAKE); + // the slice of arc to draw: everything while aiming, a short streak behind the ball in flight + const lo = flying ? Math.max(0, u0 - TAIL) : 0; + const hi = flying ? u0 : 1; if (hi - lo < 0.001) return; // Linear, not squared. Squared made both variants basically gone by a third of the flight, which // is not "less like a laser", it is "no streak at all" — and the strobing at release is the thing @@ -493,7 +490,7 @@ function drawShotBand(a, u0, flying) { const uAt = i => lo + (hi - lo) * (i / N); // s: 0 at the far end of the slice, 1 at the ball (or at the racket, while aiming) — so the // streak is always brightest where the action is and dies off away from it. - const sAt = i => tailMode ? i / N : 1 - i / N; + const sAt = i => flying ? i / N : 1 - i / N; // The shadow first, under everything: an arc hanging in the air is ambiguous about where it // comes down until something pins it to the lawn. This is the whole 3D cue. It follows the same // slice — "nothing ahead" has to mean the shadow too, or the ground still gives the future away. @@ -786,22 +783,22 @@ function drawPullHint() { const len = travel * draw; ctx.save(); ctx.globalAlpha = gone ? 0 : 0.85; - // the dotted track the thumb travels, straight down-screen = "pull back" + // the dotted track the thumb travels, straight down-screen ctx.fillStyle = UI.dim; for (let d = 7; d < travel + 4; d += 7) ctx.fillRect(ax - 1, ay + d, 2, 2); - // chevrons growing UP-court as the pull grows — the inverse relationship is the whole lesson. - // They start above his HEAD, not at his feet: pinned to the apron anchor they were drawn across - // his own legs, which read as clutter on the sprite rather than as a shot leaving him. - const head = proj(P.x, 2.4, P.z).y; + // A DOWN arrowhead at the far end of the track, and nothing pointing up anywhere. The demo used + // to grow chevrons UP-court as the pull grew — the shot going the opposite way to the thumb was + // meant to be the lesson, but an up arrow on screen is an instruction to swipe up, and that is + // the one gesture this does not have. The only direction drawn is the one you actually make. ctx.fillStyle = UI.warm; - for (let i = 0; i < 3; i++) { - const u = (i + 1) / 4; - if (draw < u * 0.9) continue; - const cy = head - 6 - i * 9, s = 5; - ctx.beginPath(); - ctx.moveTo(ax, cy - s); ctx.lineTo(ax + s, cy + s * 0.7); ctx.lineTo(ax - s, cy + s * 0.7); - ctx.fill(); - } + const av = ay + travel + 16, s = 5; // clear of where the thumb lands at full draw + ctx.beginPath(); + ctx.moveTo(ax, av + s); ctx.lineTo(ax + s, av - s * 0.7); ctx.lineTo(ax - s, av - s * 0.7); + ctx.fill(); + // ...and say it in words. The mime alone was too abstract to read as "drag your thumb down". + ctx.fillStyle = UI.warm; ctx.font = F(8); + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('PULL DOWN', ax, av + 8); // the ghost thumb ctx.globalAlpha = gone ? 0 : 0.9; ctx.fillStyle = UI.cream; @@ -1193,16 +1190,19 @@ export function render() { // The DUEL is played under tennis rules — no slingshot, no rack, serve to the box (see the // !G.interlude guards in solveShot and the aim reticle). It was still being told to pull back // to aim, which is the one thing that does nothing there. + // after a beat of inaction the apron demo takes over, and it says PULL DOWN in its own words — + // so this slot shuts up rather than printing the same instruction a second time 40 px below it + const demo = G.mode === 'splatter' && !G.interlude && G.weapon !== 4 && G.idleHintT > TUNE.pullHintAfter; const txt = G.mode === 'splatter' && !G.interlude ? (G.weapon === 4 ? (touch ? 'TAP TO UNLOAD THE GUN' : 'SPACE UNLOADS THE GUN') - : G.aim ? 'PULL BACK: FURTHER = HARDER' + : G.aim ? 'PULL DOWN: FURTHER = HARDER' : G.weapon > 0 ? `SERVE FIRES: ${sel} · ${switchHint}` - : touch ? 'PULL BACK TO AIM · RELEASE TO FIRE' : 'DRAG BACK TO AIM · SPACE SERVES') + : demo ? '' + : touch ? 'PULL DOWN TO AIM · RELEASE TO FIRE' : 'DRAG DOWN TO AIM · SPACE SERVES') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); - ctx.fillText(txt, W / 2, METER_Y()); // same slot the draw meter uses - // and after a beat of inaction, show the gesture instead of only naming it - if (G.mode === 'splatter' && !G.interlude && G.weapon !== 4 && G.idleHintT > TUNE.pullHintAfter) drawPullHint(); + if (txt) ctx.fillText(txt, W / 2, METER_Y()); // same slot the draw meter uses + if (demo) drawPullHint(); // show the gesture instead of only naming it } if (G.flash) { ctx.font = F(12); ctx.fillStyle = '#ffffff';