Безумие на чистом HTML + CSS

Играми на «чистом CSS» (т.е. без JS) нас уже давно трудно удивить. Но британскому дизайнеру Джейми Коултеру, пожалуй, удалось. Его недавняя работа в Codepen — полноценный квест с сюжетом, в котором игроку нужно выбраться из мрачного подвала то ли больницы, то ли лаборатории, где накануне произошло что-то ужасное. И заодно узнать шокирующую разгадку… в общем, то, что надо на Хэллоуин!

Игрок может перемещаться между локациями и взаимодействовать с окружающими предметами. В некоторых предметах бывает спрятано что-то нужное для дальнейшего прохождения (например, ключ), некоторые — скажем, записки на стенах — содержат полезные подсказки, а ещё есть просто приятные «пасхалки» (например, логотип Codepen и аватарки многих «звезд» фронтенда:). Некоторые предметы совмещают всё вышеперечисленное. И такая проработка мелочей, да и вся атмосфера игры, весьма впечатляет.

Но завсегдатаев Codepen — и нашего сайта — не меньше разгадки сюжета игры занимает другой вопрос:

Как же это сделано?

Структура HTML игры

Первый взгляд на HTML-код сразу показывает нам картинку-заставку с названием (.thumbnail) и несколько блоков div: .postProcess, .grain (3 шт.), .loader, .intro и .game. Codepen автоматически подключил к странице свой скрипт, но он никак не используется.

Блок .postProcess вообще пуст и, вероятно, остался от отладки. Блоки .grain отвечают за эффект «зерна кинопленки», усиливающий мрачную атмосферу. Картинка этого зерна «скачет» благодаря CSS-анимации с функцией плавности steps(), сам блок абсолютно позиционирован поверх всего и «прозрачен» для кликов благодаря pointer-events: none. Блок .loader тоже содержит лишь простую однократную анимацию, за время которой успевают загрузиться ресурсы игры. Самое важное, очевидно, скрыто в блоке .game. А блок .intro — это короткое текстовое «введение» в самом начале.

Чекбоксы загружаются…

Такая подсказка видна сразу после заставки, в блоке .loader. Да, основная логика игры реализована хорошо знакомым нам способом — через невидимые чекбоксы и радиокнопки, к состояниям которых (через псевдокласс :checked и комбинаторы + и ~) можно привязывать стили других элементов. В том числе видимость элементов <label>, управляющих другими input-ами, получая в итоге довольно сложное дерево возможных состояний.

Самый простой вариант этого мы видим в блоке .intro. В нем четыре радиокнопки из одной группы (с общим name="intro"), чередующиеся с дивами. В каждом диве, кроме последнего, лежит один абзац текста и <label> следующей радиокнопки, оформленный в виде кнопки «дальше». За показ всех дивов и его содержимого отвечают единые CSS-правила (чуть упрощено для наглядности):

.introInput:checked + div { z-index: 1;
} .introInput:not(:checked) + div label,
.introInput:not(:checked) + div p { opacity: 0;
} .introInput:not(:checked) + div p label { transition: all 1s 0s;
} .introInput:checked + div p { transition: all 1s 2s;
} .introInput:checked + div p label { transition: all 1s 2.8s;
}

За счет разных задержек перехода при переходе на очередной шаг сначала пропадает предыдущий <label>, а затем по очереди появляются новые текст и кнопка.

При выборе последней радиокнопки плавно уходит в прозрачность последний див — «затемнялка» (.overlay), и под ним открывается…

Главная сцена

Структура

