Разработка AI-системы процедурной генерации уровней

Проектируем и внедряем системы искусственного интеллекта: от прототипа до production-ready решения. Наша команда объединяет экспертизу в машинном обучении, дата-инжиниринге и MLOps, чтобы AI работал не в лаборатории, а в реальном бизнесе.
Показано 1 из 1Все 1566 услуг
Разработка AI-системы процедурной генерации уровней
Сложный
~2-4 недели
Часто задаваемые вопросы

Направления AI-разработки

Этапы разработки AI-решения

Последние работы

  • image_website-b2b-advance_0.webp
    Разработка сайта компании B2B ADVANCE
    1284
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1196
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    901
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1119
  • image_logo-advance_0.webp
    Разработка логотипа компании B2B Advance
    586
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    853

AI-процедурная генерация уровней для игр

Процедурная генерация уровней (PCG-levels) создаёт игровые пространства алгоритмически: данджоны, платформенные уровни, открытые миры, головоломки. AI-подходы добавляют семантическое понимание: система знает, где должен быть первый контакт с врагом, где спрятан секрет, какова оптимальная кривая сложности.

Алгоритмы генерации данджонов

from dataclasses import dataclass, field
from enum import Enum
import random
import numpy as np
from collections import deque

class TileType(Enum):
    WALL = 0
    FLOOR = 1
    DOOR = 2
    CHEST = 3
    SPAWN = 4
    EXIT = 5
    TRAP = 6
    BOSS_ROOM = 7

@dataclass
class DungeonConfig:
    width: int = 64
    height: int = 64
    min_rooms: int = 8
    max_rooms: int = 15
    min_room_size: tuple = (4, 4)
    max_room_size: tuple = (12, 10)
    corridor_width: int = 1
    difficulty: float = 0.5    # 0.0 – 1.0, влияет на врагов и ловушки
    dungeon_type: str = "classic"  # classic, cave, maze, ruins

