8 Bit Panda, игра для вымышленной консоли TIC-80.

Это пост о том, как я написал 8-bit panda, простой платформер в классическом стиле для вымышленной консоли TIC-80.

Поиграть в готовую игру можно здесь.

Если вы любитель ретро-игр и вам нравится программирование, то есть вероятность, что вы уже знакомы с последним трендом: вымышленными консолями. Если же нет, то стоит посмотреть на их самых известных представителей: PICO-8 и TIC-80.

Я выбрал TIC-80, потому что она бесплатна и активно разрабатывается, имеет более широкое соотношение сторон экрана (240x136), чем PICO-8 и может выполнять экспорт на множество платформ, в том числе HTML, Android и двоичные файлы для PC.

В этой статье я расскажу, как я написал для TIC-80 простой платформер 8 Bit Panda.

Главный герой


Для начала мне нужен был персонаж игрока. Я не сильно задумывался об этом: процесс дизайна в основном заключался в вопросе: «почему бы не панда?», ответом на который было: «конечно, почему бы и нет?» Так я приступил к рисованию моего первого спрайта в редакторе спрайтов TIC-80:

image


Дам вам насладиться впечатляющим отсутствием у меня художественных навыков, но тут стоит учесть, что возможны всего 2256 комбинаций 16-цветных спрайтов 8x8. Только некоторые из них окажутся пандами. Если вы не придёте к выводу, что это самое плохое сочетание, то я буду чувствовать себя польщённым.

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

image


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

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

Создание уровней


В TIC-80 есть встроенный редактор карт, который можно (и нужно) использовать для создания уровней. Пользоваться им довольно просто: это обычная матрица тайлов, каждый из которых может быть любым из 256 спрайтов в нижней части таблицы спрайтов (верхнюю часть, с индексами от 256 до 511, можно отрисовывать в процессе выполнения игры, но они не могут быть на карте, потому что для отображения требуют 9 бит).

Спрайт против тайла: в TIC-80 под «спрайтом» подразумевается одно из 512 заранее заданных изображений в картридже размером 8x8 пикселей. Тайлы карты — это просто спрайты (каждый тайл карты может быть одним из 256 спрайтов в нижней половине таблицы спрайтов). Поэтому мы будем говорить «спрайт», когда речь идёт о графическом элементе, и «тайл», когда мы будем иметь в виду ячейку карты, даже несмотря на то, что технически ячейка содержит спрайт. Подведём итог: всё это неважно, тайлы и спрайты — это одно и то же.

С помощью редактора карт я для начала создал довольно простой «уровень»:

image


Во-первых, стоит заметить, что существует два основных типа тайлов:

  • Твёрдые тайлы (тайлы земли и земли+травы), на которых игрок может стоять и которые блокируют движение игрока.
  • Декоративные тайлы (деревья, трава, фонарь, камень и т.д.). Они нужны только для красоты и не влияют ни на что в игре.

Позже мы познакомимся с тайлами-сущностями, но пока не будем о них. В коде нужно каким-то образом сообщить, является ли тайл твёрдым, или нет. Я выбрал простой подход и решил ограничиться индексом спрайта (80): если индекс спрайта < 80, то тайл является твёрдым. Если он ? 80, то тайл используется для украшения. Поэтому в таблице спрайтов я просто нарисовал все твёрдые тайлы до индекса 80, а все декоративные — после 80:

image


Но постойте, вода ведь не твёрдая! Что она делает в части с твёрдыми спрайтами? Я не рассказал вам о переопределениях: существует список переопределения твёрдости спрайтов, который может заменить твёрдость по умолчанию. Он сообщает нам, что, например, тайл воды на самом деле не является твёрдым, даже несмотря на то, что он находится в таблице спрайтов в части с твёрдыми тайлами. Но он и не является декоративным, потому что влияет на игру.

Состояние игрока


Если я чему-то и научился за свою карьеру программиста, так это тому, что глобальные переменные — плохие, но ими вполне можно пользоваться, если придумать им какое-нибудь интересное название, например «синглтон». Поэтому я определил несколько «синглтонов», задающих состояние игры. Я довольно произвольно использую этот термин, потому что это не ООП, и на самом деле они являются скорее высокоуровневыми struct, а не настоящими синглтонами.

Ну да ладно, не важно, как они называются. Давайте начнём с Plr, который задаёт состояние игрока в конкретный момент времени:

Plr={
 lives=3,
 x=0,y=0,
 grounded=false,
 ...
}

У него есть много других полей, но самое важное здесь — заметить, что этот объект хранит всё состояние игрока на текущем уровне: где игрок находится на уровне, прыгает ли он, стоит ли на твёрдой земле, плывёт, снова возвращается на поверхность, умирает, летит на самолёте (да, это одна из тех панд, что летают на самолётах), количество очков, активные бонусы и т.д.

Также есть состояние игры, отдельное от состояния игрока. Например,

Game={
  m=M.BOOT,  -- текущий режим
  lvlNo=0,   -- уровень, в который мы играем
  ...
}

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

Полезно разделять состояние игры и состояние игрока, потому что тогда довольно начинать/перезапускать уровни: достаточно просто сбросить и удалить состояние игрока, не трогая состояние игры.

Рендеринг уровня и игрока


Рендерить уровень и игрока на TIC-80 невероятно просто. Единственное, что нужно сделать — вызвать map() для отрисовки (части) карты и spr() для рисования спрайтов в любом нужном вам месте. Поскольку я рисовал мой уровень с верхнего левого угла карты, я могу просто рисовать его вот так:

COLS=30
ROWS=17
function Render()
 map(0,0,COLS,ROWS)
end

Затем я добавляю игрока:

PLAYER_SPRITE=257
spr(PLAYER_SPRITE, Plr.x, Plr.y)

И мы получим следующее:

image


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

Конечно же, всё становится сложнее, когда мы хотим реализовать сайд-скроллер, в котором камера сопровождает игрока при движении вперёд. Я сделал следующее: создал переменную Game.scr, определяющую, насколько экран прокручен вправо. Тогда при отрисовке экрана я перемещаю карту влево на это количество пикселей, а при отрисовке всего я всегда вычитаю Game.scr для рисования в нужном месте экрана, примерно так:

spr(S.PLR.STAND, Plr.x - Game.scr, Plr.y)

Кроме того, для эффективности я определяю, какая часть уровня видима из любой точки и отрисовываю на экране только этот прямоугольник карты, а не её целиком. Некрасивые подробности можно найти в функции RendMap().

Теперь нам нужно написать логику, двигающую панду в ответ на действия игрока.

Двигай пандой


Никогда не думал, что напишу статью с таким подзаголовком, но жизнь полна сюрпризов. Панда — наш главный герой, а в игре-платформере всё движется и прыгает, поэтому можно резонно сказать, что «движение панды» и есть ядро игры.

Часть с «движением» довольно проста: мы просто меняем Plr.x и Plr.y, после чего панда появлятся в другом месте. Поэтому наиболее простую реализацию движения можно написать примерно так:

if btn(2) then
 Plr.x = Plr.x - 1
elseif btn(3) then
 Plr.x = Plr.x + 1
end

Помните, что в TIC-80 btn(2) — это левая клавиша, а btn(3) — правая. Но так мы сможем двигаться только горизонтально и не сможем сталкиваться со стенами. Нам нужно что-то более сложное, учитывающее гравитацию и препятствия.

function UpdatePlr()
 if not IsOnGround() then
  -- падать
  Plr.y = Plr.y + 1
 end
 if btn(2) then
  Plr.x = Plr.x - 1
 elseif btn(3) then
  Plr.x = Plr.x + 1
 end
end

Если мы реализовали IsOnGround() правильно, то это будет большим улучшением: игрок сможет двигаться влево и вправо, и падать, когда не находится на твёрдой земле. Итак, мы уже можем ходить и падать с обрывов. Восхитительно!

Но при этом в расчёт не берутся препятствия: что произойдёт, если мы войдём (горизонтально) в стоящий на пути твёрдый тайл? Мы не должны иметь возможности войти туда. То есть в целом у нас появляется такая схема — в движении есть два этапа:

  1. Решаем, куда герой хочет пойти (с учётом таких внешних факторов, как гравитация).
  2. Решаем, можно ли разрешить герою двигаться туда (из-за препятствий).

Концепция «желания пойти» имеет широкое определение и задаёт намеренное и ненамеренное смещение: стоя на твёрдой земле, герой «хочет» двигаться вниз (из-за гравитации), но не может, потому что при движении вниз он столкнётся с землёй.

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

