javascript

Запускаем Pong в 240 вкладках браузера

  • вторник, 25 февраля 2025 г. в 00:00:07
https://habr.com/ru/articles/884564/

Что вы делаете с незакрытыми вкладками браузера? На мой взгляд, они занимают слишком много места на экране, поэтому на этой неделе я разобрался, как запустить в этих вкладках Pong.

Это 240 вкладок браузера в плотной сетке 8x30. И в них запущен Pong! Видно, что мяч и ракетки перемещаются по canvas в окне и во всех вкладках.

Код (ужасный) можно посмотреть на GitHub. Но как он работает?

Источник вдохновения

Источником вдохновения для моего проекта стал мой друг Tru, создавший на прошлой неделе версию Flappy Bird, работающую в favicon (Flappy Favi).

FlappyFavi великолепна, но понять происходящее в ней довольно сложно, ведь фавиконки очень маленькие. Я подумал, что смогу решить эту проблему.

Лучшее, что я придумал — отрисовывать изображение в нескольких вкладках. При этом возникло несколько проблем:

  • Как создать красивую сетку из вкладок для рисования на них?

  • Как обновлять состояние этих вкладок, даже если они находятся в фоновом режиме?

  • Как координировать вкладки?

Прототипирование

Первая задача заключалась в поиске способов создания сетки вкладок. Я начал с того, что открыл окно Chrome и щёлкал по кнопке новой вкладки, пока вкладки не стали очень маленькими. Это выглядело примерно так:

A tiny row of tabs. The favicons form a grid.
Маленькая строка вкладок. Фавиконки образуют сетку.

Начало многообещающее! Мы получили красивую сетку. А если открыть второе окно и правильно его расположить, то получится вторая строка.

Но мне нужна была большая сетка. Создавать её вручную было бы очень трудоёмко, поэтому я обратился к одному из своих любимых инструментов: AppleScript.

AppleScript — это мощный и причудливый инструмент для управления программами на Mac; в нём можно писать практически на английском, но он достаточно многословен и строг, поэтому по большей мере вы будете писать на Python с кучей дополнительных слов.

Но здесь он подходит идеально. Я написал скрипт, открывающий 8 окон Chrome по 30 вкладок в каждом и тщательно выравнивающий каждое окно одно под другим.

Возникла пара раздражающих проблем, например, Chrome пытается повторно открыть закрытые вкладки, поэтому в начале скрипт должен очистить их.

Но в конечном итоге код оказался относительно простым. Его ядро выглядит так:

-- Задаём границы окон (x, y, width, height)
set bounds of newWindow to {x, y, x + width, y + height}
global tabCount
set tabCount to 0

tell newWindow
  set URL of active tab to baseURL & "windowIndex=" & windowNum & "&tabIndex=" & tabCount
end tell

set tabCount to (tabCount + 1)

-- Создаём указанное количество вкладок
repeat (numTabs - 1) times
  tell newWindow
    if windowNum is (maxWindows - 1) and tabCount is (numTabs - 1) then
      make new tab with properties {URL:baseUrl & "windowIndex=" & windowNum & "&tabIndex=" & tabCount & "&isMain=true&numWindows=" & maxWindows & "&numTabs=" & numTabs & "&fullWidth=" & fullWidth}
    else
      make new tab with properties {URL:baseURL & "windowIndex=" & windowNum & "&tabIndex=" & tabCount}
    end if
  end tell
  set tabCount to (tabCount + 1)
end repeat

Быстрые обновления фавиконок

Следующая проблема связана с обновлением фавиконок.

По умолчанию браузеры ищут фавиконки по нескольким известным URL. Но можно и добавить в head HTML-разметки элемент, говорящий «моя фавиконка находится здесь».

При обновлении этого элемента браузер заменит иконку. Именно так работает FlappyFavi. Похоже, Сhrome обновляет иконку примерно четыре раза в секунду.

[Не знаю, как это делают другие браузеры. В частности, Firefox позволяет загружать анимированные фавиконки, что мне очень бы помогло. Но Firefox не позволял мне создать сетку из достаточно мелких вкладок, чтобы в ней было удобно рисовать, поэтому я остался с Chrome. Но мне бы очень хотелось поэкспериментировать с анимациями!]

Но мне было непонятно, как это будет работать, когда вкладка находится в фоне. Для повышения производительности браузеры ограничивают ресурсы, к которым имеет доступ вкладка в фоновом режиме, а большинство наших вкладок не будет на переднем плане!

Я провёл простое тестирование с крошечным циклом, обновлявшим фавиконку каждые 250 мс. И, разумеется, этот цикл работал для фоновой вкладки всего примерно раз в секунду!

Браузер выполнял троттлинг моего цикла setInterval! Я начал придумывать способы обхода этой проблемы.

Первым делом я решил использовать в своих целях web audio API — я знал, что у них есть хорошая поддержка аудио, продолжающего воспроизводиться в фоне, и что можно добавить некие обратные вызовы в код аудио; я попробовал воспроизводить беззвучный сигнал и поместить мой код в поток (thread) аудио, чтобы проверить, поможет ли это. Но эта система не заработала.

Поэтому я попробовал веб-воркеров. Веб-воркеры — это способ перенести вычислительно затратную задачу с основного потока браузера (чтобы она не препятствовала рендерингу). И я подумал, что она может подвергаться меньшему троттлингу.

Я переместил таймер в веб-воркер и заставил его при срабатывании таймера передавать post-сообщения в основной документ. И это замечательно сработало!

Код здесь немного длинный, но относительно простой. У нас есть воркер, циклически обходящий эмодзи и возвращающий их в URL данных, которые мы передаём основной вкладке, обновляющей фавиконку.

Код фавиконок веб-воркера
// воркер

let intervalId = null;
let counter = 0;
const emojis = ["🌞", "🌜", "⭐", "🌎", "🚀"];
let currentIndex = 0;

function drawEmoji(emoji) {
  // Создаём OffscreenCanvas (поддерживается воркерами)
  const canvas = new OffscreenCanvas(32, 32);
  const ctx = canvas.getContext("2d");

  ctx.font = "28px serif";
  ctx.fillText(emoji, 2, 24);

  // Преобразуем в блоб и отправляем обратно
  canvas.convertToBlob().then((blob) => {
    const reader = new FileReader();
    reader.onloadend = () => {
      counter++;
      postMessage({
        type: "update",
        dataUrl: reader.result,
        counter: counter,
      });
    };
    reader.readAsDataURL(blob);
  });
}

self.onmessage = function (e) {
  if (e.data.command === "start") {
    const interval = e.data.interval;
    if (intervalId) clearInterval(intervalId);

    intervalId = setInterval(() => {
      drawEmoji(emojis[currentIndex]);
      currentIndex = (currentIndex + 1) % emojis.length;
    }, interval);

    // Первоначальное обновление
    drawEmoji(emojis[currentIndex]);
  } else if (e.data.command === "stop") {
    if (intervalId) {
      clearInterval(intervalId);
      intervalId = null;
    }
  }
};

// основной документ
worker.onmessage = function (e) {
  if (e.data.type === "update") {
    let link =
      document.querySelector("link[rel*='icon']") ||
      document.createElement("link");
    link.type = "image/x-icon";
    link.rel = "shortcut icon";
    link.href = e.data.dataUrl;
    document.head.appendChild(link);
  }
};

Так мы получили быстрые обновления. Но если мы хотим, чтобы всё это работало между всеми вкладками, нам нужна синхронизация. Как наши вкладки должны общаться?

Общение вкладок

Общение вкладок состоит из двух подзадач:

  • Как каждая из вкладок знает, где она находится? Допустим, как ей узнать, что она третья вкладка во втором окне?

  • Какой канал коммуникации нужно использовать для обновления вкладок?

Первая задача относительно проста: вероятно, выше вы заметили моё решение в коде на AppleScript. Скрипт передаёт индекс текущего окна и вкладки как параметр запроса. Каждой вкладке просто нужно извлечь эти параметры запросов и узнать свой индекс окна и вкладки, то есть, по сути, координаты x и y.

Вторая задача чуть интереснее. Для меня самым очевидным решением было использование веб-сокетов — можно создать сервер, к которому подключается каждая вкладка, и этот сервер будет сообщать вкладкам, что им делать.

Я набросал простой proof of concept. При загрузке вкладки (внутри веб-воркера) создавали сокет-подключение к серверу, а затем обновляли свою фавиконку на основании отправленных сервером данных. Сервер отправлял два разных изображения на основании чётности вкладки.

Всё работало нормально. Я заметил две проблемы:

  • Эстетика: мне просто не хотелось работать с сервером! Мне нужно было, чтобы система работала во вкладках браузера по всему миру.

  • Синхронизация: вкладки были рассинхронизированы! Сервер отправлял обновления вкладке сразу после подключения, а для загрузки каждой вкладки требовалось какое-то время.

Чтобы решить первую проблему, я перешёл на каналы вещания; по сути, это способ распределения информации по разным вкладкам одного домена. Такой подход отличается от веб-сокетов — они выполняют передачу от одного к одному, а вещание — от одного ко многим. Но мне всё равно показалось, что это лучше подходит для моей задачи.

Для решения второй проблемы просто требовалось чуть больше кода. Я научил фоновые вкладки отправлять через канал вещания регистрационное сообщение, содержащее их индекс вкладки и окна. Основная вкладка (та, которая была на переднем плане и не подвергалась троттлингу) слушала эти сообщения и отправляла в ответ подтверждение, после которого фоновая вкладка переставала пытаться зарегистрироваться. А после того, как основная вкладка получала события регистрации для всех фоновых вкладок, она начинала выполнять анимацию.

[В моём AppleScript добавился ещё один параметр запроса, сообщающий последней открытой вкладке, что это основная вкладка, наряду с параметрами, сообщающими, сколько окон и вкладок открыто.]

Код регистрации
// воркер
bc = new BroadcastChannel("bc");
bc.addEventListener("message", (event) => {
  const msg = event.data;
  if (!msg) return;
  else if (
    msg.type === "ack" &&
    msg.tabIndex === tabIndex &&
    msg.windowIndex === windowIndex
  ) {
    clearInterval(regInterval);
    registrationDone = true;
    postMessage({ type: "registration-ack" });
  }
});

regInterval = setInterval(() => {
  bc.postMessage({ type: "register", tabIndex, windowIndex });
}, 1000);

// основная вкладка
const bc = new BroadcastChannel("bc");
const registrations = {};
if (data && data.type === "register") {
  const key = `tab_${data.tabIndex}_${data.windowIndex}`;
  console.log(`Registered: ${key}`);
  registrations[key] = true;
  bc.postMessage({
    type: "ack",
    tabIndex: data.tabIndex,
    windowIndex: data.windowIndex,
  });

  const expected = numTabs * numWindows;
  if (Object.keys(registrations).length === expected) {
    console.log("All tabs registered. Beginning...");
    runLoopGeneric({
      bc,
      worker,
      numTabs,
      numWindows,
      fullWidth,
      impl: "pong",
    });
  }
}

Из canvas в панель вкладок

Добившись достаточного контроля за вкладками, я начал думать, что же мне нужно создать. Я подумал, что будет здорово, если что-то будет отрисовываться на моей передней вкладке, а затем перемещаться в панель вкладок.

Начал я с простого прямоугольника.

Для этого мне нужно было представить, что canvas расширялся из переднего окна на все фавиконки над ним, и отрисовывать фавиконки и основной canvas в зависимости от позиции объекта.

Теллер (из шоу Пенна и Теллера) однажды сказал фразу, которую я часто вспоминаю при работе над подобными проектами.

Иногда магия создаётся просто благодаря тому, что вы потратите на что-то больше времени, чем ожидают другие.

И работая над моим проектом, я не забывал об этом.

Здесь нет никакой магии. На самом деле, я просто потратил время на измерения.

Several stacked chrome windows. Measurements indicate how tall and wide various spans (like the distance between the left side of the window and the first tab) are
Спасибо, tldraw

Расстояние между левым краем окна Chrome и первой фавиконкой 92 пикселя (по крайней мере, при таком количестве открытых вкладок). А между низом фавиконки и верхом окна — 58 пикселей. А фавиконки имеют размер 16x16. И так далее...

На основании всех этих измерений и информации о количестве открытых вкладок и окон мой код:

  • Вычисляет ширину отображаемого canvas, чтобы он идеально соответствовал фавиконкам над ним.

  • Вычисляет ширину каждой вкладки.

  • Определяет ширину и высоту всего «canvas», в который мы выполняем отрисовку, включая строки фавиконок, адресную панель и сам canvas в окне.

Code that encodes the pixel measurements from the above image.

Теперь мы можем симулировать прямоугольник, движущийся по «целому» canvas. Когда его часть под адресной строкой, мы рисуем его в «реальном canvas». Но также мы вычисляем, какие его части находятся «над» адресной строкой, и вещаем эту информацию другим вкладкам. Каждая вкладка вычисляет собственные пиксельные координаты (на основании проделанных выше вычислений) и обновляет себя, отрисовывая чёрный или белый пиксель на основании местонахождения прямоугольника.

Код прямоугольника
// основная вкладка
function transmitSquareCoords() {
  const copied = { ...square };
  updateMap = {};
  for (let t = 0; t < numTabs - 1; t++) {
    for (let w = 0; w < numWindows; w++) {
      pixels = [];
      const PIXEL_COUNT = 4;
      const FAVICON_SIZE = 16;
      const MULT = FAVICON_SIZE / PIXEL_COUNT;
      for (let yy = 0; yy < PIXEL_COUNT; yy++) {
        for (let xx = 0; xx < PIXEL_COUNT; xx++) {
          let x = tabSingle * t + (tabSingle - 16) / 2 + xx * MULT;
          let y = TOP_TO_FAVICON + HARDCODED_WINDOW_DIFF * w + yy * MULT;
          let thisSquare = {
            x,
            y,
            w: MULT,
            h: MULT,
          };
          if (intersects(thisSquare, copied)) {
            pixels.push(1);
          } else {
            pixels.push(0);
          }
        }
      }
      const key = `tab_${t}_${w}`;
      updateMap[key] = pixels;
    }
  }
  bc.postMessage({ type: "update", pixels: updateMap });
}

// фоновая вкладка
function updateFavicon(pixels) {
  const canvas = document.createElement("canvas");
  canvas.width = 4;
  canvas.height = 4;
  const ctx = canvas.getContext("2d");
  for (let i = 0; i < 16; i++) {
    const x = i % 4,
      y = Math.floor(i / 4);
    ctx.fillStyle = pixels[i] ? "#000" : "#fff";
    ctx.fillRect(x, y, 1, 1);
  }
  const faviconURL = canvas.toDataURL("image/png");
  let link = document.querySelector("link[rel='icon']");
  if (!link) {
    link = document.createElement("link");
    link.rel = "icon";
    document.head.appendChild(link);
  }
  link.href = faviconURL;
}

Ускоряем код

Всё заработало нормально. Но код требовал на удивление много ресурсов — это заметно по тому, что анимация в canvas начинается и останавливается, хоть и отрисовывается в requestAnimationFrame, а значит, должна быть плавной.

Так как анимация была дёрганной, я понял, что какая-то часть потока передней вкладки использовала слишком много ресурсов. Там почти ничего не происходило, но я подумал, что, возможно, передаю слишком много данных.

Мой основной поток вычислял состояние каждого пикселя фавиконки, а затем засовывал эти данные в канал вещания, считываемый сотнями вкладок. Можно представить, что реализация вещания делала копию каждой вкладки... возможно объём копирования получался слишком большим? Я отнёсся к этой мысли скептически, но ничего лучше придумать не мог.

