Что такое игра? Как ее написать?
Статья - рефлексия на тему игр как обучения, а игра в ней - результат этой рефлексии. В статье так же расскажу наиболее простой способ написания игры (на HTML5), инструменты и современные подходы. Для разработки нужны минимум блокнот и браузер, никакого дополнительного ПО. Запускаться игра будет в HTML, на любом устройстве с браузером (вплоть до телевизора).
Статью написал в рамках подготовки к пятничному игровому джему (GMTK Game Jam 2023). Никогда в них не участвовал, решил проверить, что успею запилить хоть что-то за пару дней и по-рефлексировать на тему. Поболейте за меня или сами поучаствуйте. Написанная в статье игра - не для джема, а для статьи, ссылка в конце. Игра из джема уже готова, можно ознакомится тут.
Игра
Немного майевтики. Прежде чем начать, ответим на вопрос - зачем делать игру вообще? И что такое игра?
В игры играют не только люди, но и животные. В статье игры в которые играют животные приводятся примеры игр птиц, рыб и даже насекомых. Авторы: Lee Alan Dugatkin, степень доктора и профессора в биологии, Sarina M. Rodrigues нейробиолог из университета Беркли (входит в топ 5 лучших университетов мира). Следовательно, делаем вывод, что:
Игра - инстинктивный способ обучения, мы делаем игру для того, чтобы чему-то научить. Играть - интересно, первостепенная эмоция в любой игре - любопытство. Поэтому "пройденные" игры, изученные вдоль и поперек перестают интересовать. Если игрок "переобучен" - игра ничему не сможет научить и ему будет скучно. Если игрок "не до обучен" - у него может не быть тех навыков и знаний, которые требуются для игры. Крайние примеры: "Игры про Кузю" и "Dawrf Fortress".
Например, я "прошел" крестики-нолики. Х ставится в центре, второй Х - по диагонали. Для 0 - по диагонали, второй 0 - в ряд с двумя Х. Всего вариантов исхода менее 100, просчитать всю игру заранее можно даже в уме по алгоритму минимакса. Если оба игрока "прошли" игру - всегда будет ничья. Однако, если второй игрок не "прошел" игру, то первому может быть интересно побеждать. Но это уже социальная механика игры.
Если игроку не интересно чему-то учиться - нужно заинтересовать его механиками (mech; megʰ - мочь), т.е. "мощью" игры.
Хороший пример: игра 2048. Она учит степеням двойки, предсказывать свои действия, принимать стратегические решения, свайпать. Но игра использует механику вызова (challenge): "Набери 2048".
Игрок осознает и эмоционально реагирует на механики (re - обратно, *ag- гнать, двигать). Сама тема эмоций - огромна, но мы классифицируем их все на два типа. Позитивные - обучение прошло успешно, негативные - есть проблемы. Если игра не объясняет механики, то они называются скрытыми, на них игрок реагирует бессознательно. Например "Время койота".
Механики
Помимо обучения игры дополняют эстетическими, социальными и материальными механиками. Социальные вызывают эмоции чувства принадлежности, чувства признания; материальные - азарт и т.д. Чаще всего - механики берут уже из известных игр, которые себя зарекомендовали, т.к. это бесконечно проще, чем изобрести новую, успешную механику. Обычно, совокупность механик называют жанр игры. Например, жанр "квест" (quest - поиски). Как следует из названия - это механика поиска чего либо. Например, "Найди Вальдо".
Интерактивность (interaction, inter - посреди, act - действие) - это про события. Игра запустилась, прошло время, тык пальцем/мышкой в экран. На эти события есть реакции игры (re - обратно, act - действие). В контексте игрока, событием будет появление на экране двух прямоугольников, а его реакцией будет эмоция (emotion, e - наружу, moveo - двигаться). В зависимости от эмоции - действие, событие в контексте игры. Больше интерактивности - лучше раскрывается механика.
Геймдизайн
Но как чему-то научить? Исходя из теорий бихевиоризма - через позитивное и негативное подкрепление. Причем, позитивное - намного эффективнее негативного.
Допустим, мы хотим научить игрока нажимать на закрашенный прямоугольник на экране. Будем рассматривать два события: игрок успел нажать на прямоугольник, или - не успел. Если игрок успел - успокаиваем и удивляем, если нет - злим. Допустим, синий - безопасный и приятный цвет (спокойствие ?), красный - опасный и неприятный цвет (злость ?). игрок справился отлично - постараемся его удивить зеленым ?. И на добивочку фиолетовый ?.
Сама тема цвета и его применения - огромна. Например, в США есть розовые тюрьмы. Согласно исследованиям - человеческий глаз наиболее восприимчив к зелёному участку спектра. Красный - кровь, синий - небо. Но это уже из области знаний о графическом дизайне. Мало кто замечал, но у многих успешных компаний синий логотип. Я инженер, поэтому буду использовать палитру "Инженерная".
Механика
Согласно отчету Contentsquare 2021 Digital Experience Benchmark, среднее по больнице время просмотра одной страницы составляет 54 секунды.
Чтобы проверить, что игрок действительно научился - будем считать, сколько раз он попал. Чем больше попыток потребуется - тем большим упорством должен обладать игрок и зависит от заинтересованности игрока. Будем считать, что ожидаемый интерес к нашей игре 10% и игрок проведет в игре 5.4 секунды.
Считаем количество успехов (successes) и неудач (fails). Если игрок справился, т.е. достиг максимального успеха (wins) - игрок обучен. Если же игрок достиг максимального числа неудач (looses) мы поможем ему и подставим прямоугольник под указатель, чтобы у него точно получилось, и сбрасываем число неудач. Это нужно, чтобы улучшить понимание механики. Это оригинальная механика, поэтому я не знаю, как она себя поведет, тем интереснее.
Есть вероятность, что мы не смогли вызвать эмоцию - тогда и сама механика в целом не будет работать. Помним закон Мерфи. Обычно просто добавляют надпись, стрелки, вызывающей желание нажать элемент (кнопка) и т.д. Для того, чтобы закрепить обучение - добавим механику поощрения. Если игрок научился и количество успешных попыток достигло максимума (wins), мы наградим игрока зелёным.
Для более ярких эмоций - воспользуемся звуком. Для этого мы будем использовать простой онлайн секвенсор (https://onlinesequencer.net/), создадим по звуку на каждое нажатие, по аналогии с цветом.
Реализация
Создадим два файла: index.html и script.js. Названия файлов на английском, как и код игры. index.html будет использоваться для запуска игры (будет открываться в браузере). Файл представляет собой документ, в котором есть тело (body) и файлы скриптов (script). Пока мы будем использовать только файл script.js. Нужно добавить две строчки в файл html:
<body></body>
<script src="script.js"></script>
Для начала, нужно настроить документ. Добавим заголовок страницы, холст, выровняем. Файл script.js хранит код игры на языке JavaScript. В первую очередь, нам нужно создать элемент холст (canvas), на котором мы будем рисовать игру. Затем, добавляем холст в тело документа (document).
Слово "контекст" означает ситуацию, в которой что-то происходит. Игра двухмерная, поэтому и рисовать мы будем в двумерном 2d контексте (context, 2D, Dimensional - измерение). Добавляем в script.js:
Подготовка страницы
const canvas = document.createElement('canvas') // Создали холст
const context = canvas.getContext('2d') // Получаем 2d контекст
document.body.appendChild(canvas) // Добавляем холст в тело документа
canvas.width = screen.width // Ширина холста по ширине экрана
canvas.height = screen.height // Высота холста по ширине экрана
//Это нужно, чтобы холст располагался в документе ровно
canvas.style.left = 0
canvas.style.top = 0
canvas.style.position = 'absolute'
//Если не во весь экран - скрываем скролл
document.body.style.overflow = 'hidden'
//Заголовок станицы
document.title = 'Game'
Следующий этап - определить значения всех переменных в игре. Сколько побед, успехов требуется, пути к файлам звуков и т.д. Я предпочитаю разделять функции от данных, поэтому каждый блок игры поместим в отдельный JS объект. Описываются они в формате JSON.
Данные
//Конфигурация уровня
const level = {
score: 0,
//Среднее время игры
defaultTime: 5400,
//Цвет текста нового уровня
levelUpColor: '#ff00ff',
//Цвет текста очков цепочки
scoreColor: '#000000',
easy: {
//Количество очков до следующего уровня
scoreUp: 10,
currentPosition: 0,
min: [100, 100],
max: [400, 300],
step: [100, 100],
positions:[ [100,100], [100,200], [200,200], [300,200] ],
lastTime: null,
bestTime: null,
},
}
//Конфигурация базы
const base = {
// Положение и размер прямоугольника
x: level.easy.positions[0][0],
y: level.easy.positions[0][1],
width: 100,
height: 100,
// Положение и размер второго
x2: level.easy.positions[0][0],
y2: level.easy.positions[0][1],
width2: 10,
height2: 10,
//Заливка
strokeStyle:'#000000',
//Закраска
fillStyle:'#aaaaaa',
//Закраска при успехе
fillStyleSuccess:'#aaffaa',
fillStyleFail:'#aaaaaa',
}
//Конфигурация механики
const mechanic = {
//Цвет успокоения
successColor: '#0000ff',
//Цвет победы
winColor: '#00ff00',
//Цвет поражения
failColor: '#ff0000',
//Закраска экрана
eraserColor: '#ffffff20',
// Количество победных нажатий
wins: 3,
// Количество неудачных нажатий
looses: 3,
successes: 0,
fails: 0,
sizeStep: 20,
winner:false,
hits: 0,
success: false,
cursor: { x: 0, y: 0 },
size: 0,
overwinSizeStep: 10,
overwin: 0,
baseWidth: base.width,
baseHeight: base.height,
pointerShrink: 0.85,
}
//Пути к звукам
const audio = {
fail:'sfx/fail.mp3',
level:'sfx/level.mp3',
success: 'sfx/success.mp3',
win:'sfx/win.mp3',
loose:'sfx/loose.mp3',
score:'sfx/score.mp3'
}
Нарисуем на холсте два прямоугольника (rectangle, rect). Один черный (#000000), другой - серый (#aaaaaa). Цвета задаются в 16-ричным числом (hexidecimal; hex). Расстояния на холсте измеряются в пикселях (pixel; px), положение измеряется от верхнего левого угла, двумя значениями с названиями икс (x) и игрик (y). Соответственно, у прямоугольника есть положение на холсте (x,y), ширина и высота (width, height), цвет (color). Он может быть обведенным (stroke) или закрашенным (fill). О том, как использовать 2D контекст холста в WEB, а так же - полное руководство.
База
//Рисуем базу
function drawBase() {
context.strokeStyle = base.strokeStyle // Цвет обводки
context.fillStyle = base.fillStyle // Цвет заливки
context.fillRect(base.x, base.y, base.width, base.height) // закрашиваем
context.strokeRect(base.x2, base.y2, base.width2, base.height2) // обводим
}
Для ощущения механики прогресса - будем закрашивать холст прозрачным белым цветом на каждое нажатие, из-за чего предыдущие попытки будут растворяться. Для отличника - будем закрашивать постоянно по времени. В среднем, в играх уровень интерактивности графики около 60 обновлений (кадров) в секунду (FPS, frames per secord). Для этого мы создадим событие по времени: каждые 1000/60, где 1000 - количество миллисекунд в секунде (милли-, milli-, одна тысячная), 60 - FPS. Т.е. каждые 16.66.. миллисекунд будет происходить закрашивание экрана прозрачным белым.
Заливка прозрачным
function fade(){
context.fillStyle = mechanic.eraserColor // Цвет заливки
context.fillRect(0, 0, canvas.width, canvas.height) // закрашиваем
//Базу и очки оставляем, т.к. не все поймут
showMaximumScore()
drawBase()
}
Регистрируем событие нажатия на экран. Событием будет нажатие указателем (pointwerdown) на тело документа. Следствие события (символ ⇒) будет меняться в зависимости от того, куда игрок попал. У указателя тоже есть положение x и у, поэтому нам нужно проверить, что указатель (точка) находится внутри прямоугольника. Есть хорошая статья на эту тему, расписывать не буду. Если попал указателем в прямоугольник в x,y с шириной и высотой width и height - попытка успешная, мы подкрепляем синим. Если не попал - красным. При каждом нажатии нарисуем на экране прямоугольник попадания в точке указателя. При попадании - сбрасываем промахи и наоборот.
Цифр количества успехов (successes) и неудач (fails) игрок не видит. Чтобы игрок чувствовал прогресс (progress, pro - вперед, gradi - идти). но нужно ему дать об этом знать. Для этого будем увеличивать размер нарисованного попадания в зависимости от successes и fails подряд. Если игрок превысит количество ошибок - закрашиваем предыдущий прямоугольник красным и рисуем новый, под указателем. Если игрок справился с обучением - рисуем попадание зеленым.
Событие при клике
function onClick(e) {
fade()
base.x2 = e.x - base.width2 / 2
base.y2 = e.y - base.height2 / 2
mechanic.hits++
mechanic.cursor = getCursorPositionOnCanvas(canvas, e)
mechanic.success =
mechanic.cursor.x >= base.x && mechanic.cursor.y >= base.y
&& mechanic.cursor.x <= base.x + base.width
&& mechanic.cursor.y <= base.y + base.height
if(mechanic.success){
new Audio(audio.success).play()
mechanic.fails = 0
mechanic.successes++
if(mechanic.successes >= mechanic.wins){
new Audio(audio.win).play()
mechanic.overwin = 1 + mechanic.successes - mechanic.wins
mechanic.size = mechanic.size + mechanic.sizeStep
mechanic.fillStyle = mechanic.winColor
level.easy.currentPosition++
level.easy.currentPosition = level.easy.currentPosition % level.easy.positions.length
if(level.easy.currentPosition === 0){
mechanic.winner = true
if(level.easy.lastTime===null){
level.easy.lastTime = new Date()
}
else {
level.easy.bestTime = new Date() - level.easy.lastTime
}
}
base.fillStyle = base.fillStyleSuccess
drawBase()
const position = level.easy.positions[level.easy.currentPosition]
base.x = position[0]
base.y = position[1]
base.x2 = position[0] + base.width / 2 - base.width2 / 2
base.y2 = position[1] + base.height / 2 - base.height2 / 2
base.fillStyle = base.fillStyleFail
drawBase()
}
else{
mechanic.fillStyle = mechanic.successColor
mechanic.size = mechanic.successes / mechanic.wins * mechanic.sizeStep
}
}
else{
base.fillStyle = base.fillStyleFail
new Audio(audio.fail).play()
level.easy.currentPosition = 0
level.easy.lastTime = null
mechanic.fails++
mechanic.successes=0
if(mechanic.fails >= mechanic.looses){
new Audio(audio.loose).play()
mechanic.winner = false
mechanic.fails = 0
context.fillStyle = mechanic.failColor
context.fillRect(base.x, base.y, base.width, base.height)
base.x = mechanic.cursor.x - base.width /2
base.y = mechanic.cursor.y - base.width /2
drawBase()
}
if(mechanic.winner){
drawBase()
}
if(mechanic.overwin > 0){
mechanic.successes=mechanic.wins
mechanic.overwin = 0
mechanic.size = 0
context.fillStyle = mechanic.failColor
context.fillRect(base.x, base.y, base.width, base.height)
drawBase()
}
mechanic.size = mechanic.fails / mechanic.looses * mechanic.sizeStep
mechanic.fillStyle = mechanic.failColor
}
context.fillStyle = mechanic.fillStyle
context.fillRect(mechanic.cursor.x - mechanic.size/2, mechanic.cursor.y - mechanic.size/2, mechanic.size , mechanic.size)
if(level.easy.bestTime !== null){
showText(e)
}
}
На этом базово обученного игрока уже можно протестировать, провести экзамен. Будем перемещать прямоугольник в новую точку, ожидая попадания с первой попытки. Повторим это несколько раз и в конце дадим оценку (score) за прожатую цепочку целей. Рассчитаем оценку как время, за которое игрок прожал все цели, чем быстрее - тем лучше. Если успешно - выведем оценку на экран.
Показать текст очков и победы
function showText(e) {
new Audio(audio.score).play()
const speed = level.defaultTime / level.easy.bestTime
const rating = 1 + Math.round(speed * 5)
const fontSize = (10 + 15 * speed)
context.font = fontSize + "px serif"
context.fillStyle = level.scoreColor
context.fillText('+' + rating + '!', e.x, e.y)
level.easy.bestTime = null
level.easy.lastTime = new Date()
if (rating > level.easy.scoreUp) {
new Audio(audio.level).play()
document.title = 'x' + level.easy.positions.length
context.fillStyle = level.levelUpColor
context.fillText('NEXT LEVEL!', e.x, e.y + fontSize)
const random = getRandom(mechanic.hits)
addLevelPosition(random)
}
}
Если игрок сдал на отлично, т.е. оценка превышает пределы допустимого - добавляем еще одну цель в цепочку и начинаем тестирование заново, показываем эту механику выводом текста с оригинальным цветом. Далее - отличнику уже нечему учиться, лишь совершенствовать навык.
Добавляем новую позицию в цепочку для отличника
function addLevelPosition(random){
const lastPosition = level.easy.positions[level.easy.positions.length-1]
const directions = []
if(lastPosition[0]>level.easy.min[0]){ directions.push( [-1,0]) }
if(lastPosition[0]<level.easy.max[0]){ directions.push( [1,0]) }
if(lastPosition[1]>level.easy.min[1]){ directions.push( [0,-1]) }
if(lastPosition[1]<level.easy.max[1]){ directions.push( [0,1]) }
const directionId = Math.round(random * (directions.length-1))
const direction = directions[directionId]
const newX = lastPosition[0]+direction[0]*level.easy.step[0]
const newY = lastPosition[1]+direction[1]*level.easy.step[1]
const newPosition = [newX,newY]
level.easy.positions.push(newPosition)
}
Детали
Это нужно, чтобы получить точку попадания на холсте, а не на всем документе. Ведь регистрируем мы событие нажатия по документу, а не холсту.
function getCursorPositionOnCanvas(canvas, evt) {
var rect = canvas.getBoundingClientRect()
return {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top
}
}
В качестве случайного значения (random) - я беру номер нажатия и рассчитываю от него косинус. Получается псевдослучайное (непредсказуемое) значение от -1 до 1, минус - убираем.
function getRandom(increment){
const random = Math.abs(Math.cos(increment))
return random
}
В игре используется 3 типа событий: игрок запустил игру - рисуем базу. Игрок нажал на документ - обрабатываем нажатие. Запускаем 2 таймера, первый - закраска экрана для отличника (winner), и для геометрического уменьшения размера попадания со временем, чтобы он не становился слишком большим. Регистрируем событие нажатия на экран. Событием будет нажатие указателем (pointwerdown) на тело документа. Следствие события (символ ⇒) будет меняться в зависимости от того, куда игрок попал.
События
drawBase()
document.body.addEventListener('pointerdown', onClick)
setInterval(()=>{
if(mechanic.winner>0){
fade()
}
}, 1000/30)
setInterval(()=>{
mechanic.size *= mechanic.pointerShrink
},100)
Полировка
После того, как механики готовы - можно приступать к "полировке" игры. Процесс разработки - бесконечен. Всегда есть что улучшить, появляются новые инструменты, новые возможности игровых систем, новые знания и навыки. Для создания и запуска игры из статьи - нужен только браузер и блокнот. Запустить можно в браузере, а это ПО есть в подавляющем числе пользовательских систем.
Однако, если есть желание и умение - даже игру с готовой механикой можно улучшить усилением эмоционального отклика (как в примере с добавлением звука). Это может быть как и графическое улучшение, так и звуковое. Можно усовершенствовать или добавить новые механики. Заменить прямоугольники на изображения, добавить фоновую музыку, добавить элементы управления и подсчета очков; мультиплеерное взаимодействие (multi - несколько, player - игрок), оптимизировать загрузку звуков и т.д. Один из успешных примеров кликера - OSU, немаловажной механикой в ней является музыкальный ритм, воздействие на чувства такта (tactus - прикосновение, толчок, удар), эмоция когда "музыка качает".
Помимо игрового, у игры может быть как графический дизайн, так и звуковой. Каждый из этих компонент игры можно улучшить, однако это требует определенных навыков в этой области. В каждой игре есть свои наиболее значимые части, сильные и слабые стороны. Проблемы и их решения. Как и причины, по которым в них будут играть. Игра - может быть предметом искусства, сочетающая в себе огромное множество других предметов искусств: живописи, музыки, песнь, поэзии и прозы, инженерного мастерства, кино и т.д. Так же, как и множество новых способов взаимодействия с чувствами игрока, например тактильные (вибромотор, лазерный пистолет, контроллер и т.д.) или пространственные (очки виртуальной реальности).
Сам процесс написания кода и код игры тоже можно улучшать. Поделюсь некоторыми на мой взгляд полезными в этом деле инструментами, которые я использовал во время написания статьи.
Редактор кода Visual Studio Code https://code.visualstudio.com/
Онлайн генератор музыки Online Sequencer https://onlinesequencer.net/
Стандартный 2D контекст сильно ограничен и не производителен, поэтому я не рекомендую использовать его в профессиональной разработке игр. С появлением WebGL - игры в браузере могут быть намного более производительными, а значит - более наворочены графически. Я его в статье не использовал, т.к. он довольно сложный. Однако, его можно использовать так:
const canvas = document.getElementById("myCanvas");
const gl = canvas.getContext("webgl");
Есть две популярные библиотеки для работы в webgl, которые упрощают работу:
https://threejs.org/ для 3D
https://pixijs.com/ для 2D
Фидбэк
Обратный отклик необычайно важен в разработке игр. Это отдельная большая тема маркетинга, QA, самой разработки. Даже если фидбэк (feed - подавать, back - назад) - это мат наполовину, он может быть намного важнее, чем простое "Клевая игра, мне понравилось". Да, чувство признания - это механика игры жизни, но фидбэк в разработке не для этого.
В маркетинге в качестве фидбэка даже собирают действия пользователя. Когда игрок запустил игру, сколько уровней смог пройти, как быстро прошел до конца. Собрать статистику и на основе анализа этих данных - принимать важные решения, которые сделают игру многократно лучше.
Обратный отклик №1
На основе обратного отклика - игра впоследствии может еще неоднократно полироваться. Я дождался первого комментария от @Zara6502 и доработал статью и саму игру. Что я понял из обратной связи:
Прямоугольник для "отличника" слишком быстро пропадает. Поэтому сделал его перманентным.
Несколько одинаковых серых прямоугольников - была неоднозначность, стал закрашивать предыдущий зеленым при успехе.
Чтобы улучшить понимание механики прогресса - перманентно вывожу количество очков на экран.
Как я и написал выше - это процесс бесконечный. Важно, чтобы негативный фидбэк вызывал синее чувство, а не красное. Ведь наша жизнь - тоже своего рода игра. Фиксы по этому фидбэку внес в игру.
Обратный отклик №2
На мобильных устройствах всплыла проблема адаптивности. Элементы не помещаются в экран. Это решается адаптивным холстом с фиксированным расширением. Проблема предвидимая, решается тестированием.
Игроку не понятно, когда появляются новые "квадраты". Это происходит при +11 и выше очков. Механика скрытая, а должна быть явная, т.к. игрок не понимает условие награды.
Подсказали крутую и оригинальную идею по изменению механики. Вполне вероятно - попробую, но это уже совсем другая история ;)
QA
Где попробовать? Выложил на itch.io. Максимум набрал 8 очков.