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

в 5:39, , рубрики: dsl, python, генерация кода, интерактивный рассказ, интерпретатор, парсинг

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

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

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

Когда разработчик говорит, что хочет написать язык программирования, обычно стоит начинать беспокоиться. Это как заводить второй домашний сервер — значит, с первым всё пошло не так. Однако иногда хочется сделать язык не ради эксперимента, а ради удобства. Например — чтобы писать не код, а сюжеты. Так родилась идея: а что, если создать 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 — это мост между ними.

Автор: sanya_belousov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js