Розробка 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, головоломках.
    """

    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: генерована сітка серіалізується в JSON та читається монобіхевіором. 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 тижнів.