Почему генерация игровых миров — это боль
Представьте: вы делаете RPG. Нужны города, персонажи, квесты, лор. Десятки часов уходят на создание контента. А потом вы понимаете, что в городе Эльдорадо живут 500 человек, но там всего три дома. Или что король Артур упоминается в диалогах, но его биография противоречит истории королевства.
Консистентность — главная проблема. Человеческий мозг плохо удерживает тысячи взаимосвязанных фактов. Нейросети генерируют текст красиво, но хаотично. Одна генерация говорит, что дракон живет в горах, другая — что в пещере у реки. Игра разваливается на глазах.
Типичная ошибка: использовать LLM как черный ящик. Запрос "сгенерируй город" дает красивый текст, но бесполезный для кода. Нет структуры, нет связей, нет возможности проверить факты.
Решение: двухфазная генерация с реестром фактов
Вместо одного запроса — два этапа. Сначала создаем структуру мира (города, фракции, ключевые NPC). Сохраняем все в SQLite базу. Потом генерируем контент (диалоги, описания, квесты), сверяясь с реестром. Если нейросеть пытается сказать, что эльфы ненавидят магию, а в реестре написано "эльфы — магическая раса", система это отловит.
Инструменты:
- Instructor — библиотека для структурированного вывода из LLM. Заставляет нейросеть возвращать данные в формате Pydantic моделей, а не просто текст.
- Локальная LLM (Mistral, Llama, Qwen) — работает без интернета, дешево, можно генерировать тонны контента. Как в статье про запуск Llama 3.1 на 6 ГБ VRAM.
- SQLite — легкая база для хранения фактов. Никаких монстров вроде PostgreSQL.
Архитектура: как это работает под капотом
Система состоит из трех слоев:
- Слой генерации — LLM + Instructor. Принимает промпты, возвращает структурированные данные.
- Слой валидации — проверяет новые факты на противоречия с существующими. Город не может быть одновременно разрушенным и процветающим.
- Слой хранения — SQLite база с таблицами: locations, characters, factions, items, relationships.
Ключевая фишка: все связанные сущности хранят ID друг друга. Персонаж знает, в каком городе живет (location_id). Город знает, какая фракция им управляет (faction_id). Когда генерируем диалог для персонажа, система подтягивает контекст из базы.
1 Устанавливаем окружение
Сначала ставим зависимости. Работаем с Python 3.10+.
pip install instructor pydantic sqlite3 llama-cpp-python
Для локальной LLM я использую llama-cpp-python — обертку над GGUF моделями. Они работают на CPU, но если есть видеокарта, лучше взять трансформеры и ускорить через CUDA. Как выбрать модель, смотрите в сравнении Qwen2.5 и Mistral.
Не берите огромные 70B модели для генерации контента. Для структурированного вывода хватит 7B-13B параметров. Mistral 7B или Llama 3.1 8B отлично справляются.
2 Создаем Pydantic схемы
Определяем, какие данные нам нужны. Без схем Instructor бесполезен.
from pydantic import BaseModel, Field
from typing import List, Optional
from enum import Enum
class FactionType(str, Enum):
KINGDOM = "kingdom"
GUILD = "guild"
CULT = "cult"
BANDIT = "bandit"
NEUTRAL = "neutral"
class Location(BaseModel):
id: int = Field(description="Unique location ID")
name: str = Field(description="Location name")
type: str = Field(description="City, village, dungeon, forest")
population: Optional[int] = Field(None, description="Approximate population")
description: str = Field(description="Detailed description")
faction_id: Optional[int] = Field(None, description="Controlling faction ID")
notable_features: List[str] = Field(default_factory=list)
class Character(BaseModel):
id: int = Field(description="Unique character ID")
name: str = Field(description="Character name")
race: str = Field(description="Elf, human, dwarf, etc.")
occupation: str = Field(description="Blacksmith, merchant, guard")
location_id: int = Field(description="Where the character resides")
faction_id: Optional[int] = Field(None, description="Faction affiliation")
personality_traits: List[str] = Field(default_factory=list)
secret: Optional[str] = Field(None, description="Hidden secret or quest hook")
Обратите внимание на поля с ID. Они создают связи между сущностями. Без них получится набор разрозненных объектов.
3 Настраиваем Instructor с локальной LLM
Подключаем модель через llama-cpp. Если у вас трансформеры, используйте from_pretrained.
from llama_cpp import Llama
import instructor
from instructor import llm_validator
# Загружаем GGUF модель
llm = Llama(
model_path="./models/mistral-7b-instruct-v0.2.Q4_K_M.gguf",
n_ctx=8192, # Контекстное окно
n_threads=8, # Количество потоков CPU
verbose=False
)
# Создаем клиент Instructor
client = instructor.from_llama(llm, mode=instructor.Mode.JSON)
# Функция для структурированной генерации
def generate_structured(prompt: str, response_model):
"""Генерируем данные по схеме Pydantic"""
try:
response = client.chat.completions.create(
model="local-model",
messages=[
{"role": "system", "content": "You are a game world generator. Return valid JSON."},
{"role": "user", "content": prompt}
],
response_model=response_model,
max_retries=3 # Автоматические повторные попытки при ошибках
)
return response
except Exception as e:
print(f"Generation failed: {e}")
return None
Параметр max_retries критически важен. LLM иногда выдает некорректный JSON. Instructor автоматически перезапрашивает модель, исправляя ошибки.
4 Создаем SQLite реестр фактов
База данных — источник истины. Все сгенерированные факты попадают сюда.
import sqlite3
from contextlib import contextmanager
class FactRegistry:
def __init__(self, db_path="world_facts.db"):
self.db_path = db_path
self.init_database()
def init_database(self):
"""Создаем таблицы для хранения мира"""
with self.get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
population INTEGER,
description TEXT NOT NULL,
faction_id INTEGER,
notable_features TEXT
)""")
conn.execute("""
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
race TEXT NOT NULL,
occupation TEXT NOT NULL,
location_id INTEGER NOT NULL,
faction_id INTEGER,
personality_traits TEXT,
secret TEXT,
FOREIGN KEY (location_id) REFERENCES locations(id)
)""")
conn.execute("""
CREATE TABLE IF NOT EXISTS factions (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
leader_id INTEGER,
description TEXT NOT NULL
)""")
# Индексы для быстрого поиска
conn.execute("CREATE INDEX IF NOT EXISTS idx_char_location ON characters(location_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_loc_faction ON locations(faction_id)")
@contextmanager
def get_connection(self):
"""Контекстный менеджер для соединения с БД"""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def check_consistency(self, new_entity, entity_type):
"""Проверяем новые факты на противоречия с существующими"""
with self.get_connection() as conn:
if entity_type == "location":
# Проверяем, нет ли уже города с таким названием
existing = conn.execute(
"SELECT id FROM locations WHERE name = ?",
(new_entity.name,)
).fetchone()
if existing:
return False, f"Location '{new_entity.name}' already exists"
elif entity_type == "character":
# Проверяем, существует ли указанная локация
location_exists = conn.execute(
"SELECT id FROM locations WHERE id = ?",
(new_entity.location_id,)
).fetchone()
if not location_exists:
return False, f"Location ID {new_entity.location_id} doesn't exist"
return True, "OK"
Функция check_consistency — страж консистентности. Она не даст создать персонажа в несуществующем городе или добавить второй город с тем же названием.
5 Генерируем мир: двухфазный подход
Фаза 1: создаем каркас мира — королевства, крупные города, фракции.
def generate_world_foundation(registry):
"""Генерируем основу мира"""
# 1. Создаем королевство
kingdom_prompt = """
Create a fantasy kingdom with:
- Name and description
- Capital city details
- Ruling faction
- 3 notable features
"""
# Используем более сложную схему для королевства
class Kingdom(BaseModel):
name: str
capital_city: Location
ruling_faction: str
features: List[str]
kingdom = generate_structured(kingdom_prompt, Kingdom)
if kingdom:
# Сохраняем столицу
is_valid, message = registry.check_consistency(kingdom.capital_city, "location")
if is_valid:
registry.save_location(kingdom.capital_city)
print(f"Created capital: {kingdom.capital_city.name}")
# 2. Генерируем дополнительные города
cities_prompt = f"""
Generate 3 additional cities for the kingdom of {kingdom.name}.
Each city should be distinct and have:
- Unique name
- Population estimate
- Primary industry or trade
- Relation to the capital
"""
class CityList(BaseModel):
cities: List[Location]
cities = generate_structured(cities_prompt, CityList)
for city in cities.cities:
is_valid, msg = registry.check_consistency(city, "location")
if is_valid:
registry.save_location(city)
print(f"Created city: {city.name}")
else:
print(f"Skipped city {city.name}: {msg}")
Фаза 2: наполняем мир деталями — персонажи, квесты, диалоги.
def populate_location(registry, location_id):
"""Населяем локацию персонажами"""
# Получаем информацию о локации
location = registry.get_location(location_id)
characters_prompt = f"""
Generate 5 interesting characters for {location['name']}, a {location['type']}.
Characters should include:
- 1 authority figure (mayor, captain)
- 2 commoners with distinct personalities
- 1 mysterious stranger
- 1 character with a secret quest hook
"""
class CharacterList(BaseModel):
characters: List[Character]
characters = generate_structured(characters_prompt, CharacterList)
for char in characters.characters:
# Устанавливаем location_id из параметра
char.location_id = location_id
is_valid, msg = registry.check_consistency(char, "character")
if is_valid:
registry.save_character(char)
print(f"Created character: {char.name} ({char.occupation})")
else:
print(f"Skipped character {char.name}: {msg}")
Ошибки, которые сломают вашу систему
Я видел десятки попыток сделать подобное. Вот что идет не так:
| Ошибка | Последствие | Решение |
|---|---|---|
| Нет проверки консистентности | Персонажи ссылаются на несуществующие локации, мир противоречит сам себе | Добавить check_consistency перед сохранением каждой сущности |
| Одна большая генерация всего мира | LLM теряет контекст, качество падает после 3000 токенов | Использовать двухфазный подход: сначала структура, потом детали |
| Хранение фактов в JSON файлах | Медленный поиск, сложные связи, проблемы с параллельным доступом | Использовать SQLite с индексами и внешними ключами |
| Отсутствие ID связей | Сущности существуют изолированно, нельзя построить отношения | Добавить location_id, faction_id во все связанные модели |
Как интегрировать с игровым движком
Сгенерированный мир — это данные в SQLite. Экспортируем их в формат, понятный вашей игре.
Для Unity:
def export_to_unity_json(registry, output_path):
"""Экспортируем мир в JSON для Unity"""
with registry.get_connection() as conn:
# Получаем все локации с персонажами
locations = conn.execute("""
SELECT l.*,
json_group_array(
json_object(
'name', c.name,
'race', c.race,
'occupation', c.occupation
)
) as characters
FROM locations l
LEFT JOIN characters c ON l.id = c.location_id
GROUP BY l.id
""").fetchall()
# Конвертируем в словарь
world_data = {
"locations": [dict(loc) for loc in locations]
}
import json
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(world_data, f, ensure_ascii=False, indent=2)
print(f"Exported {len(locations)} locations to {output_path}")
Для Godot данные можно загружать напрямую из SQLite с помощью GDScript плагина. Для веб-игр — отдавать через REST API.
Что делать, когда мир создан
База данных с миром — это не конечная точка. Это отправная.
- Динамические события — используйте реестр фактов как контекст для генерации случайных событий. Как в интеграции LLM в S.T.A.L.K.E.R. Anomaly.
- Персонализированные диалоги — генерируйте реплики NPC на основе их личности, отношений с игроком, текущих событий в мире.
- Процедурные квесты — создавайте цепочки заданий, которые учитывают состояние мира. Если в городе эпидемия, NPC могут просить найти лекарство.
- Эволюция мира — обновляйте реестр по мере развития сюжета. Город захвачен? Обновите faction_id. Персонаж погиб? Отметьте в базе.
Не генерируйте все заранее. Генерируйте по мере необходимости. Когда игрок впервые посещает город — создайте его ключевых NPC. Когда заговорит с торговцем — сгенерируйте ассортимент товаров. Это экономит ресурсы и делает мир живым.
Производительность: сколько это стоит
Локальная LLM на CPU (Mistral 7B Q4):
- Генерация города с описанием: 3-5 секунд
- 5 персонажей для локации: 8-12 секунд
- Полный мир (10 локаций, 50 NPC): 2-3 минуты
- Потребление памяти: 4-6 ГБ ОЗУ
На GPU (RTX 4060, 8 ГБ):
- Генерация города: 1-2 секунды
- 5 персонажей: 3-5 секунд
- Полный мир: 40-60 секунд
Сравните с оплатой OpenAI API: генерация аналогичного мира обошлась бы в $2-5. При активной разработке, когда мир пересоздается десятки раз, локальная модель окупается за неделю.
Следующий уровень: RAG для игровых миров
Реестр фактов — это уже база знаний. Подключите к ней RAG (Retrieval-Augmented Generation), чтобы нейросеть отвечала на вопросы о мире в контексте игры.
Игрок: "Кто правит в этом городе?"
Система:
- Ищет в SQLite информацию о текущей локации игрока
- Находит faction_id
- Ищет фракцию и ее лидера
- Генерирует ответ: "Городом правит Гильдия торговцев во главе с лордом Эдгаром."
Технически это похоже на RAG за 15 минут, но вместо документов — игровые сущности.
Самый интересный вариант: дать игроку возможность влиять на мир через диалоги. Сказал что-то важное NPC — система обновляет реестр фактов. Мир становится реактивным.
Попробуйте. Создайте маленький мир — одну деревню с десятком персонажей. Убедитесь, что связи работают, что факты не противоречат друг другу. Потом масштабируйте.
И помните: нейросеть генерирует сырой материал. Вы — геймдизайнер, который этот материал превращает в игру. Инструменты не заменяют творчество, они освобождают время для него.