C=8  -- константа размера тайла (в TIC-80 всегда 8)

-- Проверяем, есть ли у сущности прямоугольник коллизии
-- cr={x,y,w,h} может двигаться в позицию x,y
function CanMove(x,y,cr)
 local x1 = x + cr.x
 local y1 = y + cr.y
 local x2 = x1 + cr.w -1
 local y2 = y1 + cr.h - 1
 -- проверяем все тайлы, которых касается прямоугольник
 local startC = x1 // C
 local endC = x2 // C
 local startR = y1 // C
 local endR = y2 // C
 for c = startC, endC do
  for r = startR, endR do
   if IsTileSolid(mget(c, r)) then return false end
  end
 end
end

Логика довольно проста: всего лишь находим границы прямоугольника, итеративно проходим по всем тайлам, которых касается прямоугольник, и проверяем, есть ли среди них твёрдые (IsTileSolid() как раз выполняет нашу проверку "? 80", плюс переопределения). Если мы не находим на пути твёрдого тайла, то возвращаем true, означающее «хорошо, можно сюда сдвинуться». Если мы находим такой тайл, то возвращаем false, означающее «нет, сюда двигаться нельзя». Ниже проиллюстрированы две ситуации.

image


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

PLAYER_CR = {x=2,y=2,w=5,h=5}

function TryMoveBy(dx,dy)
 if CanMoveEx(Plr.x + dx, Plr.y + dy, PLAYER_CR) then
  Plr.x = Plr.x + dx
  Plr.y = Plr.y + dy
  return true
 end
 return false
end

Теперь наша реализация функции перемещения становится намного чище: сначала решаем, куда хотим пойти, потом проверяем, возможно ли это. Если возможно, то двигаемся туда.

function UpdatePlr()
 -- состояние "на земле" означает "мы не можем двигаться вниз"
 Plr.grounded = not CanMove(Plr.x, Ply.y + 1)
 if not Plr.grounded then
  -- если не на земле, то падаем.
  Plr.y = Plr.y + 1
 end
 local dx = btn(2) and -1 or (btn(3) and 1 or 0)
 local dy = 0  -- мы реализуем прыжок позже
 TryMoveBy(dx,dy)
end

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

Анимации панды


Если мы всегда будем использовать один спрайт (номер 257), то игра покажется скучной, потому что панда всегда будет в одинаковой стоящей позе. Поэтому нам нужно, чтобы панда ходила/прыгала/атаковала и т.д. Мы хотим, чтобы спрайт изменялся на основании состояния игрока. Чтобы упростить обращение к номерам спрайтов, мы объявим константы:

-- S - таблица со всеми спрайтами
S={
 -- S.PLR - таблица только со спрайтами игрока
 PLR={
  STAND=257,
  WALK1=258,   WALK2=259,  JUMP=273,   SWING=276,
  SWING_C=260, HIT=277,    HIT_C=278,  DIE=274,
  SWIM1=267,   SWIM2=268,
 }
}

Они соответствуют нескольким спрайтам панды в таблице спрайтов:

image


Итак, в нашей функции рендеринга мы решаем, какой спрайт будем использовать. Это функция RendPlr(), которая содержит следующее:

local spid
if Plr.grounded then
 if btn(2) or btn(3) then
  spid = S.PLR.WALK1 + time()%2
 else
  spid = S.PLR.STAND
 end
else
 spid = S.PLR.JUMP
end
...
spr(spid, Plr.x, Plr.y)

Что означает: если игрок находится на твёрдой земле и идёт, то выполнять анимацию ходьбы, попеременно отрисовывая спрайты S.PLR.WALK1 и S.PLR.WALK2. Если игрок находится на твёрдой земле и не идёт, использовать S.PLR.STAND. Если не на твёрдой земле (падает или прыгает), то использовать S.PLR.JUMP.

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

Прыжки


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

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

Мы представим прыжок как итерацию по «последовательности прыжка»:

JUMP_DY={-3,-3,-3,-3,-2,-2,-2,-2,1,1,0,0,0,0,0}

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

