Статья - рефлексия на тему игр как обучения, а игра в ней - результат этой рефлексии. В статье так же расскажу наиболее простой способ написания игры (на 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 очков.
