- PVSM.RU - https://www.pvsm.ru -

Постъядерный караван в 35 килобайт

35 килобайт минифицированного кода на обычном JavaScript, семь городов, пустоши, радиоактивные гекконы, съедобные кактусы, встречные караваны и бандиты. Что это? Это небольшая игра, которая запускается в браузере. Ее принципы довольно просты для повторения и в самой примитивной версии ее можно воссоздать, наверное, на любом устройстве, если там есть устройство вывода и генератор случайных чисел. Но сегодня я хочу рассказать, как я реализовал ее для современных браузеров.

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

Весь сеттинг, мир и логику можно модифицировать, как вам вздумается — исходный код и графика распространяется как public domain, то есть без ограничений на копирование и использование.

Постъядерный караван в 35 килобайт - 1

Возможности игры

Ваши следопыты могут найти съедобный кактус или довоенные консервы. В пустыне можно найти трупы других путешественников, с которых можно поднять немного денег. У встречных караванов можно купить еды или браминов, а также нанять людей. А еще к вам могут присоединиться встречные бродяги, а иногда — даже бандиты, если вы поразите их своей харизмой. Шутка. На самом деле харизмы в игре пока нет, она эмулируется рандомом.

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

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

Механика этой игры довольна проста, а её графические возможности — это диалоги, индикаторы и движение по карте мире. Ее сила — в магии текстовых игр, которые способны с помощью слов описывать самые невероятные приключения.

Основная идея и логика программы

Базовая идея игры очень проста — мы запускаем бесконечный цикл, внутри которого выполняется четыре базовых операции:

  1. Движение к заданной точке
  2. Отсчет дней
  3. Потребление еды
  4. Проверка на вероятность для события, которое нас ждет в пустоши

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

Как это происходит? Очень просто — каждое событие просто меняет числовые параметры каравана или мира, а затем сообщает об этом в лог. И вот тут возникает магия — параметры могут быть одними и теми же, но сообщать можно о совершенно разных причинах. Минус несколько единиц еды? Это могут быть нападения крыс, выпадение радиоактивных осадков или голодных бродяг. Плюс несколько единиц еды? Значит, ваши люди нашли съедобный кактус, раскопали в придорожных руинах довоенные консервы или нашли на дороге замечательные кожаные сапоги с мягкой подошвой.

Первая версия, с которой я начал экспериментировать, выглядела чистой иллюстрацией описанного алгоритма:

Постъядерный караван в 35 килобайт - 2

Я сделал этот прототип как ремейк игры про орегонский караван из этого туториала [1].

Одномерный прототип — караван для js13kGames

Игра из туториала, которую я нашел, была одномерной — караван выходил из одной точки и приходил в другую. В пути происходили случайные события, встречи с бандитами и торговцами.

Постъядерный караван в 35 килобайт - 3

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

Когда я посмотрел результат, то понял, что игру можно расширить до настоящей песочницы. Надо было лишь добавить свободы, сделать модульным код и разнести события по разным модулям. Пришлось переработать код практически полностью.

Список изменений

  1. Многоразовое путешествие — игра не прекращается с достижением цели
  2. Двухмерная карта мира и города
  3. Другой сеттинг мира и никакой дизентерии
  4. Бандиты могут вступать в переговоры и наниматься
  5. Добавлены товары, которые автоматически продаются и покупаются при достижении города
  6. Модульная система — логика на базе плагинов и наборы событий в отдельных файлах

Движок каравана и архитектура

Игра, как уже было сказано, сделана на чистом JavaScript без использования сторонних библиотек (вы можете сами добавить их, если сочтете нужным). Для отображения карты мира и интерфейса используется обычный HTML и CSS. Чтобы изменять их, используются базовые операции с DOM и классическая операция document.getElementById

Пример отображения количества игроков в караване

this.view = {}; // объект для хранения элементов DOM
this.view.crew = document.getElementById('game-stat-crew'); // находим элемент при запуске игры
// ...
this.view.crew.innerHTML = world.crew; //  записываем число людей в караване как обычный html 

WorldState — модель мира

Мир в игре — это класс WorldState. Он хранит в себе все важные параметры и не содержит никакой логики. Логику мы привяжем потом, за счет плагинов.

function WorldState(stats) {
    this.day = 0;           // текущий день, с десятичными долям
    this.crew = stats.crew; // количество людей
    this.oxen = stats.oxen; // количество быков
    this.food = stats.food; // запасы еды
    this.firepower = stats.firepower; // единиц оружия
    this.cargo = stats.cargo;   // товаров для торговли
    this.money = stats.money;   //деньги

    // лог событий, содержит день, описание и характеристику
    //  { day: 1, message: "Хорошо покушали", goodness: Goodness.positive}
    this.log = [];

    // координаты каравана, пункта отправления и назначения
    this.caravan = { x: 0, y: 0};
    this.from = {x: 0, y: 0};
    this.to = {x: 0, y: 0};

    this.distance = 0; // сколько всего пройдено

    this.gameover = false;  // gameover
    this.stop = false;    // маркер для обозначения того, что караван стоит
    this.uiLock = false; // маркер для блокировки интерфейса
}

Game — создание мира и игровой цикл

Игровой цикл запускается и управляется объектом Game. Этот же объект создает мир. Обратите внимание на поле plugins — по умолчанию это пустой массив. Game ничего не знает о плагинах, кроме двух вещей — у них должна быть функция инициализации init(world) и функция обновления update.

Game = {
    plugins: [],  // генераторы событий, 
};

Game.init = function () {
    // создаем мир по стартовому состоянию которое хранится в отдельном файле
    // в объекте StartWorldState в директории data
    this.world = new WorldState(StartWorldState);

    var i;
    for (i = 0; i < this.plugins.length; i++) {
        this.plugins[i].init(this.world);
    }
};

// добавление плагинов
Game.addPlugin = function (plugin) {
    this.plugins.push(plugin);
};

// игровой цикл
Game.update = function () {
    if (this.world.gameover) return; // никаких действий
    var i;
    for (i = 0; i < this.plugins.length; i++) {
        this.plugins[i].update();
    }
};

Game.resume = function () {
    this.interval = setInterval(this.update.bind(this), GameConstants.STEP_IN_MS);
};

Game.stop = function () {
    clearInterval(this.interval);
};

Game.restart = function () {
    this.init();
    this.resume();
};

Для запуска новой игры надо вызвать функцию Game.restart. Но прежде чем это произойдет, надо добавить какой-нибудь плагин — иначе ничего не будет происходить, у нас просто будет вхолостую работать игровой цикл.

Ешь, живи, двигайся — CorePlugin

Самые базовые действия каравана — перемещение, отсчет времени и потребление пищи — реализованы в объекте CorePlugin:

исходный код CorePlugin

CorePlugin = {};

CorePlugin.init = function (world) {
    this.world = world; // запоминаем world
    this.time = 0; // общее время с начала игры, в миллисекундах
    this.dayDelta = GameConstants.STEP_IN_MS / GameConstants.DAY_IN_MS; // сколько дней в одном шаге игру
    this.lastDay = -1;  // отслеживаем наступление нового дня
    this.speedDelta = Caravan.FULL_SPEED - Caravan.SLOW_SPEED; // разница между полной и минимальной скоростью
};

CorePlugin.update = function () {
    if (this.world.stop) return; // если стоим - никаких изменений
    this.time += GameConstants.STEP_IN_MS; // увеличение времени
    this.world.day = Math.ceil(this.time / GameConstants.DAY_IN_MS); // текущий день, целый

    // Движение каравана в зависимости от того, сколько дней прошло
    this.updateDistance(this.dayDelta, this.world);

    // события связанные с наступлением нового дня
    if (this.lastDay < this.world.day) {
        this.consumeFood(this.world);
        this.lastDay = this.world.day;
    }
};

// еда выдается один раз в день
CorePlugin.consumeFood = function (world) {
    world.food -= world.crew * Caravan.FOOD_PER_PERSON;
    if (world.food < 0) {
        world.food = 0;
    }
};

