Создаём прототип музыкальной игры (ритм-игра)

Привет всем :ghost:.
В этом посте мы создадим прототип механики к музыкальной игре(ритм-игре).

Просмотреть и скачать исходный проект можно по этой ссылке:

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

За образец возьмём игру Guitar Band (первая попавшаяся игра в Play Market :grinning_face_with_smiling_eyes:)

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

В конце этого “урока” у нас получится примерно такой результат:
Result

Прежде чем начать разрабатывать прототип, я создам пустой проект и в нём разверну git-репозиторий, для возможности сохранять изменения и в случае чего, чтобы была возможность вернуться к старым коммитам. Если вы не знаете как развернуть git-репозиторий в вашем проекте, ознакомьтесь с Система контроля версий Git.
(Я постараюсь сопровождать использование git скриншотами, но некоторые моменты в использовании git я не буду упоминать. Например, если я пишу: “закоммитим изменения”, но не пишу, что я добавил файлы в индекс. Я имею в виду также и то, что я предварительно добавил файлы в индекс).

Создание проекта

Создаём новый пустой проект:


Инициализируем git-репозиторий. Для этого зайдём в папку с проектом:

Затем открываем git bash через контекстное меню:

Создаём git-репозиторий:
image
Проверяю git-репозиторий на изменения:

Создаём первый коммит, предварительно добавив файлы в индекс:

Создадим папку с ресурсами(правый клик → New folder):



В этой папке мы будем хранить ресурсы, которые будем использовать в нашем проекте. Например, аудиофайлы, изображения, шрифты и т.д.
Добавим в папку images файлы с изображениями нот, различных кнопок, а в папку music добавим песенку.
Должно получиться примерно так:


Теперь нам нужно создать atlas, чтобы мы могли использовать наши изображения в игре:

Откроем файл .gitignore и впишем в него путь к папке: main/assets:


Теперь при добавлении файлов в индекс, эта папка будет игнорироваться:

Создание контроллера (базовое перемещения между меню и игрой)

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

Создадим папку и в нём скриптовый файл controller.script:


Создадим две новые коллекции в папке main:
image

Переименуем main.collection на controller.collection и создадим в этой коллекции подобную иерархию:


Не забудьте сменить в Properties имя на controller:

Создавая прокси-коллекию, не забудьте указать путь к соответствующей коллекции:

Создадим gui-сцены и скрипты к некоторым из них:

Изменим разрешение экрана(в идеале, нужно создавать разные display_profiles для различных ориентаций экрана или устройств, но в рамках этого урока ограничимся значениями 720x1080):



Вернемся к gui-файлам и настроем их. Перейдём в play_button.gui и добавим атлас в качестве текстуры:

Добавим шрифт по умолчанию:


Создадим кнопку:


Сначала измените размер box-ноды, а затем text-ноды. У вас должно получиться примерно так(используй горячие клавишы: W, E, R):

Добавьте кнопку самостоятельно для resume_button.gui:

Теперь перейдём в menu.gui и game.gui и добавим соответствующие template-gui, созданные ранее:



Перейдём в menu.collection и создадим игровой объект gui, добавив в него gui-сцену:




Сделай тоже самое и для game.collection:

Откроем ранее созданный скриптовый файл controller.script и заменим его код на этот:

-- Функция инициализации, вызывается при старте скрипта
function init(self)
    -- Отправляет сообщение текущему игровому объекту (".") для получения фокуса ввода
    -- Это позволяет игровому объекту обрабатывать события ввода (например, клавиатура, мышь)
    msg.post(".", "acquire_input_focus")
    
    -- Отправляет сообщение игровому объекту с идентификатором "#proxy_menu" для загрузки
    -- Это инициирует загрузку компонента меню (например, коллекции объектов меню)
    msg.post("#proxy_menu", "load")
end

-- Функция завершения, вызывается при уничтожении или отключении скрипта
function final(self)
    -- Отправляет сообщение текущему игровому объекту (".") для освобождения фокуса ввода
    -- Это прекращает обработку событий ввода текущим объектом
    msg.post(".", "release_input_focus")
end

-- Функция обработки входящих сообщений
-- Параметры:
-- self: ссылка на текущий скрипт
-- message_id: идентификатор сообщения (хэш)
-- message: данные сообщения
-- sender: отправитель сообщения
function on_message(self, message_id, message, sender)
    -- Проверяет, является ли сообщение командой "show_game"
    if message_id == hash("show_game") then
        -- Загружает компонент игры (например, коллекцию объектов игрового уровня)
        msg.post("#proxy_game", "load")
        -- Вызывает функцию завершения для компонента меню
        msg.post("#proxy_menu", "final")
        -- Отключает компонент меню (делает его неактивным)
        msg.post("#proxy_menu", "disable")
        -- Выгружает компонент меню из памяти
        msg.post("#proxy_menu", "unload")
    
    -- Проверяет, является ли сообщение командой "show_menu"
    elseif message_id == hash("show_menu") then
        -- Загружает компонент меню
        msg.post("#proxy_menu", "load")
        -- Вызывает функцию завершения для компонента игры
        msg.post("#proxy_game", "final")
        -- Отключает компонент игры (делает его неактивным)
        msg.post("#proxy_game", "disable")
        -- Выгружает компонент игры из памяти
        msg.post("#proxy_game", "unload")
    
    -- Проверяет, является ли сообщение уведомлением о завершении загрузки прокси
    elseif message_id == hash("proxy_loaded") then
        -- Выводит в консоль информацию о том, что прокси загружен, и указывает отправителя
        print("proxy_loaded", sender)
        -- Инициализирует загруженный прокси (вызывает его функцию init)
        msg.post(sender, "init")
        -- Активирует загруженный прокси (делает его активным)
        msg.post(sender, "enable")
    
    -- Проверяет, является ли сообщение уведомлением о завершении выгрузки прокси
    elseif message_id == hash("proxy_unloaded") then
        -- Выводит в консоль информацию о том, что прокси выгружен, и указывает отправителя
        print("proxy_unloaded", sender)
    end
end

Теперь сохраним проект и запустим его с помощью комбинации клавиш ctrl + B:

Если ваши пиксели отображаются смазано на игровой сцене:


Зайдите в game.project, в раздел Graphics, установите Default Texture Mag Filter на nearest:

Мы проделали хорошую работу, нужно закоммитить эти изменения:


Добавление курсора

Не будем изобретать велосипед, добавим библиотеку Defold Input от britzl в наш проект:


Может быть полезно: Как добавить библиотечную зависимость в проект на Defold
Ссылка на репозиторий:
GitHub - britzl/defold-input: Simplify input related operations such as gesture detection, input mapping and clicking/dragging game objects
Если вы все сделали правильно, в вашем проекте появилась папка:
image
В этом уроке не буду разбирать как создавать курсор, для этого я создал специальную тему:
Как создать курсор, перетаскивание объектов с помощью Defold Input от britzl
Я создал курсор и добавил его в game.script:

Не забудьте снять галочку с опции drag(вспомнил только на момент редактирования поста :sweat_smile:):

Добавим скрипты для gui-сцен:



В menu.gui_script добавим такой код:

-- Инициализация GUI-скрипта
function init(self)
    -- Захватываем фокус ввода для текущего объекта (".") для обработки событий ввода (например, касаний или кликов)
    msg.post(".", "acquire_input_focus")
    -- Сохраняем ссылку на узел кнопки паузы/возобновления ("resume_pause_button/box") в переменную self.pause_resume_button
    -- Это позволяет обращаться к кнопке в других функциях
    self.pause_resume_button = gui.get_node("resume_pause_button/box")
end

-- Финализация GUI-скрипта (вызывается при уничтожении объекта)
function final(self)
    -- Освобождаем фокус ввода, чтобы текущий объект больше не обрабатывал события ввода
    msg.post(".", "release_input_focus")
end

-- Обработка событий ввода (например, касаний или кликов)
function on_input(self, action_id, action)
    -- Проверяем, было ли нажатие (action.pressed) и находится ли курсор/касание в пределах узла кнопки паузы/возобновления
    if action.pressed and gui.pick_node(self.pause_resume_button, action.x, action.y) then
        -- Отправляем сообщение объекту с URL "controller:/controller" для вызова действия "show_menu"
        -- Это инициирует переход в главное меню
        msg.post("controller:/controller", "show_menu")
        -- Выводим отладочное сообщение в консоль для подтверждения действия
        print("Возврат в главное меню")
    end

Запускаем проект:
play_and_return

Добавим изменения в индекс и сделаем коммит:
image


Иногда некоторые файлы приходится вписывать по отдельности:
image

Добавим игровые состояния

В нашей игре будет три состояния: main_menu, playing, paused.
main_menu — когда находимся в главном меню.
playing — в данный момент играем.
paused — когда поставили игру на паузу.
Мы создадим Lua-модуль, который будет содержать код для работы с состояниями нашей игры.

В папке main создадим папу modules, в этой папке создадим Lua модуль game_state.lua:


Добавим код в game_state.lua:

-- Создаём модуль для управления состояниями игры
local M = {}

-- Определяем начальное состояние игры как "main_menu"
-- Возможные состояния: "main_menu" (главное меню), "playing" (игра активна), "paused" (игра на паузе)
M.state = "main_menu"

