Вы когда-нибудь хотели создать свой бот для игры в крестики-нолики в 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
), представленные в следующей табличке:
Взаимодействие |
Краткое описание |
---|---|
Необходимо для взаимодействия с |
|
- Ввод с чата (слэш-команды) |
|
Обработка нажатия кнопок, выбора элемента из выпадающего списка |
|
Автодополнение слэш-команд |
|
Обработка ввода текста из всплывающих модальных окон |
Давайте рассмотрим некоторые из них, необходимые для нашего бота, более подробней. Например, Slash commands
выглядят следующим образом:
Они позволяют более удобно пользоваться Discord ботами: они дают возможность добавлять кастомные параметры, которые будут подсвечивать необходимые варианты, настраивать уровни доступа к команде в настройках сервера (Server Settings > Apps > Integrations). При помощи них мы сделаем команду по вызову игры: /play
.
Также одним из важных нововведений для разработчиков ботов было создание временных сообщений (ephemeral
), которые видны только человеку, который отправил слэш-команду. Это позволило создать большое количество интересных ботов, которые теперь также можно подключить при помощи двух кликов (Server Settings > Apps > App Directory). Конкретно в нашей ситуации, нам это пригодится в качестве инструмента для ввода нашего хода.
Последний необходимый нам функционал заложен в Message Components
. При помощи него, мы можем добавлять добавлять кнопки, выпадающий список и модальное окно ввода текста. Отсюда нам пригодятся только кнопки, которые будут позволять нам двигаться по игровому полю (3x3), а также отправлять наш ход противнику.
Осталось разобраться, что мы можем отправлять в качестве ответа на Interaction
. Приведу следующую, немного переработанную, табличку:
Interaction Callback Type |
CreateInteractionResponse |
Описание |
---|---|---|
PONG |
|
Подтвердить |
CHANNEL_MESSAGE_WITH_SOURCE |
|
Ответить на взаимодействие сообщением |
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE |
|
Подтвердить взаимодействие, и изменить |
DEFERRED_UPDATE_MESSAGE |
|
[Для компонентов] Подтвердить взаимодействие и изменить оригинальное сообщение позже. Пользователь не будет видеть состояние загрузки |
UPDATE_MESSAGE |
|
[Для компонентов] Изменить сообщение, компонент который был приложен к нему |
APPLICATION_COMMAND_AUTOCOMPLETE_RESULT |
|
Ответить на взаимодействие автодополнения с предложенными вариантами |
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
переменная, хранящая позицию виртуального "курсора" игрока, который ходит в данный момент.
Простая инициализация, с подгрузкой следующих изображений из директории ресурсов:
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
найденные вами очепятки!
Siddthartha
в последнем листинге дублирование блока с объявлением Handler
dpytaylo Автор
Спасибо, поправил!