Логика начала/завершения прыжка будет примерно такой:

  • Если мы находимся на твёрдой земле и нажимаем кнопку прыжка (btn(4)), начать прыжок (задать Plr.jmp=1).
  • Если прыжок уже выполняется (Plr.jmp>0), то продолжить прыжок, пытаясь изменять позицию игрока по Y на JUMP_DY[Plr.jmp], если это возможно (в соответствии с функцией CanMove()).
  • В какой-то момент прыжка движению игрока что-то препятствует (CanMove() возвращает false), тогда мы прерываем прыжок (задаём Plr.jmp=0 и начинаем падать).

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

image

Траектория прыжка.

Сущности


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

Для начала я нарисовал несколько устрашающих врагов. В основном они ужасают качеством прорисовки, а не тем, что они страшные:

image


Я просто добавил их к таблице спрайтов и создал анимации для каждого. Также я установил новую точку разбиения: спрайты с индексами ? 128 являются сущностями, а не статичными тайлами. Так я могу просто добавлять врагов на уровень с помощью редактора карт, и буду знать, что они враги благодаря индексу их спрайтов:

image


Аналогично, многие другие объекты являются сущностями: сундуки, разрушаемые блоки, временные платформы, лифты, порталы и т.д.

При загрузке уровня я проверяю каждый тайл на карте. Если он ? 128, я удаляю тайл карты и создаю в этом месте сущность. ID сущности (EID) определяет, чем она является. Что мы используем в качестве EID? Да просто ещё раз берём номер спрайта! То есть если враг «зелёный слизняк» имеет спрайт 180, то EID зелёного слизняка будет равен 180. Всё просто.

Все враги хранятся в глобальной структуре Ents.

Анимации сущностей


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

-- цикл анимации для каждого EID.
ANIM={
 [EID.EN.SLIME]={S.EN.SLIME,295},
 [EID.EN.DEMON]={S.EN.DEMON,292},
 [EID.EN.BAT]={S.EN.BAT,296},
 [EID.FIREBALL]={S.FIREBALL,294},
 [EID.FOOD.LEAF]={S.FOOD.LEAF,288,289},
 [EID.PFIRE]={S.PFIRE,264},
 ...
}

Заметьте, что часть из них является символьными константами (например, S.EN.DEMON), когда они также совпадают с спрайтом сущности, а некоторые являются жёстко заданными целыми числами (292), потому что во втором случае это всего лишь вторичный кадр анимации, на который больше нигде не нужно ссылаться.

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

Метатеги: аннотации карт


Иногда нам нужно добавлять к картам аннотации, используемые во время выполнения игры. Например, если существует сундук с сокровищем, то нам нужно каким-то образом обозначить, что находится внутри, и как много этих объектов. В этих случаях мы используем маркеры аннотации карт, это специальные тайлы с номерами 0–12, которые никогда не отображаются (во время выполнения они удаляются с карты):

image


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

image


Метатеги могут также определить, например, где должны начинать и заканчивать движение лифты, где находится начальная позиция уровня, информация о фазах для временных платформ и прочее.

Также они используются для сжатия уровня. Мы поговорим об этом ниже.

Уровни


В игре есть 17 уровней. Где они хранятся? Ну, если вы посмотрите на память карт, то увидите следующую картину:

image


У TIC-80 есть 64 «страницы» карт, каждая из которых является одним «экраном» контента (30x17 тайлов). Страницы пронумерованы от 0 до 63.

В нашей схеме мы зарезервировали верхние 8 для использования во время выполнения игры. Там мы будем хранить уровень после распаковки (подробнее об этом позже). Тогда каждый уровень является последовательностью из 2 или 3 страниц в памяти карт. Мы также можем создать страницы для экрана обучения, экрана победы, карты мира и начального экрана. Вот версия карты с аннотациями:

image


Если вы сыграете в игру, то можете заметить, что уровни на самом деле намного длиннее, чем могли бы поместиться на 2 или 3 экранах. Но в памяти карт картриджа они намного меньше. Что же здесь происходит?

Вы уже догадались (да и я давал подсказку): уровни сжаты! В памяти карт каждый столбец хранит в верхней строке метатег, сообщающий, сколько раз повторяется столбец. Другими словами, мы реализовали простую форму сжатия RLE:

image


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

А вот для чего мы используем верхние 8 страниц карт во время выполнения: когда мы готовы играть в уровень, мы распаковываем его для игрового процесса на страницы 0–7. Вот какая логика используется для запуска уровня:

  1. Считываем упакованный уровень из нужного места в памяти карт.
  2. Распаковываем его в верхние 8 страниц в соответствии с количеством повторений в каждом столбце упакованного уровня.
  3. Ищем сущности (спрайты ? 128) и создаём их экземпляры.
  4. Ищем начальную позицию игрока (метамаркер «A») и ставим игрока туда.
  5. Начинаем играть.

Поведения сущностей


Что заставляет врага вести себя определённым образом? Возьмём для примера красного демона из игры. Откуда берётся его страстное желание метать в игрока огненные шары? Почему бы им просто не стать друзьями?

image


Каждая сущность имеет собственное поведение. Демоны бросают огненные шары и прыгают. Зелёные слизняки бродят туда-сюда. Синие монстры периодически прыгают. Сосульки падают, когда игрок находится достаточно быстро. Разрушаемые блоки разрушаются. Лифты поднимаются. Сундуки остаются сундуками, пока по ним не ударит игрок, после чего они открываются и создают своё содержимое.

Простым способом создания всей этой логики будет следующий:

if enemy.eid == EID.DEMON then
 ThrowFireballAtPlayer()
elseif enemy.eid == EID_SLIME then
 -- делаем что-то другое
elseif enemy.eid == EID_ELEVATOR then
 -- делаем что-то другое

-- ...и так далее для каждого случая...

Поначалу всё кажется простым. Но потом работа становится сложной и монотонной. Возьмём к примеру движение: когда враг движется, нам нужно выполнить кучу проверок — убедиться, что враг может пройти в заданную позицию, проверить, не упадёт ли он, и т.д. Но некоторые враги не падают, они летают (например летучие мыши). А некоторые плавают (рыбы). Некоторые враги хотят смотреть на игрока, другие нет. Некоторые враги хотят преследовать игрока. Некоторые просто занимаются своим делом, не зная, где находится игрок. А как насчёт снарядов? Огненные шары, плазменные шары, снежки. На некоторые влияет гравитация, на некоторые нет. Некоторые сталкиваются с твёрдыми объектами, другие нет. Что происходит, когда каждый из снарядов ударяет по игроку? Что происходит, когда игрок их ударяет? Так много переменных и комбинаций!

Написание блоков if/else для каждого из этих случаев через какое-то время превращается в обузу. Поэтому вместо этого мы создадим систему поведений, которая является довольно распространённым и полезным паттерном в разработке игр. Во-первых, мы определим все возможные поведения, которые может иметь сущность, и необходимые параметры. Например, она может:

  • Двигаться (Насколько? Как она обращается с краями уступов? Что происходит, когда она сталкивается с чем-то твёрдым?)
  • Падать
  • Менять направление (Как часто? Всегда смотрит на игрока?)
  • Прыгать (Как часто? Как высоко?)
  • Стрелять (Чем? В каком направлении? Целиться в игрока?)
  • Быть уязвимой (получать урон от игрока).
  • Наносить урон игроку при коллизии
  • Разрушаться при коллизии (Как долго?)
  • Автоматически разрушаться через заданный промежуток времени (Как долго?)
  • Давать бонус (Какой?)
  • и т.д...

Определив все возможные поведения, мы просто назначаем их с нужными параметрами правильным сущностям, в которых мы вызываем EBT (Entity-Behavior Table, таблицу поведений сущностей). Вот пример записи для красного демона:

-- Таблица поведений сущностей
EBT={
 ...
 [EID.EN.DEMON]={
  -- Поведения:
  beh={BE.JUMP,BE.FALL,BE.SHOOT,
   BE.HURT,BE.FACEPLR,BE.VULN},

  -- Параметры поведений:
  data={hp=1,moveDen=5,clr=7,
   aim=AIM.HORIZ,
   shootEid=EID.FIREBALL,
   shootSpr=S.EN.DEMON_THROW,
   lootp=60,
   loot={EID.FOOD.C,EID.FOOD.D}},
 },
 ...
}

Это сообщает, что демоны имеют следующие поведения: прыжок, падение, стрельба, урон, поворот к игроку, уязвимость. Кроме того, параметры указывают, что сущность имеет одно очко урона, движется каждые 5 циклов, имеет базовый цвет 7 (красный), стреляет огненными шарами (EID.FIREBALL), целится в игрока в горизонтальной плоскости (AIM.HORIZ), имеет вероятность выпадения награды в 60%, может выбрасывать еду C и D (суши). Видите — теперь мы можем задать целое поведение этого врага всего лишь в нескольких строках сочетанием различных поведений!