Блок .main на первый взгляд кажется слишком сложным, но он довольно логично структурирован. Всё, кроме отвечающих за состояние input-ов, сгруппировано по смысловым контейнерам — главное окно (.game_viewport), фигура персонажа (.player), ограничитель видимой области (эффект «светового пятна» — .lightMap) и предметы, которые можно «рассмотреть вблизи» (.overlayObjects). Увы, сами input-ы в отдельный контейнер обернуть нельзя: комбинаторы + и ~ работают только для «детей» одного родителя. Но Джейми дал им говорящие ID, благодаря чему четко выделяются их логические группы:

  • overlayObject — отвечают за те самые предметы, которые можно «приблизить»;
  • keyObject — «ключевые» объекты, т.е. то, что может менять состояние после взаимодействия (напр. дверь, которая может быть запертой и открытой);
  • noneKeyObject — «обычные» объекты, с которыми ничего не происходит (только всплывает текстовое пояснение);
  • segment — радиокнопки, управляющие переходами между локациями. Для каждого «сегмента» карты заготовлена пара таких кнопок, помеченных как «вперед» и «назад».

Персонаж

Самая простая (вроде бы) часть: абсолютно позиционированный див с единственным потомком — пустым дивом с картинкой-спрайтом в background. Но при беглом взгляде персонаж на этой картинке даже одет не так, как на экране. Неужели современному CSS уже по силам «переодеть» растровую картинку?

Разгадка проще: для этого дива работает CSS-анимация, быстро «пролистываюшая» 36 кадров по очереди (с помощью всё той же steps()) и останавливающаяся на последнем (forwards). И в стилях для этой анимации указан другой спрайт. Если выбрана радиокнопка «назад» для текущей локации — тот, где персонаж идет влево, если «вперед» — тот, где вправо. А стили анимаций в CSS-каскаде приоритетнее обычных.

В CSS это можно было сделать как-то так:

.forwards:checked ~ .player .player_sprite { animation: walkingRight-2-0 1.8s steps(36,end) forwards;
}

Зачем автор «наворотил» куда более сложный селектор… надеюсь, что ради «хэллоуинскости», чтобы код тоже выглядел пострашнее, а не потому, что не знал про комбинатор ~ :). Я бы вообще привязывал эти стили не к ID, а к двум независимым классам input-а, чтобы один отвечал только за выбор локации, а второй — только за «ориентацию» персонажа, и не дублировать логику. Ну и в принципе можно было бы обойтись одним спрайтом, «отзеркалив» элемент через transform: scaleX(-1).

Зато тень через filter:drop-shadow() — только от непрозрачной части картинки — при анимации смотрится шикарно.

Карта

В блоке .game_viewport лежат большая картинка с основной графикой всей игры и 64 сегмента игровой карты (4×16). С размещением сегментов Джейми не мудрил: старый добрый float, никаких гридов. При переходе между локациями меняется позиция всей карты целиком. Опять же, селектор для этой задачи можно было написать проще…

.segment24:checked ~ .game_viewport { top: -512px; transform: translateX(-2880px) translateY(calc(50vh - (512px/2)));
}

…но Хэллоуин, всё-таки:)

В каждом сегменте есть стандартный блок .controls с кнопками для перехода на соседние сегменты (label-ы для соотв. радиокнопок), а также уникальные для каждого сегмента «интерактивные объекты» (label-ы для соотв. чекбоксов). HTML-код сегментов делается циклом в Haml, поэтому у всех сегментов, включая крайние и неиспользуемые, в .controls есть все 4 кнопки. Недоступные скрыты через CSS.

Возьмем для примера типичный сегмент из середины карты — 25-й:

Сегмент 25

В нем нет лестниц, поэтому кнопки «вверх» и «вниз» скрыты (display: none). Стрелка «вперед» тоже: там дверь, которая пока закрыта. Кроме двери, в сегменте есть еще два объекта — тумба с документами и пугающая надпись на стене. Тумба и надпись — «неключевые» объекты: клик по ним выбирает соотв. чекбокс, и срабатывает очередной «страшный» селектор, по смыслу аналогичный

#noneKeyObject-35:checked ~ .game_viewport > .game_viewport__segment:nth-of-type(25) .response-35 { animation: response .75s forwards; display: block; pointer-events: all; z-index: 1;
}

Этот CSS показывает див с коротким текстом и кнопкой, которая тоже является label-ом для чекбокса с id="noneKeyObject-35". Поэтому клик по ней сбрасывает :checked, и див исчезает.

