diff --git a/HANDOFF.md b/HANDOFF.md index c8ea542..e9f39e4 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -442,6 +442,22 @@ the countdown panel ('CHANGEOVER · RESUMING IN n'). He kept the burst-arm weapo praise words. **A bottom/above-rack message slot was proposed and REJECTED**: that is thumb territory — covered by the hand mid-pull and outside where the eyes are. +**Fifth batch, same day — THE BATTERY PASS.** "Drains battery like there is no tomorrow." Measured +first: update() 0.01 ms, render() 0.15 ms per frame — the JS is nothing. The bill is fill-rate × +frame-rate (a 3x iPhone pushes a ~1080×2400 canvas through the compositor 60×/s) plus 60 Hz wakeups +on static screens. Three levers shipped: +- **The canvas is OPAQUE now** (`dom.js`: `getContext('2d', {alpha:false, desynchronized:true})`) + — render() clears the whole frame to a solid colour anyway, so the compositor no longer alpha- + blends 2.5 Mpx sixty times a second. Free. +- **Calm states run slow** (`calmFps()` in main.js): a SETTLED modal (world frozen, only the blink + moves) runs at 20 fps, the death summary once faded at 24. Live play stays at TUNE.fps — the 60 + was a feel decision and the governor deliberately never touches anything that moves. +- **`TUNE.lowPower`** (>= 0.5, T-panel-flippable live): caps RES at 3 — −44% of pixels painted per + frame, THE big lever. `RES` is `export let` now, re-derived in fitViewport; main.js re-fits when + the knob flips. Sprites are 224px sources so RES 3 draws them at-or-below 1:1: softer, never + mushy. OFF by default — whether the sharpness trade is worth it is Oliver's phone-call, and if he + wants it player-facing it should become a settings toggle, not a TUNE knob. + **Fourth batch, same day — the card went HORIZONTAL, and nothing draws under a modal.** The stacked icon card ran 102 px tall and its lower third hung over live court; the icon sits LEFT of the text now (boxed to 58×52 so a wide tin cannot reach the type — and it draws BIGGER than the diff --git a/config.js b/config.js index 7d6e976..98c882e 100644 --- a/config.js +++ b/config.js @@ -312,6 +312,9 @@ export const TUNE = { barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body barrelH: 1.15, // drawn height (a body is 1.8) fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped) + lowPower: 0, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery-saver + // switch, flippable live from the T panel. Play speed and fps are untouched: + // the trade is sharpness only, and 224px sprites keep it "softer", never mushy. runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable shamblerHp: 2, // splat-quality hits a classic shambler ('a'/'b') soaks before it dies — only the runner ('f') is still one-shot glass diff --git a/dom.js b/dom.js index 07b5bf9..8cc4bbb 100644 --- a/dom.js +++ b/dom.js @@ -1,3 +1,6 @@ // Canvas handles — imported by everyone, imports nothing (keeps module cycles harmless). export const cv = document.getElementById('c'); -export const ctx = cv.getContext('2d'); +// alpha:false — render() clears the whole frame to an opaque colour anyway, so declaring the canvas +// opaque lets the compositor skip blending a 2.5-megapixel layer 60 times a second (battery, not +// looks). desynchronized is a further no-op-where-unsupported compositor shortcut (Chrome). +export const ctx = cv.getContext('2d', { alpha: false, desynchronized: true }); diff --git a/index.html b/index.html index 388343c..1aca1cf 100644 --- a/index.html +++ b/index.html @@ -359,6 +359,6 @@ - + diff --git a/main.js b/main.js index 0d49d39..9a51458 100644 --- a/main.js +++ b/main.js @@ -18,9 +18,19 @@ let last = performance.now(); // next frame without the scene moving on underneath you. Halting rAF instead (the old test recipe) // freezes the picture too, which is useless for looking at a shot. let paused = false; +// CALM STATES RUN SLOW. The battery bill is fill-rate × frame-rate, and two screens can sit on a +// phone for minutes while fully static: a SETTLED modal (the world is frozen behind it — only the +// blink still moves) and the death summary once its fade and gore have played out. 20-odd fps +// there is invisible and thirds the drain; live play stays at TUNE.fps, untouched — the 60 was a +// feel decision and this deliberately never applies to anything that moves. +const calmFps = () => G.modal && G.modal.in >= 1 ? 20 + : G.mode === 'eaten' && G.eatenT > EATEN_RESTART + 1 ? 24 : 0; +let lowPowerApplied = false; // the last lowPower state fitViewport was run under (T-panel flips it live) function frame(now) { requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good) - if (TUNE.fps && now - last < 1000 / TUNE.fps - 2) return; // frame cap (battery): skip RAF ticks until it's our beat + if ((TUNE.lowPower >= 0.5) !== lowPowerApplied) { lowPowerApplied = TUNE.lowPower >= 0.5; fitViewport(); } + const cap = calmFps() || TUNE.fps; + if (cap && now - last < 1000 / cap - 2) return; // frame cap (battery): skip RAF ticks until it's our beat // THE MENU IS OPAQUE (#intro: fixed, inset 0, solid background). Painting the whole court at RES // density behind it — for the minutes a phone idles on the splash and stats screens — was pure // battery burn nobody could see. The world holds still too; nothing on it predates the menu. diff --git a/render.js b/render.js index bc0dd78..4c6b3dd 100644 --- a/render.js +++ b/render.js @@ -58,7 +58,10 @@ const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMed // nearly double the pixels it can show, on exactly the hardware least able to afford it. The same // near-1:1 logic per tier: dpr 3 -> 4, dpr 2 -> 3, dpr 1 -> 2. // THIS IS THE FIRST KNOB TO TURN DOWN if the phone ever stutters: it is the whole fill-rate budget. -export const RES = Math.max(2, Math.min(4, Math.ceil((devicePixelRatio || 3) * 1.35))); +// `let`, not const: TUNE.lowPower caps it at 3 live (fitViewport re-reads on every fit) — importers +// see the change through the module binding. +const RES_AUTO = Math.max(2, Math.min(4, Math.ceil((devicePixelRatio || 3) * 1.35))); +export let RES = RES_AUTO; export let W = 480, H = 270; // Split responsibility, and the split is deliberate: @@ -111,6 +114,10 @@ function fitBox() { export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is fitBox(); + // battery saver: RES 4 -> 3 is −44% of pixels painted per frame, by far the biggest lever there + // is. Sprites are 224px sources, so at 3 they still draw at-or-below 1:1 — softer than 4, never + // mushy. main.js re-calls fitViewport when the panel flips the knob. + RES = TUNE.lowPower >= 0.5 ? Math.min(3, RES_AUTO) : RES_AUTO; const portrait = (cv.clientHeight || innerHeight) > (cv.clientWidth || innerWidth); // Portrait height ADAPTS to the device. A fixed 270x480 is 9:16, but a modern iPhone is taller // (~390x844 = 0.46), so width bound the fit and ~150 CSS px of screen went unused at the bottom.