ГлавнаяБлогСистема скрещивания с непрерывными признаками в играх
Python

Система скрещивания с непрерывными признаками в играх

Узнайте, как сделать механики скрещивания в играх глубже: непрерывные признаки, асимметричная мутация и компактный геном. Реализуйте сами на Python.

Al
Редакция Algolitalgolit.ru
10 мин чтения9 июля 2026 г.

Большинство механик скрещивания в играх — это подброшенная монетка. Вы даёте растению или монстру пару аллелей, помечаете одну доминантной, и скрещивание двух родителей превращается в решётку Пеннета: красный × белый даёт красный, красный, красный, белый. Это верно школьному уроку биологии, но делает игру скучной. Результаты дискретны, их всего несколько, и после десяти скрещиваний игрок видит всё пространство и перестаёт интересоваться.

Я пошёл другим путём, когда создавал систему скрещивания для небольшого фермерского симулятора. Каждый признак — непрерывное число, мутация смещена так, что селекция окупается, а весь геном упаковывается в строку, которой можно поделиться, без сервера. Вот как это работает и почему выбор влияет на ощущение от механики.

Непрерывные признаки вместо аллелей

Штамм несёт пять генов, каждый — обычное число в ограниченном диапазоне:

GENE_KEYS = ['hue', 'yield', 'speed', 'value', 'hardiness']
# Диапазоны, применяемые при каждом скрещивании:
# hue       -180..180   (градусы сдвига оттенка спрайта — видимый фенотип)
# yield        0..4     (дополнительный урожай за сбор)
# speed        0..0.5   (доля сокращения времени роста)
# value        1..3.2   (множитель цены продажи)
# hardiness    0..1     (выживаемость вне сезона / в непогоду)

Никакого учёта доминантности/рецессивности, никаких пар аллелей. Признак — это просто «сколько». Это единственное решение открывает пространство дизайна: вместо дюжины дискретных фенотипов — непрерывное пятимерное пространство, и игрок выполняет градиентный спуск к нужному углу.

Единственный видимый ген — hue. Он управляет сдвигом оттенка в HSL спрайта, так что штамм, выведенный ради ценности или урожайности, также выглядит иначе с первого взгляда. Связь читаемого фенотипа с геном, которым игрок может управлять, — вот что заставляет числа чувствоваться как биология, а не как таблица.

Скрещивание: среднее родителей с контролируемым шумом

Скрещивание берёт среднее каждого гена родителей и возмущает его:

import random, math

def cross(genes_a, genes_b, rng=None):
    if rng is None:
        rng = random.Random()
    
    def avg(a, b):
        return (a + b) / 2
    
    def mutate(v, lo, hi, jitter_lo, jitter_hi, big_chance, big):
        n = v + rng.uniform(jitter_lo, jitter_hi)  # маленький дрейф
        if rng.random() < big_chance:
            n += rng.uniform(-big, big)  # редкий большой скачок
        return max(lo, min(hi, n))
    
    genes = {
        'hue': round(avg(genes_a['hue'], genes_b['hue']) + rng.uniform(-22, 22) + (rng.uniform(-60, 60) if rng.random() < 0.18 else 0)),
        'yield': round(mutate(avg(genes_a['yield'], genes_b['yield']), 0, 4, -0.2, 0.45, 0.15, 1), 2),
        'speed': round(mutate(avg(genes_a['speed'], genes_b['speed']), 0, 0.5, -0.04, 0.07, 0.15, 0.12), 3),
        'value': round(mutate(avg(genes_a['value'], genes_b['value']), 1, 3.2, -0.08, 0.22, 0.18, 0.4), 2),
        'hardiness': round(mutate(avg(genes_a['hardiness'], genes_b['hardiness']), 0, 1, -0.08, 0.13, 0.12, 0.25), 2),
    }
    return genes

Здесь две важные детали, которые стоит позаимствовать.

Асимметричный джиттер

Посмотрите на yield: маленький дрейф — uniform(-0.2, 0.45). Окно смещено в положительную сторону. То же самое для value (-0.08..0.22) и всех остальных признаков. С течением поколений популяция сама по себе дрейфует вверх, так что терпеливый селекционер получает награду ещё до того, как начнёт активно отбирать. Если центрировать джиттер на нуле, селекция превращается в случайное блуждание, и игроки чувствуют, что борются с RNG вместо того, чтобы формировать его. Несколько сотых положительного смещения — это вся разница между «это работает» и «почему мои числа всё падают».

Два масштаба мутации

Есть постоянный маленький дрейф плюс редкий (вероятность 0.15–0.18) большой скачок. Маленький дрейф делает большинство потомков мягкой вариацией родителей — предсказуемо, безопасно. Редкий большой скачок — это лотерейный билет: время от времени скрещивание выдаёт штамм далеко за пределами среднего, и это сюрприз, который заставляет игрока продолжать «ещё одно». Один шум не может дать одновременно стабильность и случайный джекпот; два члена с разными величинами и вероятностями — могут.

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