С дверью всё в принципе аналогично, но чуть интереснее. Она — ключевой объект, и связанный с ней чекбокс влияет на дальнейшее прохождение игры. Но одного чекбокса для открытия двери недостаточно! И изначально дверь ведет себя полностью аналогично «неключевому» объекту — показывает по клику див .response.fail с текстом и точно такой же кнопкой закрытия.

Всё меняется, когда дверь «активирована», т.е. до ее открытия предварительно чекнут еще один чекбокс. Тогда срабатывают уже другие селекторы (опять упрощаю для наглядности):

#keyObject-9:checked + input:checked ~ .game_viewport > .game_viewport__segment:nth-of-type(25) .response.success { display: block; animation: forcedResponse 3.2s forwards;
} #keyObject-9:checked + input:checked ~ .game_viewport > .game_viewport__segment:nth-of-type(25) .forward { display: block;
} #keyObject-9:checked + input:checked ~ .game_viewport > .game_viewport__segment:nth-of-type(25) .object { display: none;
}

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

Свет во мраке

«Пятно света», словно от мерцающего фонарика, в котором происходит действие игры (блок .lightMap) тоже устроено очень просто: еще один абсолютно позиционированный блок с фоновой картинкой в виде черного квадрата с прозрачной круглой «дыркой» в центре, невидимый для кликов (pointer-events: none) и слегка «пульсирующий» благодаря зацикленной анимации transform: scale(). Почему растровая картинка, а не радиальный градиент — еще одна загадка… но на то это и детективный квест 🙂

Всплывающие объекты

Каждый див в блоке .overlayObjects по сути аналогичен блокам в сегментах карты, всплывающим при активации «неключевых» объектов: тоже некое содержимое и кнопка выхода, являющаяся label-ом для того же самого чекбокса. Отличаются только стили, дающие эффект «показа крупным планом», поверх всего.

Ползучий ужас

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

Нативный drag-and-drop без JS? Такое возможно?

Выходит, да… но лишь для одного HTML-элемента — того, состояние которого управляется перетягиванием ползунка: <input type="range">. Точнее, для его «ручки». Но нам мало просто «перетянуть ручку»: нам нужно, чтобы под ней открылся кликабельный объект.

К счастью, все браузеры позволяют стилизовать <input type=range> по частям — с помощью нестандартных псевдоэлементов (у каждого браузера своих). Для нашей задачи нужно сделать полоску ползунка невидимой и прозрачной для кликов, а ручку — очень большой, по размеру той крышки, которую надо отодвинуть. И еще ползунок нужно сделать вертикальным, чтобы эта крышка отъезжала вниз.

В общих чертах, для этого понадобится задать pointer-events: none самому input-у, вернуть pointer-events: all ручке, каким-то способом сделать невидимой «дорожку» (Джейми предпочел «скукожить» его высоту до нуля), задать элементу и «ручке» нужные размеры, спозиционировать в нужное место и повернуть с помощью transform: rotate(90°). Усложняет задачу необходимость дублировать код как минимум для -webkit- и -moz- вариантов, а также неизбежные при стилизации элементов форм разночтения между браузерами. Так что код этого элемента вышел объемистым — экрана три (можете открыть в отладчике в режиме редактора стилей и посмотреть). Но получилось же!

Спасение от безумия в коде

Конечно, сам по себе жанр «игр на чистом HTML+CSS» — то, что традиционно попадает в разделы типа «ненормальное программирование» и никогда не рассматривается как что-то большее, чем занятное упражнение. Квест Джейми — просто идеальный пример «ненормальности» (особенно с учетом концовки:). Но обязательно ли и коду игры быть настолько безумным и пугающим? Нет ли желающих сделать форк этой игры с сохранением логики, но менее страшными селекторами и вообще более компактным CSS, который уместится в саму «песочницу» и не придется прикладывать внешним ресурсом — в порядке тренировки в CSS и просто для развлечения?

Счастливого Хэллоуина! А отважившимся на такой квеCSSт — особенно! 🙂

P.S. Это тоже может быть интересно: