Pull to refresh

«Flappy Bird» до 1КБ

Reading time 6 min
Views 21K
Неделя 30-ти строчных JS давно прошла, но воодушевлённый постами Разрабатываем Flappy Bird на Phaser (Часть I) и Как Минковский во Flappy Bird играл, я не смог удержаться не попробовать написать ASCII-версию игры «Flappy Bird» на JavaScript и уложиться при этом в 1024 символа.

Посмотреть, что из этого вышло (и поиграть) можно тут, а несжатый исходник увидеть здесь.
За деталями реализации прошу под кат.

Об игре


Я опущу скучные подробности, которые вы и сами можете найти в вики или попробовав сыграть в оригинальную игру. Вкратце же вы управляете птичкой, летящей между препятствиями. Клавишей «вверх» можно слегка кратковременно подтолкнуть её выше и таким образом маневрировать.

Поле


Я не стремился к достаточной аутентичности, да и ограничения весьма жёсткие. Поэтому пошёл на максимальные жертвы и, методом проб и ошибок, подобрал более менее оптимальный размер поля (без масштабирования) — 10 строк по 30 символов в каждой:
-+++++----+++++-----+++++-----
 +++++    +++++     +++++     
 +++++    +++++     +++++     
 +++++                        
                              
 0                            
          +++++     +++++     
 +++++    +++++     +++++     
 +++++    +++++     +++++     
--++++----+++++-----+++++-----
123

Нолик — позиция игрока (я выбрал второй слева столбец), 123 внизу слева — набранные очки.

Анимация поля


Первая задача — научиться двигать игровое поле, смещать препятствия справа налево. Можно попробовать хранить ячейки поля в двумерном массиве, но мы пойдём другим путём. Давайте рассмотрим одну из строк, заменив в ней пробелы на нули, а преграду на единички:
011111000011111000001111100000
Не трудно догадаться, что это двоичное представление числа (521110496). Тем проще для нас — двинуть его влево можно побитовой операцией сдвига влево. Помним про предел длины целочисленных значений. Для сохранения ограничения в 30 байт, просто маскируем их, отрезая всё лишнее после сдвига:
строка = 011111000011111000001111100000;
строка = строка << 1;
строка = строка & 2^30;
// строка = 111110000111110000011111000000

И вторая задача — сохранить одинаковую ширину препятствий. Четыре возможных случая (до первого ложного) выглядят так:
????010 - true -> 000011
???0110 - true -> 000111
??01110 - true -> 001111
?011110 - true -> 011111
0111110 - false

Логика тривиальна: если второй бит = 1, то истина в случае, если есть бит = 0 в следующих за вторым битах до ширины препятствия.
Итого для сдвига всех строк поля применяем:
строка <<= 1;
строка &= 2^30;
if (строка & 2) {
    for (бит = 2; бит <= ширина_препятствия; бит++) {
        if (!(строка & 1 << бит)) {
            строка |= 1;
            break;
        }
    }
}


Подсчёт очков


В этом месте у нас достаточно условий для подсчёта очков. При условии, что два препятствия не могут идти без интервала, мы добавляем балл за каждый пустой столбец поля, в котором находится игрок, если в предыдущем столбце препятствие точно было (28 и 29 — индексы двух соседних столбцов в одном из которых, с меньшим индексом, — игрок):
очки += (первая_строка_поля & 1 << 29) && !(первая_строка_поля & 1 << 28) ? 1 : 0;


Новые препятствия


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

Наглядно это можно представить в таком виде:
000011111 ->     0% (0 * 12.5) 0
000111110 ->     0% (0 * 12.5) 1
001111100 ->  12.5% (1 * 12.5) 2
011111000 ->  25.0% (2 * 12.5) 3
111110000 ->  37.5% (3 * 12.5) ..
111100000 ->  50.0% (4 * 12.5)
111000000 ->  62.5% (5 * 12.5)
110000000 ->  75.0% (6 * 12.5)
100000000 ->  87.5% (7 * 12.5)
000000000 -> 100.0% (8 * 12.5)

Первый столбик — крайние биты поля, далее идут проценты вероятностей появления нового препятствия. Восемь — максимальный интервал, который и оптимален в игре, и удобен для расчётов: 100 можно поделить на 8 и получить осязаемые значения. Крайний справа столбец — величина побитового сдвига влево той маски, которой мы будем искать и вычислять длину текущего промежутка между препятствиями.
Дело за малым: двигать побитово единицу-маску влево, пока не встретим ещё одну единицу. В этот момент, зная текущий промежуток и вероятность, пытаемся создать новое препятствие:
для каждой попытки от 0 до бесконечности {
    if (первая_строка_поля & 1 << попытка) {
        if (попытка > 1 && (попытка - 2) * 125 + очки > случайное(124..999)) {
            // создаём препятствие
        }
        break;
    }
}

Я умножил всё на 10 и таким образом избавился от нецелых значений. Кроме 100%: от тысячи (100%*10) я отнял единицу, потому что единица — это ж целый лишний байт приложения! А, как мы помним, байты надо экономить.
Добавление самих препятствий задача не сложная, но для исключения создания непроходимых участков, я добавил условие: каждое следующее препятствие должно быть на единицу больше/меньше предыдущего или равно ему, и при этом не быть меньше двух или больше пяти. Плюс выдерживаем промежуток для полёта — три строчки. Получаем:
// ВВП - высота верхнего препятствия, а не то, что вы подумали
ВВП = случайное(
    от ВВП > 2 ? ВВП - 1 : 2
    до ВВП < 5 ? ВВП + 1 : 5
);
для строк от 0 до ВВП {
    строка |= 1;
}
для строк от ВВП + 3 до последней {
    строка |= 1;
}


Рендер


Тут никаких ухищрений. Просто бежим, как старый ламповый телевизор, по строкам, а в них по столбцам и накапливаем клетки поля:
поле = '';
для всех строк (сверху вниз) {
    для всех столбцов (слева направо) {
        поле += столбец == 28 && строка == позиция_игрок ? "0" : (
            строка & 1 << столбец ? "+" : (
                !строка || строка == всего_строк - 1 ? "-" : " "
            )
        );
    }
    поле += "\n";
}
обновляем_поле;

Отдельно проверяем и рисуем игрока, а также верхнюю и нижнюю границу самого поля.

Ход


Игровой мир готов и работает. Осталось оживить персонажа. Я упростил это по максимуму. Никаких полётов по параболе, гравитации, ускорений и инерции (разве что самую малость). Пусть клавиша «вверх» задаёт импульс — запас движения вверх. И пусть каждая итерация анимации уменьшает этот импульс, если он ещё не достиг нуля. На тестах это выглядело совсем убого и птичка двигалась по явно треугольной траектории. Поэтому я немного увеличил начальный импульс и добавил движение «по инерции», если импульс равен единице:
if (нажата клавиша вверх) {
    запускаем игру, либо импульс = 3;
}

// вверх
if (импульс > 1) {
    импульс--;
    --позиция_персонажа || поражение;
// инерция
} else if (импульс) {
    импульс--;
// вниз
} else {
    позиция_персонажа < 9 ? позиция_персонажа++ : поражение;
}

Импульс не только управляет своим значением, но и позицией персонажа. Плюс проверяет, не ударились ли мы в границы поля.
Отдельно проверяем столкновение с препятствием:
if (строка_персонажа & 1 << 28) {
    поражение;
}


Жмём


На этом основная работа закончена. Добавляем вёрстку, оборачиваем приложение в анонимную функцию и проверяем в нескольких браузерах.
Результат до сжатия
<script>
(function(){
    var run = 0, imp = 0;
    function up(){
        run = 1;

        var
        pos = 2,
        rows = [1, 1, 1, 1, 0, 0, 0, 1, 1, 1],
        rowsLen = 10,
        fieldWidth = 30,
        fieldMask = Math.pow(2, fieldWidth) - 1,
        profit = 0,
        hTop = 4,
        row,
        col,
        timer = setInterval(function(){
            /**
             * Move user
             */
            if (imp > 1) {
                imp--;
                // up
                --pos || _stop();
            } else if (imp) {
                imp--;
            } else {
                // down
                pos < 9 ? pos++ : _stop();
            }


            /**
             * Move field
             *
             * 0111110 - false
             * ?011110 - true -> 011111
             * ??01110 - true -> 001111
             * ???0110 - true -> 000111
             * ????010 - true -> 000011
             */
            for (row = rowsLen; row--;) {
                rows[row] <<= 1;
                rows[row] &= fieldMask;
                if (rows[row] & 2) {
                    for (w = 2; w <= 5; w++) {
                        if (!(rows[row] & 1 << w)) {
                            rows[row] |= 1;
                            break;
                        }
                    }
                }
            }


            /**
             * Add new objects
             *
             *
             * 000011111 ->     0% (0 * 12.5) 0
             * 000111110 ->     0% (0 * 12.5) 1
             * 001111100 ->  12.5% (1 * 12.5) 2
             * 011111000 ->  25.0% (2 * 12.5) 3
             * 111110000 ->  37.5% (3 * 12.5) ..
             * 111100000 ->  50.0% (4 * 12.5)
             * 111000000 ->  62.5% (5 * 12.5)
             * 110000000 ->  75.0% (6 * 12.5)
             * 100000000 ->  87.5% (7 * 12.5)
             * 000000000 -> 100.0% (8 * 12.5)
             */
            for (var tryNum = 0; true; tryNum++) {
                if (rows[0] & 1 << tryNum) {
                    if (tryNum > 1 && (tryNum - 2) * 125 + profit > _rnd(124, 999)) {
                        hTop = _rnd(hTop > 2 ? hTop - 1 : 2, hTop < 5 ? hTop + 1 : 5); // 2..5, prev +/- 1
                        for (h = 0; h < hTop; h++) {
                            rows[h] |= 1;
                        }
                        for (h = hTop + 3; h < rowsLen; h++) {
                            rows[h] |= 1;
                        }
                    }
                    break;
                }
            }

            /**
             * Render
             */
            var text = '';
            for (row = 0; row < rowsLen; row++) {
                for (col = 29; col >= 0; col--) {
                    text += col == 28 && row == pos ? "0" : (
                        rows[row] & 1 << col ? "+" : (
                            !row || row == rowsLen - 1 ? "-" : " "
                        )
                    );
                }
                text += "\n";
            }
            profit += (rows[0] & 1 << 29) && !(rows[0] & 1 << 28) ? 1 : 0;
            text += "\n"+profit;
            pre.innerHTML = text;

            if (rows[pos] & 1 << 28) {
                _stop();
            }
        }, 250);

        var _rnd = function(min, max){
            return Math.floor(Math.random() * (max - min + 1)) + min;
        }
        var _stop = function(){
            clearInterval(timer);
            run && alert(':(');
            run = 0;
        }
    }
    onkeyup = function(e){ e.which == 38 && (run ? imp = 3 : up()); };
})()
</script>
<body onload=""><pre id="pre">press up!


Прогоняем JS через любой оптимизатор а-ля UglifyJS после чего просто переносим его в:
<body onload='сюда..'

Итого: 785 байт. Уверен, это не предел!

Ссылки


Кроме перечисленных выше, будут интересны:
Tags:
Hubs:
+29
Comments 9
Comments Comments 9

Articles