Я переписал код так, чтобы он вещал только позицию квадрата, а каждая вкладка на лету вычисляла, пересекается ли фавиконка с квадратом. Но это не помогло! Меня это озадачило.

Я решил воспользоваться древней методикой: отключать разные части кода, пока всё не заработает, и наконец нашёл проблему: код создавал сотни фавиконок в секунду, и этот процесс был слишком медленным.

По сути, мой код делал что-то подобное в каждой вкладке, чтобы создать чёрно-белое изображение размером 4x4 и превратить его в URL для фавиконки.

const ctx = bwCanvas.getContext("2d");
for (let i = 0; i < len; i++) {
  const x = i % width;
  const y = Math.floor(i / width);
  const index = (y * width + x) * 4;
  ctx.fillStyle = pixels[i] ? BLACK : WHITE;
  ctx.fillRect(x, y, 1, 1);
}
return bwCanvas.toDataURL("image/png");

И этот код заново выполнялся в каждом «кадре», вне зависимости от того, менялся ли конечный canvas. Сотни вкладок много раз в секунду заново создавали крошечные белые квадратные фавиконки!

Я внёс изменения в код, чтобы он обновлял фавиконку, только если что-то меняется, и производительность существенно выросла.

Честно говоря, я до сих пор немного не понимаю, как эта работа замедлила мою анимацию. Я понимаю, что выполнение слишком большого объёма работы в других вкладках могло замедлить машину в целом (а я использовал приличную долю ресурсов CPU), но у моей машины много свободных ядер! Очевидно, что я не знаю какого-то аспекта использования ресурсов браузерами.

Что же мы будем создавать?

Разобравшись с движением квадрата, я потратил немного времени на подчистку кода, чтобы получился небольшой «движок» для написания игр. А потом я задумался, какие же игры стоит делать.

Первым делом я подумал о «змейке». Она естественным образом состоит из блоков (что хорошо подходит для фавиконок) и её достаточно легко запрограммировать. Поэтому я написал первую часть реализации «змейки».

[Мне всегда нравился трюк, применяемый в этой игре — мы храним массив позиций змейки и на каждом игровом такте отрезаем хвост и добавляем новую голову в зависимости от текущего направления.]

Но я быстро столкнулся с проблемой. Возможно, вы её заметите.

Проблема (по крайней мере, для меня) заключается в том, что змея слишком блочная. Один из аспектов красоты анимации движущегося прямоугольника заключается в том, как он превращается из сплошного (на canvas) в дискретный (в панели вкладок). Думаю, естественнее всего воспринимать «змейку» как игру, работающую с дискретным (и маленьким) множеством блоков размером с ячейку змеи.

Поэтому я решил придумать новую игру. В конечном итоге я остановился на Pong, потому что мяч и ракетки будут регулярно перемещаться между canvas и панелью вкладок, создавая красивый эффект.

Реализация Pong

И тогда я реализовал Pong!

Откровенно говоря, об этой части проекта рассказать особо нечего. У меня довольно много опыта в разработке игр, так что после создания хорошего API для отрисовки в самом Pong не было ничего сложного.

Думаю, стоит немного объяснить мою реализацию:

  • Игрок-компьютер (справа) просто пытается всегда выравнивать центр своей ракетки с центром мяча

  • Я выполняю простые тригонометрические расчёты для вычисления угла, под которым мяч должен отразиться от ракетки (относительно центра ракетки). Это совершенно не реалистично, но позволяет варьировать углы.

  • Для этой игры я написал, наверно, двадцатую свою функцию проверки «пересекаются ли эти два квадрата».

Я очень доволен тем, как мяч и ракетки плавно перетекают в фавиконки, и добавил след от мяча, чтобы подчеркнуть движение.

Код выложен в опенсорс, но чрезвычайно уродлив; при работе над этим проектом я так и не вышел из режима прототипа. Простите.