Узнайте, как сделать механики скрещивания в играх глубже: непрерывные признаки, асимметричная мутация и компактный геном. Реализуйте сами на Python.
Большинство механик скрещивания в играх — это подброшенная монетка. Вы даёте растению или монстру пару аллелей, помечаете одну доминантной, и скрещивание двух родителей превращается в решётку Пеннета: красный × белый даёт красный, красный, красный, белый. Это верно школьному уроку биологии, но делает игру скучной. Результаты дискретны, их всего несколько, и после десяти скрещиваний игрок видит всё пространство и перестаёт интересоваться.
Я пошёл другим путём, когда создавал систему скрещивания для небольшого фермерского симулятора. Каждый признак — непрерывное число, мутация смещена так, что селекция окупается, а весь геном упаковывается в строку, которой можно поделиться, без сервера. Вот как это работает и почему выбор влияет на ощущение от механики.
Штамм несёт пять генов, каждый — обычное число в ограниченном диапазоне:
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 работает для любой маленькой структуры, которой игроки могут обмениваться: набор экипировки, сид уровня, цветовая палитра.
Если вы добавляете скрещивание или крафт с наследованием в игру, вот что оказалось полезным:
Попробуйте реализовать эту систему на Python: создайте класс Strain с пятью генами, функцию cross и encode/decode. Это отличное упражнение для понимания непрерывных пространств признаков и упаковки данных.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →