Как стать автором
Обновить

Пишем Discord бота крестики-нолики

Уровень сложностиПростой
Время на прочтение26 мин
Количество просмотров5.3K

Вы когда-нибудь хотели создать свой бот для игры в крестики-нолики в Discord? Так ещё при помощи 🚀blazingly fast🚀Rust и крейта serenity! Всех заинтересовавшихся прошу под кат.

Небольшое предисловие

Пытаясь создать Discord сервер и бота к нему для моего новоиспечённого университета, я обнаружил много интересных нововведений в Discord API для разработчиков ботов, с которыми я решил поделиться с вами в своей первой статье на Хабре. В ней я расскажу об основных нюансах написания Discord бота с использованием новых фишек, на языке программирования Rust, используя крейт serenity.

Что потребуется?

  • Минимальное знание базы языка Rust (Rust Book) и ассинхроного программирования в нём.

  • Сервер в Discord'е как минимум с одним текстовым каналом.

Приготовительные работы

Начнём по порядку. Для работы нашего бота ему необходимо создать аккаунт на сайте для разработчиков Discord. Переходим по ссылке, авторизируемся в своём Discord аккаунте и переходим во вкладку Applications. Там нажимаем на кнопку New Application:

Вводим название нашего бота и нажимаем Create:

Перед нами высвечивается окно общих настроек нашего приложения. В нём я установил иконку для нашего бота. Чтобы использовать все функции нашего приложения, надо создать пользователя-бота во вкладке слева:

Создаём бота кнопкой Add Bot и нажимаем на Reset Token:

Копируем появившийся токен, и стараемся чтобы он не попал в чужие руки, так как через него будет работать наш бот.

Последним штрихом в нашей настройке бота будет раздел Privileged Gateway Intents. Intents позволяют нам включать/выключать некоторые возможности бота такие, как Presence Intent (необходим для приложений), Server Member Intent (позволяет работать с членами гильдий(серверов)) и необходимый нам прямо сейчас Message Content Intent, который позволит нам читать текст сообщений:

Создание и настройка бота завершена. Теперь нам необходимо добавить нашего бота на наш сервер. Переходим во вкладку OAuth2/URL Generator:

Выбираем bot в Scopes, так как нам остальные не нужны. А в Bot permissions выбираем права доступа Administrator. Они позволят нам творить всё, что мы пожелаем, но если вы собираетесь делать настоящий бот, рекомендуется выбирать только необходимые для вашего бота разрешения.

После настройки бота, переходим по сгенерированному адресу, и добавляем бота на наш тестовый сервер:

Первая проба

Начнём по порядку. Во-первых нам необходимо добавить крейт serenity в наш Cargo.TOML файл:

name = "tic-tac-toe-discord-bot"
version = "0.1.0"
edition = "2021"

[dependencies]
serenity = { git = "https://github.com/serenity-rs/serenity.git", rev = "ba3be69166f54c5986e4cc9438bc5bb4606fa4c2", default-features = false, features = ["builder", "cache", "client", "model", "utils", "gateway", "rustls_backend"] }
tokio = { version = "1.22", features = ["rt-multi-thread"] }

Примечение:
Мы используем библиотеку serenity с ветки next и с определенного коммита (ba3be69166f54c5986e4cc9438bc5bb4606fa4c2).

В main.rs напишем тестовый вариант нашего бота, чтобы проверить, работает ли всё, как надо:

use serenity::async_trait;
use serenity::all::{Message, Ready};
use serenity::prelude::*;

struct Handler;

#[async_trait]
impl EventHandler for Handler {
    async fn message(&self, ctx: Context, msg: Message) {
        if msg.content == "!ping" {
            if let Err(err) = msg.reply(&ctx.http, "pong!").await {
                eprintln!("Error: {err}");
            }
        }
    }

    async fn ready(&self, _: Context, ready: Ready) {
        println!("{} has connected!", ready.user.name);
    }
}

#[tokio::main]
async fn main() {
    let intents = GatewayIntents::GUILD_MESSAGES
        | GatewayIntents::MESSAGE_CONTENT;

    let mut client = Client::builder(include_str!("./../token.txt"), intents)
        .event_handler(Handler)
        .await
        .expect("Failed to create client!");

    if let Err(err) = client.start().await {
        eprintln!("Client error: {err:?}");
    }
}

Исходный код на этой стадии проектирования бота.

Не забудьте добавить файл с токеном (token.txt) в директорию проекта

Если мы всё сделали правильно, то при вводе команды !ping бот будет нам отвечать "pong!":

Вы скажете: "Хорошо, это мы уже видели двести раз в бесконечных туториалах, можешь ли ты показать хоть что-то интересное?" И я отвечу - да. Да начнётся с этого момента самое интересное!

Рассматриваем новые возможности

В последних версиях Discord появились взаимодействия (Interactions), представленные в следующей табличке:

Взаимодействие

Краткое описание

PING

Необходимо для взаимодействия с webhook-based interactions

APPLICATION_COMMAND

- Ввод с чата (слэш-команды)
- Контекстное меню сообщения
- Контекстное меню пользователя

MESSAGE_COMPONENT

Обработка нажатия кнопок, выбора элемента из выпадающего списка

APPLICATION_COMMAND_AUTOCOMPLETE

Автодополнение слэш-команд

MODAL_SUBMIT

Обработка ввода текста из всплывающих модальных окон

Давайте рассмотрим некоторые из них, необходимые для нашего бота, более подробней. Например, Slash commands выглядят следующим образом:

 (встроенные слэш-команды)
(встроенные слэш-команды)

Они позволяют более удобно пользоваться Discord ботами: они дают возможность добавлять кастомные параметры, которые будут подсвечивать необходимые варианты, настраивать уровни доступа к команде в настройках сервера (Server Settings > Apps > Integrations). При помощи них мы сделаем команду по вызову игры: /play.