class BSPDungeonGenerator:
    """Binary Space Partitioning — классический алгоритм для данджонов"""

    def __init__(self, config: DungeonConfig):
        self.config = config
        self.grid = np.full((config.height, config.width), TileType.WALL.value)
        self.rooms = []
        self.rng = random.Random()

    def generate(self, seed: int = None) -> np.ndarray:
        if seed:
            self.rng.seed(seed)
            np.random.seed(seed)

        # 1. BSP-разбиение
        root = {"x": 1, "y": 1, "w": self.config.width - 2, "h": self.config.height - 2}
        leaves = self._split_bsp(root, depth=0, max_depth=4)

        # 2. Создание комнат в листьях
        for leaf in leaves:
            room = self._create_room_in_leaf(leaf)
            if room:
                self.rooms.append(room)
                self._carve_room(room)

        # 3. Соединение комнат коридорами
        for i in range(len(self.rooms) - 1):
            self._connect_rooms(self.rooms[i], self.rooms[i + 1])

        # 4. Размещение специальных тайлов
        self._place_spawn_and_exit()
        self._place_interactive_elements()

        return self.grid

    def _split_bsp(self, node: dict, depth: int, max_depth: int) -> list:
        if depth >= max_depth or (node["w"] < 14 and node["h"] < 14):
            return [node]

        split_horizontal = self.rng.random() > 0.5
        if node["w"] > node["h"] * 1.25:
            split_horizontal = False
        elif node["h"] > node["w"] * 1.25:
            split_horizontal = True

        leaves = []
        if split_horizontal:
            split_pos = self.rng.randint(node["y"] + 6, node["y"] + node["h"] - 6)
            child_a = {"x": node["x"], "y": node["y"], "w": node["w"], "h": split_pos - node["y"]}
            child_b = {"x": node["x"], "y": split_pos, "w": node["w"], "h": node["y"] + node["h"] - split_pos}
        else:
            split_pos = self.rng.randint(node["x"] + 6, node["x"] + node["w"] - 6)
            child_a = {"x": node["x"], "y": node["y"], "w": split_pos - node["x"], "h": node["h"]}
            child_b = {"x": split_pos, "y": node["y"], "w": node["x"] + node["w"] - split_pos, "h": node["h"]}

        leaves.extend(self._split_bsp(child_a, depth + 1, max_depth))
        leaves.extend(self._split_bsp(child_b, depth + 1, max_depth))
        return leaves

    def _create_room_in_leaf(self, leaf: dict) -> dict | None:
        max_w = min(self.config.max_room_size[0], leaf["w"] - 2)
        max_h = min(self.config.max_room_size[1], leaf["h"] - 2)
        if max_w < self.config.min_room_size[0] or max_h < self.config.min_room_size[1]:
            return None
        w = self.rng.randint(self.config.min_room_size[0], max_w)
        h = self.rng.randint(self.config.min_room_size[1], max_h)
        x = leaf["x"] + self.rng.randint(1, leaf["w"] - w - 1)
        y = leaf["y"] + self.rng.randint(1, leaf["h"] - h - 1)
        return {"x": x, "y": y, "w": w, "h": h}

    def _carve_room(self, room: dict) -> None:
        for y in range(room["y"], room["y"] + room["h"]):
            for x in range(room["x"], room["x"] + room["w"]):
                self.grid[y][x] = TileType.FLOOR.value

    def _connect_rooms(self, room_a: dict, room_b: dict) -> None:
        """L-образный коридор между центрами комнат"""
        cx_a = room_a["x"] + room_a["w"] // 2
        cy_a = room_a["y"] + room_a["h"] // 2
        cx_b = room_b["x"] + room_b["w"] // 2
        cy_b = room_b["y"] + room_b["h"] // 2

        if self.rng.random() > 0.5:
            self._carve_horizontal(cy_a, min(cx_a, cx_b), max(cx_a, cx_b))
            self._carve_vertical(cx_b, min(cy_a, cy_b), max(cy_a, cy_b))
        else:
            self._carve_vertical(cx_a, min(cy_a, cy_b), max(cy_a, cy_b))
            self._carve_horizontal(cy_b, min(cx_a, cx_b), max(cx_a, cx_b))

    def _carve_horizontal(self, y: int, x1: int, x2: int) -> None:
        for x in range(x1, x2 + 1):
            self.grid[y][x] = TileType.FLOOR.value

    def _carve_vertical(self, x: int, y1: int, y2: int) -> None:
        for y in range(y1, y2 + 1):
            self.grid[y][x] = TileType.FLOOR.value

    def _place_spawn_and_exit(self) -> None:
        if self.rooms:
            spawn_room = self.rooms[0]
            self.grid[spawn_room["y"] + 1][spawn_room["x"] + 1] = TileType.SPAWN.value
            exit_room = self.rooms[-1]
            self.grid[exit_room["y"] + 1][exit_room["x"] + 1] = TileType.EXIT.value
            # Босс-комната — самая большая комната
            boss_room = max(self.rooms, key=lambda r: r["w"] * r["h"])
            mid_y = boss_room["y"] + boss_room["h"] // 2
            mid_x = boss_room["x"] + boss_room["w"] // 2
            self.grid[mid_y][mid_x] = TileType.BOSS_ROOM.value

    def _place_interactive_elements(self) -> None:
        trap_count = int(len(self.rooms) * self.config.difficulty * 0.3)
        chest_count = max(1, int(len(self.rooms) * 0.4))

        for room in self.rng.sample(self.rooms[1:-1], min(trap_count, len(self.rooms) - 2)):
            x = self.rng.randint(room["x"] + 1, room["x"] + room["w"] - 2)
            y = self.rng.randint(room["y"] + 1, room["y"] + room["h"] - 2)
            self.grid[y][x] = TileType.TRAP.value

        for room in self.rng.sample(self.rooms, min(chest_count, len(self.rooms))):
            x = self.rng.randint(room["x"] + 1, room["x"] + room["w"] - 2)
            y = self.rng.randint(room["y"] + 1, room["y"] + room["h"] - 2)
            if self.grid[y][x] == TileType.FLOOR.value:
                self.grid[y][x] = TileType.CHEST.value

Wave Function Collapse для тайловых уровней

