Троллейбусно-Маршруточные войны (серия игр)

Материал из Абсурдопедии
Перейти к навигацииПерейти к поиску

Троллейбусно-Маршруточные Войны в Новосибирске — ряд крупнейших войн в истории Новосибирска, России и Вселенной в целом, описанные в лучшей и единственной правдивой энциклопедии в интернете.

Стоп!

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

Задача этой статьи — визуализировать эти события.

Собственно миссии по хронологии:

Оборона Ул. Школьной по улице Пьяного Хмеля[править]

Первая игра этой серии, написанная на питоне спустя 20 лет после тех событий. Так как у разрабов не было компьютера, а был только калькулятор, игра не может похвастаться 3D графикой, но в ней реализован бесконечный мир (т.е. вы не дойдёте до конца карты).

Геймплей[править]

Вы — командир троллейбуса ЗиУ-620501 № 1239 из легендарной 22-й дивизии. Ваша задача — совместно с троллейбусами 9-й дивизии (подсвечены голубым) и вашей 22-й дивизии (светло-зелёный цвет, более тусклый, чем у вашего) уничтожать маршрутки, со всех сторон едущие захватывать улицу (чуть темнее, чем остальные). Вы можете стрелять по ним, но не всё так просто, ведь с 50 хода они начнут стрелять по вам не выхлопными газами из труб, а боевыми пулями из автоматов, которые за 2-3 попадания отправят ваш троллейбус на тот свет. Однако не всё так безнадёжно, ведь при гибели 10 союзников командование решит, что это бесполезная трата сил — удерживать улицу и отступит, спася ваши жизни.

Управление[править]

Здесь всё просто: WASD — движение, ← → ↑ ↓ — стрельба (по диагонали стрелять нельзя).

Код игры (бесплатно!)[править]

import pygame
import sys
import random

# Константы
CELL_SIZE = 50
GRID_SIZE = 10
WINDOW_SIZE = CELL_SIZE * GRID_SIZE
BLOCK_SIZE = 8
FOREST_BLOCK = 30
BIG_FOREST_PROBABILITY = 10
PARK_BLOCK = 50
PARK_PROBABILITY = 10
BULLET_SPEED = 10
FIRE_LIFETIME = 13

# Параметры главной улицы
MAIN_STREET_X_START = -25
MAIN_STREET_X_END = 24
MAIN_STREET_Y = (0, 1)
CROSS_STREETS_X = [-15, 0, 15]
CROSS_STREETS_Y_RANGE = (-10, 10)

# Параметры врагов (маршруток)
ENEMY_HP = 25
ENEMY_DAMAGE = 3
ENEMY_BULLET_SPEED = 3
BULLET_DAMAGE = 10
ENEMY_SPAWN_BLOCK_SIZE = 3
ENEMY_SPAWN_MIN_DIST = 4

# Параметры союзников (троллейбусов)
ALLY_HP = 50
ALLY_DAMAGE = 10
ALLY_SHOOT_INTERVAL = 1        # стреляют каждый ход игрока
ALLY_GROUP_A_COUNT = 25
ALLY_GROUP_B_COUNT = 24

# Цвета
COLOR_ROAD = (200, 200, 200)
COLOR_MAIN_ROAD = (160, 160, 160)
COLOR_GRID = (50, 50, 50)
COLOR_TROLLEY_FALLBACK = (0, 0, 255)
COLOR_ENEMY_BG = (139, 0, 0)
COLOR_ALLY_A_BG = (173, 216, 230)  # тускло-голубой
COLOR_ALLY_B_BG = (152, 251, 152)  # тускло-салатовый

# Кэши и состояние
small_forest_cache = {}
big_forest_cache = {}
park_cache = {}
fire_cells = {}
burned_cells = set()
bullets = []
enemy_map = {}
enemy_bullets = []
explosions = {}
generated_enemy_blocks = set()
ally_map = {}           # (x,y) -> Ally

# Глобальные переменные для новой механики
turn_count = 0
enemy_damage_increased = False
allied_deaths = 0
retreat_phase = False
retreat_timer = 0
notification_text = None
notification_timer = 0

# -------------------------------------------
# Хеш и генерация кварталов (без изменений)
# -------------------------------------------
def hash_coords(bx: int, by: int) -> int:
    seed = (bx * 374761393 + by * 668265263) & 0xFFFFFFFF
    seed = (seed ^ (seed >> 13)) * 1274126177
    seed = seed ^ (seed >> 16)
    return seed

