В прошлой статье я попробовал сделать интеграцию больших языковых моделей с XWiki. В этом деле мне очень помог проект Open LLM. По хорошему дело сделано, можно расходится, но поскольку на моем стареньком компьютере нейронка скачивалась и разворачивалась мучительно долго, внутренняя жаба не позволяет все бросить, так и не найдя ей еще какого-нибудь применения.
Беглый поиск по Хабру, выявил дефицит статей по Godot, а ведь это очень достойный игровой движок с открытым исходным кодом. Простой как швейцарские часы и запускающийся чуть ли не на кофеварке.
Поэтому сегодня мы напишем примитивную игру на движке Godot, в которой с помощью REST API попросим нейронную сеть загадывать нам слова из 5 букв.
Оглавление:
Подготовительные работы
Пусть вас не смущает заголовок, на самом деле к игре можно будет подключить не только аналоги ChatGPT, но и сами языковые модели от Open AI. В принципе подойдет любое решение, поддерживающее спецификацию Open AI API. Просто мне не хотелось заморачиваться с получением ключей и обходом блокировок, поэтому я развернул доступную модель на своём компьютере.
В качестве сервера для работы с API языковых моделей я использовал OpenLLM, потому что там мне сразу на глаза попались модели для моего старого компьютера. Подробней его установку и загрузку моделей я уже разбирал в прошлой статье, поэтому не буду повторяться.
Также для разработки игры я использовал Godot 4.3, но думаю, что проект в будет работать в любой версии Godot 4.
Сценарий игры достаточно прост.
Godot отправляет REST запрос к нейросети, она возвращает нам слово. Игрок должен его угадать (ну или нет). Затем мы сохраним результат переписки чтобы нейросеть не повторялась при каждом новом запуске игры. А дальше всё по кругу.
Конечно, мы могли сделать реализацию без нейросетей. Просто случайно выбирая слова из словаря, но об этом мне было скучно писать.
Почти все формальности улажены, но прежде чем приступить к разработке осталось озвучит традиционный дисклеймер.
Я сам новичок в Godot. Я не профессиональный разработчик, поэтому решения представленные в статье, это только минимально жизнеспособная демонстрация концепта, чтобы кто-то мог вдохновится и открыть для себя Godot. Не стоит рассматривать данный пример как хорошую практику разработки.
Разработка игры в Godot
Структура проекта
В этот раз у нас не будет какой-то структуры проекта, потому что мы используем всего одну сцену и необязательный background, чтобы в игре были нескучные обои.
Поскольку игра совсем«не про красоту» я не буду останавливаться на всех элементах интерфейса.
Разберем, только те что вложены в корневой узел Game и участвуют в логике игры:
HTTPRequest – узел для запроса REST API и обработки ответа.
NewGame (Button) – кнопка для начала новой игры.
LlmWord (label) – слово загаданное нейросетью / статус операции.
LineEdit – поле для ввода ответа
EnterButton (Button) – кнопка для отправки ответа
ResultLabel (label) – поле для отображения результата (угадал не угадал)
NextWord (Button) – кнопка для запроса нового слова.
N.B. Если название и тип узла не совпадают тип указан в скобках.
Со структурой разобрались. Думаю. можно переходить непосредственно к логике.
Исходный код
Как всегда полностью код проекта можно скачать с GitHub.
Вся логика уместилась в скрипте game.gd
Полный код скрипта
extends Control
# game parameters
enum State {
START, LOADING, LOADED, NOT_LOADED, GUESSED, NOT_GUESSED, LLM_ERROR
}
var word:String
const URL := "http://localhost:3000/v1/chat/completions"
const SAVE_PATH := "user://save_progress.json"
const HEX_GREEN :="#11FF11"
const HEX_RED :="#FF2211"
const GENERATE_CONTENT = "Generate a random 5-letter noun. Output the answer only in JSON format as follows: { “result”: the generated word}"
const START_CONTENT = "Start new game"
# model request parameters
var headers = ["Content-Type: application/json", "Cache-Control: no-cache'"]
var request = {
"messages":[
],
"model": "unsloth/Llama-3.2-1B-Instruct-GGUF",
"mmax_tokens": 2048,
"stop": null,
"stream":false,
"temperature": 0.8,
"do_sample":true,
"top_p": 0.5,
"frequency_penalty": 1
}
func change_state(state:State, data={}):
"""change parameters of scene nodes.
Args:
state: Enum State
data: object for additional data (used for recieve llm hiden word)
Returns:
None
"""
%EnterButton.disabled = true
match state:
State.START:
#get history of messages, or create new if not exists
if FileAccess.file_exists(SAVE_PATH):
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
var content = file.get_as_text()
file.close()
var result_json = JSON.parse_string(content)
request["messages"]=result_json
else:
request["messages"]= [
{
"role": "user",
"content": START_CONTENT
}
]
_on_next_word_pressed()
State.LOADING:
%LlmWord.text = "Loading..."
%LineEdit.text="";
%ResultLabel.text = ""
State.LOADED:
save_messages_to_json(request["messages"], SAVE_PATH)
%LlmWord.text = data["llmWord"]
%EnterButton.disabled = true
%LineEdit.text="";
%LineEdit.editable=true;
State.NOT_LOADED:
save_messages_to_json(request["messages"], SAVE_PATH)
%LlmWord.text = 'Please press button "Next word"'
State.GUESSED:
%LlmWord.text = data["llmWord"]
%EnterButton.disabled = true
%ResultLabel.set("theme_override_colors/font_color",HEX_GREEN);
%ResultLabel.text = "Guessed!"
%LineEdit.editable=false;
State.NOT_GUESSED:
%LlmWord.text = data["llmWord"]
%EnterButton.disabled = true
%ResultLabel.set("theme_override_colors/font_color",HEX_RED);
%ResultLabel.text = "Wrong answer!"
%LineEdit.editable=false;
State.LLM_ERROR:
%LlmWord.text = "Error: LLM not response"
_:
print("Default case: No specific match found.")
func save_messages_to_json(data, file_path) -> bool:
"""Saves the given data to a JSON file.
Args:
data: The data to save (a dictionary).
file_path: The path to the JSON file (relative to the project directory).
Returns:
True if the save was successful, False otherwise.
"""
# IMPORTANT: Check if the path is valid.
if !file_path.begins_with("user://"):
print("Invalid file path (must start with 'user://')")
return false
var file = FileAccess.open(file_path, FileAccess.WRITE)
if file:
var json_data =JSON.stringify(data)
file.store_string(json_data)
file.close()
return true
else:
print("Could not open file for writing:", file_path)
return false
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
change_state(State.START)
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
pass
func _on_new_game_pressed() -> void:
request["messages"]= [
{
"role": "user",
"content": START_CONTENT
}
]
save_messages_to_json(request["messages"], SAVE_PATH)
_on_next_word_pressed()
func _on_next_word_pressed() -> void:
change_state(State.LOADING)
request["messages"].append({
"role": "user",
"content": GENERATE_CONTENT
})
$HTTPRequest.request(URL, headers, HTTPClient.METHOD_POST, JSON.stringify(request))
func _on_http_request_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
if response_code == 200:
# get message from llm
var json = JSON.parse_string(body.get_https://docs.google.com/document/d/1EBjytsBqtMY2LKeVNytRXPepqiSmQbffiyh2bjkeerA/edit?tab=t.0#heading=h.2x0l6l41mhrvstring_from_utf8())
var message = json["choices"][0]["message"]
var content = JSON.parse_string(message["content"])
# check what llm response with result object
if (content != null) and ("result" in content):
word = content["result"].to_lower()
if word.length() == 5:
request["messages"].append(message)
var masked_word = ""
for i in range(word.length()):
if i % 2 != 0: # Check if index is odd
masked_word += "*" # Change to desired character
else:
masked_word += word[i] # keep original character
change_state(State.LOADED,{"llmWord":masked_word})
else:
change_state(State.NOT_LOADED)
else:
change_state(State.NOT_LOADED)
else:
change_state(State.LLM_ERROR)
func _on_enter_button_pressed() -> void:
if word == %LineEdit.text.to_lower():
change_state(State.GUESSED,{"llmWord":word})
else:
change_state(State.NOT_GUESSED,{"llmWord":word})
# Called when user are typing symbol in LineEdit
func _on_line_edit_text_changed(new_text: String) -> void:
if new_text.length() == 5:
%EnterButton.disabled = false
else:
%EnterButton.disabled = true
# Called when user press Enter in LineEdit
func _on_line_edit_text_submitted(new_text: String) -> void:
if new_text.length() == 5:
_on_enter_button_pressed()
Давайте разберем по частям наиболее важные моменты:
enum State {
START, LOADING, LOADED, NOT_LOADED, GUESSED, NOT_GUESSED, LLM_ERROR
}
var word:String
const URL := "http://localhost:3000/v1/chat/completions"
const SAVE_PATH := "user://save_progress.json"
const HEX_GREEN :="#11FF11"
const HEX_RED :="#FF2211"
const GENERATE_CONTENT = "Generate a random 5-letter noun. Output the answer only in JSON format as follows: { “result”: the generated word}"
const START_CONTENT = "Start new game"
Переменные и константы не связанные с LLM разбирать не будем, они достаточно очевидны.
А вот несколько параметров запроса к языковой модели кратко разберем:
var headers = ["Content-Type: application/json", "Cache-Control: no-cache'"]
var request = {
"messages":[
],
"model": "unsloth/*****-3.2-1B-Instruct-GGUF",
"mmax_tokens": 2048,
"stop": null,
"stream":false,
"temperature": 0.8,
"do_sample":true,
"top_p": 0.5,
"frequency_penalty": 1
}
headers
– заголовок POST запроса, по сути важен только application/json
message
– пустой объект, тут будет наша переписка с нейронкой.
stream
– я использую false, чтобы не плясать с бубном при обработке ответа в узле узел HTTPRequest из коробки вроде не заточен под стримы.
Остальными параметрами можно смело баловаться.
func change_state(state:State, data={}):
"""change parameters of scene nodes.
Args:
state: Enum State
data: object for additional data (used for recieve llm hiden word)
Returns:
None
"""
%EnterButton.disabled = true
match state:
State.START:
#get history of messages, or create new if not exists
if FileAccess.file_exists(SAVE_PATH):
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
var content = file.get_as_text()
file.close()
var result_json = JSON.parse_string(content)
request["messages"]=result_json
else:
request["messages"]= [
{
"role": "user",
"content": START_CONTENT
}
]
_on_next_word_pressed()
State.LOADING:
%LlmWord.text = "Loading..."
%LineEdit.text="";
%ResultLabel.text = ""
State.LOADED:
save_messages_to_json(request["messages"], SAVE_PATH)
%LlmWord.text = data["llmWord"]
%EnterButton.disabled = true
%LineEdit.text="";
%LineEdit.editable=true;
State.NOT_LOADED:
save_messages_to_json(request["messages"], SAVE_PATH)
%LlmWord.text = 'Please press button "Next word"'
State.GUESSED:
%LlmWord.text = data["llmWord"]
%EnterButton.disabled = true
%ResultLabel.set("theme_override_colors/font_color",HEX_GREEN);
%ResultLabel.text = "Guessed!"
%LineEdit.editable=false;
State.NOT_GUESSED:
%LlmWord.text = data["llmWord"]
%EnterButton.disabled = true
%ResultLabel.set("theme_override_colors/font_color",HEX_RED);
%ResultLabel.text = "Wrong answer!"
%LineEdit.editable=false;
State.LLM_ERROR:
%LlmWord.text = "Error: LLM not response"
_:
print("Default case: No specific match found.")
Функция по большей части для переключения состояния UI в зависимости от того на какой стадии игра.
func save_messages_to_json(data, file_path) -> bool:
"""Saves the given data to a JSON file.
Args:
data: The data to save (a dictionary).
file_path: The path to the JSON file (relative to the project directory).
Returns:
True if the save was successful, False otherwise.
"""
# IMPORTANT: Check if the path is valid.
if !file_path.begins_with("user://"):
print("Invalid file path (must start with 'user://')")
return false
var file = FileAccess.open(file_path, FileAccess.WRITE)
if file:
var json_data =JSON.stringify(data)
file.store_string(json_data)
file.close()
return true
else:
print("Could not open file for writing:", file_path)
return false
Метод создает файл user://save_progress.json
N.B. типовое нерасположение папки user можно посмотреть в документации.
В данный файл мы записываем данные о нашей переписке с нейронами (поле message).
Поскольку у опробованных мной LLM (включая ChatGPT 4o) не очень хорошо с рандомом на коротких запросах, сохранение переписки нам пригодится, чтобы при каждом новом запуске не начинать игру с одних и тех же стартовых слов.
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
change_state(State.START)
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
pass
Функции созданные по умолчанию.
В первой мы при запуске программы переводим состояние UI в стадию START.
А вторая не делает ничего.
func _on_new_game_pressed() -> void:
request["messages"]= [
{
"role": "user",
"content": START_CONTENT
}
]
save_messages_to_json(request["messages"], SAVE_PATH)
_on_next_word_pressed()
Функция начинает игру с чистого листа.
Для этого она сбрасывает контекст переписки и перезаписывает его в файл.
После чего запрашивает новое слово, с помощью логики метода _on_next_word_pressed()
.
func _on_next_word_pressed() -> void:
change_state(State.LOADING)
request["messages"].append({
"role": "user",
"content": GENERATE_CONTENT
})
$HTTPRequest.request(URL, headers, HTTPClient.METHOD_POST, JSON.stringify(request))
Устанавливаем статус характерный для ожидания пока модель выдаст нам слово. И отправляем запрос к запущенному серверу OpenLLM.
func _on_http_request_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
if response_code == 200:
# get message from llm
var json = JSON.parse_string(body.get_string_from_utf8())
var message = json["choices"][0]["message"]
var content = JSON.parse_string(message["content"])
# check what llm response with result object
if (content != null) and ("result" in content):
word = content["result"].to_lower()
if word.length() == 5:
request["messages"].append(message)
var masked_word = ""
for i in range(word.length()):
if i % 2 != 0: # Check if index is odd
masked_word += "*" # Change to desired character
else:
masked_word += word[i] # keep original character
change_state(State.LOADED,{"llmWord":masked_word})
else:
change_state(State.NOT_LOADED)
else:
change_state(State.NOT_LOADED)
else:
change_state(State.LLM_ERROR)
func _on_next_word_pressed() -> void:
change_state(State.LOADING)
request["messages"].append({
"role": "user",
"content": GENERATE_CONTENT
})
$HTTPRequest.request(URL, headers, HTTPClient.METHOD_POST, JSON.stringify(request))
Обрабатываем полученный ответ на запрос.
Разные нейронки отвечают по разному, но мы всегда ждем, что они согласно нашему исходному запросу, додумаются все же вставить загаданное слово в объект result.
Например,{ "result": "chair" }
Поэтому мы дополнительно конвертируем объект content, поскольку в нем тоже лежит текст в формате JSON.
Если удалось получить слово, то мы проверяем, что оно из пяти букв. После чего маскируем четные символы. В противном случае будет либо ошибка, либо предложение повторить запрос.
func _on_enter_button_pressed() -> void:
if word == %LineEdit.text.to_lower():
change_state(State.GUESSED,{"llmWord":word})
else:
change_state(State.NOT_GUESSED,{"llmWord":word})
# Called when user are typing symbol in LineEdit
func _on_line_edit_text_changed(new_text: String) -> void:
if new_text.length() == 5:
%EnterButton.disabled = false
else:
%EnterButton.disabled = true
# Called when user press Enter in LineEdit
func _on_line_edit_text_submitted(new_text: String) -> void:
if new_text.length() == 5:
_on_enter_button_pressed()
Последние три функции отвечают за работу с ответом пользователя.
Мы почти всегда выключаем кнопку отправки ответа игрока. Включается же она только в тех случаях когда он наберет слово из 5 букв.
Ну а функция func _on_line_edit_text_submitte
d это обработка нажатия клавиши Enter. для удобства пользователя, по сути работает также как кнопка EnterButton.
Результат
Теперь, когда все готово осталось только наслаждаться результатом.
Более детально можно посмотреть в gif формате. Он у меня немного «плывёт», поэтому спрячу под спойлер.
Alexa_Name
Я все жду, когда ллм прикрутят к какому нибудь Годвиллю, чтобы началась настоящая дичь)