В прошлой статье я попробовал сделать интеграцию больших языковых моделей с 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_submitted это обработка нажатия клавиши Enter. для удобства пользователя, по сути работает также как  кнопка EnterButton.

Результат

Теперь, когда все готово осталось только наслаждаться результатом.

Успешно отгадали слово
Успешно отгадали слово

Более детально можно посмотреть в gif формате. Он у меня немного «плывёт», поэтому спрячу под спойлер.

Смотреть gif

Комментарии (1)


  1. Alexa_Name
    18.12.2024 09:06

    Я все жду, когда ллм прикрутят к какому нибудь Годвиллю, чтобы началась настоящая дичь)