А как насчёт этих гор на фоне?


О, вы заметили горы на фоне! Если посмотреть в память карт, то там вы их не найдёте. Они также не движутся точно вместе с игроком: они имеют эффект параллакса и двигаются медленнее, чем передний план, создавая впечатление того, что они далеко на фоне.

Как получается этот эффект? На самом деле мы создаём горы во время выполнения. При загрузке уровня мы случайно (но с постоянным начальным числом генератора) генерируем карту возвышений с одним значением на ячейку, и делаем её немного больше, чем уровень (в общем примерно 300 значений или около того).

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

Переопределения палитры


Одна из отличных возможностей TIC-80 заключается в том, что она позволяет переопределять палитру, записывая значения в некоторые адреса памяти ОЗУ. Это значит, что каждый из наших уровней может иметь «переопределение палитры», которое мы задаём при запуске:

function SetPal(overrides)
 for c=0,15 do
  local clr=PAL[c]
  if overrides and overrides[c] then
    clr=overrides[c]
  end
  poke(0x3fc0+c*3+0,(clr>>16)&255)
  poke(0x3fc0+c*3+1,(clr>>8)&255)
  poke(0x3fc0+c*3+2,clr&255)
 end
end

Адрес ОЗУ 0x3fc0 является началом области, в которой TIC-80 хранит свою палитру, поэтому для изменения палитры нам достаточно записать байты в эту память.

Карта мира


Карту мира я добавил на поздних этапах разработки. Она служит для того, чтобы сообщить игроку о его прогрессе. На ней показаны 17 уровней игры, разбросанных по 6 островам:

image


Игрок может перемещаться с помощью клавиш со стрелками и входить на уровень нажатием на Z. Карта мира реализована в двух страницах карт: передней и фоновой.

Фоновая страница (страница 62 в памяти карт) просто содержит статичные части карты:

image


Во время выполнения на неё накладывается передняя страница (страница 61):

image


Эта страница показывает, где находятся уровни. Тайлы «1», «2» и «3» являются уровнями. Игра узнаёт, к каким островам они принадлежат, выполняя поиск метамаркера (1–6) вокруг тайла. То есть, например, когда игра смотрит на тайл «2» на острове 3 (в правой части карты), она замечает, что рядом с ним есть метамаркер «3», то есть она узнаёт, что это уровень 3–2.

Маркер «A» обозначает стартовую позицию игрока, а маркер «B» — стартовую позицию каждого острова при восстановлении сохранённой игры.

Звуковые эффекты и музыка


Я написал все звуковые эффекты и музыку с помощью встроенного редактора TIC-80. Хотел бы я сказать, что у меня есть хороший способ создания звуковых эффектов, но на самом деле я просто нажимал на кнопки и случайно изменял числа, пока не добился нужного. Мне кажется, это вполне разумный способ создания звуковых ретро-эффектов.

image


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

image


Я сочинил 8 треков (предел для TIC-80):

  1. Музыка A для уровней, используется на островах 1–5.
  2. Музыка B для уровней, используется на островах 1–5.
  3. Мелодия конца уровня (короткая).
  4. Музыка C для уровней, используется на островах 1–5.
  5. Музыка карты мира.
  6. Тема начального экрана.
  7. Музыка уровня для острова 6 (последние 2 уровня)
  8. Тема конца игры (экран «The End»)

Заключение


Создание этой игры принесло мне невероятное количество радости, и я глубоко благодарен создателю TIC-80 (Вадиму Григоруку) за появление этой отличной платформы. Надеюсь, вам так же понравится играть в игру (и заниматься хакингом исходного кода, если захотите!), как мне понравилось создавать её!

В будущем я собираюсь писать и другие игры для TIC-80 и похожих на неё консолей.

Комментарии (1)


  1. DSLow
    24.10.2017 21:29

    Хотел бы спросить:
    Величина дистанции прыжка ведь же статичная?
    Не задумывался ли приделать соотношение к длительности нажатия кнопки (aka короткий клик — низкий прыжок, длительное зажатие — высокий). Интересна реализация.

    Мб кто-то спрашивал в исходнике?