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
|
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.
|
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
|
**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
|
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
|
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
|
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)
|
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)
|
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
|
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
|
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
|
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).
|
// Canvas handles — imported by everyone, imports nothing (keeps module cycles harmless).
|
||||||
export const cv = document.getElementById('c');
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=132"></script>
|
<script type="module" src="main.js?v=133"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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)
|
// 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.
|
// freezes the picture too, which is useless for looking at a shot.
|
||||||
let paused = false;
|
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) {
|
function frame(now) {
|
||||||
requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good)
|
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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// 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.
|
// 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;
|
export let W = 480, H = 270;
|
||||||
|
|
||||||
// Split responsibility, and the split is deliberate:
|
// 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
|
export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is
|
||||||
fitBox();
|
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);
|
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
|
// 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.
|
// (~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