О чем этот пример
В этой статье мы разберём классическую технику создания плавного пиксельного скроллера, вдохновлённого демосценой 90-х, с использованием объекта `Blitter` в Phaser 3. `Blitter` — это высокопроизводительный менеджер для отрисовки множества спрайтов ("бобов") с минимальными накладными расходами, что идеально подходит для эффектов типа «бегущей строки» с сотнями элементов. Вы научитесь организовывать данные шрифта, применять математические функции для анимации и управлять рендерингом через один холст.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
const fNoop = 0, // No effect
fSin1 = 1, // Sine 1
fSin2 = 2, // Sine 2
fBce1 = 3, // Bounce 1
fBce2 = 4, // Bounce 2
fRot1 = 5, // Rotate slow
fRot2 = 6, // Rotate quick
fCyld = 7, // Cylinder
fTria = 8, // Triangle
fGrow = 9; // Pack/Unpack
const colrY = 0, // Yellow
colrB = 1, // Blue
colrP = 2, // Purple
flgDE = 3, // Germany
flgFR = 4, // France
flgBE = 5, // Belgium
flgGB = 6, // Great-Britain
flgSE = 7, // Sweden
flgNL = 8, // Netherlands
flgSC = 9, // Scotland
flgES = 10, // Spain
flgAU = 11, // Australia
flgPT = 12, // Portugal
flgCH = 13, // Switzerland
flgLU = 14; // Luxembourg
// Scrolltext
let text = [
[fNoop, colrY, 0, 'phaser 3'],
[fSin1, colrB, 0, 'phaser 3'],
[fSin2, colrP, 0, 'phaser 3'],
[fBce1, colrY, 0, 'phaser 3'],
[fBce2, colrB, 0, 'phaser 3'],
[fRot1, colrP, 0, 'phaser 3'],
[fRot2, colrY, 0, 'phaser 3'],
[fCyld, colrB, 0, 'phaser 3'],
[fTria, colrP, 0, 'phaser 3'],
[fGrow, colrY, 0, 'phaser 3'],
[fSin1, colrB, 0, 'ohhh the new blitter object'],
[fRot1, colrY, 0, 'can do some really cool stuff'],
[fBce1, colrP, 0, 'don\'t you think so?'],
[fCyld, colrY, 0, 'Let\'s do the twist again!'],
[fTria, colrB, 0, 'A demo masterminded by Alien.'],
[fSin2, colrP, 0, 'Would you like to see some more bobs? Ok:'],
[fRot2, colrY, 0, '# ## ### ######'],
[fGrow, colrP, 0, 'You just saw 384 masked 3-bitplane bobs per VBL...'],
[fRot1, colrY, 0, 'Anyway, let\'s have some greetings now.'],
[fTria, colrP, 0, 'First the megagreetings. They go to:'],
[fTria, flgDE, 0, 'Delta Force'],
[fGrow, flgFR, 0, 'Legacy'],
[fGrow, flgFR, 0, 'Overlanders'],
[fCyld, flgFR, 0, 'Poltergeist'],
[fSin2, flgDE, 0, 'TEX'],
[fGrow, flgFR, 1, 'Vegetables.'],
[fNoop, colrB, 0, 'Normal greetings go to:'],
[fNoop, flgFR, 0, '1984 ABCS 85'],
[fTria, flgDE, 0, 'ACF'],
[fTria, flgFR, 0, 'Mathias Agopian'],
[fRot1, flgFR, 0, 'Alcatraz'],
[fSin1, flgDE, 0, 'BMT'],
[fSin1, flgFR, 0, 'DNT Crew'],
[fGrow, flgBE, 1, 'Dr. Satan'],
[fNoop, flgGB, 0, 'Dynamic Duo'],
[fCyld, flgSE, 0, 'Electra'],
[fTria, flgGB, 0, 'Electronic Images'],
[fSin2, flgFR, 0, 'Equinox'],
[fGrow, flgNL, 0, 'Eternal'],
[fRot2, flgSC, 0, 'Fingerbobs'],
[fBce2, flgSE, 0, 'Flexible Front'],
[fBce2, flgNL, 0, 'Galtan 6'],
[fBce1, flgFR, 0, 'Laurent Z.'],
[fBce1, flgBE, 0, 'Lem and Nic'],
[fTria, flgFR, 0, 'Mad Vision'],
[fGrow, flgFR, 0, 'MCoder'],
[fRot2, flgFR, 0, 'Naos'],
[fBce2, flgGB, 0, 'Neil of Cor Blimey'],
[fCyld, flgDE, 0, 'Newline'],
[fCyld, flgFR, 0, 'Next NGC'],
[fBce1, flgSE, 0, 'Omega Phalanx'],
[fBce1, flgFR, 0, 'Prism Quartex'],
[fGrow, flgES, 1, 'Red Devil'],
[fNoop, flgDE, 0, 'The Respectables'],
[fSin1, flgGB, 0, 'Ripped Off'],
[fRot2, flgAU, 0, 'Sewer Software'],
[fTria, flgFR, 0, 'Silents'],
[fBce1, flgPT, 0, 'Paulo Simoes'],
[fCyld, flgCH, 0, 'Spreadpoint'],
[fNoop, flgFR, 0, 'ST Magazine'],
[fNoop, flgNL, 0, 'ST News'],
[fNoop, flgDE, 0, 'Sven Meyer'],
[fBce2, flgSE, 0, 'Sync'],
[fBce1, flgSE, 0, 'TCB'],
[fGrow, flgCH, 0, 'TDA'],
[fGrow, flgGB, 0, 'TLB'],
[fGrow, flgDE, 0, 'TNT-Crew'],
[fSin2, flgDE, 0, 'TOS Magazin'],
[fSin2, flgFR, 0, 'Tsunoo Rhilty'],
[fRot2, flgDE, 0, 'TVI'],
[fGrow, flgLU, 0, 'ULM'],
[fGrow, flgFR, 0, 'Undead'],
[fCyld, flgGB, 0, 'XXX International'],
[fCyld, flgFR, 0, 'Yoda'],
[fCyld, flgFR, 0, 'Zarathoustra.'],
[fNoop, colrP, 0, 'Alien\'s special greetings are flying over to:'],
[fSin2, flgFR, 0, 'Atm Alain Hurtig Nicolas Chouckroun'],
[fTria, flgDE, 0, 'Flix Big Alec'],
[fSin1, flgGB, 0, 'Manikin'],
[fSin1, flgNL, 0, 'Digital Insanity'],
[fSin2, flgFR, 0, 'Fury'],
[fGrow, flgLU, 0, 'Gunstick'],
[fRot2, flgFR, 0, 'Dbug II'],
[fTria, flgDE, 0, 'ES Gogo'],
[fGrow, flgSE, 0, 'Tanis'],
[fRot1, flgFR, 0, 'Gordon Thomas Landspurg'],
[fBce1, flgGB, 0, 'Kreator 4mat'],
[fTria, flgFR, 0, 'Moby Audio Monster'],
[fNoop, colrP, 0, 'Douglas Adams and Rodney Matthews. Now a little comment about ripping...'],
[fSin1, colrY, 0, 'ST Connexion\'s policy is the following:'],
[fGrow, colrP, 0, 'All our code is copyrighted and may not be re-used in any program or modified in any way whatsoever.'],
[fNoop, colrB, 0, 'It is also illegal to distribute it under any form other than that in which it was released:'],
[fSin2, colrP, 0, 'Delta Force\'s Punish Your Machine Demo.'],
[fNoop, colrY, 0, 'If you wonder why we have such a strict policy, it is because some commercial lamers helped themselves to parts of our code, as well as inumerable groups.'],
[fSin1, colrP, 0, 'Some hints about the 4-bit hardware scroller coming up... In January 1991, I (Alien) got my 4-bit hardware-scroller to work.'],
[fSin2, colrP, 0, 'It allows you to move the whole screen left and right by increments of 4 pixels. The source has been published in my series of articles about overscan in ST Magazine, a French publication.'],
[fTria, colrY, 0, 'As this technique is brand-new, it may not work on some Atari ST\'s. If it doesn\'t work on yours, please contact us.'],
[fBce2, colrB, 0, 'Time to grab a pen.......... To obtain 4 bit hardware scrolling on an ST, you have to switch to midrez after the passage to hirez used to free the left border, and then wait 0,4,8,12 cycles before switching back to lowrez.'],
[fNoop, colrP, 0, 'This affects the way the shifter works, and delays the displaying of the picture by a multiple of 500 nanoseconds, which results in an effective shift in the picture...'],
[fSin2, colrY, 0, 'Note the method described here does not work on some STE\'s. Time is up, so let\'s cut the crap:'],
[fRot1, colrB, 0, 'According to Vickers of Legacy sitting next to us, you\'ve been reading this text for over an hour...'],
[fCyld, colrP, 0, 'But the original version was over 10 kb long, ensuring 4 hours of pleasant reading!'],
[fGrow, colrY, 0, 'See you in the next St Connexion Production, scheduled to be released within the next 2 years! '],
],
frame,
letters,
structure,
text_num = -1,
callback = null,
tile = null,
counter = -1,
first = 0,
iteration = null,
skip = false,
posy,
blitter = null;
class Example extends Phaser.Scene
{
constructor ()
{
scanFont;
super({
key: 'Example',
});
this.scanFont = scanFont
}
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('font', 'assets/tests/twist/bob-font.png');
this.load.atlas('bobs', 'assets/tests/twist/bobs.png', 'assets/tests/twist/bobs.json');
}
create ()
{
this.scanFont();
blitter = this.add.blitter(0, 0, 'bobs');
frame = this.textures.getFrame('bobs', 'bob2');
}
generateStructure (text)
{
let letter;
let c = 0;
structure = []
// Spaces before the text
for (let i = 0 ; i < 34 ; i++) {
structure[c++] = [];
}
// Run through the text
for (let x = 0 ; x < text.length ; x++) {
letter = letters[text.toUpperCase().charCodeAt(x) - 32];
for (let i = 0 ; i < letter.length ; i++) {
structure[c++] = letter[i];
}
// Add a spacer column (except for # character)
if (text.toUpperCase().charCodeAt(x) != 35) {
structure[c++] = [];
}
}
// Spaces after the text
for (var i = 0 ; i < 26 ; i++) {
structure[c++] = [];
}
}
drawFont (position, increment, callback, tile)
{
blitter.clear();
const mid = 140;
for (let x = 0 ; x <= 26 ; x++)
{
const col = structure[position + x];
for (let y = 0 ; y < 16 ; y++)
{
if (col[y] === 1)
{
switch (callback)
{
// Sine
case fSin1: posy = mid + 30 * Math.sin((x + iteration) / 8) + (y - 8) * 10; break;
case fSin2: posy = mid + 30 * Math.sin((x + iteration) / 6) + (y - 8) * 10; break;
// Bounce
case fBce1: posy = mid + 20 - 40 * Math.abs(Math.sin((x + iteration) / 8.75)) + (y - 8) * 10; break;
case fBce2: posy = mid + 20 - 40 * Math.abs(Math.sin((x + iteration) / 17.5)) + (y - 8) * 10; break;
// Rotate
case fRot1: posy = mid + ((y - 8) * 10) * Math.sin((x + iteration) / 8); break;
case fRot2: posy = mid + ((y - 8) * 10) * Math.sin((x + iteration) / 5); break;
// Cylinder
case fCyld: posy = mid + 100 * Math.sin((x + y + iteration) / 8.75); break;
// Triangular
case fTria: posy = mid + 10 * Math.abs((((x + iteration) / 8) % 12) - 6) - 30 + ((y - 8) * 10); break;
// Pack/Unpack
case fGrow: posy = mid + (y - 8) * (10 + 3 * Math.sin((x + iteration) / 10)); break;
// No effect
default: posy = mid + (y - 8) * 10; break;
}
blitter.create(x * 32 - increment * 8, Math.round(posy) * 2, frame);
}
}
}
}
update ()
{
// Load next scroller
if (text_num < 0 || counter < 0)
{
first++;
text_num++;
if (text_num == text.length)
{
text_num = 0;
}
callback = text[text_num][0];
tile = text[text_num][1];
this.generateStructure(text[text_num][3]);
counter = (structure.length - 26) * 4 - 1;
iteration = 0;
frame = this.textures.getFrame('bobs', 'bob' + (tile + 1).toString());
}
// Draw 4-bit scroller
this.drawFont(Math.floor(iteration / 4), iteration % 4, callback, tile);
// Next iteration
counter--;
iteration++;
}
}
const config = {
type: Phaser.AUTO,
width: 821,
height: 552,
parent: 'phaser-example',
scene: Example
};
const game = new Phaser.Game(config);
function scanFont ()
{
var font_canvas = Phaser.Display.Canvas.CanvasPool.create(this, 120, 102);
var ctx = font_canvas.getContext('2d');
ctx.drawImage(this.textures.get('font').source[0].image, 0, 0);
var imageData = ctx.getImageData(0, 0, font_canvas.width, font_canvas.height);
letters = [];
for (var y = 0 ; y < 6 ; y++) {
for (var x = 0 ; x < 10 ; x++) {
var letter = [];
for (var i = 0 ; i < 12 ; i++) {
var col = [], tot = 0;
for (var j = 0 ; j < 17 ; j++) {
var index = ((y * 17 + j) * imageData.width + (x * 12 + i)) * 4;
col[j] = (imageData.data[index] > 0) ? 1 : 0;
tot += col[j];
}
if (tot > 0) letter[i] = col; else continue;
}
// Space
if (x == 0 && y == 0) {
for (var i = 0 ; i < 8 ; i++) {
letter[i] = [];
}
}
letters[y * 10 + x] = letter;
}
}
}
/*
* +================================================================================+
* | A CODEF/HTML5 remake of the screen "Let's Do The Twist Again" by ST Connexion, |
* | released in the Punish Your Machine demo by Delta Force (Atari ST, 1991) |
* | |
* | It was the first (and only one?) demo featuring 4-bit syncscroller, that |
* | allowed to shift the screen by increments of 4 pixels, whereas a "classic" |
* | syncscroller had a 16 px accuracy. |
* | |
* | Alien of ST Connexion did a series of articles about overscan in ST Magazine, |
* | a French publication, that were partially translated in Alive diskmag. |
* | |
* | http://demozoo.org/productions/123966/ |
* +--------------------------------------------------------------------------------+
* | Music mod.art by Noise/Celtic : |
* | http://janeway.exotica.org.uk/release.php?id=33713 |
* | Which is a cover of Fallen Angel by Mysterious Art : |
* | http://www.discogs.com/Mysterious-Art-Omen.../master/91953 |
* +================================================================================+
* | Copyleft 2015 by Dyno <dyno@aldabase.com> |
* +================================================================================+
*/
Подготовка данных: шрифт и текст
Основа эффекта — растровый шрифт, где каждый символ представлен матрицей 12x17 пикселей. Функция scanFont анализирует PNG-изображение, преобразуя его в массив letters, где каждый элемент — это двумерный массив (столбцы) со значениями 1 (пиксель есть) или 0 (пусто).
function scanFont() {
// Создаём временный canvas для анализа изображения шрифта
var font_canvas = Phaser.Display.Canvas.CanvasPool.create(this, 120, 102);
var ctx = font_canvas.getContext('2d');
ctx.drawImage(this.textures.get('font').source[0].image, 0, 0);
var imageData = ctx.getImageData(0, 0, font_canvas.width, font_canvas.height);
// Заполняем массив letters данными о каждом символе
letters = [];
// ... (логика парсинга столбцов)
}
Массив text определяет содержимое скроллера: каждый элемент — это кортеж [эффект, цвет, флаг, строка]. Константы в начале файла (например, fSin1, colrY) задают тип анимации и индекс спрайта ("боба") для отрисовки.
Генерация структуры столбцов
Перед отрисовкой текст преобразуется в структуру столбцов через generateStructure. Для каждого символа берётся его матрица из letters и добавляется в общий массив structure. Между символами вставляются пустые столбцы (кроме символа `#`), а также добавляются отступы в начале и конце для плавного входа и выхода текста.
generateStructure(text) {
structure = [];
let c = 0;
// Добавляем 34 пустых столбца перед текстом
for (let i = 0; i < 34; i++) { structure[c++] = []; }
// Проходим по каждому символу текста
for (let x = 0; x < text.length; x++) {
let letter = letters[text.toUpperCase().charCodeAt(x) - 32];
for (let i = 0; i < letter.length; i++) {
structure[c++] = letter[i];
}
// Добавляем разделитель, если это не '#'
if (text.toUpperCase().charCodeAt(x) != 35) { structure[c++] = []; }
}
// Добавляем 26 пустых столбцов после текста
for (let i = 0; i < 26; i++) { structure[c++] = []; }
}
Таким образом, structure становится полным набором столбцов для всего отрезка текста с эффектом.
Отрисовка эффектов через Blitter
Ключевой метод drawFont рендерит видимую часть скроллера. Он проходит по 27 столбцам (ширина области) из structure, начиная с позиции position. Для каждого пикселя (значение 1 в столбце) вычисляется координата Y с помощью математической функции, заданной параметром callback (например, синусоида, отскок, вращение).
drawFont(position, increment, callback, tile) {
blitter.clear(); // Очищаем Blitter перед каждой отрисовкой
const mid = 140; // Центральная линия по вертикали
for (let x = 0; x <= 26; x++) {
const col = structure[position + x];
for (let y = 0; y < 16; y++) {
if (col[y] === 1) {
// Вычисляем posy в зависимости от эффекта
switch (callback) {
case fSin1: posy = mid + 30 * Math.sin((x + iteration) / 8) + (y - 8) * 10; break;
// ... другие эффекты
default: posy = mid + (y - 8) * 10; break;
}
// Создаём "боб" на рассчитанной позиции
blitter.create(x * 32 - increment * 8, Math.round(posy) * 2, frame);
}
}
}
}
blitter.create() добавляет новый спрайт в пакет отрисовки. Параметр increment обеспечивает плавное смещение на 4 пикселя за шаг, имитируя аппаратный скроллинг.
Цикл анимации и управление состоянием
В методе update происходит управление переключением текстовых блоков и обновление анимации. Когда counter (счётчик кадров для текущего текста) достигает нуля, загружается следующий блок из массива text.
update() {
// Если нужен новый текст, инициализируем его
if (text_num < 0 || counter < 0) {
text_num++;
if (text_num == text.length) { text_num = 0; }
callback = text[text_num][0];
tile = text[text_num][1];
this.generateStructure(text[text_num][3]);
counter = (structure.length - 26) * 4 - 1; // Длина в кадрах
iteration = 0;
frame = this.textures.getFrame('bobs', 'bob' + (tile + 1).toString());
}
// Отрисовываем скроллер
this.drawFont(Math.floor(iteration / 4), iteration % 4, callback, tile);
// Обновляем счётчики
counter--;
iteration++;
}
iteration увеличивается каждый кадр и используется в математических формулах для анимации. Деление iteration / 4 даёт позицию в структуре с учётом 4-пиксельного скроллинга.
Настройка Blitter и загрузка ресурсов
В методе create инициализируется основной объект Blitter и загружается текстура для "бобов". Blitter работает как пакетный рендерер: все спрайты, созданные через blitter.create(), отрисовываются за один вызов отрисовки (draw call), что критично для производительности.
create() {
this.scanFont(); // Анализируем шрифт
blitter = this.add.blitter(0, 0, 'bobs'); // Создаём Blitter
frame = this.textures.getFrame('bobs', 'bob2'); // Задаём начальный кадр
}
Атлас bobs содержит различные варианты спрайтов (по цветам), а frame определяет, какой именно спрайт используется в данный момент.
Что попробовать дальше
Использование Blitter в Phaser 3 позволяет создавать высокопроизводительные эффекты с большим количеством спрайтов, будь то скроллеры, частицы или тайловые карты. Экспериментируйте: попробуйте добавить новые математические функции для эффектов (например, спирали или волны), изменить палитру «бобов» в реальном времени или комбинировать несколько Blitter для многослойного скроллинга. Для вдохновения изучите архив demozoo.org — там множество идей из золотой эры демосцены.
