Статья - рефлексия на тему игр как обучения, а игра в ней - результат этой рефлексии. В статье так же расскажу наиболее простой способ написания игры (на HTML5), инструменты и современные подходы. Для разработки нужны минимум блокнот и браузер, никакого дополнительного ПО. Запускаться игра будет в HTML, на любом устройстве с браузером (вплоть до телевизора).

Статью написал в рамках подготовки к пятничному игровому джему (GMTK Game Jam 2023). Никогда в них не участвовал, решил проверить, что успею запилить хоть что-то за пару дней и по-рефлексировать на тему. Поболейте за меня или сами поучаствуйте. Написанная в статье игра - не для джема, а для статьи, ссылка в конце. Игра из джема уже готова, можно ознакомится тут.

Игра

Немного майевтики. Прежде чем начать, ответим на вопрос - зачем делать игру вообще? И что такое игра?

В игры играют не только люди, но и животные. В статье игры в которые играют животные приводятся примеры игр птиц, рыб и даже насекомых. Авторы: Lee Alan Dugatkin, степень доктора и профессора в биологии, Sarina M. Rodrigues нейробиолог из университета Беркли (входит в топ 5 лучших университетов мира). Следовательно, делаем вывод, что:

Игра - инстинктивный способ обучения, мы делаем игру для того, чтобы чему-то научить. Играть - интересно, первостепенная эмоция в любой игре - любопытство. Поэтому "пройденные" игры, изученные вдоль и поперек перестают интересовать. Если игрок "переобучен" - игра ничему не сможет научить и ему будет скучно. Если игрок "не до обучен" - у него может не быть тех навыков и знаний, которые требуются для игры. Крайние примеры: "Игры про Кузю" и "Dawrf Fortress".

Например, я "прошел" крестики-нолики. Х ставится в центре, второй Х - по диагонали. Для 0 - по диагонали, второй 0 - в ряд с двумя Х. Всего вариантов исхода менее 100, просчитать всю игру заранее можно даже в уме по алгоритму минимакса. Если оба игрока "прошли" игру - всегда будет ничья. Однако, если второй игрок не "прошел" игру, то первому может быть интересно побеждать. Но это уже социальная механика игры.

Если игроку не интересно чему-то учиться - нужно заинтересовать его механиками (mech; megʰ - мочь), т.е. "мощью" игры.

2048

Хороший пример: игра 2048. Она учит степеням двойки, предсказывать свои действия, принимать стратегические решения, свайпать. Но игра использует механику вызова (challenge): "Набери 2048".

Время койота

Игрок осознает и эмоционально реагирует на механики (re - обратно, *ag- гнать, двигать). Сама тема эмоций - огромна, но мы классифицируем их все на два типа. Позитивные - обучение прошло успешно, негативные - есть проблемы. Если игра не объясняет механики, то они называются скрытыми, на них игрок реагирует бессознательно. Например "Время койота".

Механики

Найди Вальдо

Помимо обучения игры дополняют эстетическими, социальными и материальными механиками. Социальные вызывают эмоции чувства принадлежности, чувства признания; материальные - азарт и т.д. Чаще всего - механики берут уже из известных игр, которые себя зарекомендовали, т.к. это бесконечно проще, чем изобрести новую, успешную механику. Обычно, совокупность механик называют жанр игры. Например, жанр "квест" (quest - поиски). Как следует из названия - это механика поиска чего либо. Например, "Найди Вальдо".

Интерактивность (interaction, inter - посреди, act - действие) - это про события. Игра запустилась, прошло время, тык пальцем/мышкой в экран. На эти события есть реакции игры (re - обратно, act - действие). В контексте игрока, событием будет появление на экране двух прямоугольников, а его реакцией будет эмоция (emotion, e - наружу, moveo - двигаться). В зависимости от эмоции - действие, событие в контексте игры. Больше интерактивности - лучше раскрывается механика.

Геймдизайн

Но как чему-то научить? Исходя из теорий бихевиоризма - через позитивное и негативное подкрепление. Причем, позитивное - намного эффективнее негативного.

Допустим, мы хотим научить игрока нажимать на закрашенный прямоугольник на экране. Будем рассматривать два события: игрок успел нажать на прямоугольник, или - не успел. Если игрок успел - успокаиваем и удивляем, если нет - злим. Допустим, синий - безопасный и приятный цвет (спокойствие ?), красный - опасный и неприятный цвет (злость ?). игрок справился отлично - постараемся его удивить зеленым ?. И на добивочку фиолетовый ?.

Сама тема цвета и его применения - огромна. Например, в США есть розовые тюрьмы. Согласно исследованиям - человеческий глаз наиболее восприимчив к зелёному участку спектра. Красный - кровь, синий - небо. Но это уже из области знаний о графическом дизайне. Мало кто замечал, но у многих успешных компаний синий логотип. Я инженер, поэтому буду использовать палитру "Инженерная".

Механика

Согласно отчету Contentsquare 2021 Digital Experience Benchmark, среднее по больнице время просмотра одной страницы составляет 54 секунды.

Считаем количество успехов (successes) и неудач (fails). Если игрок справился, т.е. достиг максимального успеха (wins) - игрок обучен, игра окончена. Если же игрок не справляется - то ему нужно помочь. Если игрок достиг максимального числа неудач (looses) мы поможем ему и подставим прямоугольник под указатель, чтобы у него точно получилось, и сбрасываем число неудач. Это нужно, чтобы улучшить понимание механики. Таким образом мы объясняем, как оно работает. Есть вероятность, что мы не смогли вызвать эмоцию - тогда и сама механика в целом не будет работать. Помним закон Мерфи. Палитра "Инженерная"

Чтобы проверить, что игрок действительно научился - будем считать, сколько раз он попал. Чем больше попыток потребуется - тем большим упорством должен обладать игрок и зависит от заинтересованности игрока. Будем считать, что ожидаемый интерес к нашей игре 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'
}

2D

Нарисуем на холсте два прямоугольника (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)

Полировка

После того, как механики готовы - можно приступать к "полировке" игры. Процесс разработки - бесконечен. Всегда есть что улучшить, появляются новые инструменты, новые возможности игровых систем, новые знания и навыки. Для создания и запуска игры из статьи - нужен только браузер и блокнот. Запустить можно в браузере, а это ПО есть в подавляющем числе пользовательских систем.

OSU

Однако, если есть желание и умение - даже игру с готовой механикой можно улучшить усилением эмоционального отклика (как в примере с добавлением звука). Это может быть как и графическое улучшение, так и звуковое. Можно усовершенствовать или добавить новые механики. Заменить прямоугольники на изображения, добавить фоновую музыку, добавить элементы управления и подсчета очков; мультиплеерное взаимодействие (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