Показываем среднее, применяем шум

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

def preview_cross(genes_a, genes_b):
    def avg(a, b):
        return (a + b) / 2
    genes = {
        'hue': round(avg(genes_a['hue'], genes_b['hue'])),
        'yield': round(avg(genes_a['yield'], genes_b['yield']), 2),
        'speed': round(avg(genes_a['speed'], genes_b['speed']), 3),
        'value': round(avg(genes_a['value'], genes_b['value']), 2),
        'hardiness': round(avg(genes_a['hardiness'], genes_b['hardiness']), 2),
    }
    return genes  # чистое среднее, без uniform, без большого скачка

preview_cross — детерминированное ожидаемое значение: без uniform, без джекпота. Интерфейс показывает его в реальном времени, когда игрок наводит на два семени, так что он может планировать цель.

cross — то, что выполняется, когда игрок подтверждает, и добавляет шум обратно. Разрыв между ними — момент напряжения: вы примерно знаете, что получите, но не точно. Этот разрыв и есть игра.

Геном, помещающийся в сообщение чата

Штаммы можно передавать, и я не хотел для этого бэкенд. Пять чисел с плавающей точкой и индекс базовой культуры упаковываются в одну строку в base-36:

def encode(strain, crops):
    """Кодирует штамм в строку GRN1-..."""
    g = strain['genes']
    bi = next(i for i, c in enumerate(crops) if c['id'] == strain['base'])
    parts = [
        bi,
        round(g['hue']) + 360,  # сдвиг отрицательных в положительные
        round(g['yield'] * 10),  # фиксированная точка: 1 десятичный знак
        round(g['speed'] * 1000),  # 3 десятичных знака
        round(g['value'] * 100),  # 2 десятичных знака
        round(g['hardiness'] * 100),  # 2 десятичных знака
    ]
    return 'GRN1-' + '.'.join(format(max(0, n), 'x') for n in parts)

Хитрости в упаковке. Каждое число с плавающей точкой превращается в целое фиксированной точности умножением на точность (yield нужен один знак, speed — три) и округлением. Отрицательный hue получает +360, так что всё неотрицательно, затем format(n, 'x') (hex) делает каждое число компактным и безопасным для URL. Префикс GRN1- — тег версии, так что будущий формат GRN2 может изменить layout без молчаливого чтения старых кодов. Декодирование обращает процесс и повторно зажимает каждое значение, потому что строка пришла извне и ей нельзя доверять:

def decode(code, crops):
    """Декодирует строку GRN1-... в штамм или None при ошибке"""
    if not code or not code.startswith('GRN1-'):
        return None
    parts_str = code[5:].split('.')
    if len(parts_str) != 6:
        return None
    parts = []
    for s in parts_str:
        try:
            parts.append(int(s, 16))
        except ValueError:
            return None
    if any(p < 0 for p in parts):
        return None
    base = crops[parts[0]] if parts[0] < len(crops) else None
    if base is None:
        return None
    genes = {
        'hue': max(-180, min(180, parts[1] - 360)),
        'yield': max(0, min(4, parts[2] / 10)),
        'speed': max(0, min(0.5, parts[3] / 1000)),
        'value': max(1, min(3.2, parts[4] / 100)),
        'hardiness': max(0, min(1, parts[5] / 100)),
    }
    return {'base': base['id'], 'genes': genes}

Штамм (база 3, hue +42, yield 1.2, speed 0.12, value ×1.5, hardiness 0.3) кодируется в GRN1-3.186.c.78.96.1e — достаточно коротко, чтобы вставить в комментарий, без базы данных, без аккаунтов. Тот же паттерн фиксированная-точка-затем-hex работает для любой маленькой структуры, которой игроки могут обмениваться: набор экипировки, сид уровня, цветовая палитра.

Что я бы оставил

Если вы добавляете скрещивание или крафт с наследованием в игру, вот что оказалось полезным:

  • Непрерывные признаки побеждают дискретные аллели по реиграбельности — пространство больше, и градиенты ощущаются намеренными.
  • Сместите окно мутации слегка положительно, чтобы настойчивость вознаграждалась без жёсткой XP-трассы.
  • Разделите мутацию на малую-частую и большую-редкую, чтобы получить и стабильность, и джекпоты.
  • Показывайте детерминированное среднее, применяйте шум при подтверждении. Разрыв — это веселье.
  • Версионируйте любой код для обмена и перепроверяйте всё при декодировании.

Попробуйте реализовать эту систему на Python: создайте класс Strain с пятью генами, функцию cross и encode/decode. Это отличное упражнение для понимания непрерывных пространств признаков и упаковки данных.

#скрещивание#непрерывные признаки#генетический алгоритм#игровая механика#Python
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →