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:
parent
bc72c5dc30
commit
15b37e7cc9
1 changed files with 88 additions and 24 deletions
112
index.html
112
index.html
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue