Язык для интерактивных историй — это весело до тех пор, пока сюжет не превращается в механическую цепочку заранее известных развилок. Чтобы истории жили дольше одного прохождения, им нужна случайность. В этой статье я расскажу, как можно встроить элемент «судьбы» в сам DSL: добавить рандом, вероятности, броски кубиков и даже скрытые триггеры. Всё это — на Python, с реальными примерами кода, а не только с теорией.

Почему предопределённые истории быстро наскучивают

Когда пишешь первый прототип языка для текстовых историй, он кажется волшебным: простые сцены, ветки, условия — и у тебя уже своя мини-игра. Но стоит пройти её второй раз, и магия пропадает: всё те же сцены, всё те же выборы, игрок уже «знает» что будет дальше.

Нам нужен элемент неожиданности, как в настольных RPG — там всегда есть шанс провала или успеха, даже если у персонажа высокий навык. Без этого ощущение «судьбы» исчезает, а история превращается в сухую диаграмму переходов.

DSL без случайности — это пьеса. DSL со случайностью — это уже жизнь.

Синтаксис судьбы: придумываем новые конструкции

В исходном языке у меня были только scene, text, choice и set. Этого достаточно, чтобы описывать предопределённые развилки, но никак не случайность. Пришлось добавить новые ключевые слова:

  • random — бросок случайного числа;

  • chance — проверка вероятности (например, «30% что персонаж оступится»);

  • dice — синтаксический сахар для привычных кубиков (1d6, 2d10 и т.д.);

  • trigger — скрытые условия, которые срабатывают неожиданно.

Пример на DSL:

scene bridge:
    text "Ты идёшь по старому мосту."
    chance 0.3 -> fall
    choice "Продолжить путь" -> forest

scene fall:
    text "Доска ломается, и ты падаешь вниз."
    set health -= dice(1d6)
    choice "Выбраться из ямы" -> forest

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

Реализация случайности на Python

Теперь самое интересное — как это реализовать в интерпретаторе. Самый простой вариант — использовать встроенный модуль random.

import random
import re

class StoryRunner:
    def __init__(self, scenes):
        self.scenes = scenes
        self.state = {"health": 10}

    def roll_dice(self, notation: str) -> int:
        """Бросок кубиков в формате 2d6 или 1d20"""
        match = re.match(r"(\d+)d(\d+)", notation)
        if not match:
            raise ValueError("Неверный формат кубика")
        count, sides = map(int, match.groups())
        return sum(random.randint(1, sides) for _ in range(count))

    def run_chance(self, probability: float) -> bool:
        return random.random() < probability

    def execute(self, command):
        if command.startswith("chance"):
            _, prob, target = command.split()
            if self.run_chance(float(prob)):
                return target
        elif command.startswith("dice"):
            _, expr = command.split()
            return self.roll_dice(expr)
        return None

Да, это упрощённый фрагмент, но уже с ним можно сделать «живую» историю.

Как это меняет восприятие игрока

Вместо детерминированного сценария теперь у нас есть «кубик судьбы». Игрок больше не уверен, что его выбор приведёт к конкретному результату. Даже при одинаковом маршруте он может упасть с моста, а может и пройти без происшествий.

И что важно: разработчик (или писатель) сам решает, где добавить случайность. Хочешь классическую RPG-механику — бросай кубики на каждый чих. Хочешь драматичное повествование — оставь ключевые сцены детерминированными, а случайность добавь в мелочи.

Именно так появляется эффект «реиграбельности»: игрок захочет попробовать снова, потому что результат не предсказуем.

Расширение возможностей: скрытые триггеры и невидимая рука

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

scene cave:
    text "Ты входишь в пещеру."
    trigger chance 0.2 -> ambush
    choice "Осмотреться" -> inside

scene ambush:
    text "Из тени выскакивает гоблин!"
    set health -= 2
    choice "Драться" -> fight

Игрок думает, что идёт «по сюжету», но внезапно может случиться засада. Это делает историю живой — не только выборы влияют, но и сама «судьба».

Оптимизация и отладка

Случайность — штука коварная. Чтобы проверить корректность игры, я сделал «режим симуляции»: интерпретатор прогоняет историю тысячи раз и считает частоту событий.

def simulate(story, start, runs=1000):
    results = {"fall": 0, "safe": 0}
    for _ in range(runs):
        outcome = story.play_once(start)
        results[outcome] += 1
    return results

С помощью этого режима можно отлаживать баланс. Например, если шанс упасть с моста по коду 30%, но симуляция показывает 50% — значит где-то ошибка в логике.

Итоги

Добавление случайности в DSL превращает его из «интерактивной книги» в настоящий нарративный движок. Сценарист может играть с судьбой: иногда подсовывать игроку случайные события, иногда оставлять выбор детерминированным. Это делает истории более живыми и реиграбельными.

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

Комментарии (1)


  1. azTotMD
    22.08.2025 20:14

    Делаем с товарищем подобную интерактивную историю в виде ТГ бота. Пока там один тренировочный сценарий. Я написал ещё один, но мы его пока ещё не выкатили. Наверное, как доработаю, напишу здесь статью про этот проект.

    Если кому интересно: