The battery pass: paint less, wake less
- opaque canvas: the compositor stops alpha-blending 2.5Mpx at 60Hz - settled modals and the death summary idle at 20-24 fps; play untouched - TUNE.lowPower caps RES at 3 (-44% pixels/frame), flippable live Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
911035af00
commit
3b6a82c97d
6 files changed
+43
-4
No files matched your search
+16
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
+1
-1
@@ -359,6 +359,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=132"></script>
|
||||
<script type="module" src="main.js?v=133"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user