// обновить пройденный путь в зависимости от потраченного времени в днях
CorePlugin.updateDistance = function (dayDelta, world) {
    var maxWeight = getCaravanMaxWeight(world);
    var weight = getCaravanWeight(world);

    // при перевесе - Caravan.SLOW_SPEED
    // при 0 весе - Caravan.FULL_SPEED
    var speed = Caravan.SLOW_SPEED + (this.speedDelta) * Math.max(0, 1 - weight/maxWeight);

    // расстояние, которое может пройти караван при такой скорости
    var distanceDelta = speed * dayDelta;

    // вычисляем расстояние до цели
    var dx = world.to.x - world.caravan.x;
    var dy = world.to.y - world.caravan.y;

    // если мы находимся около цели - останавливаемся
    if(areNearPoints(world.caravan, world.to, Caravan.TOUCH_DISTANCE)){
        world.stop = true;
        return;
    }

    // до цели еще далеко - рассчитываем угол перемещения
    // и получаем смещение по координатам
    var angle = Math.atan2(dy, dx);
    world.caravan.x += Math.cos(angle) * distanceDelta;
    world.caravan.y += Math.sin(angle) * distanceDelta;
    world.distance += distanceDelta;
};

// регистрируем плагин в игре
Game.addPlugin(CorePlugin);

Тут все элементарно. Сначала при запуске игры у нас вызывается init, который позволит сохранить ссылку на модель мира. Затем в игровом цикле у нас будет вызываться update, который будет менять мир к лучшему, как любят говорить персонажи из сериала «Силиконовая долина». Шутка — меняться мир будет во все стороны.

Наш базовый плагин отсчитывает время в миллисекундах, переводит их в дни, а затем обновляет дистанцию и запасы еды. В принципе, объект плагина просто должен содержать функции init(world) и update(), а делать он может что угодно. Можно даже просто вызывать какую-нибудь другую игру на HTML5 или создавать диалоговое окно.

Чтобы подключить плагин, надо добавить его код между определением объекта Game и первым вызовом Game.restart(). Примерно так, как это сделано сейчас в index.html:

<script src="js/Game.js"></script>

<!-- плагины -->
<script src="js/plugins/CorePlugin.js"></script>

<!-- запуск игры -->
<script>
    Game.restart();
</script>

Итак, как сделать игру про караван

Если вы опытный программист — вы, безусловно, можете реализовать подобную игрушку на любом языке и графическом уровне, просто отталкиваясь от самой идеи «цикл, в котором происходит перемещение и рандомный выбор из массива событий». Думаю, прекрасно получится даже игра для bash, тем более, что недавние публикации показывают [2], что там есть весьма интересные графические возможности.

Если вам знаком JavaScript хотя бы на базовом уровне — вы можете скачать исходники к игре, которую я сделал на этом языке, и свободно модифицировать и улучшать их. Вы можете использовать любую архитектуру, если не устроит текущая. Но самый легкий вариант — это просто добавить плагин, реализованный по принципу, описанному выше.

Существующие плагины можно отключать (убирая их исходный код из index.html или закомментировав строку с Game.addPlugin(SomePlugin) в конце их кода). Они ничего не знают друг о друге и просто меняют модель мира или интерфейс игры.

Ну и последний вариант, для писателей — просто открывать файлы в директории data и редактировать описания событий и константы. Хотя это те же JavaScript-исходники, они довольны просты для изменений. Особенно тексты. Чтобы доказать это, я вкратце расскажу, как устроены другие плагины в текущей версии.

Случайные события

Все примитивные случайные события лежат в файле data/RandomEvents.js в переменной RandomEvents в таком формате:

