Привет всем
.
В этом посте мы создадим прототип механики к музыкальной игре(ритм-игре).
Просмотреть и скачать исходный проект можно по этой ссылке:
Ритм-игра — жанр, в котором игровой процесс завязан на синхронизации действий игрока с музыкальным ритмом или битом.
За образец возьмём игру Guitar Band (первая попавшаяся игра в Play Market
)

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

Прежде чем начать разрабатывать прототип, я создам пустой проект и в нём разверну git-репозиторий, для возможности сохранять изменения и в случае чего, чтобы была возможность вернуться к старым коммитам. Если вы не знаете как развернуть git-репозиторий в вашем проекте, ознакомьтесь с Система контроля версий Git.
(Я постараюсь сопровождать использование git скриншотами, но некоторые моменты в использовании git я не буду упоминать. Например, если я пишу: “закоммитим изменения”, но не пишу, что я добавил файлы в индекс. Я имею в виду также и то, что я предварительно добавил файлы в индекс).
Создание проекта
Создаём новый пустой проект:
Инициализируем git-репозиторий. Для этого зайдём в папку с проектом:
Затем открываем git bash через контекстное меню:
Создаём git-репозиторий:
Проверяю git-репозиторий на изменения:
Создаём первый коммит, предварительно добавив файлы в индекс:
Создадим папку с ресурсами(правый клик → New folder):
В этой папке мы будем хранить ресурсы, которые будем использовать в нашем проекте. Например, аудиофайлы, изображения, шрифты и т.д.
Добавим в папку images файлы с изображениями нот, различных кнопок, а в папку music добавим песенку.
Должно получиться примерно так:
Теперь нам нужно создать atlas, чтобы мы могли использовать наши изображения в игре:
Откроем файл .gitignore и впишем в него путь к папке: main/assets:
Теперь при добавлении файлов в индекс, эта папка будет игнорироваться:
Создание контроллера (базовое перемещения между меню и игрой)
Мы создадим скрипт, который будет управлять перемещением между главным меню и игрой. Будет загружать и выгружать коллекции через прокси-коллекции.
Создадим папку и в нём скриптовый файл controller.script:
Создадим две новые коллекции в папке main:
Переименуем 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
Если вы все сделали правильно, в вашем проекте появилась папка:

В этом уроке не буду разбирать как создавать курсор, для этого я создал специальную тему:
Как создать курсор, перетаскивание объектов с помощью Defold Input от britzl
Я создал курсор и добавил его в
game.script:Не забудьте снять галочку с опции drag(вспомнил только на момент редактирования поста
Добавим скрипты для 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
Запускаем проект:

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

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

Добавим игровые состояния
В нашей игре будет три состояния: 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

Посмотрим на консоль и увидим изменения игровых состояний:
Добавим изменения в индекс и закоммитим изменения:
Расставим клавиши
Наша задача на этом этапе расставить игровые объекты — кнопки, в сторону которых будут двигаться ноты, в будущем.
Для этого создадим игровой объект button.go в main и скрипт button.script в scripts:

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

Перейдём в 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. У вас должен выводиться такой текст, когда вы кликаете по кнопкам:

Сделаем коммит:
Создание 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 файл:
Если вы запустите проект, то увидите отладочную информацию:
Закоммитим изменения:

Создание и движение нот
На этом этапе мы создадим движение нот в направлении кнопок.
Всё аналогично, как и п 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

Закоммитим изменения:
Тапы по нотам
На этом этапе создадим возможность тапа по нотам.
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), обеспечивая логику ритм-игры без подсчёта очков.

Закоммитим изменения:
Подсчёт очков
На этом этапе добавим возможность вести подсчёт очков и комбинаций.
Добавим две текстовые ноды в 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

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









































