def get_quarter_info(x: int, y: int):
    bx = x // BLOCK_SIZE
    by = y // BLOCK_SIZE
    hsh = hash_coords(bx, by)
    w = 5 + (hsh % 3)
    h = 5 + ((hsh // 4) % 3)
    max_dx = BLOCK_SIZE - w
    max_dy = BLOCK_SIZE - h
    dx = (hsh // 16) % (max_dx + 1) if max_dx >= 0 else 0
    dy = (hsh // 64) % (max_dy + 1) if max_dy >= 0 else 0
    x0 = bx * BLOCK_SIZE + dx
    y0 = by * BLOCK_SIZE + dy
    if x0 <= x < x0 + w and y0 <= y < y0 + h:
        return (x0, y0, w, h)
    return None
def distance_to_main_street(x: int, y: int) -> int:
    # Расстояние по X до диапазона главной улицы
    if x < MAIN_STREET_X_START:
        dx = MAIN_STREET_X_START - x
    elif x > MAIN_STREET_X_END:
        dx = x - MAIN_STREET_X_END
    else:
        dx = 0
    # Расстояние по Y до ближайшей из строк 0 или 1
    if y <= 0:
        dy = -y          # для y=0 даёт 0, для y<0 – положительное
    else:                # y >= 1
        dy = y - 1       # для y=1 даёт 0, для y>1 – положительное
    return dx + dy

# -------------------------------------------
# Главная улица и пересечения
# -------------------------------------------
def is_main_street(x: int, y: int) -> bool:
    return (MAIN_STREET_X_START <= x <= MAIN_STREET_X_END and y in MAIN_STREET_Y)

def is_cross_street(x: int, y: int) -> bool:
    return (x in CROSS_STREETS_X and CROSS_STREETS_Y_RANGE[0] <= y <= CROSS_STREETS_Y_RANGE[1])

def is_building_row(x: int, y: int) -> bool:
    if not (MAIN_STREET_X_START <= x <= MAIN_STREET_X_END):
        return False
    if y not in (-1, 2):
        return False
    if x in CROSS_STREETS_X:
        return False
    return True

# -------------------------------------------
# Леса (маленькие и большие) – без изменений
# -------------------------------------------
def generate_small_forests(fx: int, fy: int):
    key = (fx, fy)
    if key in small_forest_cache:
        return
    hsh = hash_coords(fx, fy)
    rng = random.Random(hsh)
    count = 1 + (hsh % 6)
    all_trees = set()
    for _ in range(count):
        length = 1 + (rng.randint(0, 9))
        start = None
        for _ in range(100):
            tx = fx * FOREST_BLOCK + rng.randint(0, FOREST_BLOCK - 1)
            ty = fy * FOREST_BLOCK + rng.randint(0, FOREST_BLOCK - 1)
            if get_quarter_info(tx, ty) is None:
                start = (tx, ty)
                break
        if start is None:
            continue
        forest = set()
        forest.add(start)
        x, y = start
        directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
        for _ in range(length - 1):
            rng.shuffle(directions)
            moved = False
            for dx, dy in directions:
                nx, ny = x + dx, y + dy
                if (fx * FOREST_BLOCK <= nx < (fx + 1) * FOREST_BLOCK and
                    fy * FOREST_BLOCK <= ny < (fy + 1) * FOREST_BLOCK and
                    get_quarter_info(nx, ny) is None):
                    forest.add((nx, ny))
                    x, y = nx, ny
                    moved = True
                    break
            if not moved:
                break
        all_trees.update(forest)
    small_forest_cache[key] = all_trees

def generate_big_forest(fx: int, fy: int):
    key = (fx, fy)
    if key in big_forest_cache:
        return
    hsh = hash_coords(fx, fy)
    if (hsh % 100) >= BIG_FOREST_PROBABILITY:
        big_forest_cache[key] = set()
        return
    rng = random.Random(hsh)
    length = 50 + (hsh % 407)
    start = None
    for _ in range(200):
        tx = fx * FOREST_BLOCK + rng.randint(0, FOREST_BLOCK - 1)
        ty = fy * FOREST_BLOCK + rng.randint(0, FOREST_BLOCK - 1)
        if get_quarter_info(tx, ty) is None:
            start = (tx, ty)
            break
    if start is None:
        big_forest_cache[key] = set()
        return
    forest = set()
    forest.add(start)
    x, y = start
    directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
    for _ in range(length - 1):
        rng.shuffle(directions)
        moved = False
        for dx, dy in directions:
            nx, ny = x + dx, y + dy
            if (fx * FOREST_BLOCK <= nx < (fx + 1) * FOREST_BLOCK and
                fy * FOREST_BLOCK <= ny < (fy + 1) * FOREST_BLOCK and
                get_quarter_info(nx, ny) is None):
                forest.add((nx, ny))
                x, y = nx, ny
                moved = True
                break
        if not moved:
            break
    big_forest_cache[key] = forest

def is_big_forest(x: int, y: int) -> bool:
    fx = x // FOREST_BLOCK
    fy = y // FOREST_BLOCK
    key = (fx, fy)
    if key not in big_forest_cache:
        generate_big_forest(fx, fy)
    return (x, y) in big_forest_cache.get(key, set())

def is_forest(x: int, y: int) -> bool:
    fx = x // FOREST_BLOCK
    fy = y // FOREST_BLOCK
    key = (fx, fy)
    if key not in big_forest_cache:
        generate_big_forest(fx, fy)
    if (x, y) in big_forest_cache.get(key, set()):
        return True
    if key not in small_forest_cache:
        generate_small_forests(fx, fy)
    return (x, y) in small_forest_cache.get(key, set())

# -------------------------------------------
# Парки развлечений
# -------------------------------------------
def generate_park(bx: int, by: int):
    key = (bx, by)
    if key in park_cache:
        return
    hsh = hash_coords(bx, by)
    if (hsh % 100) >= PARK_PROBABILITY:
        park_cache[key] = set()
        return
    rng = random.Random(hsh)
    area = 2 + rng.randint(0, 13)
    attractions = ['🎪', '🎢', '🎡', '⛲', '🎠']
    start = None
    for _ in range(100):
        tx = bx * PARK_BLOCK + rng.randint(0, PARK_BLOCK - 1)
        ty = by * PARK_BLOCK + rng.randint(0, PARK_BLOCK - 1)
        if get_quarter_info(tx, ty) is None and not is_forest(tx, ty):
            start = (tx, ty)
            break
    if start is None:
        park_cache[key] = set()
        return
    park = set()
    park.add(start)
    x, y = start
    directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
    for _ in range(area - 1):
        rng.shuffle(directions)
        moved = False
        for dx, dy in directions:
            nx, ny = x + dx, y + dy
            if (bx * PARK_BLOCK <= nx < (bx + 1) * PARK_BLOCK and
                by * PARK_BLOCK <= ny < (by + 1) * PARK_BLOCK and
                get_quarter_info(nx, ny) is None and
                not is_forest(nx, ny) and
                (nx, ny) not in park):
                park.add((nx, ny))
                x, y = nx, ny
                moved = True
                break
        if not moved:
            break
    park_cache[key] = park

def is_park(x: int, y: int) -> bool:
    bx = x // PARK_BLOCK
    by = y // PARK_BLOCK
    key = (bx, by)
    if key not in park_cache:
        generate_park(bx, by)
    return (x, y) in park_cache.get(key, set())

def park_emoji(x: int, y: int) -> str:
    hsh = hash_coords(x, y)
    attractions = ['🎪', '🎢', '🎡', '⛲', '🎠']
    return attractions[hsh % len(attractions)]

# -------------------------------------------
# Проходимость и препятствия (с учётом союзников)
# -------------------------------------------
def is_burned(x: int, y: int) -> bool:
    return (x, y) in burned_cells

def is_on_fire(x: int, y: int) -> bool:
    return (x, y) in fire_cells

def is_walkable(x: int, y: int) -> bool:
    # Если клетка занята союзником - непроходима
    if (x, y) in ally_map:
        return False
    if is_on_fire(x, y):
        return False
    if is_burned(x, y):
        return True
    if is_forest(x, y) or is_park(x, y):
        return False
    if is_main_street(x, y) or is_cross_street(x, y):
        return True
    if is_building_row(x, y):
        return False
    info = get_quarter_info(x, y)
    if info is None:
        return True
    x0, y0, w, h = info
    on_edge = (x == x0 or x == x0 + w - 1 or y == y0 or y == y0 + h - 1)
    if not on_edge:
        return True
    if (x == x0 and y == y0 + h // 2) or \
       (x == x0 + w - 1 and y == y0 + h // 2) or \
       (y == y0 and x == x0 + w // 2) or \
       (y == y0 + h - 1 and x == x0 + w // 2):
        return True
    return False

def is_solid_obstacle(x: int, y: int) -> bool:
    # Союзники - твёрдые препятствия для пуль врагов и игрока? 
    # Для пуль игрока и союзников они не препятствие (пролетают)
    # Для пуль врагов - препятствие (останавливаются)
    # Но мы будем проверять в соответствующих классах, поэтому здесь оставим только стены/лес/здания
    if is_on_fire(x, y):
        return False
    if is_burned(x, y):
        return False
    if is_forest(x, y) or is_park(x, y):
        return True
    if is_main_street(x, y) or is_cross_street(x, y):
        return False
    if is_building_row(x, y):
        return True
    info = get_quarter_info(x, y)
    if info is None:
        return False
    x0, y0, w, h = info
    on_edge = (x == x0 or x == x0 + w - 1 or y == y0 or y == y0 + h - 1)
    if not on_edge:
        return False
    if (x == x0 and y == y0 + h // 2) or \
       (x == x0 + w - 1 and y == y0 + h // 2) or \
       (y == y0 and x == x0 + w // 2) or \
       (y == y0 + h - 1 and x == x0 + w // 2):
        return False
    return True

# -------------------------------------------
# Класс пули игрока (не взаимодействует с союзниками)
# -------------------------------------------
class Bullet:
    def __init__(self, x, y, dx, dy):
        self.x = x
        self.y = y
        self.dx = dx
        self.dy = dy
        self.speed = BULLET_SPEED
        self.alive = True

    def update(self):
        global enemy_map, explosions
        if not self.alive:
            return
        for _ in range(self.speed):
            new_x = self.x + self.dx
            new_y = self.y + self.dy

            # Попадание во врага
            enemy = enemy_map.get((new_x, new_y))
            if enemy:
                enemy.hp -= BULLET_DAMAGE
                if enemy.hp <= 0:
                    explosions[(new_x, new_y)] = 1
                    del enemy_map[(new_x, new_y)]
                self.alive = False
                break

            # Препятствие (стена, лес, здание)
            if is_solid_obstacle(new_x, new_y):
                if not is_on_fire(new_x, new_y):
                    fire_cells[(new_x, new_y)] = FIRE_LIFETIME
                self.alive = False
                break

            self.x = new_x
            self.y = new_y

# -------------------------------------------
# Класс союзника (троллейбус)
# -------------------------------------------
class Ally:
    def __init__(self, x, y, group='A'):
        self.x = x
        self.y = y
        self.hp = ALLY_HP
        self.group = group  # 'A' или 'B'
        self.direction = 1   # для группы A: 1 - вправо, -1 - влево
        self.color = COLOR_ALLY_A_BG if group == 'A' else COLOR_ALLY_B_BG

    def update(self):
        """Движение и стрельба (вызывается каждый ход игрока)"""
        # Движение только для группы А
        if self.group == 'A':
            new_x = self.x + self.direction
            # Проверяем, что клетка свободна и в пределах главной улицы
            if (MAIN_STREET_X_START <= new_x <= MAIN_STREET_X_END and
                self.y in MAIN_STREET_Y and
                (new_x, self.y) not in ally_map and
                (new_x, self.y) not in enemy_map and
                is_walkable(new_x, self.y)):
                # Перемещаем
                del ally_map[(self.x, self.y)]
                self.x = new_x
                ally_map[(self.x, self.y)] = self
            else:
                # Разворачиваемся
                self.direction *= -1

        # Стрельба
        self.shoot()

    def shoot(self):
        """Стреляет по ближайшему врагу в прямой видимости."""
        global enemy_map, enemy_bullets
        if not enemy_map:
            return
        # Находим ближайшего врага по манхэттенскому расстоянию (или евклидову)
        target = None
        min_dist = float('inf')
        for (ex, ey), enemy in enemy_map.items():
            # Проверяем видимость по горизонтали или вертикали
            if self.x == ex or self.y == ey:
                # Проверяем, нет ли препятствий на линии
                if self.x == ex:
                    step = 1 if ey > self.y else -1
                    y_check = self.y + step
                    blocked = False
                    while y_check != ey:
                        if is_solid_obstacle(self.x, y_check) or (self.x, y_check) in ally_map:
                            blocked = True
                            break
                        y_check += step
                    if not blocked:
                        dist = abs(ey - self.y)
                        if dist < min_dist:
                            min_dist = dist
                            target = (ex, ey)
                elif self.y == ey:
                    step = 1 if ex > self.x else -1
                    x_check = self.x + step
                    blocked = False
                    while x_check != ex:
                        if is_solid_obstacle(x_check, self.y) or (x_check, self.y) in ally_map:
                            blocked = True
                            break
                        x_check += step
                    if not blocked:
                        dist = abs(ex - self.x)
                        if dist < min_dist:
                            min_dist = dist
                            target = (ex, ey)
        if target:
            tx, ty = target
            dx = 0 if tx == self.x else (1 if tx > self.x else -1)
            dy = 0 if ty == self.y else (1 if ty > self.y else -1)
            # Стреляем только если по одной оси
            if dx == 0 or dy == 0:
                enemy_bullets.append(EnemyBullet(self.x, self.y, dx, dy, is_enemy=False))

# -------------------------------------------
# Класс пули врага (может поражать игрока и союзников)
# -------------------------------------------
class EnemyBullet:
    def __init__(self, x, y, dx, dy, is_enemy=True):
        self.x = x
        self.y = y
        self.dx = dx
        self.dy = dy
        self.speed = ENEMY_BULLET_SPEED if is_enemy else BULLET_SPEED
        self.alive = True
        self.is_enemy = is_enemy   # True - пуля врага, False - пуля союзника

    def update(self, trolley):
        global ally_map, enemy_map, explosions, allied_deaths, fire_cells
        if not self.alive:
            return
        for _ in range(self.speed):
            new_x = self.x + self.dx
            new_y = self.y + self.dy

            if self.is_enemy:
                # Пуля врага: поражает игрока или союзника
                if new_x == trolley.x and new_y == trolley.y:
                    trolley.hp -= ENEMY_DAMAGE
                    self.alive = False
                    break
                ally = ally_map.get((new_x, new_y))
                if ally:
                    ally.hp -= ENEMY_DAMAGE
                    if ally.hp <= 0:
                        explosions[(new_x, new_y)] = 1
                        del ally_map[(new_x, new_y)]
                        allied_deaths += 1
                    self.alive = False
                    break
                # Проверка на твёрдое препятствие (стены, лес, здания)
                if is_solid_obstacle(new_x, new_y):
                    if not is_on_fire(new_x, new_y):
                        fire_cells[(new_x, new_y)] = FIRE_LIFETIME
                    self.alive = False
                    break
            else:
                # Пуля союзника: поражает врагов
                enemy = enemy_map.get((new_x, new_y))
                if enemy:
                    enemy.hp -= ALLY_DAMAGE
                    if enemy.hp <= 0:
                        explosions[(new_x, new_y)] = 1
                        del enemy_map[(new_x, new_y)]
                    self.alive = False
                    break
                # Столкновение с твёрдым препятствием (стены, лес, здания)
                if is_solid_obstacle(new_x, new_y):
                    if not is_on_fire(new_x, new_y):
                        fire_cells[(new_x, new_y)] = FIRE_LIFETIME
                    self.alive = False
                    break

            self.x = new_x
            self.y = new_y

# -------------------------------------------
# Класс игрока (троллейбус) – без изменений, кроме проверки на союзников в движении
# -------------------------------------------
class Trolleybus:
    def __init__(self):
        self.x, self.y = 0, 0
        self.hp = 50
        while not is_walkable(self.x, self.y) or is_on_fire(self.x, self.y) or is_burned(self.x, self.y):
            self.x += 1

    def move(self, dx: int, dy: int) -> bool:
        global enemy_map, ally_map
        new_x = self.x + dx
        new_y = self.y + dy
        # Проверяем проходимость (учитывая союзников)
        if not is_walkable(new_x, new_y) and not is_on_fire(new_x, new_y):
            return False

        if is_on_fire(new_x, new_y):
            if self.hp >= 10:
                self.hp -= 10
                self.x, self.y = new_x, new_y
                self.check_enemy_collision()
                return True
            else:
                return False

        self.x, self.y = new_x, new_y
        self.check_enemy_collision()
        return True

    def check_enemy_collision(self):
        enemy = enemy_map.get((self.x, self.y))
        if enemy:
            self.hp -= ENEMY_DAMAGE

# -------------------------------------------
# Генерация врагов (без изменений)
# -------------------------------------------
def generate_enemy_for_block(bx: int, by: int):
    key = (bx, by)
    if key in generated_enemy_blocks:
        return
    generated_enemy_blocks.add(key)

    hsh = hash_coords(bx, by)
    idx = hsh % 25
    cell_x = bx * ENEMY_SPAWN_BLOCK_SIZE + (idx % ENEMY_SPAWN_BLOCK_SIZE)
    cell_y = by * ENEMY_SPAWN_BLOCK_SIZE + (idx // ENEMY_SPAWN_BLOCK_SIZE)

    if cell_y >= 0:
        dist = cell_y - 1 if cell_y >= 2 else 0
    else:
        dist = -cell_y
    if dist < ENEMY_SPAWN_MIN_DIST:
        return

    if (is_walkable(cell_x, cell_y) and
        not is_on_fire(cell_x, cell_y) and
        not is_burned(cell_x, cell_y) and
        (cell_x, cell_y) not in enemy_map and
        (cell_x, cell_y) not in ally_map):
        enemy = Enemy(cell_x, cell_y)
        enemy_map[(cell_x, cell_y)] = enemy

# -------------------------------------------
# Генерация союзников (добавлено)
# -------------------------------------------
def spawn_allies():
    """Детерминированный спавн 49 союзников."""
    global ally_map
    ally_map.clear()
    hsh = hash_coords(0, 0)  # базовый хеш для последовательности
    rng = random.Random(hsh)

    # Группа A: 25 на главной улице
    main_cells = []
    for x in range(MAIN_STREET_X_START, MAIN_STREET_X_END + 1):
        for y in MAIN_STREET_Y:
            if is_walkable(x, y) and (x, y) not in ally_map and (x, y) not in enemy_map:
                main_cells.append((x, y))
    # Перемешиваем детерминированно
    rng.shuffle(main_cells)
    count_a = min(ALLY_GROUP_A_COUNT, len(main_cells))
    for i in range(count_a):
        x, y = main_cells[i]
        ally = Ally(x, y, 'A')
        ally_map[(x, y)] = ally

    # Группа B: 24 на перекрёстках и концах, не далее 7 клеток от главной (по вертикали)
    # Допустимые x: перекрёстки и концы
    allowed_x = set(CROSS_STREETS_X)
    allowed_x.add(MAIN_STREET_X_START)
    allowed_x.add(MAIN_STREET_X_END)
    # y от -7 до 7, исключая главную (0,1)
    allowed_y = list(range(-7, 8))
    allowed_y = [y for y in allowed_y if y not in MAIN_STREET_Y]
    cells_b = []
    for x in allowed_x:
        for y in allowed_y:
            if is_walkable(x, y) and (x, y) not in ally_map and (x, y) not in enemy_map:
                cells_b.append((x, y))
    rng.shuffle(cells_b)
    count_b = min(ALLY_GROUP_B_COUNT, len(cells_b))
    for i in range(count_b):
        x, y = cells_b[i]
        ally = Ally(x, y, 'B')
        ally_map[(x, y)] = ally

# -------------------------------------------
# Класс врага (изменён ИИ)
# -------------------------------------------
class Enemy:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.hp = ENEMY_HP

    def move_towards_main_street(self):
        """
        Выбирает направление, уменьшающее расстояние до главной улицы.
        Если клетка в этом направлении является твёрдым препятствием – стреляет по ней (поджигает) и не двигается.
        Если клетка проходима – двигается.
        В остальных случаях (огонь, союзник, враг) – стоит на месте.
        """
        global enemy_bullets
        directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
        best_dir = None
        best_dist = None

        # Находим направление, которое сильнее всего уменьшает расстояние до главной улицы
        for dx, dy in directions:
            nx, ny = self.x + dx, self.y + dy
            dist = distance_to_main_street(nx, ny)
            if best_dist is None or dist < best_dist:
                best_dist = dist
                best_dir = (dx, dy)

        if best_dir is None:
            return self.x, self.y

        dx, dy = best_dir
        nx, ny = self.x + dx, self.y + dy

        # Проверяем, проходима ли клетка
        if is_walkable(nx, ny):
            # Если проходима и не занята – двигаемся
            if (nx, ny) not in enemy_map and (nx, ny) not in ally_map:
                return nx, ny
            else:
                # занята другим врагом или союзником – стоим
                return self.x, self.y
        else:
            # Не проходима – проверяем, является ли твёрдым препятствием
            if is_solid_obstacle(nx, ny):
                # Стреляем по препятствию (создаём пулю врага)
                enemy_bullets.append(EnemyBullet(self.x, self.y, dx, dy, is_enemy=True))
                # Не двигаемся
                return self.x, self.y
            else:
                # Например, горит – просто стоим
                return self.x, self.y

    def shoot(self, trolley):
        """Стреляет по ближайшему троллейбусу (игрок или союзник) в прямой видимости."""
        global ally_map
        # Собираем все цели: игрок + союзники
        targets = [(trolley.x, trolley.y)]
        for (ax, ay) in ally_map.keys():
            targets.append((ax, ay))
        # Находим ближайшую цель по прямой видимости
        best_target = None
        min_dist = float('inf')
        for (tx, ty) in targets:
            if self.x == tx or self.y == ty:
                # Проверка видимости
                if self.x == tx:
                    step = 1 if ty > self.y else -1
                    y_check = self.y + step
                    blocked = False
                    while y_check != ty:
                        if is_solid_obstacle(self.x, y_check) or (self.x, y_check) in ally_map:
                            blocked = True
                            break
                        y_check += step
                    if not blocked:
                        dist = abs(ty - self.y)
                        if dist < min_dist:
                            min_dist = dist
                            best_target = (tx, ty)
                elif self.y == ty:
                    step = 1 if tx > self.x else -1
                    x_check = self.x + step
                    blocked = False
                    while x_check != tx:
                        if is_solid_obstacle(x_check, self.y) or (x_check, self.y) in ally_map:
                            blocked = True
                            break
                        x_check += step
                    if not blocked:
                        dist = abs(tx - self.x)
                        if dist < min_dist:
                            min_dist = dist
                            best_target = (tx, ty)
        if best_target:
            tx, ty = best_target
            dx = 0 if tx == self.x else (1 if tx > self.x else -1)
            dy = 0 if ty == self.y else (1 if ty > self.y else -1)
            if dx == 0 or dy == 0:  # только по одной оси
                enemy_bullets.append(EnemyBullet(self.x, self.y, dx, dy, is_enemy=True))

# -------------------------------------------
# Основной цикл
# -------------------------------------------
def main():
    global bullets, fire_cells, burned_cells, enemy_bullets, explosions, enemy_map, ally_map
    global turn_count, enemy_damage_increased, allied_deaths, retreat_phase, retreat_timer, notification_text, notification_timer, ENEMY_DAMAGE

    pygame.init()
    screen = pygame.display.set_mode((WINDOW_SIZE, WINDOW_SIZE))
    pygame.display.set_caption("Троллейбус с союзниками")
    clock = pygame.time.Clock()

    trolley = Trolleybus()
    spawn_allies()  # создаём 49 союзников

    game_over = False
    game_over_message = ""

    try:
        font = pygame.font.SysFont("Segoe UI Emoji", CELL_SIZE - 6)
    except:
        font = pygame.font.SysFont("Arial", CELL_SIZE - 6)

    running = True
    while running:
        player_acted = False
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN and not game_over and not retreat_phase:
                if event.key == pygame.K_w:
                    trolley.move(0, -1)
                    player_acted = True
                elif event.key == pygame.K_s:
                    trolley.move(0, 1)
                    player_acted = True
                elif event.key == pygame.K_a:
                    trolley.move(-1, 0)
                    player_acted = True
                elif event.key == pygame.K_d:
                    trolley.move(1, 0)
                    player_acted = True
                elif event.key == pygame.K_UP:
                    bullets.append(Bullet(trolley.x, trolley.y, 0, -1))
                    player_acted = True
                elif event.key == pygame.K_DOWN:
                    bullets.append(Bullet(trolley.x, trolley.y, 0, 1))
                    player_acted = True
                elif event.key == pygame.K_LEFT:
                    bullets.append(Bullet(trolley.x, trolley.y, -1, 0))
                    player_acted = True
                elif event.key == pygame.K_RIGHT:
                    bullets.append(Bullet(trolley.x, trolley.y, 1, 0))
                    player_acted = True

        if game_over:
            screen.fill((0,0,0))
            text = pygame.font.SysFont("Arial", 40).render(game_over_message, True, (255,0,0))
            screen.blit(text, (WINDOW_SIZE//2 - text.get_width()//2, WINDOW_SIZE//2 - 20))
            pygame.display.flip()
            clock.tick(30)
            continue

        # Если наступила фаза отступления, обрабатываем только таймер и отрисовку
        if retreat_phase:
            # Уменьшаем таймер
            if retreat_timer > 0:
                retreat_timer -= 1
            else:
                # Время вышло, показываем финальное сообщение и завершаем
                screen.fill((0,0,0))
                victory_text = pygame.font.SysFont("Arial", 50).render("Миссия выполнена!", True, (0,255,0))
                screen.blit(victory_text, (WINDOW_SIZE//2 - victory_text.get_width()//2, WINDOW_SIZE//2 - 25))
                pygame.display.flip()
                pygame.time.wait(2000)
                running = False
                continue

            # Рисуем игровой мир (без обновления логики) - повторяем код отрисовки
            screen.fill((0, 0, 0))
            offset_x = trolley.x - GRID_SIZE // 2
            offset_y = trolley.y - GRID_SIZE // 2

            for i in range(GRID_SIZE):
                for j in range(GRID_SIZE):
                    gx = offset_x + i
                    gy = offset_y + j
                    rect = pygame.Rect(i * CELL_SIZE, j * CELL_SIZE, CELL_SIZE, CELL_SIZE)

                    draw_fire = is_on_fire(gx, gy)
                    draw_burned = is_burned(gx, gy)
                    draw_enemy = (gx, gy) in enemy_map
                    draw_ally = (gx, gy) in ally_map
                    draw_forest = is_forest(gx, gy)
                    draw_park = is_park(gx, gy)
                    draw_building = not is_walkable(gx, gy) and not draw_fire and not draw_burned and not draw_ally

                    if draw_fire:
                        try:
                            fire_surf = font.render("🔥", True, (255, 0, 0))
                            screen.blit(fire_surf, fire_surf.get_rect(center=rect.center))
                        except:
                            pygame.draw.rect(screen, (255, 100, 0), rect)
                    elif draw_burned:
                        pygame.draw.rect(screen, COLOR_ROAD, rect)
                    elif draw_ally:
                        ally = ally_map[(gx, gy)]
                        pygame.draw.rect(screen, ally.color, rect)
                    elif draw_enemy:
                        pygame.draw.rect(screen, COLOR_ENEMY_BG, rect)
                    elif draw_forest:
                        try:
                            emoji = "🌳" if hash_coords(gx, gy) % 2 == 0 else "🌲"
                            if is_big_forest(gx, gy):
                                choice = hash_coords(gx, gy) % 3
                                emoji = ["🌳", "🪾", "🌲"][choice]
                            text_surface = font.render(emoji, True, (0, 128, 0))
                            screen.blit(text_surface, text_surface.get_rect(center=rect.center))
                        except:
                            pygame.draw.rect(screen, (34, 139, 34), rect)
                    elif draw_park:
                        try:
                            emoji = park_emoji(gx, gy)
                            text_surface = font.render(emoji, True, (255, 20, 147))
                            screen.blit(text_surface, text_surface.get_rect(center=rect.center))
                        except:
                            pygame.draw.rect(screen, (255, 105, 180), rect)
                    elif draw_building:
                        try:
                            emoji = "🏢" if (gx * 31 + gy * 17) % 2 == 0 else "🏠"
                            text_surface = font.render(emoji, True, (0, 0, 0))
                            screen.blit(text_surface, text_surface.get_rect(center=rect.center))
                        except:
                            pygame.draw.rect(screen, (80, 80, 120), rect)
                    else:
                        color = COLOR_MAIN_ROAD if is_main_street(gx, gy) else COLOR_ROAD
                        pygame.draw.rect(screen, color, rect)

                    pygame.draw.rect(screen, COLOR_GRID, rect, 1)

            # Враги
            for (x, y), enemy in enemy_map.items():
                sx = (x - offset_x) * CELL_SIZE
                sy = (y - offset_y) * CELL_SIZE
                rect = pygame.Rect(sx, sy, CELL_SIZE, CELL_SIZE)
                try:
                    text = font.render("🚐", True, (255, 255, 255))
                    screen.blit(text, text.get_rect(center=rect.center))
                except:
                    pygame.draw.rect(screen, (0, 0, 255), rect.inflate(-10, -10))

            # Союзники
            for (x, y), ally in ally_map.items():
                sx = (x - offset_x) * CELL_SIZE
                sy = (y - offset_y) * CELL_SIZE
                rect = pygame.Rect(sx, sy, CELL_SIZE, CELL_SIZE)
                try:
                    text = font.render("🚎", True, (0, 0, 0))
                    screen.blit(text, text.get_rect(center=rect.center))
                except:
                    pygame.draw.rect(screen, (0, 0, 255), rect.inflate(-10, -10))

            # Взрывы
            for (x, y) in explosions:
                sx = (x - offset_x) * CELL_SIZE
                sy = (y - offset_y) * CELL_SIZE
                rect = pygame.Rect(sx, sy, CELL_SIZE, CELL_SIZE)
                try:
                    text = font.render("💥", True, (255, 0, 0))
                    screen.blit(text, text.get_rect(center=rect.center))
                except:
                    pygame.draw.rect(screen, (255, 255, 0), rect)

            # Пули игрока
            for bullet in bullets:
                sx = (bullet.x - offset_x) * CELL_SIZE + CELL_SIZE // 2
                sy = (bullet.y - offset_y) * CELL_SIZE + CELL_SIZE // 2
                pygame.draw.circle(screen, (255, 255, 0), (sx, sy), 4)

            # Пули врагов и союзников
            for b in enemy_bullets:
                sx = (b.x - offset_x) * CELL_SIZE + CELL_SIZE // 2
                sy = (b.y - offset_y) * CELL_SIZE + CELL_SIZE // 2
                color = (255, 0, 0) if b.is_enemy else (0, 255, 0)
                pygame.draw.circle(screen, color, (sx, sy), 4)

            # Троллейбус игрока
            player_rect = pygame.Rect(5 * CELL_SIZE, 5 * CELL_SIZE, CELL_SIZE, CELL_SIZE)
            pygame.draw.rect(screen, (0, 255, 0), player_rect)
            try:
                trolley_text = font.render("🚎", True, (0, 0, 255))
                trolley_rect = trolley_text.get_rect(center=player_rect.center)
                screen.blit(trolley_text, trolley_rect)
            except:
                pygame.draw.rect(screen, COLOR_TROLLEY_FALLBACK, player_rect.inflate(-10, -10))

            # Информация
            try:
                info_font = pygame.font.SysFont("Arial", 20)
            except:
                info_font = font
            hp_text = info_font.render(f"HP: {trolley.hp}", True, (255, 255, 255))
            screen.blit(hp_text, (10, 10))
            coord_text = info_font.render(f"X:{trolley.x} Y:{trolley.y}", True, (255, 255, 255))
            screen.blit(coord_text, (10, 35))
            allies_text = info_font.render(f"Союзников: {len(ally_map)}", True, (255, 255, 255))
            screen.blit(allies_text, (10, 60))
            deaths_text = info_font.render(f"Погибло союзников: {allied_deaths}", True, (255, 255, 255))
            screen.blit(deaths_text, (10, 85))

            # Уведомление, если есть
            if notification_text and notification_timer > 0:
                notif_font = pygame.font.SysFont("Arial", 30)
                notif_surf = notif_font.render(notification_text, True, (255, 255, 0))
                notif_rect = notif_surf.get_rect(center=(WINDOW_SIZE//2, 30))
                bg_rect = notif_rect.inflate(20, 10)
                pygame.draw.rect(screen, (0,0,0), bg_rect)
                screen.blit(notif_surf, notif_rect)
                notification_timer -= 1

            pygame.display.flip()
            clock.tick(30)
            continue

        # ----- Нормальный ход игры -----
        if player_acted:
            # Увеличиваем счётчик ходов
            turn_count += 1

            # Проверяем достижение 50-го хода для увеличения урона врагов
            if turn_count >= 50 and not enemy_damage_increased:
                enemy_damage_increased = True
                ENEMY_DAMAGE = 25
                notification_text = "Маршрутки стали использовать автоматы"
                notification_timer = 60  # 2 секунды при 30 FPS

            # Генерация врагов для видимых блоков
            offset_x = trolley.x - GRID_SIZE // 2
            offset_y = trolley.y - GRID_SIZE // 2
            for i in range(GRID_SIZE):
                for j in range(GRID_SIZE):
                    gx = offset_x + i
                    gy = offset_y + j
                    bx = gx // ENEMY_SPAWN_BLOCK_SIZE
                    by = gy // ENEMY_SPAWN_BLOCK_SIZE
                    generate_enemy_for_block(bx, by)

            # 1. Движение врагов (используем новый ИИ)
            for enemy in list(enemy_map.values()):
                old_pos = (enemy.x, enemy.y)
                new_x, new_y = enemy.move_towards_main_street()
                if (new_x, new_y) != old_pos:
                    if is_main_street(new_x, new_y):
                        game_over = True
                        game_over_message = "Маршрутка захватила главную улицу! Вы проиграли!"
                        break
                    del enemy_map[old_pos]
                    enemy.x, enemy.y = new_x, new_y
                    enemy_map[(new_x, new_y)] = enemy
            if game_over:
                continue

            # 2. Стрельба врагов (по троллейбусам)
            for enemy in list(enemy_map.values()):
                enemy.shoot(trolley)

            # 3. Движение и стрельба союзников
            for ally in list(ally_map.values()):
                ally.update()  # внутри стрельба

            # 4. Обновление пуль игрока
            for bullet in bullets:
                bullet.update()
            bullets = [b for b in bullets if b.alive]

            # 5. Обновление пуль врагов и союзников (все в enemy_bullets)
            for b in enemy_bullets:
                b.update(trolley)
            enemy_bullets = [b for b in enemy_bullets if b.alive]

            # 6. Взрывы
            to_remove_exp = []
            for (x, y), timer in explosions.items():
                if timer <= 0:
                    to_remove_exp.append((x, y))
                else:
                    explosions[(x, y)] = timer - 1
            for pos in to_remove_exp:
                del explosions[pos]

            # 7. Огонь
            to_remove_fire = []
            for (x, y), t in fire_cells.items():
                if t <= 1:
                    to_remove_fire.append((x, y))
                    burned_cells.add((x, y))
                else:
                    fire_cells[(x, y)] = t - 1
            for pos in to_remove_fire:
                del fire_cells[pos]

            # Проверка смерти игрока
            if trolley.hp <= 0:
                game_over = True
                game_over_message = "Троллейбус был уничтожен! Вы проиграли!"

            # Проверка условия отступления (10 погибших союзников)
            if allied_deaths >= 10 and not retreat_phase and not game_over:
                retreat_phase = True
                retreat_timer = 120  # 4 секунды при 30 FPS
                notification_text = "Далее удерживать улицу нецелесообразно, отступаем!!!"
                notification_timer = 120  # будет уменьшаться в цикле

        # Отрисовка (нормальный режим)
        screen.fill((0, 0, 0))
        offset_x = trolley.x - GRID_SIZE // 2
        offset_y = trolley.y - GRID_SIZE // 2

        for i in range(GRID_SIZE):
            for j in range(GRID_SIZE):
                gx = offset_x + i
                gy = offset_y + j
                rect = pygame.Rect(i * CELL_SIZE, j * CELL_SIZE, CELL_SIZE, CELL_SIZE)

                draw_fire = is_on_fire(gx, gy)
                draw_burned = is_burned(gx, gy)
                draw_enemy = (gx, gy) in enemy_map
                draw_ally = (gx, gy) in ally_map
                draw_forest = is_forest(gx, gy)
                draw_park = is_park(gx, gy)
                draw_building = not is_walkable(gx, gy) and not draw_fire and not draw_burned and not draw_ally

                if draw_fire:
                    try:
                        fire_surf = font.render("🔥", True, (255, 0, 0))
                        screen.blit(fire_surf, fire_surf.get_rect(center=rect.center))
                    except:
                        pygame.draw.rect(screen, (255, 100, 0), rect)
                elif draw_burned:
                    pygame.draw.rect(screen, COLOR_ROAD, rect)
                elif draw_ally:
                    ally = ally_map[(gx, gy)]
                    pygame.draw.rect(screen, ally.color, rect)
                elif draw_enemy:
                    pygame.draw.rect(screen, COLOR_ENEMY_BG, rect)
                elif draw_forest:
                    try:
                        emoji = "🌳" if hash_coords(gx, gy) % 2 == 0 else "🌲"
                        if is_big_forest(gx, gy):
                            choice = hash_coords(gx, gy) % 3
                            emoji = ["🌳", "🪾", "🌲"][choice]
                        text_surface = font.render(emoji, True, (0, 128, 0))
                        screen.blit(text_surface, text_surface.get_rect(center=rect.center))
                    except:
                        pygame.draw.rect(screen, (34, 139, 34), rect)
                elif draw_park:
                    try:
                        emoji = park_emoji(gx, gy)
                        text_surface = font.render(emoji, True, (255, 20, 147))
                        screen.blit(text_surface, text_surface.get_rect(center=rect.center))
                    except:
                        pygame.draw.rect(screen, (255, 105, 180), rect)
                elif draw_building:
                    try:
                        emoji = "🏢" if (gx * 31 + gy * 17) % 2 == 0 else "🏠"
                        text_surface = font.render(emoji, True, (0, 0, 0))
                        screen.blit(text_surface, text_surface.get_rect(center=rect.center))
                    except:
                        pygame.draw.rect(screen, (80, 80, 120), rect)
                else:
                    color = COLOR_MAIN_ROAD if is_main_street(gx, gy) else COLOR_ROAD
                    pygame.draw.rect(screen, color, rect)

                pygame.draw.rect(screen, COLOR_GRID, rect, 1)

        # Враги
        for (x, y), enemy in enemy_map.items():
            sx = (x - offset_x) * CELL_SIZE
            sy = (y - offset_y) * CELL_SIZE
            rect = pygame.Rect(sx, sy, CELL_SIZE, CELL_SIZE)
            try:
                text = font.render("🚐", True, (255, 255, 255))
                screen.blit(text, text.get_rect(center=rect.center))
            except:
                pygame.draw.rect(screen, (0, 0, 255), rect.inflate(-10, -10))

        # Союзники
        for (x, y), ally in ally_map.items():
            sx = (x - offset_x) * CELL_SIZE
            sy = (y - offset_y) * CELL_SIZE
            rect = pygame.Rect(sx, sy, CELL_SIZE, CELL_SIZE)
            try:
                text = font.render("🚎", True, (0, 0, 0))
                screen.blit(text, text.get_rect(center=rect.center))
            except:
                pygame.draw.rect(screen, (0, 0, 255), rect.inflate(-10, -10))

        # Взрывы
        for (x, y) in explosions:
            sx = (x - offset_x) * CELL_SIZE
            sy = (y - offset_y) * CELL_SIZE
            rect = pygame.Rect(sx, sy, CELL_SIZE, CELL_SIZE)
            try:
                text = font.render("💥", True, (255, 0, 0))
                screen.blit(text, text.get_rect(center=rect.center))
            except:
                pygame.draw.rect(screen, (255, 255, 0), rect)

        # Пули игрока
        for bullet in bullets:
            sx = (bullet.x - offset_x) * CELL_SIZE + CELL_SIZE // 2
            sy = (bullet.y - offset_y) * CELL_SIZE + CELL_SIZE // 2
            pygame.draw.circle(screen, (255, 255, 0), (sx, sy), 4)

        # Пули врагов и союзников
        for b in enemy_bullets:
            sx = (b.x - offset_x) * CELL_SIZE + CELL_SIZE // 2
            sy = (b.y - offset_y) * CELL_SIZE + CELL_SIZE // 2
            color = (255, 0, 0) if b.is_enemy else (0, 255, 0)
            pygame.draw.circle(screen, color, (sx, sy), 4)

        # Троллейбус игрока
        player_rect = pygame.Rect(5 * CELL_SIZE, 5 * CELL_SIZE, CELL_SIZE, CELL_SIZE)
        pygame.draw.rect(screen, (0, 255, 0), player_rect)
        try:
            trolley_text = font.render("🚎", True, (0, 0, 255))
            trolley_rect = trolley_text.get_rect(center=player_rect.center)
            screen.blit(trolley_text, trolley_rect)
        except:
            pygame.draw.rect(screen, COLOR_TROLLEY_FALLBACK, player_rect.inflate(-10, -10))

        # Информация
        try:
            info_font = pygame.font.SysFont("Arial", 20)
        except:
            info_font = font
        hp_text = info_font.render(f"HP: {trolley.hp}", True, (255, 255, 255))
        screen.blit(hp_text, (10, 10))
        coord_text = info_font.render(f"X:{trolley.x} Y:{trolley.y}", True, (255, 255, 255))
        screen.blit(coord_text, (10, 35))
        allies_text = info_font.render(f"Союзников: {len(ally_map)}", True, (255, 255, 255))
        screen.blit(allies_text, (10, 60))
        deaths_text = info_font.render(f"Погибло союзников: {allied_deaths}", True, (255, 255, 255))
        screen.blit(deaths_text, (10, 85))
        turn_text = info_font.render(f"Ход: {turn_count}", True, (255, 255, 255))
        screen.blit(turn_text, (10, 110))

        # Уведомление, если есть
        if notification_text and notification_timer > 0:
            notif_font = pygame.font.SysFont("Arial", 30)
            notif_surf = notif_font.render(notification_text, True, (255, 255, 0))
            notif_rect = notif_surf.get_rect(center=(WINDOW_SIZE//2, 30))
            bg_rect = notif_rect.inflate(20, 10)
            pygame.draw.rect(screen, (0,0,0), bg_rect)
            screen.blit(notif_surf, notif_rect)
            notification_timer -= 1
        else:
            # Сбрасываем текст, когда таймер истёк
            if notification_text is not None:
                notification_text = None

        pygame.display.flip()
        clock.tick(30)

    pygame.quit()
    sys.exit()

if __name__ == "__main__":
    main()

PS. Остальные миссии ещё не готовы, пишите пожелания к ним.