var RandomEvents = [
    {
        goodness: Goodness.negative,
        stat: 'crew',
        value: -4,
        text: 'На караван напал смертокогть! Людей: -$1'
    },
    {
        goodness: Goodness.negative,
        stat: 'food',
        value: -10,
        text: 'Кротокрысы на привале сожрали часть еды. Пропало пищи: -$1'
    },
  {
        goodness: Goodness.positive,
        stat: 'money',
        value: 15,
        text: 'У дороги найден мертвый путешественник. На теле найдены монеты. Денег: +$1'
    },
    {
        goodness: Goodness.positive,
        stat: 'crew',
        value: 2,
        text: 'Вы встретили одиноких путников, которые с радостью хотят присоединиться к вам. Людей: +$1'
    },

Объекты случайных событий содержатся в фигурных скобках. В этом примере я привел только четыре объекта, но вообще их число может быть каким угодно. Только имейте в виду, что при увеличении числа событий вероятность выпадения конкретно отдельного из них уменьшается.

Первое поле — goodness- означает позитивную, негативную и нейтральную окраску сообщения в логе. Второе поле — stat — содержит название параметра WorldState, который должен меняться. Value — это среднее значение для изменения этого параметра. Последнее поле — должно содержать любой произвольный текст, описывающий произошедшее. Вместо символом $1 в текст будет подставлено реальное изменение параметра, которое выпадет в игре.

Случайные события проверяются на выпадение в объекте RandomEventPlugin и радуют взгляд игрока в логе:

Постъядерный караван в 35 килобайт - 4

Последнее примечание: в RandomEvents.js вы найдете переменную с константой вероятности выпадения случайного события. Она задается как среднее число событий на один игровой день каравана. Когда я экспериментировал с разными значениями, то обнаружил, что слишком много случайных событий, на которые нельзя никак повлиять, начинают дико раздражать. Отсутствие интерактива — это главный минус этих простых событий. Вот почему я почесал голову и решил сделать универсальный модуль диалогов, который можно вызывать из других плагинов.

Как написать или отредактировать диалог

За диалоговую систему отвечает объект DialogWindow. Если вы заглянете в его исходный код, то увидите мутанта, который находит в HTML-коде нужный div-элемент и привязывает к нему общий обработчик кликов мышкой. Идея заключается в том, что когда мы просим этот объект показать нам новый диалог, мы передаем ему массив наших диалогов. И обработчик нажатия на конкретный выбор описывается в конкретном диалоге в таком формате:

var DeathDialogs = {
    "start": {
        icon: "images/pic_death.jpg", // ссылка на url картинки
        title: "Погибший в пустоши", // заголовок диалога
        desc: "",                                // статический текст диалога
        desc_action: function (world, rule) { // функция для создания вычисляемого текста диалога
            var desc = " Причина смерти: "+rule.text+". Вы сумели пройти "+Math.floor(world.distance) + " миль и накопить "+Math.floor(world.money) + " денег";
            desc += "Может быть, следующим караванщикам повезет больше?"
            return desc;
        },
        choices:[  // массив выборов
            {
                text: 'Начать новую игру', // текст на кнопке
                action: function () { return "stop"; }  // функция, возвращающая тег следующего диалога
            }
        ]
    },
};

В диалогах смерти только один вариант, описанный как поле «start». Но таких вариантов может быть бесконечно много. К примеру, в диалогах бандитов я реализовал 12 развилок. Как происходит переход между ними? Наш универсальный объект DialogWindow при вызове функции show сохраняет у себя список переданных диалогов и показывает тот, который определен в поле «start».

При отображении очередного диалога его массив choices отображается как набор кнопок, в атрибуты которых записывается номер выбора. А все функции action из choices записываются во внутренний массив dialogActions. При клике мышкой на кнопке выбора универсальный обработчик определяет номер функции в dialogActions и вызывает ее, попутно передавая два аргумента, которые мы решили использовать в этом диалоге. Таким образом, в диалогах с бандитами функция action в конкретном choice может принимать состояние мира (world) и описание текущих бандитов (bandits). А в диалогах к другим плагинам — другие параметры. Да можно и вообще без них, особенно, если смысл выбора — просто закончить диалог, как при геймувере.

Постъядерный караван в 35 килобайт - 5

Чтобы диалог закончился и игрок вернулся к карте мира, надо, чтобы функция action в объекте choice возвращала один из зарезервированных тегов «finish»,«exit»,«stop». Вообще смысл этой функции в том, чтобы возвращать имя следующей развилки. Но до этого заветного return-a можно и порой нужно вставить любую логику, любые вычисления, которые позволят выбрать следующую развилку — «run», «fight» или, быть может, даже «love».

Как вызвать диалог из плагина

В любой момент времени в update любого работающего плагина можно вызвать диалог следующим образом:

    // ... где-то в недрах update у объекта-плагина
    // останавливаем караван, аналог паузы
    world.stop = true; 
   // просим показать диалог с набором развилок из DeathDialogs
   // в развилки будут передаваться аргументы world и rule
    DialogWindow.show(DeathDialogs, world, rule, this);

Также в плагине должна быть реализована функция onDialogClose — этот коллбэк будет вызываться после закрытия диалога. Пример из плагина, определяющего наступление смерти:

DeathCheck.onDialogClose = function () {
    Game.restart();
};

Краткое описание существующих плагинов

В текущей версии игры используются следующие плагины:

Map2DPlugin — перемещение каравана по карте. Поиск городов, которые задаются в index.html как обычные div с параметрами top и left. Здесь же определяется прибытие в город и происходит автоматическая торговля.

ShopPlugin — генерация случайных встречных караванов или других торговцев. Позволяет купить еду, браминов и нанять наёмников. Или ничего не покупать и пойти дальше.

BanditPlugin — встречи и диалоги с бандитами. Примерно на середине этого плагина я понял, что с идеей сделать простой туториал я погорячился. Простого не получилось, извините. Бандиты могут наняться к вам, а если они умирают с голоду — попросят принять их бесплатно, если вы откажете при найме за деньги.

DropPlugin — плагин перегруза. В прототипе из туториала про орегонский караван игра сама автоматически сбрасывала вещи — сначала оружие, а потом еду. Это было не очень комфортно и вызывало недоумение — как так-то? Ведь с оружием можно добыть еду, а «жареным мясом нельзя убить врага» (с) один известный стрим по Fallout 4. Так что я решил сделать диалог, в котором ты просто выбираешь, от чего избавиться.

DeathCheck — плагин проверки на смерть. Это пока очень простой плагин, и к нему наверняка могут быть вопросы. К примеру, почему люди умирают сразу, когда заканчивается еда? Ну, предлагаю ответить на этот вопрос самостоятельно — или подождать следующей версии, если все пойдет хорошо.

WorldViewPlugin — интерфейсный плагин. Он просто обновляет интерфейс, показывая текущие параметры мира. Возможно, эта идея покажется кому-то странной — самостоятельный объект интерфейса, который отслеживает в цикле обновления изменения переменных. Но зато благодаря этой странной идее мы избавились от многочисленных updateUi и получили независимость между блоками разной логики.

Небольшие советы по текущей игре и балансу

Жизненно важный параметр сейчас — количество еды. Пополнить ее можно при встречах с другими караванами — если они происходят, следует закупаться по максимуму. Конечно, разумно было бы добавить покупку и в городах, но будем считать, что пока все они страдают от недостатка еды.

Второй жизненно важный параметр — количество людей в караване. Они должны быть. Если всех людей выбивают — игра заканчивается.

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

Ссылки и дистрибутивы

Я использовал графику с лицензией Creative Common 0 с ресурсов pixabay.org и opengameart.org, так что и графика, и код распространяется на этих условиях — свободное копирование и использование, без каких-либо обязательств.

Исходный код можно взять с GitHub [3] или скачать zip-архивом отсюда [4]. Первый вариант предпочтительнее, так как чаще обновляется.

Для тестирования даже на локальном компьютере достаточно открыть index.html в браузере — там не используются функции, которым обязательно требуется сервер.

Живой билд игры можно потестить здесь [5]. Верстка рассчитана на обычные мониторы, не на мобильные экраны.

Автор: Zoolander

Источник [6]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/264026

Ссылки в тексте:

[1] этого туториала: https://gamedevacademy.org/js13kgames-tutorial/

[2] недавние публикации показывают: https://habrahabr.ru/post/335960/

[3] GitHub: https://github.com/Kvisaz/nukecaravan

[4] отсюда: http://skachat-besplatno.ru/gamelab/zoolander/nukecaravan.zip

[5] потестить здесь: http://skachat-besplatno.ru/gamelab/zoolander/

[6] Источник: https://habrahabr.ru/post/336724/