Также одним из важных нововведений для разработчиков ботов было создание временных сообщений (ephemeral), которые видны только человеку, который отправил слэш-команду. Это позволило создать большое количество интересных ботов, которые теперь также можно подключить при помощи двух кликов (Server Settings > Apps > App Directory). Конкретно в нашей ситуации, нам это пригодится в качестве инструмента для ввода нашего хода.

Последний необходимый нам функционал заложен в Message Components. При помощи него, мы можем добавлять добавлять кнопки, выпадающий список и модальное окно ввода текста. Кнопку и раскрывающийся список необходимо добавлять в `ActionRow` сообщения, где количество кнопок может быть максимум пять, а список только один. А самих ActionRow можно добавить тоже только пять к сообщению. Отсюда нам пригодится информация только про ActionRow и кнопки, которые будут позволять нам двигаться по игровому полю (3x3), а также отправлять наш ход противнику.

(Примеры всех элементов интерфейса, предоставляемых Message Components)
(Примеры всех элементов интерфейса, предоставляемых Message Components)

Interaction Callback Type

CreateInteractionResponse

Описание

PONG

Pong

Подтвердить PING взаимодействие

CHANNEL_MESSAGE_WITH_SOURCE

Message

Ответить на взаимодействие сообщением

DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE

Defer

Подтвердить взаимодействие, и изменить interaction response позже. Пользователь будет видеть состояние загрузки

DEFERRED_UPDATE_MESSAGE

Acknowledge

[Для компонентов] Подтвердить взаимодействие и изменить оригинальное сообщение позже. Пользователь не будет видеть состояние загрузки

UPDATE_MESSAGE

UpdateMessage

[Для компонентов] Изменить сообщение, компонент который был приложен к нему

APPLICATION_COMMAND_AUTOCOMPLETE_RESULT

Autocomplete

Ответить на взаимодействие автодополнения с предложенными вариантами

MODAL

Modal

Ответить на взаимодействие при помощи всплывающего модального окна

Здесь я заменил центральный столбец значений Discord API на столбец значений enum CreateInteractionResponse из serenity, чтобы было более наглядно видно, что я предлагаю использовать в нашем боте. В нём будут использоваться только CHANNEL_MESSAGE_WITH_SOURCE (Message) и DEFERRED_UPDATE_MESSAGE (Acknowledge). Первое мы будем использовать всегда, когда надо будет прислать новое сообщение, а последнее только в одном случае, когда мы будем обрабатывать ход игрока, редактируя уже хранящиеся в памяти значения CommandInteraction.

Примечание:
Эти и другие возможности Discord API можно посмотреть на сайте https://discord.com/developers/docs/intro. Рекомендую!

Начинаем писать код

Первое, с чего следовало бы начать, это переделать нашу команду !ping на современные слэш-команды. Для этого добавим файл ping.rs с двумя фунциями, которые позволят нам создать нашу первую слэш-команду!:

use serenity::all::{CommandInteraction, CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage};
use serenity::prelude::Context;

pub fn register() -> CreateCommand {
    CreateCommand::new("ping")
        .description("Creates ephemeral message with \"pong\" text")
}

pub async fn command(ctx: Context, interaction: CommandInteraction) {
    interaction.create_response(&ctx.http, CreateInteractionResponse::Message(
        CreateInteractionResponseMessage::new()
            .ephemeral(true)
            .content("pong!")
    ))
    .await
    .expect("failed to create interaction");
}

Функция register позволяет нам присвоить имя нашей слэш-команде и сделать к ней описание (а также указать необходимые параметры для ввода), а в функции application_command мы проводим обработку вызова команды, выдавая в качестве ответа сообщение, которое будет видно только отправителю.

Осталось связать наш модуль ping с нашей предыдущей программой. Для этого нам необходимо добавить interaction_create метод в EventHandler трейт, который находится в main.rs файле:

mod ping;

use serenity::all::Interaction;
use serenity::builder::{CreateInteractionResponse, CreateInteractionResponseMessage};

#[async_trait]
impl EventHandler for Handler {
    async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
        match interaction {
            Interaction::Command(command) => {
                match command.data.name.as_str() {
                    "ping" => ping::command(ctx, command).await,
                    _ => {
                        command.create_response(&ctx.http, CreateInteractionResponse::Message(
                            CreateInteractionResponseMessage::new()
                                .ephemeral(true)
                                .content("Invalid command!")
                        ))
                        .await
                        .expect("failed to create response");
                    }
                }               
            }

            _ => (),
        }
    }

    async fn ready(&self, ctx: Context, ready: Ready) {
        println!("{} has connected!", ready.user.name);

        // Trying to get our guild
        let guild = ready.guilds[0];
        assert_eq!(guild.unavailable, true);
        let guild_id = guild.id;

        // We use guild application commands because
        // Command::create_global_application_command may take up
        // to an hour to be updated in the user slash commands list.
        guild_id.set_application_commands(&ctx.http, vec![
            ping::register(), 
        ])
        .await
        .expect("failed to create application command");
    }
}

Также не забываем зарегистрировать нашу команду в методе ready. Достаточно один раз зарегистрировать команду, и она там будет находится даже после перезапуска бота, но для простоты, мы её будем всегда её регистрировать, когда стартуем бота.

Примечание:
Если вы хотите удалить слэш-команду, используйте функцию GuildId::delete_application_command.

В результате мы получаем следующее:

А также слэш-команда появилась в серверных настройках нашего бота (Server Settings > Apps > Integrations):

Примечание:
Чтобы можно было видеть ID объектов (например, тех же слэш-команд), при нажатии правой кнопки по ним, необходимо включить режим разработчика в User Settings > App Settings > Advanced > Developer Mode.

Исходный код текущего этапа.

Реализация идеи крестиков-ноликов

Имея на руках опыт работы со слэш-командами, мы можем предположить, что можно создать команду /play, выводящую ephemeral сообщение для каждого человека, который её введет. Следовательно, каждый игрок, вводя эту команду, будет получать сообщение, сообщающее, что нужно дождаться второго игрока, или сразу будет начинать игровую сессию. Каждую игровую сессию мы будем хранить в Vec<Arc<Mutex<GameSession>>, где, для простоты примера, все элементы GameSession будут обернуты одним Mutex'ом. Для реализации движения на карте будет использоваться кнопки, а также не будем забывать про то, что их можно делать ненажимаемыми, следовательно можно будет уменьшить количество условий для проверки хода.

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

[dependencies]
image = "0.24"
imageproc = "0.23"
serenity = { git = "https://github.com/serenity-rs/serenity.git", branch = "next", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "unstable_discord_api"] }
tokio = { version = "1.22", features = ["rt-multi-thread"] }

Главная структура, которая будет хранить загруженные в память изображения крестика, нолика и полосок, которые будут перечеркивать победную троицу. Также мы сразу отрисуем игровое поле и запишем в new_game_canvas, которое мы будем потом переиспользовать каждый раз, когда будет начинаться новая игра. После каждого вызова слэш-команды /play мы будем проверять, хочет ли ещё кто-то поиграть в wait_user, если да, мы запустим новую сессию и добавим её в sessions, иначе положим нашего игрока подождать в поле wait_user:

use std::sync::Arc;

use image::{ImageBuffer, Rgba, Rgb};
use serenity::all::{UserId, CommandInteraction, Message};
use tokio::sync::Mutex;

#[derive(Default)]
pub struct Game {
    x_image: ImageBuffer<Rgb<u8>, Vec<u8>>,
    o_image: ImageBuffer<Rgb<u8>, Vec<u8>>,

    horizontal_scratch: ImageBuffer<Rgba<u8>, Vec<u8>>,
    vertical_scratch: ImageBuffer<Rgba<u8>, Vec<u8>>,
    diagonal_scratch_1: ImageBuffer<Rgba<u8>, Vec<u8>>, // Left to right
    diagonal_scratch_2: ImageBuffer<Rgba<u8>, Vec<u8>>, // Right to left

    new_game_canvas: ImageBuffer<Rgb<u8>, Vec<u8>>,

    wait_user: Mutex<Option<(UserId, CommandInteraction, String, Message)>>,

    sessions: Mutex<Vec<Arc<Mutex<GameSession>>>>,
}

Как можно было заметить, wait_user хранит не только id пользователя, но также CommandInteraction, которое мы будем изменять для изображения холста игры и кнопок. Третья String хранит имя пользователя (либо имя члена сервера, а если его нет - оригинальное имя). В то же время, можно было использовать следующее форматирование: <@USER_ID> - и хранить только UserId, но оно не работает в заголовках embed сообщений, так что мы будем запоминать имя нашего игрока при старте его игровой сессии. Последний элемент кортежа (Message) хранит сообщение, которое будет общедоступно всем, и будет показывать ход игры между двумя игроками.

Следующий код описывает структуру игровой сессии:

#[derive(Clone, Copy, PartialEq)]
enum GameCell {
    None,
    First,
    Second,
}

impl Default for GameCell {
    fn default() -> Self {
        GameCell::None
    }
}

struct GameSession {
    player: (UserId, CommandInteraction, String, Message), // Third element is a name of player
    player2: (UserId, CommandInteraction, String, Option<Message>), // No message in a same channel

    stage: usize,
    cursor_pos: usize,

    map: [GameCell; 9],
    canvas: ImageBuffer<Rgb<u8>, Vec<u8>>,
}

Из интересного, здесь имеется stage переменная, которая будет обозначать, кто сейчас ходит, а также cursor_pos переменная, хранящая позицию виртуального "курсора" игрока, который ходит в данный момент.

Простая инициализация, с подгрузкой следующих изображений из директории ресурсов:

(Их можно найти и скачать в GitHub репозитории)
(Их можно найти и скачать в GitHub репозитории)
impl Game {
    pub fn new() -> Self {
        let x_image = image::open("./resources/x.png").expect("x.png").into_rgb8();
        let o_image = image::open("./resources/o.png").expect("o.png").into_rgb8();

        let horizontal_scratch = image::open("./resources/1.png").expect("1.png").into_rgba8();
        let vertical_scratch = image::open("./resources/2.png").expect("2.png").into_rgba8();
        let diagonal_scratch_1 = image::open("./resources/3.png").expect("3.png").into_rgba8();
        let diagonal_scratch_2 = image::open("./resources/4.png").expect("4.png").into_rgba8();

        let new_game_canvas = draw_new_game_canvas();

        Self {
            x_image,
            o_image,

            horizontal_scratch,
            vertical_scratch,
            diagonal_scratch_1,
            diagonal_scratch_2,

            new_game_canvas,

            ..Default::default()
        }
    }
}

В функции draw_new_game_canvas() происходит отрисовка нашего игрового поля:

use imageproc::drawing::draw_filled_rect_mut;
use imageproc::rect::Rect;

fn draw_new_game_canvas() -> ImageBuffer<Rgb<u8>, Vec<u8>> {
    let mut canvas = ImageBuffer::new(300, 300);

    // Background
    draw_filled_rect_mut(
        &mut canvas,
        Rect::at(0, 0).of_size(300, 300),
        BACKGROUND,
    );

    draw_filled_rect_mut(
        &mut canvas,
        Rect::at(98, 0).of_size(4, 300),
        GRAY,
    );

    draw_filled_rect_mut(
        &mut canvas,
        Rect::at(198, 0).of_size(4, 300),
        GRAY,
    );

    draw_filled_rect_mut(
        &mut canvas,
        Rect::at(0, 98).of_size(300, 4),
        GRAY,
    );

    draw_filled_rect_mut(
        &mut canvas,
        Rect::at(0, 198).of_size(300, 4),
        GRAY,
    );

    canvas
}

Конечно, не забудем инициализировать наши слэш-команды. Также я решил добавить команду-заглушку /stop:

use serenity::all::CreateCommand;

impl Game {
	// ...
	
	pub fn register_play() -> CreateCommand {
		CreateCommand::new("play")
			.description("Start the game")
	}
	
