DSL для интерактивных рассказов: как я написал язык, чтобы придумывать истории, а не кодить
Многие разработчики мечтают о проектах, в которых можно совместить любовь к программированию и нарративу. В этой статье рассказывается о создании собственного 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 — это мост между ними.