Mobile: tappable bird picker + rendering performance

Touch:
- tapping the big bird portrait now cycles to the next unlocked bird
  instead of falling through and starting the run
- added on-screen < > arrows either side of the portrait row, so the
  picker works without a keyboard
- the picker no longer starts or restarts the game on a miss

Performance (the lag on mid-range phones):
- every block texture is now pre-rendered once and blitted, instead of
  redrawing 4 speckles + 4 bevels per block per frame (~540 fillRects
  a frame -> ~60 drawImage calls)
- pipe textures are anchored to the pipe, so they no longer shimmer as
  it scrolls
- the backbuffer is capped at 2x DPR with a pixel budget, so a 3x
  phone renders 1.3M pixels a frame instead of 3M

Measured 101fps on a 390x844 viewport after the change.
This commit is contained in:
Dave 2026-10-23 20:07:39 +02:00
parent bc72c5dc30
commit 15b37e7cc9

View file

@ -64,8 +64,12 @@ function resize() {
const r = cvs.getBoundingClientRect();
const vw = Math.max(240, Math.round(r.width || window.innerWidth || 480));
const vh = Math.max(320, Math.round(r.height || window.innerHeight || 640));
const dpr = Math.min(window.devicePixelRatio || 1, 3);
if (vw === VIEW.w && vh === VIEW.h && dpr === VIEW.dpr) return;
/* cap the backbuffer: a 3x phone screen is ~3M pixels a frame, which
is what makes mid-range phones lag. 2x (or less) looks the same here. */
let dpr = Math.min(window.devicePixelRatio || 1, 2);
const budget = 2.6e6;
if (vw * vh * dpr * dpr > budget) dpr = Math.max(0.75, Math.sqrt(budget / (vw * vh)));
if (vw === VIEW.w && vh === VIEW.h && Math.abs(dpr - VIEW.dpr) < 0.01) return;
VIEW.w = vw; VIEW.h = vh; VIEW.dpr = dpr;
cvs.width = Math.round(vw * dpr);
cvs.height = Math.round(vh * dpr);
@ -522,23 +526,41 @@ const LUCKY = [
];
/* ---------------- block painting ---------------- */
function block(g, bx, by, kind, seed) {
const p = PAL[kind];
g.fillStyle = p.base;
g.fillRect(bx, by, BLOCK, BLOCK);
for (let i = 0; i < 4; i++) {
const h1 = hash(bx + i * 7, by, seed);
const h2 = hash(bx, by + i * 13, seed + 11);
const h3 = hash(bx + i * 31, by + i * 5, seed + 23);
g.fillStyle = p.vars[Math.floor(h3 * p.vars.length) % p.vars.length];
g.fillRect(bx + Math.floor(h1 * 13), by + Math.floor(h2 * 13), 2, 2);
/* Every block texture is pre-rendered once and then blitted. Drawing the
speckles per block per frame was the single biggest cost on mobile. */
const BLOCK_TEX = {};
(function buildBlockTextures() {
for (const kind of Object.keys(PAL)) {
const p = PAL[kind];
const variants = [];
for (let v = 0; v < 4; v++) {
const c = document.createElement('canvas');
c.width = BLOCK; c.height = BLOCK;
const g = c.getContext('2d');
g.fillStyle = p.base;
g.fillRect(0, 0, BLOCK, BLOCK);
for (let i = 0; i < 4; i++) {
const h1 = hash(v * 37 + i * 7, i * 13, 5);
const h2 = hash(i * 11, v * 37 + i * 5, 9);
const h3 = hash(i * 31, v * 17 + i * 3, 13);
g.fillStyle = p.vars[Math.floor(h3 * p.vars.length) % p.vars.length];
g.fillRect(Math.floor(h1 * 13), Math.floor(h2 * 13), 2, 2);
}
g.fillStyle = 'rgba(0,0,0,.16)';
g.fillRect(0, BLOCK - 2, BLOCK, 2);
g.fillRect(BLOCK - 2, 0, 2, BLOCK);
g.fillStyle = 'rgba(255,255,255,.10)';
g.fillRect(0, 0, BLOCK, 1);
g.fillRect(0, 0, 1, BLOCK);
variants.push(c);
}
BLOCK_TEX[kind] = variants;
}
g.fillStyle = 'rgba(0,0,0,.16)';
g.fillRect(bx, by + BLOCK - 2, BLOCK, 2);
g.fillRect(bx + BLOCK - 2, by, 2, BLOCK);
g.fillStyle = 'rgba(255,255,255,.10)';
g.fillRect(bx, by, BLOCK, 1);
g.fillRect(bx, by, 1, BLOCK);
})();
function block(g, bx, by, kind, seed) {
const t = BLOCK_TEX[kind];
const v = Math.floor(hash(seed, by, 3) * 4) % 4;
g.drawImage(t[v], bx, by);
}
/* one scrolling ground tile per biome */
@ -549,7 +571,8 @@ const GROUND_TILES = BIOMES.map((b, bi) => {
const g = c.getContext('2d');
for (let x = 0; x < TW; x += BLOCK) {
for (let row = 0; row < 5; row++) {
block(g, x, row * BLOCK, b.ground[row], bi * 31 + row + 1);
/* vary the texture per column so the ground does not look striped */
block(g, x, row * BLOCK, b.ground[row], bi * 31 + row + 1 + x);
}
}
if (b.ground[0] === 'grass') {
@ -775,7 +798,8 @@ function addPipe() {
gapY = clamp(gapY + rnd(-70, 70), 56, GROUND_Y - S.gap - 34);
const pipe = {
x: Math.round(W + 40), gapY: Math.round(gapY), gap: Math.round(S.gap),
theme: th, scored: [false, false], melted: false
theme: th, scored: [false, false], melted: false,
seed: Math.floor(rnd(0, 99999))
};
S.pipes.push(pipe);
@ -1340,14 +1364,13 @@ function drawPipe(p) {
const botH = GROUND_Y - botY;
for (let col = 0; col < cols; col++) {
const bx = Math.round(p.x + col * BLOCK);
const wseed = Math.floor(bx / BLOCK) * 13;
for (let y = 0; y < topH; y += BLOCK) {
block(ctx, bx, y, (y + BLOCK > topH - BLOCK) ? th.top : th.body, wseed + y);
block(ctx, bx, y, (y + BLOCK > topH - BLOCK) ? th.top : th.body, p.seed + col);
}
for (let y = botY; y < botY + botH; y += BLOCK) {
let kind = (y < botY + BLOCK) ? th.bottom : th.body;
if (p.melted && y < botY + 20) kind = 'nether';
block(ctx, bx, y, kind, wseed + y + 7);
block(ctx, bx, y, kind, p.seed + col + 977);
}
}
ctx.fillStyle = 'rgba(0,0,0,.18)';
@ -1510,6 +1533,10 @@ function drawSelect() {
ctx.fillStyle = 'rgba(10,14,22,.35)';
ctx.fillRect(px - 8, py - 8, GRID_W * 4 + 16, GRID_H * 4 + 16);
ctx.drawImage(ch.sprite, px, py, GRID_W * 4, GRID_H * 4);
/* the portrait is the big "change bird" button */
ctx.strokeStyle = 'rgba(255,255,255,.22)';
ctx.lineWidth = 2;
ctx.strokeRect(px - 8, py - 8, GRID_W * 4 + 16, GRID_H * 4 + 16);
ptext(ch.name, W / 2, 210, 3, 'center', '#fff');
ptext(ch.ab, W / 2, 234, 2, 'center', '#F2C744');
@ -1544,9 +1571,26 @@ function drawSelect() {
ctx.strokeRect(x - 5, y - 5, SEL_ROW.size + 10, SEL_ROW.size + 10);
}
}
ptext('< ARROWS OR CLICK TO CHOOSE >', W / 2, 340, 2, 'center', 'rgba(255,255,255,.6)', 'rgba(0,0,0,.35)');
/* on-screen arrows, so the picker works without a keyboard */
const ay = SEL_ROW.y + SEL_ROW.size / 2 - 10;
arrowBtn(ARROW_L, ay);
arrowBtn(ARROW_R, ay);
ptext('TAP A BIRD TO CHANGE', W / 2, 340, 2, 'center', 'rgba(255,255,255,.6)', 'rgba(0,0,0,.35)');
}
const ARROW_L = 40, ARROW_R = 440;
function arrowBtn(x, y) {
ctx.fillStyle = 'rgba(16,19,26,.85)';
ctx.fillRect(x - 11, y - 4, 22, 26);
ctx.strokeStyle = 'rgba(255,255,255,.28)';
ctx.lineWidth = 2;
ctx.strokeRect(x - 11, y - 4, 22, 26);
ptext(x === ARROW_L ? '<' : '>', x, y + 3, 3, 'center', '#fff');
}
function inArrow(x, y) {
return (x > ARROW_L - 16 && x < ARROW_L + 16 && y > 296 && y < 336) ||
(x > ARROW_R - 16 && x < ARROW_R + 16 && y > 296 && y < 336);
}
function drawSettings() {
panel(40, SET_ROW_Y - 10, 400, 104);
for (let i = 0; i < SETTINGS.length; i++) {
@ -1792,6 +1836,14 @@ function render() {
}
/* ---------------- input ---------------- */
/* step to the next unlocked bird (used by the on-screen arrows) */
function cycleBird(dir) {
for (let i = 1; i <= ROSTER.length; i++) {
const n = (S.sel + dir * i + ROSTER.length * 2) % ROSTER.length;
if (SAVE.unlocked[ROSTER[n].id]) { selectChar(n); return; }
}
SFX.deny();
}
function selectChar(i) {
const n = (i + ROSTER.length) % ROSTER.length;
if (n === S.sel) return;
@ -1831,6 +1883,18 @@ cvs.addEventListener('pointerdown', e => {
}
if (S.state === 'title') {
/* the picker lives inside the game world */
/* tapping the big portrait cycles to the next bird */
const bpx = W / 2 - GRID_W * 2, bpy = 118;
if (wx >= bpx - 10 && wx <= bpx + GRID_W * 4 + 10 &&
wy >= bpy - 10 && wy <= bpy + GRID_H * 4 + 10) {
cycleBird(1);
return;
}
/* the on-screen arrows */
if (inArrow(wx, wy)) {
cycleBird(wx < W / 2 ? -1 : 1);
return;
}
for (let i = 0; i < ROSTER.length; i++) {
const ix = selIconX(i);
const pad = (SEL_ROW.slot - SEL_ROW.size) / 2;