	pub fn register_stop() -> CreateCommand {
		CreateCommand::new("stop")
			.description("Unimplemented")
	}

	// ...
}

Теперь нам предстоит написать один из самых важных методов для нашей программы: command(), который будет обрабатывать ввод слэш-команды /play и запускать игровую сессию:

use serenity::all::Context;

impl Game {
	// ...
	
	pub async fn command(&self, ctx: Context, interaction: CommandInteraction) {
		// ...
	}

	// ...
}

Начнём с отсеивания ненужных нам вариантов. В начале сразу отсеим команду /stop, выводя текст "Unimplemented!":

use serenity::all::{CreateInteractionResponse, CreateInteractionResponseMessage};

pub async fn command(&self, ctx: Context, interaction: CommandInteraction) {
	if interaction.data.name == "stop" {
		interaction.create_response(&ctx.http, CreateInteractionResponse::Message(
			CreateInteractionResponseMessage::new()
				.ephemeral(true)
				.content("Unimplemented!")
		))
		.await
		.unwrap();

		return;
	}

	if self.is_player_already_in_game(&ctx.http, &interaction).await {
		return;
	}

	// ...
}

Также проверим, есть ли наш игрок уже в какой-либо игре:

use serenity::all::{CreateEmbed, Http};

impl Game {
	// ...

	async fn is_player_already_in_game(&self, http: &Http, interaction: &CommandInteraction) -> bool {
        let message = CreateInteractionResponse::Message(
            CreateInteractionResponseMessage::new()
                .ephemeral(true)
                .embed(
                    CreateEmbed::new()
                        .title("Start a new game")
                        .description("You have already in the game. For starting a new game you should use the `/stop` command.")
                )
        );

        {
            if let Some(val) = self.wait_user.lock().await.as_ref() {
                if val.0 == interaction.user.id {
                    interaction.create_response(http, message)
                    .await
                    .unwrap();

                    return true;
                }
            }
        }

        let sessions = self.sessions.lock().await;

        for session in &*sessions {
            let session = session.lock().await;

            if session.player.0 == interaction.user.id
                || session.player2.0 == interaction.user.id
            {
                interaction.create_response(http, message)
                .await
                .unwrap();

                return true;
            }
        }

        false
    }

	// ...
}

Последовательно ищем такой же UserId в self.wait_user, а потом во всех уже идущих игровых сессиях. После того, как мы отсеяли ненужные нам варианты, мы проверяем, ждет ли кто-то ещё старта игры в self.wait_user, иначе мы записываем нашего текущего игрока в эту переменную:

use serenity::all::{CreateEmbedAuthor, CreateMessage};

pub async fn command(&self, ctx: Context, interaction: CommandInteraction) {
	// ...

	let (player, player2) = {
		let val = {
			self.wait_user.lock().await.take()
		};

		let name = match &interaction.member {
			Some(val) => val.nick.clone().unwrap_or_else(|| interaction.user.name.clone()),
			None => interaction.user.name.clone(),
		};

		if let Some(val) = val {
			interaction.create_response(&ctx.http, CreateInteractionResponse::Message(
				CreateInteractionResponseMessage::new()
					.ephemeral(true)
					.embed(
						CreateEmbed::new()
							.title("Please, wait")
					)
				)
			)
			.await
			.unwrap();

			// Channel ids are unique
			if interaction.channel_id != val.1.channel_id {
				let message = interaction.channel_id.send_message(&ctx.http, 
					CreateMessage::new()
						.embed(
							CreateEmbed::new()
								.title(
									format!(
										"The game between {} and {} in progress!",
										val.2,
										name,
									)
								)
						)
				)
				.await
				.unwrap();

				(
					val,
					(interaction.user.id, interaction, name, Some(message)),
				)
			}
			else {
				(
					val,
					(interaction.user.id, interaction, name, None),
				)
			}
		}
		else {
			let icon_url = interaction.user.avatar_url().unwrap_or_else(||
				interaction.user.default_avatar_url()
			);

			let message = interaction.channel_id.send_message(&ctx.http, CreateMessage::new()
				.embed(
					CreateEmbed::new()
					.author(
						CreateEmbedAuthor::new(name.clone())
							.icon_url(icon_url)
					)
					.title(format!("{} wants to play tic-tac-toe game!", name))
					.description("You can join to him/her/them by using the `/play` command.")
				)
			)
			.await
			.unwrap();

			interaction.create_response(&ctx.http, CreateInteractionResponse::Message(
				CreateInteractionResponseMessage::new()
					.ephemeral(true)
					.embed(
						CreateEmbed::new()
							.title("Please, wait for second player...")
					)
				)
			)
			.await
			.unwrap();

			*self.wait_user.lock().await = Some((interaction.user.id, interaction, name, message)); 
			return;
		}
	};

	// ...
}

После того, как мы дождались двух игроков, мы добавляем их в общий массив всех игровых сессий:

pub async fn command(&self, ctx: Context, interaction: CommandInteraction) {
	// ...

	 let new_game = Arc::new(Mutex::new(GameSession {
		player,
		player2,

		stage: 0,
		cursor_pos: 4,

		map: Default::default(),
		canvas: self.new_game_canvas.clone(),
	}));

	{
		self.sessions.lock().await.push(Arc::clone(&new_game));
	}

	self.process_session(&ctx.http, &mut *new_game.lock().await).await;
}

И под конец вызываем метод self.process_session(), который будет отображать первый кадр нашей игры:

impl Game {
	async fn process_session(&self, http: &Http, session: &mut GameSession) {
        match session.stage {
            0 => {
                show_game_message(
                    http,
                    &session.player.1,
                    session.cursor_pos,
                    &session.map,
                    &session.canvas,
                ).await;

                show_wait_and_common_message(
                    http,
                    &session.player2.1,
                    &session.canvas,
                    &session.player.2,
                    &session.player2.2,
                    &mut session.player.3,
                    session.player2.3.as_mut(),
                ).await;
            }
            1 => {
                show_game_message(
                    http,
                    &session.player2.1,
                    session.cursor_pos,
                    &session.map,
                    &session.canvas,
                ).await;

                show_wait_and_common_message(
                    http,
                    &session.player.1,
                    &session.canvas,
                    &session.player.2,
                    &session.player2.2,
                    &mut session.player.3,
                    session.player2.3.as_mut(),
                ).await;
            }
            _ => unreachable!(),
        }
    }
}

Ниже я сразу покажу три похожие функции, которые обновляют окно у игрока в зависимости от того, ждёт ли он хода или уже ходит:

use serenity::all::{ComponentInteraction, EditInteractionResponse, EditMessage};

async fn show_wait_and_common_message(
    http: &Http,
    interaction: &CommandInteraction,
    canvas: &ImageBuffer<Rgb<u8>, Vec<u8>>,
    player_name: &str,
    player2_name: &str,
    common_message: &mut Message,
    common_message2: Option<&mut Message>,
) {
    let embed = CreateEmbed::new()
        .title("Game in process")
        .description("Waiting for your turn.")
        .thumbnail("attachment://thumbnail.png");

    let action_row = generate_disabled_action_row();
    let attachment = generate_attachment_rgb8(canvas, "canvas.png");

    interaction.edit_response(http, EditInteractionResponse::new()
        .add_embed(embed)
        .components(vec![action_row])
        .new_attachment(attachment.clone())
    ).await.unwrap();

    let edited_message = EditMessage::new()
        .embed(CreateEmbed::new()
            .title(format!(
                "Game between {} and {} in the progress!",
                player_name,
                player2_name,
            ))
            .description("You can play this game too by using the `/play` command.")
            .attachment("canvas.png")
        )
        .attachment(attachment);

    if let Some(val) = common_message2 {
        val.edit(http, edited_message.clone()).await.unwrap();
    }

    common_message.edit(http, edited_message).await.unwrap();
}

async fn show_game_message(
    http: &Http,
    interaction: &CommandInteraction,
    cursor_pos: usize,
    map: &[GameCell],
    canvas: &ImageBuffer<Rgb<u8>, Vec<u8>>,
) {
    let embed = CreateEmbed::new()
    .title("Your turn")
    .description("Press arrows buttons for moving selection square.");

    let action_row = if map[cursor_pos] != GameCell::None {
        generate_game_action_row(true, cursor_pos)
    }
    else {
        generate_game_action_row(false, cursor_pos)
    };

    let mut cloned = canvas.clone();

    draw_select_outline(&mut cloned, cursor_pos);

    interaction.edit_response(http, EditInteractionResponse::new()
        .embed(embed)
        .components(vec![action_row])
        .new_attachment(generate_attachment_rgb8(&cloned, "canvas.png"))
    )
    .await
    .unwrap();
}

async fn update_game_message(http: &Http, interaction: &ComponentInteraction, session: &GameSession) {
    let embed = CreateEmbed::new()
        .title("Your turn")
        .description("Press arrows buttons for moving selection square.");

    let action_row = if session.map[session.cursor_pos] != GameCell::None {
        generate_game_action_row(true, session.cursor_pos)
    }
    else {
        generate_game_action_row(false, session.cursor_pos)
    };

    let mut cloned = session.canvas.clone();

    draw_select_outline(&mut cloned, session.cursor_pos);

    interaction.edit_response(http, EditInteractionResponse::new()
        .embed(embed)
        .components(vec![action_row])
        .new_attachment(generate_attachment_rgb8(&cloned, "canvas.png"))
    )
    .await
    .unwrap();
}

Можно было заметить, что функция update_game_message() использует ComponentInteraction, а не CommandInteraction. Это связано с тем, что нажатия кнопок, выбор элемента из раскрывающего списка и ввод текста вызывают Message Component interaction, а не Application Command interaction, как при вызове слэш-команд. В дальнейшем мы напишем функцию, которая будет обрабатывать нажатия кнопок.

Третья функция update_game_message() используется только когда игрок, который ходит, двигается по карте:

Для отрисовки красного квадрата выбора клетки, воспользуемся следующей функцией:

fn draw_select_outline(canvas: &mut ImageBuffer<Rgb<u8>, Vec<u8>>, cell: usize) {
    match cell {
        0 => {
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 + 98, CELLS[cell].1).of_size(4, 102), 
                RED,
            );
    
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0, CELLS[cell].1 + 98).of_size(98, 4), 
                RED,
            );
        }

        1 => {
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 + 98, CELLS[cell].1).of_size(4, 102), 
                RED,
            );
    
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 + 98).of_size(100, 4), 
                RED,
            );

            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1).of_size(4, 98), 
                RED,
            );
        }

        2 => {
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 + 98).of_size(102, 4), 
                RED,
            );

            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1).of_size(4, 98), 
                RED,
            );
        }

        3 => {
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0, CELLS[cell].1 - 2).of_size(102, 4), 
                RED,
            );

            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 + 98, CELLS[cell].1 + 2).of_size(4, 100), 
                RED,
            );
    
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0, CELLS[cell].1 + 98).of_size(98, 4), 
                RED,
            );
        }

        4 => {
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 - 2).of_size(104, 4), 
                RED,
            );

            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 + 98, CELLS[cell].1 + 2).of_size(4, 100), 
                RED,
            );
    
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 + 98).of_size(100, 4), 
                RED,
            );

            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 + 2).of_size(4, 96), 
                RED,
            );
        }

        5 => {
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 - 2).of_size(102, 4), 
                RED,
            );

            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 + 98).of_size(102, 4), 
                RED,
            );

            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 + 2).of_size(4, 96), 
                RED,
            );
        }

        6 => {
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0, CELLS[cell].1 - 2).of_size(102, 4), 
                RED,
            );

            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 + 98, CELLS[cell].1 + 2).of_size(4, 98), 
                RED,
            );
        }

        7 => {
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 - 2).of_size(104, 4), 
                RED,
            );

            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 + 98, CELLS[cell].1 + 2).of_size(4, 98), 
                RED,
            );
    
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 + 2).of_size(4, 98), 
                RED,
            );
        }

        8 => {
            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 - 2).of_size(102, 4), 
                RED,
            );

            draw_filled_rect_mut(
                canvas,
                Rect::at(CELLS[cell].0 - 2, CELLS[cell].1 + 2).of_size(4, 98), 
                RED,
            );
        }

        _ => unreachable!(),
    }
}

Где массив CELLS - захардкоженные значения позиций ячеек:

const CELLS: [(i32, i32); 9] = [
    (0, 0),
    (100, 0),
    (200, 0),

    (0, 100),
    (100, 100),
    (200, 100),

    (0, 200),
    (100, 200),
    (200, 200),
];

Функции (generate_disabled_action_row() и generate_game_action_row()) особого интереса не представляют, кроме того, что я решил не убирать кнопки игроку, ожидающему своей очереди, а просто их отключать:

use serenity::all::{ButtonStyle, CreateActionRow, CreateButton};

fn generate_disabled_action_row() -> CreateActionRow {
    let left = CreateButton::new("left")
        .label("←")
        .style(ButtonStyle::Secondary)
        .disabled(true);
    
    let down = CreateButton::new("down")
        .label("↓")
        .style(ButtonStyle::Secondary)
        .disabled(true);

    let up = CreateButton::new("up")
        .label("↑")
        .style(ButtonStyle::Secondary)
        .disabled(true);

    let right = CreateButton::new("right")
        .label("→")
        .style(ButtonStyle::Secondary)
        .disabled(true);

    let send = CreateButton::new("send")
        .label("Send")
        .style(ButtonStyle::Primary)
        .disabled(true);

    let action_row = CreateActionRow::Buttons(vec![
        left,
        down,
        up,
        right,
        send,
    ]);

    action_row
}

fn generate_game_action_row(send_disabled: bool, cursor_position: usize) -> CreateActionRow {
    let mut left = CreateButton::new("left")
        .label("←")
        .style(ButtonStyle::Secondary);
    
    if [0, 3, 6].contains(&cursor_position) {
        left = left.disabled(true);
    }
    
    let mut down = CreateButton::new("down")
        .label("↓")
        .style(ButtonStyle::Secondary); 

    if cursor_position >= 6 {
        down = down.disabled(true);
    }

    let mut up = CreateButton::new("up")
        .label("↑")
        .style(ButtonStyle::Secondary);

    if cursor_position <= 2 {
        up = up.disabled(true);
    }

    let mut right = CreateButton::new("right")
        .label("→")
        .style(ButtonStyle::Secondary); 

    if [2, 5, 8].contains(&cursor_position) {
        right = right.disabled(true);
    }

    let send = CreateButton::new("send")
        .label("Send")
        .style(ButtonStyle::Primary)
        .disabled(send_disabled);

    let action_row = CreateActionRow::Buttons(vec![
        left,
        down,
        up,
        right,
        send,
    ]);

    action_row
}

Осталась одна нераскрытая функция, которая позволит создать вложение (CreateAttachment) для нашего сообщения:

use std::io::{BufWriter, Cursor};
use image::{ColorType, ImageOutputFormat};
use serenity::all::CreateAttachment;

fn generate_attachment(image: &[u8], width: u32, height: u32, name: &'static str, color_type: ColorType) -> CreateAttachment {
    let buffer = Vec::new();
    let cursor = Cursor::new(buffer);
    let mut buffered_writer = BufWriter::new(cursor);

    image::write_buffer_with_format(
        &mut buffered_writer,
        &image,
        width,
        height,
        color_type,
        ImageOutputFormat::Png,
    )
    .expect("failed to write in buffer");

    let buffer = buffered_writer.into_inner().unwrap().into_inner();

    CreateAttachment::bytes(buffer, name)
}

fn generate_attachment_rgb8(image: &ImageBuffer<Rgb<u8>, Vec<u8>>, name: &'static str) -> CreateAttachment {
    generate_attachment(image, image.width(), image.height(), name, ColorType::Rgb8)
}

Осталось уже совсем немного. Обработаем Message Component interaction:

impl Game {
	// ...

	pub async fn component(&self, ctx: Context, component: ComponentInteraction) {
        // We are calling this because we are editing the component
        // interaction or answering to the original interaction in the progress_game()
        component.create_response(&ctx.http, CreateInteractionResponse::Acknowledge).await.unwrap();

        let original_session = self.get_current_game(&component).await.unwrap();
        let mut session = original_session.lock().await;

        match component.data.custom_id.as_str() {
            "left" => {
                if ![0, 3, 6].contains(&session.cursor_pos) {
                    session.cursor_pos -= 1;
                }

                update_game_message(&ctx.http, &component, &session).await;
            }

            "down" => {
                if session.cursor_pos <= 5 {
                    session.cursor_pos += 3
                }

                update_game_message(&ctx.http, &component, &session).await;
            }

            "up" => {
                if session.cursor_pos >= 3 {
                    session.cursor_pos -= 3
                }

                update_game_message(&ctx.http, &component, &session).await;
            }

            "right" => {
                if ![2, 5, 8].contains(&session.cursor_pos) {
                    session.cursor_pos += 1
                }

                update_game_message(&ctx.http, &component, &session).await;
            }

            "send" => {
                'condition: {
                    if component.user.id == session.player.0 {
                        if session.map[session.cursor_pos] != GameCell::None { // Unreachable in default situation
                            break 'condition;
                        }

                        let cursor_pos = session.cursor_pos;
                        session.map[cursor_pos] = GameCell::First;
                        self.draw_x(&mut session.canvas, cursor_pos);
                    }
                    else {
                        if session.map[session.cursor_pos] != GameCell::None {
                            break 'condition;
                        }

                        let cursor_pos = session.cursor_pos;
                        session.map[cursor_pos] = GameCell::Second;
                        self.draw_o(&mut session.canvas, cursor_pos);
                    }
                };

                let map = &session.map;
                
                // Checking for win
                // 0 1 2
                // 3 4 5
                // 6 7 8
                let (win_player, id) = if map[0] != GameCell::None && (map[0] == map[1]) && (map[1] == map[2]) {
                    (map[0], 0)
                }
                else if map[3] != GameCell::None && (map[3] == map[4]) && (map[4] == map[5]) {
                    (map[3], 1)
                }
                else if map[6] != GameCell::None && (map[6] == map[7]) && (map[7] == map[8]) {
                    (map[6], 2)
                }

                else if map[0] != GameCell::None && (map[0] == map[3]) && (map[3] == map[6]) {
                    (map[0], 3)
                }
                else if map[1] != GameCell::None && (map[1] == map[4]) && (map[4] == map[7]) {
                    (map[1], 4)
                }
                else if map[2] != GameCell::None && (map[2] == map[5]) && (map[5] == map[8]) {
                    (map[2], 5)
                }

                else if map[0] != GameCell::None && (map[0] == map[4]) && (map[4] == map[8]) {
                    (map[0], 6)
                }
                else if map[2] != GameCell::None && (map[2] == map[4]) && (map[4] == map[6]) {
                    (map[2], 7)
                }

                else {
                    let mut was_none = false;
                    for cell in map {
                        if *cell == GameCell::None {
                            was_none = true;
                            break;
                        }
                    }
                    
                    if !was_none {
                        let message = EditMessage::new()
                            .add_embed(CreateEmbed::new()
                                .title(
                                    format!(
                                        "The game between {} and {} has finished!",
                                        session.player.2,
                                        session.player2.2,
                                    )
                                )
                                .description("No one wins!")
                                .attachment("canvas.png")
                            )
                            .attachment(generate_attachment_rgb8(&session.canvas, "canvas.png"));

                        self.end_game_with_message(&ctx.http, &mut session, &original_session, message).await;
                        return;
                    }

                    session.stage = (session.stage + 1) % 2;
                    session.cursor_pos = 4;

                    self.process_session(&ctx.http, &mut session).await;
                    return;
                };

                let attachment = self.generate_end_attachment(&mut session, id).await;

                match win_player {
                    GameCell::First => {
                        let message = EditMessage::new()
                            .add_embed(CreateEmbed::new()
                                .title(
                                    format!(
                                        "The game between {} and {} has finished!",
                                        session.player.2,
                                        session.player2.2,
                                    )
                                )
                                .description(format!("💥 {} has won! 💥", session.player.2))
                                .attachment("canvas.png")
                            )
                            .attachment(attachment);

                        self.end_game_with_message(&ctx.http, &mut session, &original_session, message).await;
                    },
                    GameCell::Second => {
                        let message = EditMessage::new()
                            .add_embed(CreateEmbed::new()
                                .title(
                                    format!(
                                        "The game between {} and {} has finished!",
                                        session.player.2,
                                        session.player2.2,
                                    )
                                )
                                .description(format!("💥 {} has won! 💥", session.player.2))
                                .attachment("canvas.png")
                            )
                            .attachment(attachment);

                        self.end_game_with_message(&ctx.http, &mut session, &original_session, message).await;
                    },
                    GameCell::None => unreachable!(),
                }
            }
            _ => unreachable!(),
        }
    }

	// ...
}

Не забываем указать некоторые вспомогательные методы:

use imageproc::drawing::Canvas;

impl Game {
	// ...
	
	async fn get_current_game(&self, message_component: &ComponentInteraction) -> Option<Arc<Mutex<GameSession>>> {
        let sessions = self.sessions.lock().await;

        let mut has_game = None;
        for session in sessions.iter() {
            let session_lock = session.lock().await;
            if session_lock.player.0 == message_component.user.id || 
                session_lock.player2.0 == message_component.user.id
            {
                has_game = Some(Arc::clone(session));
            }
        }

        has_game
    }
    
    fn draw_x(&self, image: &mut ImageBuffer<Rgb<u8>, Vec<u8>>, cell_index: usize) {
        for y in 0..80 {
            for x in 0..80 {
                image.draw_pixel(
                    CELLS[cell_index].0 as u32 + 10 + x,
                    CELLS[cell_index].1 as u32 + 10 + y,
                    *self.x_image.get_pixel(x, y),
                );
            }
        }
    }
    
    fn draw_o(&self, image: &mut ImageBuffer<Rgb<u8>, Vec<u8>>, cell_index: usize) {
        for y in 0..80 {
            for x in 0..80 {
                image.draw_pixel(
                    CELLS[cell_index].0 as u32 + 10 + x,
                    CELLS[cell_index].1 as u32 + 10 + y,
                    *self.o_image.get_pixel(x, y),
                );
            }
        } 
    }

	// ...
}

Мы были обязаны указать у каждой кнопки свой custom id, который нам пригодился здесь, для того, чтобы понять какая кнопка была нажата. Тут же мы проверяем, не закончилась ли наша игра победой одного из игрока, либо ничьей. В конце удаляем у каждого игрока ephemeral сообщения, оставляя только то, которое было доступно всем, в котором пишем результат прошедшей игры:

impl Game {
	// ...
	async fn generate_end_attachment(&self, session: &mut GameSession, id: u32) -> CreateAttachment {        
        match id {
            0..=2 => {
                for y in 100 * id..100 * (id + 1) {
                    for x in 0..300 {
                        fill_pixel(&mut session.canvas, &self.horizontal_scratch, x, y);
                    }
                }
            }

            3..=5 => {
                for y in 0..300 { 
                    for x in 100 * (id - 3)..100 * (id - 2) {
                        fill_pixel(&mut session.canvas, &self.vertical_scratch, x, y);
                    }
                }
            }

            6 => {
                for y in 0..300 { 
                    for x in 0..300 {
                        fill_pixel(&mut session.canvas, &self.diagonal_scratch_1, x, y);
                    }
                }
            }

            7 => {
                for y in 0..300 { 
                    for x in 0..300 {
                        fill_pixel(&mut session.canvas, &self.diagonal_scratch_2, x, y);
                    }
                }
            }

            _ => unreachable!(),
        }

        generate_attachment_rgb8(&session.canvas, "canvas.png")
    }

    async fn end_game_with_message(&self, http: &Http, session: &mut GameSession, original_session: &Arc<Mutex<GameSession>>, message: EditMessage) {
        session.player.1.delete_response(http).await.unwrap();
        session.player2.1.delete_response(http).await.unwrap();

        if let Some(val) = &mut session.player2.3 {
            val.edit(http, message.clone()).await.unwrap();
        }

        session.player.3.edit(http, message).await.unwrap();
        
        let mut games = self.sessions.lock().await;
        let pos = games.iter().position(|val| Arc::ptr_eq(val, original_session));
        games.swap_remove(pos.unwrap());
    }
}

В методе generate_end_attachment() рендеринг происходит следующим интересным образом: мы берём полупрозрачное изображении нашей полоски (которая перечеркивает победную троицу) и накладываем на последнюю версию холста при помощи функции fill_pixel():

fn fill_pixel(canvas: &mut ImageBuffer<Rgb<u8>, Vec<u8>>, scratch: &ImageBuffer<Rgba<u8>, Vec<u8>>, x: u32, y: u32) {
    let pixel = canvas.get_pixel(x, y).0;
    let pixel2 = scratch.get_pixel(x, y).0;

    let alpha = pixel2[3] as f32 / 255.0;
    let mut output = Rgb([0, 0, 0]);

    for i in 0..=2 {
        let pixel_f32 = pixel[i] as f32 / 255.0;
        let pixel2_f32 = pixel2[i] as f32 / 255.0;

        output.0[i] = ((pixel_f32 * (1.0 - alpha) + pixel2_f32 * alpha) * 255.0).clamp(0.0, 255.0) as u8;
    }

    canvas.draw_pixel(x, y, output);
}

Нам этом точно всё. То есть всё с модулем game.rs, мы с ним покончили. Осталось только подправить main.rs и запустить нашего бота!:

// main.rs
mod game;

use game::Game;

struct Handler {
    game: Game,
}

impl Handler {
    fn new() -> Self {
        Self {
            game: Game::new(),
        }
    }
}

#[async_trait]
impl EventHandler for Handler {
    async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
        match interaction {
            Interaction::Command(command) => {
                match command.data.name.as_str() {
                    "ping" => ping::command(ctx, command).await,
                    "play" => self.game.command(ctx, command).await,
                    _ => {
                        command.create_response(&ctx.http, CreateInteractionResponse::Message(
                            CreateInteractionResponseMessage::new()
                                .ephemeral(true)
                                .content("Invalid command!")
                        ))
                        .await
                        .expect("failed to create response");
                    }
                }               
            }

            Interaction::Component(component) => {
                self.game.component(ctx, component).await;
            }

            _ => (), // Now other variants are not important
        }
    }

    async fn ready(&self, ctx: Context, ready: Ready) {
        println!("{} has connected!", ready.user.name);

        // Trying to get our guild
        let guild = ready.guilds[0];
        assert_eq!(guild.unavailable, true);
        let guild_id = guild.id;

        // We use guild application commands because
        // Command::create_global_application_command may take up
        // to an hour to be updated in the user slash commands list.
        guild_id.set_application_commands(&ctx.http, vec![
            Game::register_play(),
            Game::register_stop(),
            ping::register(), 
        ])
        .await
        .expect("failed to create application command");
    }
}

#[tokio::main]
async fn main() {
    let intents = GatewayIntents::GUILD_MESSAGES
        | GatewayIntents::MESSAGE_CONTENT;

    let mut client = Client::builder(include_str!("./../token.txt"), intents)
        .event_handler(Handler::new()) // Calling the new() function
        .await
        .expect("Failed to create client!");

    if let Err(err) = client.start().await {
        eprintln!("Client error: {err:?}");
    }
}

Исходники бота.

Запускаем бота и наблюдаем следующий результат:

Можно заметить, что взаимодействия в Discord имеют довольно большой таймаут. Предполагаю, что ещё вносит свою лепту то, что мы меняем изображение, это тоже делает задержку. Так что для каких-то очень динамичных игр это не подойдет. Конечно, сейчас уже доступны (по подписке Nitro) активности в голосовых каналах, правда которые могут разрабатывать только команда Discord. Также я не уверен, что мы в скором времени увидим их общедоступными для всех пользователей и разработчиков, хотя я бы посмотрел на это. Но главную цель этого туториала - познакомиться с уже доступными возможностями для разработки своего собственного бота - мы выполнили.

Спасибо, что прочитали эту статью. Могу от себя сказать, что я наконец-то понял, насколько тяжело писать статьи. Надеюсь, и эта кому-то поможет или даст вдохновение. Пишите в комментариях ваши замечения и отправляйте при помощи CTRL + Enter найденные вами очепятки!

UPD:
@TheDanikReal в комментариях подметил, что можно было создать ряд из кнопок (3x3), т.к. можно создать несколько экземпляров(до 5) ActionRow и добавить их к сообщению. Изначально я не сделал так, так как знал только то, что можно к ActionRow до 5 кнопок. Если реализовать идею @TheDanikReal из комментариев (сделать 9 кнопок, по одной кнопке на одну ячейку поля) то можно получить более динамичный и интересный геймплей из-за уменьшения задержек во время игры.

Ссылки:

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы оцениваете перспективу активностей в голосовых чатах?
25% Перспективная вещь2
50% Будет более полезна, когда дадут возможность создавать их не только разработчикам Discord4
12.5% Бесполезна / Не имеет большого смысла1
12.5% Не знаю / Не слышал об этой возможности1
Проголосовали 8 пользователей. Воздержался 1 пользователь.
Теги:
Хабы:
Всего голосов 9: ↑9 и ↓0+9
Комментарии4

Публикации