-- Функция для установки нового состояния игры
function M.set_state(new_state)
    -- Если устанавливается состояние "paused" (пауза)
    if new_state == "paused" then
        -- Отправляем сообщение объекту "controller:/controller" для установки временного шага в 0 (пауза физики/анимации)
        -- factor = 0 останавливает обновление, mode = 1 указывает на непрерывный режим
        msg.post("controller:/controller", "set_time_step", { factor = 0, mode = 1 })
        -- Выводим отладочное сообщение с новым состоянием
        print("GAME_STATE: " .. new_state)
    -- Если устанавливается состояние "playing" (игра активна)
    elseif new_state == "playing" then
        -- Отправляем сообщение для установки временного шага в 1 (нормальное обновление игры)
        msg.post("controller:/controller", "set_time_step", { factor = 1, mode = 1 })
        -- Выводим отладочное сообщение с новым состоянием
        print("GAME_STATE: " .. new_state)
    -- Если устанавливается состояние "main_menu" (главное меню)
    elseif new_state == "main_menu" then
        -- Никаких дополнительных сообщений не отправляем, только логируем
        print("GAME_STATE: " .. new_state)
    end
    -- Обновляем текущее состояние модуля
    M.state = new_state
end

-- Функция для получения текущего состояния игры
function M.get_state()
    -- Возвращаем текущее значение M.state
    return M.state
end

-- Функция проверки, находится ли игра в состоянии "main_menu"
function M.is_main_menu()
    -- Возвращает true, если текущее состояние — "main_menu", иначе false
    return M.state == "main_menu"
end

-- Функция проверки, находится ли игра в состоянии "playing"
function M.is_playing()
    -- Возвращает true, если текущее состояние — "playing", иначе false
    return M.state == "playing"
end

-- Функция проверки, находится ли игра на паузе
function M.is_paused()
    -- Возвращает true, если текущее состояние — "paused", иначе false
    return M.state == "paused"
end

-- Функция для перехода в главное меню
function M.to_main_menu()
    -- Устанавливаем состояние "main_menu" через set_state
    M.set_state("main_menu")
end

-- Функция для начала игры
function M.start_game()
    -- Устанавливаем состояние "playing" через set_state
    M.set_state("playing")
end

-- Функция для постановки игры на паузу
function M.pause()
    -- Проверяем, что игра в состоянии "playing", чтобы избежать паузы из других состояний
    if M.is_playing() then
        -- Устанавливаем состояние "paused" через set_state
        M.set_state("paused")
    end
end

-- Функция для возобновления игры
function M.resume()
    -- Проверяем, что игра в состоянии "paused", чтобы возобновить только из паузы
    if M.is_paused() then
        -- Устанавливаем состояние "playing" через set_state
        M.set_state("playing")
    end
end

-- Функция для переключения между паузой и игрой
function M.toggle_pause()
    -- Если игра на паузе, возобновляем её
    if M.is_paused() then
        M.resume()
    -- Если игра активна, ставим на паузу
    elseif M.is_playing() then
        M.pause()
    end
end

-- Возвращаем модуль для использования в других скриптах
return M

Изменим файл controller.script:

-- Подключает модуль game_state, который, вероятно, отвечает за управление состоянием игры
-- (например, отслеживание, находится ли игра в состоянии меню или игрового процесса)
local GAME_STATE = require("main.modules.game_state")

-- Функция инициализации, вызывается при старте скрипта
function init(self)
    -- Отправляет сообщение текущему игровому объекту (".") для получения фокуса ввода
    -- Это позволяет объекту обрабатывать события ввода (например, клавиатура, мышь)
    msg.post(".", "acquire_input_focus")
    
    -- Отправляет сообщение игровому объекту с идентификатором "#proxy_menu" для загрузки
    -- Это инициирует загрузку компонента меню (например, коллекции объектов меню)
    msg.post("#proxy_menu", "load")
end

-- Функция завершения, вызывается при уничтожении или отключении скрипта
function final(self)
    -- Отправляет сообщение текущему игровому объекту (".") для освобождения фокуса ввода
    -- Это прекращает обработку событий ввода текущим объектом
    msg.post(".", "release_input_focus")
end

-- Функция обработки входящих сообщений
-- Параметры:
-- self: ссылка на текущий скрипт
-- message_id: идентификатор сообщения (хэш)
-- message: данные сообщения
-- sender: отправитель сообщения
function on_message(self, message_id, message, sender)
    -- Проверяет, является ли сообщение командой "show_game"
    if message_id == hash("show_game") then
        -- Загружает компонент игры (например, коллекцию объектов игрового уровня)
        msg.post("#proxy_game", "load")
        -- Вызывает функцию завершения для компонента меню
        msg.post("#proxy_menu", "final")
        -- Отключает компонент меню (делает его неактивным)
        msg.post("#proxy_menu", "disable")
        -- Выгружает компонент меню из памяти
        msg.post("#proxy_menu", "unload")
        -- Проверяет текущее состояние игры через модуль GAME_STATE
        -- Если состояние не равно "playing", устанавливает состояние "playing"
        if GAME_STATE.get_state() ~= "playing" then
            GAME_STATE.set_state("playing")
        end
    
    -- Проверяет, является ли сообщение командой "show_menu"
    elseif message_id == hash("show_menu") then
        -- Загружает компонент меню
        msg.post("#proxy_menu", "load")
        -- Вызывает функцию завершения для компонента игры
        msg.post("#proxy_game", "final")
        -- Отключает компонент игры (делает его неактивным)
        msg.post("#proxy_game", "disable")
        -- Выгружает компонент игры из памяти
        msg.post("#proxy_game", "unload")
        -- Проверяет текущее состояние игры через модуль GAME_STATE
        -- Если состояние не равно "main_menu", устанавливает состояние "main_menu"
        if GAME_STATE.get_state() ~= "main_menu" then
            GAME_STATE.set_state("main_menu")
        end
    
    -- Проверяет, является ли сообщение уведомлением о завершении загрузки прокси
    elseif message_id == hash("proxy_loaded") then
        -- Выводит в консоль информацию о том, что прокси загружен, и указывает отправителя
        print("proxy_loaded", sender)
        -- Инициализирует загруженный прокси (вызывает его функцию init)
        msg.post(sender, "init")
        -- Активирует загруженный прокси (делает его активным)
        msg.post(sender, "enable")
    
    -- Проверяет, является ли сообщение уведомлением о завершении выгрузки прокси
    elseif message_id == hash("proxy_unloaded") then
        -- Выводит в консоль информацию о том, что прокси выгружен, и указывает отправителя
        print("proxy_unloaded", sender)
    end
end

Перейдём в game.gui и создадим в Nodes box ноду, должно получиться примерно так(не забудьте добавить шрифт и текстуру):

На этом этапе мы создадим gui-ноду и изменим gui-скрипт, с помощью которых мы сможем кликать по gui-ноде и отправлять сообщение контроллеру о том, что нужно перейти в главное меню.
Для этого откроем скриптовый файл, прикрепленный к game.gui:
Измените код:

-- Подключаем модуль для управления состояниями игры
local GAME_STATE = require("main.modules.game_state")

-- Инициализация скрипта контроллера
function init(self)
    -- Захватываем фокус ввода для текущего объекта (".") для обработки событий ввода
    msg.post(".", "acquire_input_focus")
    -- Отправляем сообщение для загрузки прокси-объекта главного меню
    -- Это инициирует загрузку сцены главного меню (например, menu.collection)
    msg.post("#proxy_menu", "load")
end

-- Финализация скрипта (вызывается при уничтожении объекта)
function final(self)
    -- Освобождаем фокус ввода, чтобы текущий объект больше не обрабатывал события ввода
    msg.post(".", "release_input_focus")
end

-- Обработка входящих сообщений
function on_message(self, message_id, message, sender)
    -- Обработка сообщения "show_game" (переход к игровой сцене)
    if message_id == hash("show_game") then
        -- Загружаем прокси-объект игровой сцены (например, game.collection)
        msg.post("#proxy_game", "load")
        -- Вызываем финализацию главного меню (выполняет final() в скриптах меню)
        msg.post("#proxy_menu", "final")
        -- Отключаем прокси-объект главного меню (делает его неактивным)
        msg.post("#proxy_menu", "disable")
        -- Выгружаем прокси-объект главного меню из памяти
        msg.post("#proxy_menu", "unload")
        -- Если текущее состояние не "playing", устанавливаем его через GAME_STATE
        -- Это синхронизирует состояние игры с игровой сценой
        if GAME_STATE.get_state() ~= "playing" then
            GAME_STATE.set_state("playing")
        end
    -- Обработка сообщения "show_menu" (переход к главному меню)
    elseif message_id == hash("show_menu") then
        -- Загружаем прокси-объект главного меню
        msg.post("#proxy_menu", "load")
        -- Вызываем финализацию игровой сцены (выполняет final() в скриптах игры)
        msg.post("#proxy_game", "final")
        -- Отключаем прокси-объект игровой сцены
        msg.post("#proxy_game", "disable")
        -- Выгружаем прокси-объект игровой сцены из памяти
        msg.post("#proxy_game", "unload")
        -- Если текущее состояние не "main_menu", устанавливаем его через GAME_STATE
        -- Это синхронизирует состояние игры с главным меню
        if GAME_STATE.get_state() ~= "main_menu" then
            GAME_STATE.set_state("main_menu")
        end
    -- Обработка сообщения "proxy_loaded" (прокси-объект загружен)
    elseif message_id == hash("proxy_loaded") then
        -- Логируем, какой прокси-объект был загружен (sender — URL прокси, например, #proxy_menu или #proxy_game)
        print("proxy_loaded", sender)
        -- Инициируем загруженный прокси-объект (вызывает init() в скриптах сцены)
        msg.post(sender, "init")
        -- Активируем прокси-объект (делает сцену видимой и активной)
        msg.post(sender, "enable")
    -- Обработка сообщения "proxy_unloaded" (прокси-объект выгружен)
    elseif message_id == hash("proxy_unloaded") then
        -- Логируем, какой прокси-объект был выгружен
        print("proxy_unloaded", sender)
    end
end

game_state
Посмотрим на консоль и увидим изменения игровых состояний:


Добавим изменения в индекс и закоммитим изменения:


Расставим клавиши

Наша задача на этом этапе расставить игровые объекты — кнопки, в сторону которых будут двигаться ноты, в будущем.

Для этого создадим игровой объект button.go в main и скрипт button.script в scripts:
image
Структура компонентов игрового объекта:


Не забудьте растянуть объект столкновения до границ спрайта:

Создаём еще один скрипт, назовём его controller_game:
image
Переходим в game.collection и создаём в сцене игровой объект game, добавляя компоненты фабрика(прототипом выбираем button.go) и компонент скрипт:
image
Перейдём в game.controller и введём такой код:

local GAME_STATE = require "main.modules.game_state"

function init(self)
	GAME_STATE.set_state("playing")

	self.button_spawn_position = {
		vmath.vector3(100, 90, 0),
		vmath.vector3(270, 90, 0),
		vmath.vector3(440, 90, 0),
		vmath.vector3(610, 90, 0)
	}
	-- Спавн кнопок
	for i = 1, 4 do
		local id = factory.create("#buttonfactory", self.button_spawn_position[i], nil, {}, 2)
		sprite.play_flipbook(id, "button_" .. i)
	end
end

Сохраняем проект и запускаем:


Перейдём в button.script и вставим такой код:

-- Подключает модуль cursor, который предоставляет функциональность  взаимодействия с курсором
local cursor = require "in.cursor"

-- Функция обработки входящих сообщений
-- Параметры:
-- self: ссылка на текущий скрипт
-- message_id: идентификатор сообщения (хэш)
-- message: данные сообщения (например, дополнительные параметры, такие как группа объектов)
-- sender: отправитель сообщения
function on_message(self, message_id, message, sender)
    -- Проверяет, является ли сообщение событием "trigger_response"
    -- (это событие физического столкновения или триггера в игре)
    if message_id == hash("trigger_response") then
    -- Проверяет, является ли сообщение событием нажатия курсора
    -- (например, клик мыши или касание экрана, определённое в модуле cursor как cursor.PRESSED)
    elseif message_id == cursor.PRESSED then
        -- Проверяет, принадлежит ли объект, на который нажали, группе с идентификатором "button"
        if message.group == hash("button") then
            -- Выводит в консоль сообщение о том, что была нажата кнопка
            -- go.get_id() возвращает идентификатор текущего игрового объекта
            print("Button pressed: " .. tostring(go.get_id()))
        end
    -- Проверяет, является ли сообщение событием отпускания курсора
    -- (например, отпускание кнопки мыши или завершение касания, определённое как cursor.RELEASED)
    elseif message_id == cursor.RELEASED then
    end
end

Не забудьте установить маски и группы для объектов столкновения, для курсора(в game.collection) и button.go. У вас должен выводиться такой текст, когда вы кликаете по кнопкам:
image
Сделаем коммит:

Создание audio_manager

Во время игры будет играть песня, создадим Lua-модуль, аналогичный game_state.lua для управления музыкальными композициями.

Создадим в папке modules модуль audio_manager.lua:

Добавим туда код:

-- Создаёт локальную таблицу M, которая будет содержать функции модуля и возвращаться в конце
local M = {}

-- Таблица для хранения информации о звуковых треках
local tracks = {
    -- Пример трека с именем "music"
    music = {
        url = "/track#sound", -- Путь к звуковому ресурсу (например, в Defold это ссылка на звуковой объект)
        loop = false,         -- Флаг зацикливания трека (отключен для теста)
        state = "stopped"    -- Текущее состояние трека (остановлен)
    }
}

-- Функция для воспроизведения звукового трека
-- Аргумент: name - имя трека
function M.play(name)
    -- Получает трек из таблицы tracks по имени
    local track = tracks[name]
    -- Проверяет, существует ли трек
    if not track then 
        -- Если трек не найден, выводит ошибку в консоль и завершает выполнение
        print("Audio error: Track not found: " .. tostring(name))
        return 
    end
    -- Выводит в консоль информацию о начале воспроизведения трека
    print("Playing track: " .. name .. ", url: " .. track.url)
    -- Запускает воспроизведение трека с указанным URL и параметром зацикливания
    sound.play(track.url, { loop = track.loop })
    -- Обновляет состояние трека на "playing" (воспроизводится)
    track.state = "playing"
end

-- Функция для приостановки воспроизведения трека
-- Аргумент: name - имя трека
function M.pause(name)
    -- Получает трек из таблицы tracks по имени
    local track = tracks[name]
    -- Проверяет, существует ли трек и находится ли он в состоянии воспроизведения
    if not track or track.state ~= "playing" then 
        -- Если трек не найден или не воспроизводится, выводит предупреждение и завершает выполнение
        print("Audio warning: Cannot pause track: " .. tostring(name) .. ", state: " .. tostring(track and track.state or "nil"))
        return 
    end
    -- Выводит в консоль информацию о приостановке трека
    print("Pausing track: " .. name)
    -- Приостанавливает воспроизведение трека
    sound.pause(track.url)
    -- Обновляет состояние трека на "paused" (приостановлен)
    track.state = "paused"
end

-- Функция для возобновления воспроизведения приостановленного трека
-- Аргумент: name - имя трека
function M.resume(name)
    -- Получает трек из таблицы tracks по имени
    local track = tracks[name]
    -- Проверяет, существует ли трек и находится ли он в состоянии паузы
    if not track or track.state ~= "paused" then 
        -- Если трек не найден или не приостановлен, выводит предупреждение и завершает выполнение
        print("Audio warning: Cannot resume track: " .. tostring(name) .. ", state: " .. tostring(track and track.state or "nil"))
        return 
    end
    -- Выводит в консоль информацию о возобновлении трека
    print("Resuming track: " .. name)
    -- Возобновляет воспроизведение трека, снимая паузу
    sound.set_paused(track.url, false)
    -- Обновляет состояние трека на "playing" (воспроизводится)
    track.state = "playing"
end

-- Функция для остановки воспроизведения трека
-- Аргумент: name - имя трека
function M.stop(name)
    -- Получает трек из таблицы tracks по имени
    local track = tracks[name]
    -- Проверяет, существует ли трек
    if not track then 
        -- Если трек не найден, выводит ошибку в консоль и завершает выполнение
        print("Audio error: Track not found: " .. tostring(name))
        return 
    end
    -- Выводит в консоль информацию об остановке трека
    print("Stopping track: " .. name)
    -- Останавливает воспроизведение трека
    sound.stop(track.url)
    -- Обновляет состояние трека на "stopped" (остановлен)
    track.state = "stopped"
end

-- Функция для получения текущего состояния трека
-- Аргумент: name - имя трека
-- Возвращает: состояние трека или "unknown", если трек не найден
function M.get_state(name)
    -- Получает трек из таблицы tracks по имени
    local track = tracks[name]
    -- Проверяет, существует ли трек
    if not track then 
        -- Если трек не найден, выводит ошибку и возвращает "unknown"
        print("Audio error: Track not found: " .. tostring(name))
        return "unknown" 
    end
    -- Возвращает текущее состояние трека
    return track.state
end

-- Функция для установки громкости трека
-- Аргументы: name - имя трека, value - уровень громкости
function M.set_gain(name, value)
    -- Получает трек из таблицы tracks по имени
    local track = tracks[name]
    -- Проверяет, существует ли трек
    if not track then 
        -- Если трек не найден, выводит ошибку и завершает выполнение
        print("Audio error: Track not found: " .. tostring(name))
        return 
    end
    -- Выводит в консоль информацию об установке громкости
    print("Setting gain for track: " .. name .. ", value: " .. value)
    -- Устанавливает громкость для трека
    sound.set_gain(track.url, value)
end

-- Функция для регистрации нового трека
-- Аргументы: name - имя трека, url - путь к звуковому ресурсу, loop - флаг зацикливания
function M.register(name, url, loop)
    -- Выводит в консоль информацию о регистрации трека
    print("Registering track: " .. name .. ", url: " .. url .. ", loop: " .. tostring(loop))
    -- Добавляет новый трек в таблицу tracks
    tracks[name] = {
        url = url,              -- Путь к звуковому ресурсу
        loop = loop or false,   -- Флаг зацикливания (по умолчанию false, если не указан)
        state = "stopped"       -- Начальное состояние трека (остановлен)
    }
end

-- Возвращает таблицу M, содержащую все функции модуля, чтобы их можно было использовать извне
return M

Перейдём controller_game.script и добавим в начале файла:

local AUDIO = require "main.modules.audio_manager" -- Подключаем audio_manager`

В конце функции init(..) добавим запустим песню:

AUDIO.play("music") - начинаем проигрывать песню

В функции update(..), в самом начале, добавим:

	if GAME_STATE.is_paused() then
        -- если игра на паузе, то ничего не делай
		return
	end

В функцию on_message(..) добавим:

	if GAME_STATE.get_state() == "paused" then
		if message_id == hash("pause_music") then
                        -- ставим песню на паузу
			AUDIO.pause("music")
		elseif message_id == hash("resume_music") then
                        -- убираем песню с паузы
			AUDIO.resume("music")
		elseif message_id == hash("play_music") then
                        -- начинаем проигрывать песню с самого начала
			AUDIO.play("music")
		end
		return
	end

В game.collection создадим игровой объект track и добавим компонент sound:


При запуске игры должна начать проигрывать песня. Стоит отметить, что Defold поддерживает только музыкальные файлы с формата ogg и wav(текущая версия Defold 1.10.13).
Сохранили файл в индекс и закоммитили изменения:

Забыл сказать, изменим код в game.gui_script, чтобы по нажатию gui-ноды песня ставилась на паузу или возобновлялась:

-- Подключает модуль game_state для управления состоянием игры (например, "playing", "paused", "main_menu")
local GAME_STATE = require "main.modules.game_state"
-- Подключает модуль audio_manager для управления звуковыми треками
local AUDIO = require "main.modules.audio_manager"

-- Функция инициализации, вызывается при старте скрипта
function init(self)
    -- Инициализирует флаг активности паузы как false (пауза не активна)
    self.pause_active = false
    -- Отправляет сообщение текущему игровому объекту (".") для получения фокуса ввода
    -- Это позволяет объекту обрабатывать события ввода (например, клики мыши или касания)
    msg.post(".", "acquire_input_focus")
    -- Получает узел GUI для кнопки паузы/возобновления (предположительно, кнопка с анимациями "pause" и "resume")
    self.pause_resume_button = gui.get_node("resume_pause_button/resume_pause")
    -- Получает узел GUI для кнопки возврата в главное меню
    self.menu_button = gui.get_node("back_in_menu")
    -- Активирует кнопку паузы/возобновления (делает её видимой и доступной для взаимодействия)
    gui.set_enabled(self.pause_resume_button, true)
    -- Деактивирует кнопку возврата в меню (скрывает или делает недоступной)
    gui.set_enabled(self.menu_button, false)
end

-- Функция завершения, вызывается при уничтожении или отключении скрипта
function final(self)
    -- Отправляет сообщение текущему игровому объекту (".") для освобождения фокуса ввода
    -- Это прекращает обработку событий ввода текущим объектом
    msg.post(".", "release_input_focus")
end

-- Функция обработки событий ввода (например, кликов мыши или касаний экрана)
-- Параметры:
-- self: ссылка на текущий скрипт
-- action_id: идентификатор действия ввода (например, нажатие кнопки)
-- action: данные действия (например, координаты клика, флаг нажатия)
function on_input(self, action_id, action)
    -- Проверяет, было ли действие ввода нажатием (например, клик мыши или касание экрана)
    if action.pressed then
        -- Проверяет, попал ли клик/касание на кнопку паузы/возобновления
        if gui.pick_node(self.pause_resume_button, action.x, action.y) then
            -- Переключает состояние игры (между "playing" и "paused") с помощью модуля GAME_STATE
            GAME_STATE.toggle_pause()
            -- Если игра перешла в состояние паузы
            if GAME_STATE.get_state() == "paused" then
                -- Приостанавливает воспроизведение звукового трека "music"
                AUDIO.pause("music")
                -- Устанавливает флаг паузы в true
                self.pause_active = true
                -- Проигрывает анимацию "resume" для кнопки паузы/возобновления
                -- (вероятно, это иконка "play" для возобновления игры)
                gui.play_flipbook(self.pause_resume_button, "resume")
                -- Активирует кнопку возврата в главное меню
                gui.set_enabled(self.menu_button, true)
            -- Если игра перешла в состояние воспроизведения
            elseif GAME_STATE.get_state() == "playing" then
                -- Возобновляет воспроизведение звукового трека "music"
                AUDIO.resume("music")
                -- Проигрывает анимацию "pause" для кнопки паузы/возобновления
                -- (вероятно, это иконка "pause" для приостановки игры)
                gui.play_flipbook(self.pause_resume_button, "pause")
                -- Деактивирует кнопку возврата в главное меню
                gui.set_enabled(self.menu_button, false)
            end
            -- Завершает обработку ввода, чтобы избежать дальнейших проверок
            return
        end
        -- Проверяет, попал ли клик/касание на кнопку возврата в главное меню
        if gui.pick_node(self.menu_button, action.x, action.y) then
            -- Переключает состояние игры на главное меню с помощью модуля GAME_STATE
            GAME_STATE.to_main_menu()
            -- Сбрасывает флаг паузы
            self.pause_active = false
            -- Деактивирует кнопку паузы/возобновления
            gui.set_enabled(self.pause_resume_button, false)
            -- Деактивирует кнопку возврата в главное меню
            gui.set_enabled(self.menu_button, false)
            -- Отправляет сообщение объекту "controller:/controller" для отображения главного меню
            msg.post("controller:/controller", "show_menu")
            -- Завершает обработку ввода
            return
        end
    end
end

Чтение файлов из JSON

На этом этапе мы добавим возможность читать JSON файлы. Чтобы в будущем знать биты громкости песни и на основе их выстраивать уровень.

Создадим папку data и также исключим её из git_ignore:


В этой папке будем хранить JSON(Знакомство с JSON в Defold) файлы, содержащие биты громкости на определенной секунде. Эта тема выходит за рамки этого поста, поэтому я не хочу заострять внимание на этом, чтобы урок не получился ещё больше. Скажу только то, что я это делал с помощью самостоятельно созданной программы на Python используя пакет Librosa.
JSON файл у меня получился примерно такой(там 511 строк кода):

Для примера можете сделать что-нибудь подобное:

{
    "tempo_bpm": 129.19921875,
    "beat_times": [
        8.4,
        8.9,
        9.4,
        9.8,
        10.0,
        12.0,
        12.5,
        13.7
    ]
}

Перейдём в game.collection и в init(..) добавим:

...
-- Загрузка данных песни из JSON-файла
-- Запоминает время начала загрузки для замера производительности
local start_load = os.clock()

-- Загружает содержимое файла song_data.json из папки /main/data/ с помощью функции Defold
local file = sys.load_resource("/main/data/song_data.json")

-- Выводит в консоль время, затраченное на загрузку файла (в секундах)
print("JSON load time: " .. (os.clock() - start_load) .. " seconds")

-- Проверяет, удалось ли загрузить файл
if not file then
    -- Если файл не загружен, выводит ошибку и завершает выполнение
    print("Ошибка: Не удалось загрузить файл song_data.json")
    return
end

-- Декодирует содержимое JSON-файла в Lua-таблицу
self.beatmap = json.decode(file)

-- Проверяет, успешно ли декодирован JSON и содержит ли он поле beat_times
if not self.beatmap or not self.beatmap.beat_times then
    -- Если JSON некорректен или отсутствует поле beat_times, выводит ошибку и завершает выполнение
    print("Ошибка: Некорректный формат JSON или отсутствует beat_times")
    return
end

-- Выводит первые 5 элементов массива beat_times для проверки содержимого
-- Использует table.concat для объединения значений в строку с запятыми
print("Первые 5 beat_times:", table.concat({unpack(self.beatmap.beat_times, 1, 5)}, ", "))

-- Создаёт пустую таблицу для хранения данных о нотах (beats)
self.beats = {}

-- Проходит по массиву beat_times из beatmap
for i, time in ipairs(self.beatmap.beat_times) do
    -- Для каждой временной метки создаёт запись о ноте и добавляет её в таблицу beats
    -- Каждая нота содержит:
    -- time: время появления ноты (из beat_times)
    -- track: случайная дорожка (от 1 до 4), на которой появится нота
    -- type: тип ноты (в данном случае всегда "tap", т.е. нажатие)
    table.insert(self.beats, { time = time, track = math.random(1, 4), type = "tap" })
end

-- Выводит общее количество нот, которые будут созданы (размер таблицы beats)
print("Количество нот для спавна:", #self.beats)
...

Перейдите в game.project и включите в Custom Resources ваш json файл:

Если вы запустите проект, то увидите отладочную информацию:

Закоммитим изменения:
image

Создание и движение нот

На этом этапе мы создадим движение нот в направлении кнопок.
Всё аналогично, как и п button.go, за исключением того, что мы не создаём скрипт для этого игрового объекта:


Создадим фабрику для нот в game.collection в game:

Добавим изменения в файл controller_game.script(итоговый текущий код):

-- Подключает модуль game_state для управления состоянием игры (например, "playing", "paused")
local GAME_STATE = require "main.modules.game_state"
-- Подключает модуль audio_manager для управления звуковыми треками
local AUDIO = require "main.modules.audio_manager"

-- Функция инициализации, вызывается при старте скрипта
function init(self)
    -- Устанавливает начальное состояние игры как "playing" (игра активна)
    math.randomseed(os.time()) -- Инициализирует генератор случайных чисел с текущим временем
    GAME_STATE.set_state("playing")

    -- Задаёт позиции спавна кнопок (4 кнопки на экране, расположенные по горизонтали)
    self.button_spawn_position = {
        vmath.vector3(100, 90, 0), -- Позиция 1-й кнопки
        vmath.vector3(270, 90, 0), -- Позиция 2-й кнопки
        vmath.vector3(440, 90, 0), -- Позиция 3-й кнопки
        vmath.vector3(610, 90, 0)  -- Позиция 4-й кнопки
    }
    
    -- Устанавливает скорость движения нот (в пикселях в секунду)
    self.speed = 300
    -- Создаёт пустую таблицу для хранения активных нот
    self.notes = {}
    -- Задаёт позиции дорожек, по которым движутся ноты (начальные позиции спавна нот)
    self.track = {
        vmath.vector3(100, 1100, 0), -- Позиция 1-й дорожки
        vmath.vector3(270, 1100, 0), -- Позиция 2-й дорожки
        vmath.vector3(440, 1100, 0), -- Позиция 3-й дорожки
        vmath.vector3(610, 1100, 0)  -- Позиция 4-й дорожки
    }
    
    -- Создаёт 4 кнопки с помощью фабрики #buttonfactory и устанавливает для них анимации
    for i = 1, 4 do
        -- Создаёт кнопку на заданной позиции с масштабом 2
        local id = factory.create("#buttonfactory", self.button_spawn_position[i], nil, {}, 2)
        -- Проигрывает анимацию для кнопки (например, "button_1", "button_2" и т.д.)
        sprite.play_flipbook(id, "button_" .. i)
    end

    -- Загрузка данных песни из JSON-файла
    local start_load = os.clock() -- Замеряет время начала загрузки
    local file = sys.load_resource("/main/data/song_data.json") -- Загружает JSON-файл
    print("JSON load time: " .. (os.clock() - start_load) .. " seconds") -- Выводит время загрузки
    if not file then
        -- Если файл не загружен, выводит ошибку и завершает выполнение
        print("Ошибка: Не удалось загрузить файл song_data.json")
        return
    end
    -- Декодирует JSON в таблицу Lua
    self.beatmap = json.decode(file)
    if not self.beatmap or not self.beatmap.beat_times then
        -- Если JSON некорректен или отсутствует beat_times, выводит ошибку и завершает
        print("Ошибка: Некорректный формат JSON или отсутствует beat_times")
        return
    end

    -- Выводит первые 5 временных меток для проверки содержимого beat_times
    print("Первые 5 beat_times:", table.concat({unpack(self.beatmap.beat_times, 1, 5)}, ", "))

    -- Создаёт таблицу beats для хранения данных о нотах
    self.beats = {}
    for i, time in ipairs(self.beatmap.beat_times) do
        -- Для каждой временной метки создаёт ноту с:
        -- time: время появления ноты
        -- track: случайная дорожка (1–4)
        -- type: тип ноты (в данном случае только "tap")
        table.insert(self.beats, { time = time, track = math.random(1, 4), type = "tap" })
    end
    print("Количество нот для спавна:", #self.beats) -- Выводит общее количество нот

    -- Инициализирует индекс текущей ноты (начинает с 1)
    self.current_beat_index = 1
    -- Запоминает время начала игры
    self.start_time = os.clock()
    -- Инициализирует прошедшее время
    self.elapsed_time = 0

    -- Вычисляет время, за которое нота проходит от точки спавна (y=1100) до кнопки (y=50)
    self.travel_time = (1100 - 50) / self.speed -- 1150 / 300 ≈ 3.833 сек
    
    -- Запускает воспроизведение музыки через audio_manager
    AUDIO.play("music")
end

-- Функция обновления, вызывается каждый кадр
-- Параметры: self (скрипт), dt (время между кадрами в секундах)
function update(self, dt)
    -- Если игра на паузе, прекращает обновление
    if GAME_STATE.is_paused() then
        return
    end

    -- Обновляет прошедшее время с момента начала игры
    self.elapsed_time = os.clock() - self.start_time

    -- Проверяет, есть ли ещё ноты для спавна
    if self.current_beat_index <= #self.beats then
        local beat = self.beats[self.current_beat_index]
        -- Вычисляет время спавна ноты (за вычетом времени движения до кнопки)
        local spawn_time = beat.time - self.travel_time
        -- Если текущее время превысило время спавна ноты
        if self.elapsed_time >= spawn_time then
            local track_number = beat.track -- Получает номер дорожки
            local track_pos = self.track[track_number] -- Получает позицию дорожки
            -- Создаёт ноту с помощью фабрики #notefactory с масштабом 1.8
            local id = factory.create("#notefactory", track_pos, nil, {}, 1.8)
            -- Устанавливает анимацию в зависимости от типа ноты
            if beat.type == "tap" then
                sprite.play_flipbook(id, "note_" .. track_number)
            elseif beat.type == "hold" then
                sprite.play_flipbook(id, "note_hold_" .. track_number)
            end
            -- Сохраняет данные о ноте (время бита и номер дорожки)
            self.notes[id] = { beat_time = beat.time, track = track_number }
            -- Переходит к следующей ноте
            self.current_beat_index = self.current_beat_index + 1
            -- Выводит информацию о спавне ноты
            print(string.format("Нота заспавнена на треке %d для бита в %.2f сек", track_number, beat.time))
        end
    end

    -- Обновляет позиции всех активных нот
    for note_id, note_data in pairs(self.notes) do
        if go.exists(note_id) then -- Проверяет, существует ли нота
            local pos = go.get_position(note_id) -- Получает текущую позицию ноты
            -- Перемещает ноту вниз со скоростью self.speed
            pos = pos + vmath.vector3(0, -self.speed * dt, 0)
            go.set_position(pos, note_id) -- Устанавливает новую позицию
            -- Проверяет, достигла ли нота позиции кнопки (y ≈ 50)
            if math.abs(pos.y - 50) < 5 then
                -- Выводит отладочную информацию о ноте, достигшей кнопки
                print(string.format("Note at button: note_id=%s, y=%.2f, time=%.3f, beat_time=%.3f, time_diff=%.3f",
                    tostring(note_id), pos.y, self.elapsed_time, note_data.beat_time, math.abs(self.elapsed_time - note_data.beat_time)))
            end
            -- Если нота вышла за пределы экрана (y <= -20), удаляет её
            if pos.y <= -20 then
                go.delete(note_id) -- Удаляет объект ноты
                self.notes[note_id] = nil -- Удаляет данные ноты из таблицы
                print("Note deleted (out of bounds): note_id=" .. tostring(note_id))
                -- Отправляет сообщение о пропуске ноты
                msg.post("/game", "note_miss")
            end
        end
    end
end

-- Функция обработки входящих сообщений
-- Параметры: self (скрипт), message_id (идентификатор сообщения), message (данные), sender (отправитель)
function on_message(self, message_id, message, sender)
    -- Если игра на паузе, обрабатывает сообщения, связанные с музыкой
    if GAME_STATE.get_state() == "paused" then
        if message_id == hash("pause_music") then
            -- Приостанавливает музыку
            AUDIO.pause("music")
        elseif message_id == hash("resume_music") then
            -- Возобновляет музыку
            AUDIO.resume("music")
        elseif message_id == hash("play_music") then
            -- Запускает музыку
            AUDIO.play("music")
        end
        return
    end
end

spawn_notes
Закоммитим изменения:

Тапы по нотам

На этом этапе создадим возможность тапа по нотам.
button.script — управляет поведением кнопок, обрабатывая взаимодействие с нотами и нажатия игрока.

-- Подключает модуль cursor для обработки событий ввода (например, кликов мыши или касаний экрана)
local cursor = require "in.cursor"

-- Функция инициализации, вызывается при старте скрипта
function init(self)
    -- Создаёт пустую таблицу для хранения ID нот, которые находятся в зоне триггера кнопки
    self.notes_inside = {}
    -- Выводит в консоль сообщение об инициализации кнопки с её идентификатором
    print("Button initialized, ID: " .. tostring(go.get_id()))
end

-- Функция обработки входящих сообщений
-- Параметры:
-- self: ссылка на текущий скрипт
-- message_id: идентификатор сообщения (хэш)
-- message: данные сообщения (например, информация о триггере или вводе)
-- sender: отправитель сообщения
function on_message(self, message_id, message, sender)
    -- Проверяет, является ли сообщение событием триггера (вход/выход объекта)
    if message_id == hash("trigger_response") then
        -- Проверяет, что объект, взаимодействующий с триггером, принадлежит группе "note"
        if message.other_group == hash("note") then
            -- Если нота вошла в зону триггера
            if message.enter then
                -- Добавляет ID ноты в таблицу notes_inside
                self.notes_inside[message.other_id] = true
                -- Логирует вход ноты в триггер с указанием ID ноты и кнопки
                print(string.format("Нота вошла в триггер: note_id=%s, button_id=%s", tostring(message.other_id), tostring(go.get_id())))
            -- Если нота вышла из зоны триггера
            elseif message.enter == false then
                -- Удаляет ноту из notes_inside, только если она больше не существует в игре
                if not go.exists(message.other_id) then
                    self.notes_inside[message.other_id] = nil
                    -- Логирует выход ноты из триггера
                    print(string.format("Note exited trigger: note_id=%s, button_id=%s", tostring(message.other_id), tostring(go.get_id())))
                end
            end
        end
    -- Проверяет, является ли сообщение событием нажатия (например, клик мыши или касание)
    elseif message_id == cursor.PRESSED then
        -- Проверяет, что нажатие относится к группе "button" (т.е. нажата эта кнопка)
        if message.group == hash("button") then
            -- Логирует нажатие кнопки с её ID
            print("Button pressed: " .. tostring(go.get_id()))
            -- Подсчитывает количество нот в зоне триггера
            local notes_count = 0
            for _ in pairs(self.notes_inside) do
                notes_count = notes_count + 1
            end
            -- Формирует строку со списком ID всех нот в зоне триггера
            local notes_list = ""
            for note_id, _ in pairs(self.notes_inside) do
                notes_list = notes_list .. tostring(note_id) .. ", "
            end
            -- Логирует информацию о нажатии кнопки и нотах в триггере
            print(string.format("Нажатая кнопка: button_id=%s, notes_inside_count=%d, notes=[%s]", 
                tostring(go.get_id()), notes_count, notes_list))
            -- Очищает таблицу notes_inside от удалённых нот
            for note_id, _ in pairs(self.notes_inside) do
                if not go.exists(note_id) then
                    print("Очистка удаленной ноты: note_id=" .. tostring(note_id))
                    self.notes_inside[note_id] = nil
                end
            end
            -- Получает текущее время (для синхронизации с ритмом) и позицию кнопки
            local current_time = os.clock()
            local button_pos = go.get_position()
            -- Проверяет все ноты в зоне триггера
            for note_id, _ in pairs(self.notes_inside) do
                if go.exists(note_id) then
                    -- Получает позицию ноты
                    local note_pos = go.get_position(note_id)
                    -- Логирует информацию о проверке попадания по ноте
                    print(string.format("Sending check_note_hit: note_id=%s, button_pos_x=%.2f, note_pos_y=%.2f, current_time=%.3f", 
                        tostring(note_id), button_pos.x, note_pos.y, current_time))
                    -- Отправляет сообщение объекту "/game" для проверки попадания по ноте
                    -- Передаёт ID ноты, позицию кнопки и текущее время
                    msg.post("/game", "check_note_hit", {
                        note_id = note_id,
                        button_pos = button_pos,
                        current_time = current_time
                    })
                    -- Завершает обработку после первой найденной ноты
                    return
                else
                    -- Логирует, если нота не существует
                    print("Ноты не существует: note_id=" .. tostring(note_id))
                end
            end
            -- Если в зоне триггера нет нот, логирует это
            if not next(self.notes_inside) then
                print("Нет нот в триггере во время нажатия кнопки")
            end
        end
    -- Проверяет, является ли сообщение событием отпускания (например, отпускание мыши)
    elseif message_id == cursor.RELEASED then
        -- Ничего не делает (пустой блок для возможной будущей логики)
    end
end

controller_game.script — управляет основной логикой ритм-игры, включая спавн нот, их движение и проверку попаданий.

-- Подключает модуль game_state для управления состоянием игры (например, "playing", "paused")
local GAME_STATE = require "main.modules.game_state"
-- Подключает модуль audio_manager для управления звуковыми треками
local AUDIO = require "main.modules.audio_manager"

-- Функция инициализации, вызывается при старте скрипта
function init(self)
    -- Устанавливает начальное состояние игры как "playing" (игра активна)
    math.randomseed(os.time()) -- Инициализирует генератор случайных чисел с текущим временем
    GAME_STATE.set_state("playing")

    -- Задаёт позиции спавна кнопок (4 кнопки на экране по горизонтали, y=90)
    self.button_spawn_position = {
        vmath.vector3(100, 90, 0), -- Позиция 1-й кнопки
        vmath.vector3(270, 90, 0), -- Позиция 2-й кнопки
        vmath.vector3(440, 90, 0), -- Позиция 3-й кнопки
        vmath.vector3(610, 90, 0)  -- Позиция 4-й кнопки
    }

    -- Устанавливает скорость движения нот (в пикселях в секунду)
    self.speed = 300
    -- Создаёт пустую таблицу для хранения активных нот
    self.notes = {}
    -- Задаёт позиции дорожек для спавна нот (вверху экрана, y=1100)
    self.track = {
        vmath.vector3(100, 1100, 0), -- Позиция 1-й дорожки
        vmath.vector3(270, 1100, 0), -- Позиция 2-й дорожки
        vmath.vector3(440, 1100, 0), -- Позиция 3-й дорожки
        vmath.vector3(610, 1100, 0)  -- Позиция 4-й дорожки
    }

    -- Создаёт 4 кнопки с помощью фабрики #buttonfactory
    for i = 1, 4 do
        -- Создаёт кнопку на заданной позиции с масштабом 2
        local id = factory.create("#buttonfactory", self.button_spawn_position[i], nil, {}, 2)
        -- Устанавливает анимацию для кнопки (например, "button_1", "button_2" и т.д.)
        sprite.play_flipbook(id, "button_" .. i)
    end

    -- Загрузка данных песни из JSON-файла
    local start_load = os.clock() -- Замеряет время начала загрузки
    local file = sys.load_resource("/main/data/song_data.json") -- Загружает JSON-файл
    print("JSON load time: " .. (os.clock() - start_load) .. " seconds") -- Выводит время загрузки
    if not file then
        -- Если файл не загружен, выводит ошибку и завершает выполнение
        print("Ошибка: Не удалось загрузить файл song_data.json")
        return
    end
    -- Декодирует JSON в таблицу Lua
    self.beatmap = json.decode(file)
    if not self.beatmap or not self.beatmap.beat_times then
        -- Если JSON некорректен или отсутствует beat_times, выводит ошибку и завершает
        print("Ошибка: Некорректный формат JSON или отсутствует beat_times")
        return
    end

    -- Выводит первые 5 временных меток для проверки содержимого beat_times
    print("Первые 5 beat_times:", table.concat({unpack(self.beatmap.beat_times, 1, 5)}, ", "))

    -- Создаёт таблицу beats для хранения данных о нотах
    self.beats = {}
    for i, time in ipairs(self.beatmap.beat_times) do
        -- Для каждой временной метки создаёт ноту с:
        -- time: время появления ноты
        -- track: случайная дорожка (1–4)
        -- type: тип ноты (в данном случае только "tap")
        table.insert(self.beats, { time = time, track = math.random(1, 4), type = "tap" })
    end
    print("Количество нот для спавна:", #self.beats) -- Выводит общее количество нот

    -- Инициализирует индекс текущей ноты (начинает с 1)
    self.current_beat_index = 1
    -- Запоминает время начала игры
    self.start_time = os.clock()
    -- Инициализирует прошедшее время
    self.elapsed_time = 0

    -- Вычисляет время движения ноты от точки спавна (y=1100) до кнопки (y=50)
    self.travel_time = (1100 - 50) / self.speed -- 1050 / 300 ≈ 3.5 сек

    -- Запускает воспроизведение музыки через audio_manager
    AUDIO.play("music")
end

-- Функция обновления, вызывается каждый кадр
-- Параметры: self (скрипт), dt (время между кадрами в секундах)
function update(self, dt)
    -- Если игра на паузе, прекращает обновление
    if GAME_STATE.is_paused() then
        return
    end

    -- Обновляет прошедшее время с момента начала игры
    self.elapsed_time = os.clock() - self.start_time

    -- Проверяет, есть ли ещё ноты для спавна
    if self.current_beat_index <= #self.beats then
        local beat = self.beats[self.current_beat_index]
        -- Вычисляет время спавна ноты (за вычетом времени движения до кнопки)
        local spawn_time = beat.time - self.travel_time
        -- Если текущее время превысило время спавна ноты
        if self.elapsed_time >= spawn_time then
            local track_number = beat.track -- Получает номер дорожки
            local track_pos = self.track[track_number] -- Получает позицию дорожки
            -- Создаёт ноту с помощью фабрики #notefactory с масштабом 1.8
            local id = factory.create("#notefactory", track_pos, nil, {}, 1.8)
            -- Устанавливает анимацию в зависимости от типа ноты
            if beat.type == "tap" then
                sprite.play_flipbook(id, "note_" .. track_number)
            elseif beat.type == "hold" then
                sprite.play_flipbook(id, "note_hold_" .. track_number)
            end
            -- Сохраняет данные о ноте (время бита и номер дорожки)
            self.notes[id] = { beat_time = beat.time, track = track_number }
            -- Переходит к следующей ноте
            self.current_beat_index = self.current_beat_index + 1
            -- Выводит информацию о спавне ноты
            print(string.format("Нота заспавнена на треке %d для бита в %.2f сек", track_number, beat.time))
        end
    end

    -- Обновляет позиции всех активных нот
    for note_id, note_data in pairs(self.notes) do
        if go.exists(note_id) then -- Проверяет, существует ли нота
            local pos = go.get_position(note_id) -- Получает текущую позицию ноты
            -- Перемещает ноту вниз со скоростью self.speed
            pos = pos + vmath.vector3(0, -self.speed * dt, 0)
            go.set_position(pos, note_id) -- Устанавливает новую позицию
            -- Проверяет, достигла ли нота позиции кнопки (y ≈ 90)
            if math.abs(pos.y - 90) < 5 then
                -- Выводит отладочную информацию о ноте, достигшей кнопки
                print(string.format("Нота на кнопке: note_id=%s, y=%.2f, time=%.3f, beat_time=%.3f, time_diff=%.3f",
                    tostring(note_id), pos.y, self.elapsed_time, note_data.beat_time, math.abs(self.elapsed_time - note_data.beat_time)))
            end
            -- Если нота вышла за пределы экрана (y <= -20), удаляет её
            if pos.y <= -20 then
                go.delete(note_id) -- Удаляет объект ноты
                self.notes[note_id] = nil -- Удаляет данные ноты из таблицы
                print("Note deleted (out of bounds): note_id=" .. tostring(note_id))
                -- Отправляет сообщение о промахе
                msg.post("/game", "note_miss")
            end
        end
    end
end

-- Функция обработки входящих сообщений
-- Параметры: self (скрипт), message_id (идентификатор сообщения), message (данные), sender (отправитель)
function on_message(self, message_id, message, sender)
    -- Если игра на паузе, обрабатывает только команды управления музыкой
    if GAME_STATE.get_state() == "paused" then
        if message_id == hash("pause_music") then
            -- Приостанавливает музыку
            AUDIO.pause("music")
        elseif message_id == hash("resume_music") then
            -- Возобновляет музыку
            AUDIO.resume("music")
        elseif message_id == hash("play_music") then
            -- Запускает музыку
            AUDIO.play("music")
        end
        return
    end

    -- Обработка запроса на удаление ноты
    if message_id == hash("request_delete_note") then
        local note_id = message.delete_id_note
        -- Проверяет, зарегистрирована ли нота и существует ли в игре
        if self.notes[note_id] and go.exists(note_id) then
            go.delete(note_id) -- Удаляет объект ноты
            self.notes[note_id] = nil -- Удаляет данные ноты из таблицы
            print("Note deleted (hit): note_id=" .. tostring(note_id))
        end
    -- Обработка сообщения о попадании по ноте
    elseif message_id == hash("note_hit") then
        -- Логика подсчёта очков отсутствует (удалена)
    -- Обработка промаха
    elseif message_id == hash("note_miss") then
        -- Логика подсчёта очков отсутствует (удалена)
    -- Обработка проверки попадания по ноте
    elseif message_id == hash("check_note_hit") then
        print("Получен check_note_hit: note_id=" .. tostring(message.note_id))
        local note_id = message.note_id
        -- Вычисляет текущее время относительно начала игры
        local current_time = message.current_time - self.start_time
        local button_pos = message.button_pos

        -- Проверяет, зарегистрирована ли нота в self.notes
        if self.notes[note_id] then
            local note_data = self.notes[note_id]
            local beat_time = note_data.beat_time
            local time_diff = math.abs(current_time - beat_time)
            local track_number = note_data.track

            -- Определяет временные окна для оценки точности нажатия
            local PERFECT_WINDOW = 0.3 -- ±300 мс для "идеального" попадания
            local GOOD_WINDOW = 0.5    -- ±500 мс для "хорошего" попадания
            local accuracy = "miss"
            if time_diff <= PERFECT_WINDOW then
                accuracy = "perfect"
            elseif time_diff <= GOOD_WINDOW then
                accuracy = "good"
            end

            -- Определяет номер дорожки кнопки на основе её x-координаты
            local button_track = nil
            for i, pos in ipairs(self.button_spawn_position) do
                if math.abs(pos.x - button_pos.x) < 0.1 then
                    button_track = i
                    break
                end
            end

            -- Логирует данные нажатия для отладки
            print(string.format("Тап Дебаг: note_id=%s, tap_time=%.3f, beat_time=%.3f, time_diff=%.3f, accuracy=%s, note_track=%d, button_track=%s",
                tostring(note_id), current_time, beat_time, time_diff, accuracy, track_number, button_track or "nil"))

            -- Проверяет, совпадает ли дорожка кнопки с дорожкой ноты
            if button_track and button_track == track_number then
                -- Если дорожки совпадают, отправляет сообщение о попадании
                print(string.format("Попадание: note_id=%s, time_diff=%.3f, accuracy=%s", tostring(note_id), time_diff, accuracy))
                msg.post("/game", "note_hit", { accuracy = accuracy })
                msg.post("/game", "request_delete_note", { delete_id_note = note_id })
            else
                -- Если дорожки не совпадают, отправляет сообщение о промахе
                print("Ошибка: неправильный трэк, note_track=" .. track_number .. ", button_track=" .. (button_track or "nil"))
                msg.post("/game", "note_miss")
            end
        else
            -- Если нота не найдена в self.notes, отправляет сообщение о промахе
            print("Промах: нота не обнаружена в self.notes, id=" .. tostring(message.note_id))
            msg.post("/game", "note_miss")
        end
    end
end

Как они работают вместе

  • button.script отслеживает ноты в зоне кнопки и при нажатии отправляет check_note_hit в /game.
  • controller_game.script спавнит ноты, перемещает их, проверяет попадания по времени и треку, и удаляет ноты при попадании или выходе за экран.
  • Взаимодействие происходит через сообщения (check_note_hit, note_hit, note_miss, request_delete_note), обеспечивая логику ритм-игры без подсчёта очков.
    TapAtButton

Закоммитим изменения:

Подсчёт очков

На этом этапе добавим возможность вести подсчёт очков и комбинаций.

Добавим две текстовые ноды в game.gui:

Добавим в game.gui такой код:

-- Функция инициализации, вызывается при старте скрипта
function init(self)
    -- ... 
    -- ранее добавленный код 
    
    -- Инициализирует счётчик очков игры (начинается с 0)
    self.score = 0
    -- Устанавливает текст для узла GUI с идентификатором "score_label", отображая начальный счёт
    gui.set_text(gui.get_node("score_label"), "Score: 0")
    -- Устанавливает текст для узла GUI с идентификатором "combo_label", отображая начальное комбо (0)
    gui.set_text(gui.get_node("combo_label"), "Combo: 0")
end

-- ... ранее добавленный код 

-- Функция обработки входящих сообщений
-- Параметры:
-- self: ссылка на текущий скрипт
-- message_id: идентификатор сообщения (хэш)
-- message: данные сообщения
-- sender: отправитель сообщения
function on_message(self, message_id, message, sender)
    -- Проверяет, является ли сообщение запросом на обновление счёта
    if message_id == hash("update_score") then
        -- Обновляет значение очков в скрипте, используя переданное значение или 0, если оно отсутствует
        self.score = message.score or 0
        -- Обновляет текст в узле GUI "score_label", отображая текущий счёт
        gui.set_text(gui.get_node("score_label"), "Score: " .. (message.score or 0))
        -- Обновляет текст в узле GUI "combo_label", отображая текущее комбо (или 0, если не передано)
        gui.set_text(gui.get_node("combo_label"), "Combo: " .. (message.combo or 0))
    end
    -- ... ранее добавленный код
end

Добавим новый код в controller_game.script. Новый код отмечен в комментариях как “НАЧАЛО ДОБАВЛЕННОГО КОДА”:

-- Подключает модуль game_state для управления состоянием игры (например, "playing", "paused")
local GAME_STATE = require "main.modules.game_state"
-- Подключает модуль audio_manager для управления звуковыми треками
local AUDIO = require "main.modules.audio_manager"

-- Определяет свойство delay (задержка), но оно не используется в коде
go.property("delay", 0)

--- НАЧАЛО ДОБАВЛЕННОГО КОДА ---
-- Функция для расчёта очков за попадание по ноте
-- Параметры:
-- accuracy: точность попадания ("perfect", "good", "miss")
-- combo: текущее количество последовательных попаданий
local function get_score_for_hit(accuracy, combo)
    -- Устанавливает базовые очки в зависимости от точности попадания
    local base_score = 0
    if accuracy == "perfect" then
        base_score = 1000 -- 1000 очков за "идеальное" попадание
    elseif accuracy == "good" then
        base_score = 500  -- 500 очков за "хорошее" попадание
    elseif accuracy == "miss" then
        return 0          -- 0 очков за промах
    end

    -- Вычисляет множитель на основе текущего комбо (увеличивается на 0.05 за каждое попадание, макс. 3.0)
    local multiplier = math.min(1 + combo * 0.05, 3.0)
    -- Возвращает итоговые очки, округлённые вниз
    return math.floor(base_score * multiplier)
end

-- Функция для обработки попадания по ноте и обновления очков/комбо
-- Параметры:
-- self: ссылка на текущий скрипт
-- accuracy: точность попадания ("perfect", "good", "miss")
local function handle_hit(self, accuracy)
    -- Если промах, сбрасывает комбо
    if accuracy == "miss" then
        self.combo = 0
    else
        -- При попадании увеличивает комбо на 1
        self.combo = self.combo + 1
        -- Обновляет максимальное комбо, если текущее больше
        if self.combo > self.max_combo then
            self.max_combo = self.combo
        end
    end

    -- Вычисляет очки за попадание с учётом текущего комбо
    local score_add = get_score_for_hit(accuracy, self.combo)
    -- Добавляет очки к общему счёту
    self.score = self.score + score_add

    -- Логирует результат попадания (точность, текущее комбо, общий счёт)
    print(string.format("Hit: %s | Combo: %d | Score: %d ", accuracy, self.combo, self.score))

    -- Отправляет сообщение объекту "/gui#game" для обновления отображаемых очков и комбо
    msg.post("/gui#game", "update_score", {
        score = self.score,
        combo = self.combo,
        max_combo = self.max_combo
    })
end
--- КОНЕЦ ДОБАВЛЕННОГО КОДА ---

-- Функция инициализации, вызывается при старте скрипта
function init(self)
    --- НАЧАЛО ДОБАВЛЕННОГО КОДА ---
    -- Инициализирует счётчик очков, текущее комбо и максимальное комбо
    self.score = 0
    self.combo = 0
    self.max_combo = 0
    --- КОНЕЦ ДОБАВЛЕННОГО КОДА ---

    -- Устанавливает начальное состояние игры как "playing" (игра активна)
    math.randomseed(os.time()) -- Инициализирует генератор случайных чисел
    GAME_STATE.set_state("playing")

    -- Задаёт позиции спавна кнопок (4 кнопки по горизонтали, y=90)
    self.button_spawn_position = {
        vmath.vector3(100, 90, 0), -- Позиция 1-й кнопки
        vmath.vector3(270, 90, 0), -- Позиция 2-й кнопки
        vmath.vector3(440, 90, 0), -- Позиция 3-й кнопки
        vmath.vector3(610, 90, 0)  -- Позиция 4-й кнопки
    }

    -- Устанавливает скорость движения нот (пикселей в секунду)
    self.speed = 300
    -- Создаёт пустую таблицу для хранения активных нот
    self.notes = {}
    -- Задаёт позиции дорожек для спавна нот (вверху экрана, y=1100)
    self.track = {
        vmath.vector3(100, 1100, 0), -- Позиция 1-й дорожки
        vmath.vector3(270, 1100, 0), -- Позиция 2-й дорожки
        vmath.vector3(440, 1100, 0), -- Позиция 3-й дорожки
        vmath.vector3(610, 1100, 0)  -- Позиция 4-й дорожки
    }

    -- Создаёт 4 кнопки с помощью фабрики #buttonfactory
    for i = 1, 4 do
        local id = factory.create("#buttonfactory", self.button_spawn_position[i], nil, {}, 2)
        -- Устанавливает анимацию для кнопки (например, "button_1", "button_2" и т.д.)
        sprite.play_flipbook(id, "button_" .. i)
    end

    -- Загружает данные песни из JSON-файла
    local start_load = os.clock() -- Замеряет время начала загрузки
    local file = sys.load_resource("/main/data/song_data.json") -- Загружает JSON-файл
    print("JSON load time: " .. (os.clock() - start_load) .. " seconds") -- Выводит время загрузки
    if not file then
        print("Ошибка: Не удалось загрузить файл song_data.json")
        return
    end
    self.beatmap = json.decode(file)
    if not self.beatmap or not self.beatmap.beat_times then
        print("Ошибка: Некорректный формат JSON или отсутствует beat_times")
        return
    end

    -- Выводит первые 5 временных меток для проверки
    print("Первые 5 beat_times:", table.concat({unpack(self.beatmap.beat_times, 1, 5)}, ", "))

    -- Создаёт таблицу beats на основе beat_times
    self.beats = {}
    for i, time in ipairs(self.beatmap.beat_times) do
        table.insert(self.beats, { time = time, track = math.random(1, 4), type = "tap" })
    end
    print("Количество нот для спавна:", #self.beats)

    -- Инициализирует индекс текущей ноты, время начала и прошедшее время
    self.current_beat_index = 1
    self.start_time = os.clock()
    self.elapsed_time = 0

    -- Вычисляет время движения ноты от спавна (y=1100) до кнопки (y=50)
    self.travel_time = (1100 - 50) / self.speed -- 1050 / 300 ≈ 3.5 сек

    -- Запускает музыку через audio_manager
    AUDIO.play("music")
end

-- Функция обновления, вызывается каждый кадр
function update(self, dt)
    -- Прерывает обновление, если игра на паузе
    if GAME_STATE.is_paused() then
        return
    end

    -- Обновляет прошедшее время
    self.elapsed_time = os.clock() - self.start_time

    -- Спавнит ноты с учётом времени движения
    if self.current_beat_index <= #self.beats then
        local beat = self.beats[self.current_beat_index]
        local spawn_time = beat.time - self.travel_time
        if self.elapsed_time >= spawn_time then
            local track_number = beat.track
            local track_pos = self.track[track_number]
            -- Создаёт ноту с помощью фабрики
            local id = factory.create("#notefactory", track_pos, nil, {}, 1.8)
            -- Устанавливает анимацию в зависимости от типа ноты
            if beat.type == "tap" then
                sprite.play_flipbook(id, "note_" .. track_number)
            elseif beat.type == "hold" then
                sprite.play_flipbook(id, "note_hold_" .. track_number)
            end
            -- Сохраняет данные о ноте
            self.notes[id] = { beat_time = beat.time, track = track_number }
            self.current_beat_index = self.current_beat_index + 1
            print(string.format("Нота заспавнена на треке %d для бита в %.2f сек", track_number, beat.time))
        end
    end

    -- Обновляет позиции нот и удаляет их, если они вышли за экран
    for note_id, note_data in pairs(self.notes) do
        if go.exists(note_id) then
            local pos = go.get_position(note_id)
            pos = pos + vmath.vector3(0, -self.speed * dt, 0)
            go.set_position(pos, note_id)
            -- Логирует, когда нота достигает кнопки (y ≈ 50)
            if math.abs(pos.y - 50) < 5 then
                print(string.format("Note at button: note_id=%s, y=%.2f, time=%.3f, beat_time=%.3f, time_diff=%.3f",
                    tostring(note_id), pos.y, self.elapsed_time, note_data.beat_time, math.abs(self.elapsed_time - note_data.beat_time)))
            end
            -- Удаляет ноту, если она вышла за экран (y <= -20)
            if pos.y <= -20 then
                go.delete(note_id)
                self.notes[note_id] = nil
                print("Note deleted (out of bounds): note_id=" .. tostring(note_id))
                msg.post("/game", "note_miss")
            end
        end
    end
end

-- Функция обработки входящих сообщений
function on_message(self, message_id, message, sender)
    -- Если игра на паузе, обрабатывает только команды управления музыкой
    if GAME_STATE.get_state() == "paused" then
        if message_id == hash("pause_music") then
            AUDIO.pause("music")
        elseif message_id == hash("resume_music") then
            AUDIO.resume("music")
        elseif message_id == hash("play_music") then
            AUDIO.play("music")
        end
        return
    end

    -- Обработка запроса на удаление ноты
    if message_id == hash("request_delete_note") then
        local note_id = message.delete_id_note
        if self.notes[note_id] and go.exists(note_id) then
            go.delete(note_id)
            self.notes[note_id] = nil
            print("Note deleted (hit): note_id=" .. tostring(note_id))
        end
    --- НАЧАЛО ДОБАВЛЕННОГО КОДА ---
    -- Обработка сообщения о попадании по ноте
    elseif message_id == hash("note_hit") then
        -- Обрабатывает попадание с помощью функции handle_hit
        handle_hit(self, message.accuracy)
    -- Обработка промаха
    elseif message_id == hash("note_miss") then
        -- Обрабатывает промах с помощью функции handle_hit
        handle_hit(self, "miss")
    --- КОНЕЦ ДОБАВЛЕННОГО КОДА ---
    -- Обработка проверки попадания по ноте
    elseif message_id == hash("check_note_hit") then
        print("Received check_note_hit: note_id=" .. tostring(message.note_id))
        local note_id = message.note_id
        local button_pos = message.button_pos
        local current_time = message.current_time - self.start_time

        -- Проверяет, зарегистрирована ли нота
        if self.notes[note_id] then
            local note_data = self.notes[note_id]
            local beat_time = note_data.beat_time
            local time_diff = math.abs(current_time - beat_time)
            local track_number = note_data.track

            -- Определяет временные окна для точности
            local PERFECT_WINDOW = 0.3 -- ±300 мс для "perfect"
            local GOOD_WINDOW = 0.5    -- ±500 мс для "good"
            local accuracy = "miss"
            if time_diff <= PERFECT_WINDOW then
                accuracy = "perfect"
            elseif time_diff <= GOOD_WINDOW then
                accuracy = "good"
            end

            -- Проверяет трек кнопки
            local button_track = nil
            for i, pos in ipairs(self.button_spawn_position) do
                if math.abs(pos.x - button_pos.x) < 0.1 then
                    button_track = i
                    break
                end
            end

            -- Логирует данные нажатия для отладки
            print(string.format("Tap Debug: note_id=%s, tap_time=%.3f, beat_time=%.3f, time_diff=%.3f, accuracy=%s, note_track=%d, button_track=%s",
                tostring(note_id), current_time, beat_time, time_diff, accuracy, track_number, button_track or "nil"))

            -- Проверяет совпадение трека
            if button_track and button_track == track_number then
                print(string.format("Hit: note_id=%s, time_diff=%.3f, accuracy=%s", tostring(note_id), time_diff, accuracy))
                msg.post("/game", "note_hit", { accuracy = accuracy })
                msg.post("/game", "request_delete_note", { delete_id_note = note_id })
 Chel else
                print("Miss: wrong track, note_track=" .. track_number .. ", button_track=" .. (button_track or "nil"))
                msg.post("/game", "note_miss")
            end
        else
            print("Miss: note not found in self.notes, id=" .. tostring(message.note_id))
            msg.post("/game", "note_miss")
        end
    end
end

score
Делаем коммит:

На этом моменте остановимся! Всем спасибо за внимание. Если у вас имеются какие-нибудь вопросы или нашли ошибку в этой теме, пишите :ghost:

Спасибо Честеру за предоставленные ассеты :orange_heart:

2 лайка