class WaveFunctionCollapse:
    """
    WFC генерирует уровни по образцу: анализирует паттерны в примере тайловой карты
    и генерирует новые карты с теми же локальными паттернами.
    Применяется в платформерах, изометрических RPG, puzzle-играх.
    """

    def __init__(self, sample_grid: np.ndarray, pattern_size: int = 3):
        self.pattern_size = pattern_size
        self.patterns, self.weights = self._extract_patterns(sample_grid)
        self.adjacency = self._compute_adjacency()

    def _extract_patterns(self, grid: np.ndarray) -> tuple:
        patterns = {}
        h, w = grid.shape
        p = self.pattern_size

        for y in range(h - p + 1):
            for x in range(w - p + 1):
                pattern = tuple(grid[y:y+p, x:x+p].flatten())
                patterns[pattern] = patterns.get(pattern, 0) + 1

        all_patterns = list(patterns.keys())
        weights = [patterns[p] for p in all_patterns]
        return all_patterns, weights

    def _compute_adjacency(self) -> dict:
        """Для каждого паттерна определяем допустимых соседей по 4 направлениям"""
        adjacency = {i: {d: set() for d in ["up", "down", "left", "right"]}
                     for i in range(len(self.patterns))}
        p = self.pattern_size

        for i, pat_a in enumerate(self.patterns):
            grid_a = np.array(pat_a).reshape(p, p)
            for j, pat_b in enumerate(self.patterns):
                grid_b = np.array(pat_b).reshape(p, p)
                # Проверяем совместимость перекрытий
                if np.array_equal(grid_a[1:, :], grid_b[:-1, :]):
                    adjacency[i]["down"].add(j)
                    adjacency[j]["up"].add(i)
                if np.array_equal(grid_a[:, 1:], grid_b[:, :-1]):
                    adjacency[i]["right"].add(j)
                    adjacency[j]["left"].add(i)

        return adjacency

    def generate(self, output_size: tuple) -> np.ndarray:
        h, w = output_size
        # Каждая клетка содержит набор возможных паттернов
        wave = [[set(range(len(self.patterns))) for _ in range(w)] for _ in range(h)]
        result = np.zeros((h, w), dtype=int)

        while True:
            # Находим клетку с минимальной энтропией (не коллапсировавшую)
            min_entropy = float("inf")
            min_cell = None
            for y in range(h):
                for x in range(w):
                    if len(wave[y][x]) > 1:
                        entropy = len(wave[y][x])
                        if entropy < min_entropy:
                            min_entropy = entropy
                            min_cell = (y, x)

            if min_cell is None:
                break

            # Коллапс клетки с минимальной энтропией
            y, x = min_cell
            possible = list(wave[y][x])
            weights = [self.weights[p] for p in possible]
            total = sum(weights)
            chosen = random.choices(possible, weights=[w/total for w in weights])[0]
            wave[y][x] = {chosen}

            # Пропагация ограничений (BFS)
            queue = deque([(y, x)])
            while queue:
                cy, cx = queue.popleft()
                for dy, dx, direction, opposite in [(-1,0,"up","down"),(1,0,"down","up"),(0,-1,"left","right"),(0,1,"right","left")]:
                    ny, nx = cy + dy, cx + dx
                    if 0 <= ny < h and 0 <= nx < w and len(wave[ny][nx]) > 1:
                        allowed = set()
                        for pat_idx in wave[cy][cx]:
                            allowed |= self.adjacency[pat_idx][direction]
                        new_options = wave[ny][nx] & allowed
                        if new_options != wave[ny][nx]:
                            wave[ny][nx] = new_options
                            queue.append((ny, nx))

        # Собираем результат из первого тайла каждого паттерна
        for y in range(h):
            for x in range(w):
                if wave[y][x]:
                    pat_idx = next(iter(wave[y][x]))
                    result[y][x] = self.patterns[pat_idx][0]
        return result

AI-оценка и улучшение уровней

from openai import AsyncOpenAI

client = AsyncOpenAI()

async def evaluate_level_design(level_grid: np.ndarray, config: DungeonConfig) -> dict:
    """LLM анализирует ASCII-представление уровня и даёт дизайн-оценку"""
    TILE_CHARS = {0: "#", 1: ".", 2: "+", 3: "C", 4: "S", 5: "E", 6: "^", 7: "B"}
    ascii_map = "\n".join(
        "".join(TILE_CHARS.get(int(cell), "?") for cell in row)
        for row in level_grid
    )

    response = await client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "system",
            "content": """Ты — геймдизайнер, специалист по level design.
            Оцени данжон по критериям и предложи улучшения.

            Обозначения: # стена, . пол, + дверь, C сундук, S спавн, E выход, ^ ловушка, B босс

            Верни JSON: {
                flow_score: 1-10,  // логичность маршрута
                pacing_score: 1-10,  // распределение напряжения
                exploration_score: 1-10,  // мотивация исследовать
                issues: ["описание проблемы"],
                improvements: ["конкретные правки"],
                estimated_playtime_minutes: int
            }"""
        }, {
            "role": "user",
            "content": f"Сложность: {config.difficulty}\nКарта:\n{ascii_map[:2000]}"
        }],
        response_format={"type": "json_object"}
    )
    import json
    return json.loads(response.choices[0].message.content)

Интеграция с Unity и Unreal Engine

Unity: генерируемый grid сериализуется в JSON и читается MonoBehaviour-скриптом. Tilemap API заполняет TileBase по типам тайлов, NavMesh запекается автоматически через NavMeshSurface.

Unreal Engine 5: PCG-граф в PCG Framework принимает параметры как атрибуты, Procedural Mesh Component строит геометрию из данных генератора, World Partition управляет загрузкой больших данжонов по чанкам.

Сравнение алгоритмов по типу игры

Алгоритм Тип уровней Детерминированность Контроль дизайнера
BSP Данджоны, здания Полная (по seed) Высокий
WFC Тайловые, платформеры Полная (по seed) Через sample-карту
Cellular Automata Пещеры, органика Полная Средний
Noise + Biomes Открытые миры Полная Через параметры
ML-генерация (GAN) Все типы Частичная Низкий

PCG-система с BSP-данджоном и оценкой через LLM — 3–4 недели. Полноценный генератор с WFC, поддержкой тем биомов и Unity-плагином — 8–12 недель.