Как стать автором
Обновить

DSL для интерактивных рассказов: как я написал язык, чтобы придумывать истории, а не кодить

Уровень сложностиСложный
Время на прочтение3 мин
Количество просмотров2.1K

Многие разработчики мечтают о проектах, в которых можно совместить любовь к программированию и нарративу. В этой статье рассказывается о создании собственного DSL (domain-specific language) для интерактивных историй — от формализации сценарных структур до реализации интерпретатора на Python. Много кода, много боли, немного магии.

Почему вообще писать свой язык?

Когда разработчик говорит, что хочет написать язык программирования, обычно стоит начинать беспокоиться. Это как заводить второй домашний сервер — значит, с первым всё пошло не так. Однако иногда хочется сделать язык не ради эксперимента, а ради удобства. Например — чтобы писать не код, а сюжеты. Так родилась идея: а что, если создать DSL для интерактивных рассказов?

Не визуальный редактор, не движок вроде Twine, а именно язык. Чтобы можно было брать блокнот, писать "на языке истории" и потом это интерпретировать. Нечто между Markdown и Python, где переменные — это состояния персонажей, а if — это не условие, а поворот сюжета.

И вот что получилось.

Как устроена интерактивная история

Допустим, у нас есть герой, который может пойти в лес или остаться в деревне. Каждый выбор ведёт к новым событиям, которые могут изменять внутреннее состояние персонажа. То есть у нас есть:

  • Сцены — куски текста с событиями

  • Переходы — выборы, которые определяют, куда идти дальше

  • Состояния — переменные, влияющие на доступность переходов

Это похоже на конечный автомат, только с эмоциями.

Как должен выглядеть DSL

Хотелось, чтобы язык выглядел почти как пьеса:

scene intro:
    text "Вы стоите у ворот старого леса."
    choice "Пойти в лес" -> forest
    choice "Остаться в деревне" -> village

scene forest:
    text "Лес шумит. Что-то явно следит за вами."
    set courage += 1
    choice "Бежать" -> village if courage < 3
    choice "Продвигаться вперёд" -> deep_forest

Просто? Да. Но за этим стоит парсинг, интерпретация, контекст выполнения, контроль переменных, условия и всё, от чего потом хочется лечь на пол и кричать.

Первый шаг: парсер на Python

Была мысль использовать готовые парсер-комбинаторы вроде Lark, но я решил не усложнять. Написал грубый парсер на регулярках.

import re

class Scene:
    def __init__(self, name):
        self.name = name
        self.text = ""
        self.choices = []
        self.effects = []

class StoryParser:
    def __init__(self, source):
        self.source = source
        self.scenes = {}

    def parse(self):
        blocks = re.split(r'\n(?=scene )', self.source)
        for block in blocks:
            lines = block.strip().split('\n')
            if not lines:
                continue
            header = lines[0]
            match = re.match(r'scene (\w+):', header)
            if not match:
                continue
            scene_name = match.group(1)
            scene = Scene(scene_name)
            for line in lines[1:]:
                line = line.strip()
                if line.startswith('text'):
                    scene.text = re.findall(r'"(.*?)"', line)[0]
                elif line.startswith('choice'):
                    choice_text, target = re.findall(r'"(.*?)" -> (\w+)', line)[0]
                    scene.choices.append((choice_text, target))
                elif line.startswith('set'):
                    scene.effects.append(line)
            self.scenes[scene_name] = scene
        return self.scenes

Ничего волшебного — просто распарсили и сохранили.

Исполнение: интерпретатор истории

После парсера нужен интерпретатор — чтобы текст показывался, эффекты применялись, а переходы учитывали условия.

class StoryRunner:
    def __init__(self, scenes):
        self.scenes = scenes
        self.state = {"courage": 0}
        self.current = "intro"

    def run(self):
        while True:
            scene = self.scenes[self.current]
            print(scene.text)
            for effect in scene.effects:
                exec(effect.replace("set", "self.state.update({"))
            for idx, (text, target) in enumerate(scene.choices):
                print(f"{idx+1}. {text}")
            choice = int(input("> ")) - 1
            _, next_scene = scene.choices[choice]
            self.current = next_scene

Да, exec() — страшный зверь. Но для прототипа сойдёт. На проде так делать не надо, а в истории — можно.

Ошибки, которых я не ожидал

  • Условные переходы. Писать if courage < 3 — это не просто строка, а мини-выражение, которое надо безопасно парсить и исполнять.

  • Циклы. Один игрок сделал себе бесконечный луп между двумя сценами и не заметил.

  • Поддержка локализации — ад.

  • Хотелось JSON, но он не умеет в ссылки между сценами. DSL проще.

Итог: получилось нечто живое

Сейчас язык используется в небольшом проекте, где дети пишут свои сюжеты. Никто из них не знает, что это "язык". Они просто пишут текст, и он работает.

Это было сложно. Иногда казалось, что проще сесть и вручную всё заскриптовать. Но когда понимаешь, что дал кому-то инструмент для творчества — это совсем другой кайф.


Заключение

Создание DSL — это не про эго и не про замену существующим инструментам. Это про подход, когда ты подстраиваешь язык под задачу, а не наоборот. И если задача — рассказывать истории, почему бы не дать этим историям свой голос?

Код — это структура. История — это душа. DSL — это мост между ними.

Теги:
Хабы:
+10
Комментарии3

Публикации

Работа

Data Scientist
44 вакансии

Ближайшие события