Участник:Prionosuchus
Материал из Абсурдопедии
Перейти к навигацииПерейти к поискуPrionosuchus — древняя амфибия, жившая в пермском периоде (т.е. до Хренозоя).
Коды для игр[править]
Абсурдосубнатика[править]
Превращаю из обычной в абсурдную постепенно.
1.1-е обновление
- исправлено: карта почти нужного вида
- добавлено: ЧБОС-кит
1.2-е обновление
- добавлены рыбы-кабачки и рыбы-баклажаны
- а также делящийся при повреждении бешеный мох
2-е обновление:
- добавлены големы, рыбы шурупы, отвёртки, гвозди, болты.
- добавлен ядерный гриб
- убрана вся фауна оригинала
3-е обновление:
- добавлена птица-Говносрун и её еда — горох
- добавлен Акласур
- добавлена пиявка-гидра
- проведено деление авроры на отсеки и оптимизация
- скрафтить можно в любых обломках
4-е обновление:
- добавлен джип и бронетранспортёр
- можно пробивать стены торпедами
- добавлено 100 рабочих, а игрок теперь - их прораб (можно отключить в настройках N)
- добавлены патроны (теперь нельзя стрелять бесконечно)
- добавлены новые рыбы:
- Рыба-огурец - 0,8 м Быстрая, зелёная, покрыта мелкими шипиками
- Рыба-патиссон - 1,5 м Медленная, плоская, прочность втрое больше рыбы-кабачка
- Рыба-гайка - 0,3 м При опасности прикрепляется к рыбе-болту
- Рыба-болт - Носитель рыб-гаек
- Гидрорыба - Хищник с короткими ротовыми щупальцами
- говносрун теперь стреляет мощной очередью, а его снаряды разрушаются при ударе
- исправлено название АКЛАСУРа
- теперь есть ресурсы и в самой Авроре
Сам код ниже:
# -*- coding: utf-8 -*-
# МегоСубнатика2D — полная обновлённая игра.
# Настройка начальных предметов: START_INVENTORY.
# Установка зависимости: pip install pygame
# THREE_SPECIES_REMOVED_V1
import pygame
import random
import math
import sys
import os
from collections import OrderedDict
AA = 2
def P(x, y):
return (int(x * AA), int(y * AA))
def poly(surface, color, points, width=0):
pts = [P(x, y) for x, y in points]
pygame.draw.polygon(surface, color, pts, width * AA)
def ellipse(surface, color, rect, width=0):
x, y, w, h = rect
pygame.draw.ellipse(surface, color, pygame.Rect(int(x * AA), int(y * AA), int(w * AA), int(h * AA)), width * AA)
def rect(surface, color, r, width=0, radius=0):
x, y, w, h = r
pygame.draw.rect(surface, color, pygame.Rect(int(x * AA), int(y * AA), int(w * AA), int(h * AA)), width * AA, border_radius=radius * AA)
def line(surface, color, a, b, width=1):
pygame.draw.line(surface, color, P(*a), P(*b), width * AA)
def circle(surface, color, pos, radius, width=0):
pygame.draw.circle(surface, color, P(*pos), int(radius * AA), width * AA)
def aaline(surface, color, points, closed=False):
pygame.draw.aalines(surface, color, closed, [P(x, y) for x, y in points])
def draw_cyclops(surface, cx, cy, scale=1.0):
def pt(x, y):
return (cx + x * scale, cy + y * scale)
shadow = [pt(-295, -45), pt(-260, -72), pt(-150, -91), pt(70, -88), pt(205, -65), pt(290, -33), pt(318, 0), pt(290, 33), pt(205, 65), pt(70, 88), pt(-150, 91), pt(-260, 72), pt(-295, 45)]
poly(surface, (0, 7, 10), shadow)
body = [pt(-300, 0), pt(-283, -40), pt(-245, -67), pt(-180, -82), pt(-60, -87), pt(85, -81), pt(183, -68), pt(248, -46), pt(289, -21), pt(307, 0), pt(289, 21), pt(248, 46), pt(183, 68), pt(85, 81), pt(-60, 87), pt(-180, 82), pt(-245, 67), pt(-283, 40)]
poly(surface, (126, 132, 126), body)
pygame.draw.aalines(surface, (33, 38, 37), True, [P(x, y) for x, y in body])
center_panel = [pt(-225, -54), pt(-135, -66), pt(65, -67), pt(175, -55), pt(248, -32), pt(267, 0), pt(248, 32), pt(175, 55), pt(65, 67), pt(-135, 66), pt(-225, 54), pt(-254, 0)]
poly(surface, (177, 181, 172), center_panel)
orange = (224, 102, 28)
orange_dark = (151, 61, 18)
orange_light = (246, 131, 39)
top_orange = [pt(-205, -61), pt(-110, -76), pt(70, -74), pt(161, -61), pt(174, -46), pt(58, -50), pt(-116, -51)]
bottom_orange = [pt(-205, 61), pt(-110, 76), pt(70, 74), pt(161, 61), pt(174, 46), pt(58, 50), pt(-116, 51)]
poly(surface, orange_dark, top_orange)
poly(surface, orange_dark, bottom_orange)
inner_top = [pt(-160, -58), pt(-80, -68), pt(60, -67), pt(137, -57), pt(146, -50), pt(54, -54), pt(-92, -54)]
inner_bottom = [pt(-160, 58), pt(-80, 68), pt(60, 67), pt(137, 57), pt(146, 50), pt(54, 54), pt(-92, 54)]
poly(surface, orange, inner_top)
poly(surface, orange, inner_bottom)
line(surface, orange_light, pt(-120, -61), pt(75, -61), 2)
line(surface, orange_light, pt(-120, 61), pt(75, 61), 2)
spine = [pt(-245, -20), pt(-130, -30), pt(85, -28), pt(210, -18), pt(269, 0), pt(210, 18), pt(85, 28), pt(-130, 30), pt(-245, 20)]
poly(surface, (77, 82, 79), spine)
spine2 = [pt(-215, -12), pt(-105, -18), pt(91, -16), pt(192, -10), pt(237, 0), pt(192, 10), pt(91, 16), pt(-105, 18), pt(-215, 12)]
poly(surface, (112, 117, 111), spine2)
tower = [pt(-48, -32), pt(59, -30), pt(102, -20), pt(115, 0), pt(102, 20), pt(59, 30), pt(-48, 32), pt(-78, 19), pt(-84, 0), pt(-78, -19)]
poly(surface, (48, 53, 52), tower)
tower_inner = [pt(-38, -22), pt(50, -21), pt(82, -14), pt(94, 0), pt(82, 14), pt(50, 21), pt(-38, 22), pt(-63, 13), pt(-68, 0), pt(-63, -13)]
poly(surface, (93, 102, 99), tower_inner)
glass = (20, 76, 88)
glass_light = (49, 139, 152)
windows = [(-24, -15, 25, 12), (5, -15, 25, 12), (34, -14, 23, 11), (61, -11, 17, 9), (-24, 3, 25, 12), (5, 3, 25, 12), (34, 3, 23, 11), (61, 2, 17, 9)]
for x, y, w, h in windows:
rect(surface, glass, (cx + x * scale, cy + y * scale, w * scale, h * scale), radius=max(1, int(3 * scale)))
line(surface, glass_light, pt(x + 3, y + 3), pt(x + w - 3, y + 3), max(1, int(1.5 * scale)))
ellipse(surface, (43, 47, 46), (cx - 122 * scale, cy - 17 * scale, 39 * scale, 34 * scale))
ellipse(surface, (122, 130, 125), (cx - 116 * scale, cy - 12 * scale, 27 * scale, 24 * scale), width=max(1, int(3 * scale)))
line(surface, (35, 38, 38), pt(-115, 0), pt(-91, 0), max(1, int(2 * scale)))
for x in [-190, -145, -100, 120, 155, 190]:
line(surface, (69, 73, 70), pt(x, -48), pt(x + 8, 48), max(1, int(scale)))
for x in [-190, -142, -94, 118, 156]:
rect(surface, (67, 71, 68), (cx + x * scale, cy - 15 * scale, 32 * scale, 30 * scale), width=max(1, int(scale)), radius=max(1, int(3 * scale)))
stern = [pt(-300, -36), pt(-336, -30), pt(-356, -17), pt(-365, 0), pt(-356, 17), pt(-336, 30), pt(-300, 36), pt(-282, 20), pt(-282, -20)]
poly(surface, (58, 61, 60), stern)
engine_positions = [-46, 46]
for ey in engine_positions:
poly(surface, (62, 65, 62), [pt(-238, ey - 20), pt(-319, ey - 25), pt(-335, ey - 14), pt(-335, ey + 14), pt(-319, ey + 25), pt(-238, ey + 20)])
ellipse(surface, (39, 42, 42), (cx - 355 * scale, cy + (ey - 20) * scale, 72 * scale, 40 * scale))
ellipse(surface, (103, 109, 103), (cx - 344 * scale, cy + (ey - 14) * scale, 46 * scale, 28 * scale))
ellipse(surface, (18, 31, 31), (cx - 337 * scale, cy + (ey - 10) * scale, 31 * scale, 20 * scale))
ellipse(surface, (38, 157, 175), (cx - 334 * scale, cy + (ey - 7) * scale, 20 * scale, 14 * scale))
ellipse(surface, (122, 226, 231), (cx - 330 * scale, cy + (ey - 4) * scale, 11 * scale, 8 * scale))
poly(surface, (72, 77, 74), [pt(-178, -71), pt(-130, -118), pt(-50, -113), pt(-72, -76)])
poly(surface, (72, 77, 74), [pt(-178, 71), pt(-130, 118), pt(-50, 113), pt(-72, 76)])
poly(surface, orange, [pt(-131, -108), pt(-78, -103), pt(-95, -79), pt(-153, -82)])
poly(surface, orange, [pt(-131, 108), pt(-78, 103), pt(-95, 79), pt(-153, 82)])
nose = [pt(213, -40), pt(273, -28), pt(306, -8), pt(312, 0), pt(306, 8), pt(273, 28), pt(213, 40), pt(235, 18), pt(246, 0), pt(235, -18)]
poly(surface, (198, 200, 188), nose)
for yy in [-16, 4]:
rect(surface, (19, 79, 89), (cx + 244 * scale, cy + yy * scale, 34 * scale, 12 * scale), radius=max(1, int(3 * scale)))
for yy in [-25, 25]:
circle(surface, (55, 64, 61), pt(261, yy), 7 * scale)
circle(surface, (186, 229, 217), pt(263, yy), 4 * scale)
circle(surface, (235, 255, 236), pt(264, yy), 2 * scale)
line(surface, (215, 112, 37), pt(112, -35), pt(190, -29), max(1, int(4 * scale)))
line(surface, (215, 112, 37), pt(112, 35), pt(190, 29), max(1, int(4 * scale)))
def draw_seamoth(surface, cx, cy, scale=1.0):
def pt(x, y):
return (cx + x * scale, cy + y * scale)
white = (210, 213, 201)
light = (234, 235, 217)
dark = (54, 59, 57)
orange = (225, 111, 27)
orange2 = (247, 142, 39)
glass = (17, 72, 84)
glass_light = (67, 169, 179)
ellipse(surface, (0, 7, 10), (cx - 105 * scale, cy - 58 * scale, 215 * scale, 116 * scale))
poly(surface, dark, [pt(-20, -34), pt(-79, -89), pt(-122, -83), pt(-93, -43), pt(-61, -19)])
poly(surface, dark, [pt(-20, 34), pt(-79, 89), pt(-122, 83), pt(-93, 43), pt(-61, 19)])
poly(surface, white, [pt(-42, -40), pt(-81, -76), pt(-106, -71), pt(-82, -44), pt(-55, -25)])
poly(surface, white, [pt(-42, 40), pt(-81, 76), pt(-106, 71), pt(-82, 44), pt(-55, 25)])
for yy in (-72, 72):
ellipse(surface, (40, 45, 44), (cx - 113 * scale, cy + (yy - 16) * scale, 45 * scale, 32 * scale))
ellipse(surface, (105, 111, 106), (cx - 105 * scale, cy + (yy - 11) * scale, 29 * scale, 22 * scale))
ellipse(surface, (20, 110, 126), (cx - 98 * scale, cy + (yy - 7) * scale, 17 * scale, 14 * scale))
ellipse(surface, (90, 220, 225), (cx - 93 * scale, cy + (yy - 4) * scale, 8 * scale, 8 * scale))
body = [pt(-86, 0), pt(-71, -32), pt(-40, -50), pt(0, -58), pt(42, -51), pt(78, -33), pt(103, -10), pt(109, 0), pt(103, 10), pt(78, 33), pt(42, 51), pt(0, 58), pt(-40, 50), pt(-71, 32)]
poly(surface, white, body)
outline = [pt(-79, 0), pt(-62, -27), pt(-32, -43), pt(5, -48), pt(42, -42), pt(72, -27), pt(94, -8), pt(98, 0), pt(94, 8), pt(72, 27), pt(42, 42), pt(5, 48), pt(-32, 43), pt(-62, 27)]
pygame.draw.aalines(surface, (57, 62, 59), True, [P(x, y) for x, y in outline])
poly(surface, orange, [pt(-48, -42), pt(-24, -52), pt(-9, -54), pt(-19, -29), pt(-39, -23)])
poly(surface, orange, [pt(-48, 42), pt(-24, 52), pt(-9, 54), pt(-19, 29), pt(-39, 23)])
cockpit = [pt(-23, -33), pt(21, -37), pt(55, -27), pt(77, -11), pt(84, 0), pt(77, 11), pt(55, 27), pt(21, 37), pt(-23, 33), pt(-42, 20), pt(-48, 0), pt(-42, -20)]
poly(surface, dark, cockpit)
glass_shape = [pt(-13, -27), pt(20, -30), pt(49, -22), pt(68, -9), pt(74, 0), pt(68, 9), pt(49, 22), pt(20, 30), pt(-13, 27), pt(-30, 16), pt(-35, 0), pt(-30, -16)]
poly(surface, glass, glass_shape)
line(surface, glass_light, pt(-17, -20), pt(42, -17), max(1, int(2 * scale)))
line(surface, (119, 205, 208), pt(41, -15), pt(60, -7), max(1, int(2 * scale)))
line(surface, (37, 44, 43), pt(18, -29), pt(18, 29), max(1, int(3 * scale)))
poly(surface, light, [pt(70, -28), pt(101, -12), pt(112, 0), pt(101, 12), pt(70, 28), pt(83, 10), pt(87, 0), pt(83, -10)])
for yy in [-15, 15]:
circle(surface, (47, 56, 54), pt(92, yy), 7 * scale)
circle(surface, (181, 224, 214), pt(94, yy), 4.5 * scale)
circle(surface, (244, 255, 235), pt(95, yy), 2 * scale)
poly(surface, (72, 77, 73), [pt(-86, -22), pt(-111, -18), pt(-123, -8), pt(-127, 0), pt(-123, 8), pt(-111, 18), pt(-86, 22)])
ellipse(surface, (39, 44, 43), (cx - 130 * scale, cy - 18 * scale, 37 * scale, 36 * scale))
ellipse(surface, (22, 124, 140), (cx - 125 * scale, cy - 12 * scale, 24 * scale, 24 * scale))
ellipse(surface, (94, 224, 227), (cx - 120 * scale, cy - 8 * scale, 15 * scale, 16 * scale))
ellipse(surface, (75, 82, 78), (cx - 72 * scale, cy - 18 * scale, 35 * scale, 36 * scale))
ellipse(surface, orange2, (cx - 66 * scale, cy - 12 * scale, 23 * scale, 24 * scale), width=max(1, int(3 * scale)))
for bx, by in [(-55, -33), (-55, 33), (62, -28), (62, 28)]:
circle(surface, (79, 82, 78), pt(bx, by), 3 * scale)
def _make_reference_vehicle_sprite(draw_func, logical_size, center, target_width):
logical_w, logical_h = logical_size
source = pygame.Surface((logical_w * AA, logical_h * AA), pygame.SRCALPHA)
draw_func(source, center[0], center[1], 1.0)
bounds = source.get_bounding_rect(min_alpha=1)
if bounds.width > 0 and bounds.height > 0:
source = source.subsurface(bounds).copy()
target_width = max(1, int(target_width))
target_height = max(1, int(round(target_width * source.get_height() / max(1, source.get_width()))))
return pygame.transform.smoothscale(source, (target_width, target_height)).convert_alpha()
def generate_detailed_cyclops_vehicle_sprite(target_width=180):
return _make_reference_vehicle_sprite(draw_cyclops, (760, 270), (390, 135), target_width)
def generate_detailed_seamoth_vehicle_sprite(target_width=86):
return _make_reference_vehicle_sprite(draw_seamoth, (300, 220), (150, 110), target_width)
MAP_SIZE = 1000
WHALE_NAME = 'Чёрно-бело-оранжево-синий кит'
WHALE_HP = 10000
WHALE_SPEED = 2.0
WHALE_PUNISHMENT_DRAGONS = 20
LAND_BIOMES = frozenset(('Горы', 'Побережье', 'Острова', 'Леса'))
WALK_SPEED = 0.94
TREE_SPECIES = ('Сосна', 'Ель', 'Дуб', 'Берёза', 'Краснолистное дерево', 'Пальма')
FOREST_TREES_PER_400_CELLS = 25
MAD_MOSS_MIN_DIAMETER = 0.2
MAD_MOSS_MAX_DIAMETER = 1.5
MAD_MOSS_SPEED = 0.1
MAD_MOSS_GROWTH_PER_FRAME = 0.0005
MAD_MOSS_PER_400_FOREST = 1.6
MAD_MOSS_MAX_COLONIES = 650
GOLEM_ENABLED = True
GOLEM_MAX_COLONIES = 240
GOLEM_WIND_SPEED = 0.018
GOLEM_FORMATION_MASS = 100.0
GOLEM_REASSEMBLE_MIN = 15 * 60
GOLEM_REASSEMBLE_MAX = 32 * 60
GOLEM_DAMAGE_COOLDOWN = 75
GOLEM_DATA = {'stone': {'name': 'Каменный голем', 'hp': 50, 'damage': 10, 'diameter': 2.0, 'speed': 0.025, 'color': (120, 132, 140)}, 'water': {'name': 'Водяной голем', 'hp': 32, 'damage': 5, 'diameter': 1.65, 'speed': 0.055, 'color': (58, 158, 226)}, 'wood': {'name': 'Деревянный голем', 'hp': 38, 'damage': 7, 'diameter': 1.85, 'speed': 0.035, 'color': (126, 91, 53)}, 'dung': {'name': 'Говняный голем', 'hp': 19, 'damage': 3, 'diameter': 1.4, 'speed': 0.065, 'color': (115, 79, 42)}}
def is_land_biome(biome):
return biome is not None and biome.name in LAND_BIOMES
CELL_SIZE = 30
VIEW_RADIUS = 10
CAMERA_ZOOM_MIN = 0.6
CAMERA_ZOOM_MAX = 1.8
CAMERA_ZOOM_STEP = 1.15
ZOOM_DETAIL_ENABLED = True
ZOOM_DETAIL_START = 1.1
ZOOM_DETAIL_SOURCE_FACTOR = 2.6
ZOOM_DETAIL_ROTATION_STEP = 6
ZOOM_DETAIL_ROTATED_CACHE_MAX = 90
NIGHT_EDGE_VIGNETTE = True
NIGHT_EDGE_FADE = 0.43
NIGHT_PLANKTON_ENABLED = True
PREDATOR_DETECTION_RANGE_BY_NAME = {'Морской дракон-левиафан': 6.5, 'Чёрно-бело-оранжево-синий кит': 8.0}
PREDATOR_DETECTION_RANGE_DEFAULT = 4.0
NIGHT_PLANKTON_MAX = 520
NIGHT_FRY_CHANCE = 0.18
NIGHT_FLEE_CHANCE = 0.36
NIGHT_VISIBLE_BRIGHTNESS = 0.5
SCREEN_SIZE = (VIEW_RADIUS * 2 + 1) * CELL_SIZE
INVENTORY_SIZE = 30
INVENTORY_COLS = 6
INVENTORY_ROWS = 5
START_INVENTORY = {'Мотылёк': 1, 'Циклоп': 1, 'Лёгкий джип': 1, 'Бронетранспортёр': 1, 'Ремкомплект': 3, 'Сканер': 1, 'Нож': 1, 'Подводное ружьё': 1, 'Коробка патронов (20)': 1, 'Морской дракон-левиафан': 1}
START_ITEM_REGISTRY = {}
LIGHT_ATTRACTION = {}
LIGHT_AVERSION = {'Рыба-баклажан': 1.0}
LIGHTING_ENABLED = True
VEHICLE_HEADLIGHTS_DEFAULT = True
FLARES_DO_NOT_AFFECT_PREDATORS_AT_NIGHT = True
SLOT_SIZE = 50
SLOT_MARGIN = 10
WORLD_CHUNK_SIZE = 32
CAVE_CHUNK_SIZE = 32
ANIMAL_CHUNK_SIZE = 16
ROTATION_STEP = 3
MAX_BIOME_CACHE = 50000
MAX_CAVE_CACHE = 50000
FISH_COUNT_MULTIPLIER = 3
CAVE_TUNNEL_ECOSYSTEM_OVERRIDES = {}
BIOLUMINESCENCE_ENABLED = True
MIDNIGHT_BLACKOUT = True
MIDNIGHT_BLACKOUT_HALF_WINDOW = 0.05
BIOGLOW_DAY_ALPHA = 105
BIOGLOW_NIGHT_ALPHA = 250
START_CAPSULE_WIDTH = 8
START_CAPSULE_HEIGHT = 6
START_CAPSULE_INNER_WIDTH = 6
START_CAPSULE_INNER_HEIGHT = 4
def quantize_angle(angle):
return int(round(angle / ROTATION_STEP) * ROTATION_STEP) % 360
def load_image_with_proportions(path, target_size, colorkey=(255, 255, 255)):
if not os.path.exists(path):
return None
image = pygame.image.load(path).convert()
image.set_colorkey(colorkey)
original_width, original_height = image.get_size()
scale = min(target_size / original_width, target_size / original_height)
new_width = max(1, int(original_width * scale))
new_height = max(1, int(original_height * scale))
scaled = pygame.transform.smoothscale(image, (new_width, new_height))
result = pygame.Surface((target_size, target_size), pygame.SRCALPHA)
x = (target_size - new_width) // 2
y = (target_size - new_height) // 2
result.blit(scaled, (x, y))
return result.convert_alpha()
def generate_pike_style_sprite(length, width, body_color, spine_color, belly_color, fin_color, spot_colors, tail_color=(255, 165, 0), eye_color=(255, 255, 255), pupil_color=(0, 0, 0)):
w = max(8, int(length))
h = max(4, int(width))
surf = pygame.Surface((w, h), pygame.SRCALPHA)
pygame.draw.ellipse(surf, body_color, (0, 0, w, h))
pygame.draw.ellipse(surf, spine_color, pygame.Rect(0, 0, w, h // 2 + 2))
pygame.draw.ellipse(surf, belly_color, pygame.Rect(0, h // 2 - 2, w, h // 2 + 2))
eye_radius = max(2, int(0.09 * h))
pupil_radius = max(1, int(0.05 * h))
eye_x = int(0.8 * w)
eye_y1 = int(0.35 * h)
eye_y2 = int(0.65 * h)
pygame.draw.circle(surf, eye_color, (eye_x, eye_y1), eye_radius)
pygame.draw.circle(surf, eye_color, (eye_x, eye_y2), eye_radius)
pupil_offset = int(0.2 * eye_radius)
pygame.draw.circle(surf, pupil_color, (eye_x + pupil_offset, eye_y1), pupil_radius)
pygame.draw.circle(surf, pupil_color, (eye_x + pupil_offset, eye_y2), pupil_radius)
dorsal_x = int(0.2 * w)
dorsal_w = int(0.5 * w)
dorsal_h = max(2, int(h * 0.3))
pygame.draw.ellipse(surf, fin_color, (dorsal_x, 0, dorsal_w, dorsal_h))
pectoral_width = max(2, int(0.08 * w))
pectoral_height = max(2, int(0.15 * h))
pygame.draw.ellipse(surf, fin_color, (int(0.6 * w), int(0.05 * h), pectoral_width, pectoral_height))
pygame.draw.ellipse(surf, fin_color, (int(0.6 * w), int(0.8 * h), pectoral_width, pectoral_height))
tail_width = int(0.3 * w)
tail_height = int(0.9 * h)
tail_x = -tail_width // 2
tail_y = (h - tail_height) // 2
pygame.draw.ellipse(surf, tail_color, (tail_x, tail_y, tail_width, tail_height))
if spot_colors:
for _ in range(random.randint(3, 6)):
cx = random.uniform(0.2, 0.8) * w
cy = random.uniform(0.15, 0.4) * h
rx = random.uniform(0.03, 0.09) * w
ry = random.uniform(0.03, 0.09) * h
color = random.choice(spot_colors)
pygame.draw.ellipse(surf, color, (int(cx - rx / 2), int(cy - ry / 2), int(rx), int(ry)))
pygame.draw.ellipse(surf, color, (int(cx - rx / 2), int(h - cy - ry / 2), int(rx), int(ry)))
for _ in range(random.randint(2, 4)):
cx = random.uniform(0.3, 0.7) * w
cy = random.uniform(0.05, 0.2) * h
rx = random.uniform(0.02, 0.06) * w
ry = random.uniform(0.01, 0.04) * h
pygame.draw.ellipse(surf, spot_colors[0], (int(cx - rx / 2), int(cy - ry / 2), int(rx), int(ry)))
return surf.convert_alpha()
def generate_chbos_whale_sprite(length=300, width=96):
length, width = (max(40, int(length)), max(24, int(width)))
W, H = (1100, 530)
surf = pygame.Surface((W, H), pygame.SRCALPHA)
D = pygame.draw
D.polygon(surf, (20, 27, 34), [(220, 245), (117, 249), (94, 238), (83, 249), (110, 267), (222, 269)])
D.polygon(surf, (29, 33, 39), [(110, 252), (52, 150), (25, 134), (50, 208), (78, 261), (43, 317), (22, 376), (55, 365), (118, 274)])
D.polygon(surf, (234, 122, 29), [(107, 255), (44, 164), (35, 149), (67, 208), (87, 260), (60, 312), (35, 357), (51, 356), (113, 270)])
D.line(surf, (255, 183, 84), (67, 215), (103, 259), 5)
D.polygon(surf, (16, 28, 40), [(695, 195), (615, 69), (570, 35), (550, 47), (582, 159)])
D.polygon(surf, (232, 238, 229), [(666, 307), (569, 424), (535, 493), (570, 473), (723, 325)])
D.polygon(surf, (184, 197, 207), [(661, 308), (569, 441), (563, 463), (720, 323)])
for i in range(5):
D.line(surf, (115, 133, 147), (658 + i * 6, 318), (555 + i * 3, 468 - i * 6), 2)
contour = [(190, 263), (235, 203), (333, 161), (477, 135), (612, 128), (726, 149), (806, 172), (878, 201), (939, 231), (989, 246), (1016, 263), (989, 284), (938, 303), (878, 326), (803, 351), (713, 374), (591, 392), (451, 384), (325, 348), (235, 303)]
D.polygon(surf, (13, 22, 29), contour)
D.ellipse(surf, (21, 73, 112), (216, 142, 782, 241))
D.polygon(surf, (12, 33, 57), [(220, 260), (295, 195), (430, 157), (575, 151), (706, 159), (828, 207), (987, 250), (966, 280), (822, 323), (698, 368), (542, 376), (370, 332)])
D.polygon(surf, (231, 234, 226), [(575, 310), (681, 311), (782, 317), (860, 307), (923, 294), (961, 276), (946, 294), (880, 321), (804, 344), (717, 367), (596, 380), (524, 366)])
D.polygon(surf, (245, 244, 235), [(723, 169), (818, 182), (900, 210), (943, 230), (901, 224), (825, 200), (780, 205), (740, 194)])
for i in range(8):
y = 310 + i * 7
D.arc(surf, (145, 164, 176), (590 - i * 9, y - 25, 345 + i * 7, 68), 0.18, 2.9, 2)
D.polygon(surf, (16, 103, 158), [(398, 163), (534, 142), (647, 154), (737, 171), (777, 222), (769, 299), (718, 348), (605, 378), (464, 357), (373, 310)])
D.polygon(surf, (36, 153, 199), [(472, 172), (567, 156), (681, 180), (735, 227), (716, 268), (634, 273), (526, 246)])
for i in range(9):
offset = i * 14
D.arc(surf, (11, 70, 116), (415 + offset, 165, 300, 174), 0.1, 2.8, 2)
D.polygon(surf, (229, 110, 22), [(210, 253), (239, 212), (303, 181), (389, 163), (448, 182), (452, 229), (436, 306), (390, 335), (303, 331), (242, 297)])
D.polygon(surf, (247, 151, 44), [(237, 246), (288, 199), (367, 182), (420, 202), (410, 254), (365, 307), (290, 315)])
for x, y, r in [(289, 218, 8), (327, 272, 7), (376, 197, 5), (401, 294, 6), (261, 273, 4), (347, 304, 4), (419, 239, 3)]:
D.circle(surf, (36, 33, 33), (x, y), r)
D.polygon(surf, (16, 22, 27), [(684, 171), (768, 156), (859, 170), (930, 204), (984, 239), (1027, 258), (1018, 282), (962, 304), (887, 321), (808, 321), (746, 292), (690, 245)])
D.polygon(surf, (36, 43, 47), [(718, 179), (797, 170), (871, 186), (940, 224), (982, 247), (872, 233), (802, 223)])
D.polygon(surf, (229, 234, 231), [(769, 277), (854, 280), (934, 272), (1001, 262), (979, 282), (906, 309), (847, 318), (795, 313)])
for i in range(8):
D.arc(surf, (138, 147, 150), (792, 241 + i * 4, 211, 49 + i * 2), 0.2, 3.1, 2)
D.circle(surf, (4, 13, 19), (874, 222), 10)
D.circle(surf, (173, 190, 195), (871, 219), 3)
D.arc(surf, (7, 13, 18), (895, 252, 124, 38), 3.2, 6.1, 5)
for j in range(13):
xx = 741 + j * 17
yy = int(173 + 0.025 * (xx - 830) ** 2 / 10)
D.circle(surf, (65, 73, 75), (xx, yy), 3 if j % 3 else 5)
D.circle(surf, (190, 193, 178), (xx + 1, yy - 1), 1)
for i in range(8):
xx = 355 + 49 * i
yy = 190 + int(15 * math.sin(i * 0.75))
D.line(surf, (93, 173, 209), (xx, yy), (xx + 27, yy + 9), 2)
cut = _crop_alpha_surface(surf)
target_w = length + max(12, round(length * 0.22))
target_h = max(round(width * 1.8), width + 24)
return pygame.transform.smoothscale(cut, (target_w, target_h)).convert_alpha()
def _crop_alpha_surface(surface):
bounds = surface.get_bounding_rect(min_alpha=1)
if bounds.width > 0 and bounds.height > 0:
return surface.subsurface(bounds).copy()
return surface.copy()
def generate_detailed_sea_dragon_sprite(length, width):
"""Детальный дракон-левиафан сверху: бронированные зелёные пластины,
четыре длинных боковых отростка, руки с когтями, рога и фиолетовые
перепончатые плавники. Источник рисуется вертикально, затем разворачивается
так, чтобы базовое направление в игре было вправо.
"""
W, H = (560, 1000)
s = pygame.Surface((W, H), pygame.SRCALPHA)
cx = W // 2
DARK = (23, 54, 44)
GREEN_DARK = (38, 89, 62)
GREEN = (67, 128, 76)
GREEN_LIGHT = (105, 166, 94)
SCALE_DARK = (34, 70, 58)
PURPLE_DARK = (54, 30, 72)
PURPLE = (105, 51, 107)
PURPLE_LIGHT = (164, 84, 143)
ORANGE = (255, 119, 32)
ORANGE_LIGHT = (255, 197, 75)
CLAW = (238, 226, 161)
EYE = (255, 185, 73)
def poly(color, pts, w=0):
pygame.draw.polygon(s, color, [(int(x), int(y)) for x, y in pts], w)
def ell(color, rect, w=0):
pygame.draw.ellipse(s, color, pygame.Rect(*[int(v) for v in rect]), w)
def circ(color, pos, r, w=0):
pygame.draw.circle(s, color, (int(pos[0]), int(pos[1])), max(1, int(r)), w)
def thick_path(points, widths, outer, inner):
for i in range(len(points) - 1):
a = points[i]
b = points[i + 1]
ww = max(2, int((widths[i] + widths[i + 1]) / 2))
pygame.draw.line(s, outer, a, b, ww + 7)
pygame.draw.line(s, inner, a, b, ww)
circ(inner, a, ww // 2)
circ(inner, points[-1], max(2, widths[-1] // 2))
tendrils = [([(210, 515), (150, 610), (125, 720), (142, 835)], [28, 22, 15, 8], -1), ([(240, 520), (205, 645), (200, 765), (180, 902)], [30, 22, 15, 8], -1), ([(320, 520), (355, 645), (360, 765), (380, 902)], [30, 22, 15, 8], 1), ([(350, 515), (410, 610), (435, 720), (418, 835)], [28, 22, 15, 8], 1)]
for idx, (pts, widths, side) in enumerate(tendrils):
thick_path(pts, widths, DARK, GREEN_DARK)
for j in range(1, 5):
t = j / 5
p0, p1 = (pts[min(len(pts) - 2, int(t * (len(pts) - 1)))], pts[min(len(pts) - 1, int(t * (len(pts) - 1)) + 1)])
local = t * (len(pts) - 1) % 1.0
x = p0[0] + (p1[0] - p0[0]) * local
y = p0[1] + (p1[1] - p0[1]) * local
circ((255, 90, 25, 75), (x, y), 8)
circ(ORANGE, (x, y), 3)
tip = pts[-1]
base = pts[-2]
if side < 0:
fin = [(base[0] - 4, base[1] + 20), (tip[0] - 45, tip[1] - 20), (tip[0] - 12, tip[1] + 56), (tip[0] + 12, tip[1] + 8)]
else:
fin = [(base[0] + 4, base[1] + 20), (tip[0] + 45, tip[1] - 20), (tip[0] + 12, tip[1] + 56), (tip[0] - 12, tip[1] + 8)]
poly(PURPLE_DARK, fin)
inner_fin = [(x + (cx - x) * 0.08, y) for x, y in fin]
poly(PURPLE, inner_fin)
pygame.draw.aalines(s, PURPLE_LIGHT, True, fin)
central = [(cx, 470), (cx + 7, 590), (cx - 4, 710), (cx + 5, 830), (cx, 945)]
thick_path(central, [72, 58, 42, 27, 13], DARK, GREEN_DARK)
pygame.draw.line(s, GREEN_LIGHT, (cx - 13, 535), (cx - 8, 870), 4)
poly(DARK, [(cx - 25, 900), (cx, 975), (cx + 28, 900), (cx + 7, 920), (cx - 7, 920)])
poly(GREEN_DARK, [(cx - 18, 903), (cx, 960), (cx + 20, 903), (cx + 5, 920), (cx - 5, 920)])
body = [(cx - 88, 165), (cx - 112, 235), (cx - 101, 340), (cx - 80, 455), (cx - 55, 535), (cx, 565), (cx + 55, 535), (cx + 80, 455), (cx + 101, 340), (cx + 112, 235), (cx + 88, 165)]
poly(DARK, body)
body2 = [(cx - 78, 172), (cx - 99, 238), (cx - 88, 334), (cx - 69, 444), (cx - 48, 516), (cx, 545), (cx + 48, 516), (cx + 69, 444), (cx + 88, 334), (cx + 99, 238), (cx + 78, 172)]
poly(GREEN, body2)
for i in range(14):
y = 190 + i * 24
taper = max(0, i - 6)
half = 78 - taper * 4
if i < 4:
half = 82 + i * 4
pts = [(cx - half, y), (cx - 18, y - 10), (cx, y - 15), (cx + 18, y - 10), (cx + half, y), (cx + half - 10, y + 13), (cx, y + 8), (cx - half + 10, y + 13)]
poly(SCALE_DARK, pts)
poly(GREEN_LIGHT, [(x + (cx - x) * 0.08, yy + 2) for x, yy in pts])
pygame.draw.aaline(s, (128, 181, 111), (cx - half + 10, y + 1), (cx + half - 10, y + 1))
for i in range(6):
y = 238 + i * 35
poly(PURPLE_DARK, [(cx - 63, y), (cx - 15, y + 10), (cx, y + 18), (cx + 15, y + 10), (cx + 63, y), (cx + 45, y + 13), (cx, y + 28), (cx - 45, y + 13)])
for sign in (-1, 1):
shoulder = (cx + sign * 92, 245)
elbow = (cx + sign * 150, 326)
wrist = (cx + sign * 167, 405)
thick_path([shoulder, elbow, wrist], [38, 30, 21], DARK, GREEN)
pygame.draw.aaline(s, GREEN_LIGHT, (shoulder[0] - sign * 6, shoulder[1]), (wrist[0] - sign * 4, wrist[1] - 5))
for k in range(3):
bx = wrist[0] + sign * (3 + k * 7)
by = wrist[1] + k * 2
poly(CLAW, [(bx, by), (bx + sign * (20 + k * 2), by + 17 + k * 3), (bx + sign * (7 + k), by + 9)])
for k in range(4):
t = (k + 1) / 5
x = elbow[0] + (wrist[0] - elbow[0]) * t
y = elbow[1] + (wrist[1] - elbow[1]) * t
poly(GREEN_LIGHT, [(x, y), (x + sign * 18, y - 5), (x + sign * 4, y + 8)])
head = [(cx - 92, 120), (cx - 71, 71), (cx - 32, 46), (cx, 39), (cx + 32, 46), (cx + 71, 71), (cx + 92, 120), (cx + 70, 173), (cx, 192), (cx - 70, 173)]
poly(DARK, head)
poly(GREEN, [(x + (cx - x) * 0.08, y + 4) for x, y in head])
for sign in (-1, 1):
poly(DARK, [(cx + sign * 28, 58), (cx + sign * 52, 10), (cx + sign * 42, 72)])
poly(GREEN_LIGHT, [(cx + sign * 30, 58), (cx + sign * 48, 17), (cx + sign * 40, 72)])
poly(DARK, [(cx + sign * 78, 100), (cx + sign * 130, 70), (cx + sign * 86, 121)])
poly(GREEN_LIGHT, [(cx + sign * 80, 102), (cx + sign * 124, 76), (cx + sign * 86, 117)])
fin = [(cx + sign * 58, 75), (cx + sign * 112, 37), (cx + sign * 98, 112), (cx + sign * 71, 137)]
poly(PURPLE_DARK, fin)
poly(PURPLE, [(x + (cx - x) * 0.05, y + 3) for x, y in fin])
pygame.draw.aalines(s, PURPLE_LIGHT, True, fin)
for sign in (-1, 1):
ex = cx + sign * 34
ey = 115
circ((14, 20, 18), (ex, ey), 8)
circ(EYE, (ex, ey), 5)
circ((255, 245, 180), (ex - sign * 1, ey - 2), 2)
for i in range(5):
y = 82 + i * 17
half = 47 + i * 7
poly(SCALE_DARK, [(cx - half, y), (cx, y - 8), (cx + half, y), (cx + half - 8, y + 10), (cx, y + 7), (cx - half + 8, y + 10)])
for sign in (-1, 1):
for i in range(11):
y = 185 + i * 30
x = cx + sign * (76 - min(i, 7) * 3)
circ((255, 100, 25, 65), (x, y), 7)
circ(ORANGE if i % 2 else ORANGE_LIGHT, (x, y), 2.6)
for sign in (-1, 1):
for i in range(7):
y = 180 + i * 42
x = cx + sign * (91 - max(0, i - 3) * 5)
poly(GREEN_LIGHT, [(x, y), (x + sign * 20, y + 7), (x, y + 15)])
s = _crop_alpha_surface(s)
s = pygame.transform.rotate(s, -90)
s = _crop_alpha_surface(s)
length = max(80, int(length))
width = max(28, int(width))
margin = max(10, int(max(length, width) * 0.12))
target_w = length + margin * 2
target_h = max(width * 3, width + margin * 2)
return pygame.transform.smoothscale(s, (target_w, target_h)).convert_alpha()
def generate_zucchini_fish_sprite(length, width):
"""Зелёная рыба-кабачок: длинное салатовое тело, тёмные продольные полосы и крупные треугольные плавники."""
length = max(18, int(length))
width = max(8, int(width))
margin = max(6, int(max(length, width) * 0.22))
surf_w = length + margin * 2
surf_h = max(width * 3, width + margin * 2)
surf = pygame.Surface((surf_w, surf_h), pygame.SRCALPHA)
cx, cy = (surf_w // 2, surf_h // 2)
left = margin
right = left + length
def P(points):
return [(int(x), int(y)) for x, y in points]
body_dark = (32, 175, 78, 255)
body_main = (165, 225, 18, 255)
body_light = (194, 247, 56, 255)
stripe_c = (32, 160, 92, 220)
fin_main = (48, 190, 82, 235)
fin_edge = (18, 128, 62, 255)
eye_white = (245, 248, 245, 255)
eye_pupil = (20, 22, 20, 255)
body = [(left, cy), (left + length * 0.07, cy - width * 0.16), (left + length * 0.2, cy - width * 0.34), (left + length * 0.44, cy - width * 0.5), (left + length * 0.72, cy - width * 0.46), (left + length * 0.92, cy - width * 0.22), (right, cy), (left + length * 0.92, cy + width * 0.22), (left + length * 0.72, cy + width * 0.46), (left + length * 0.44, cy + width * 0.5), (left + length * 0.2, cy + width * 0.34), (left + length * 0.07, cy + width * 0.16)]
pygame.draw.polygon(surf, body_dark, P(body))
inner = [(left + length * 0.03, cy), (left + length * 0.12, cy - width * 0.12), (left + length * 0.24, cy - width * 0.27), (left + length * 0.46, cy - width * 0.38), (left + length * 0.73, cy - width * 0.35), (left + length * 0.9, cy - width * 0.17), (right - length * 0.02, cy), (left + length * 0.9, cy + width * 0.17), (left + length * 0.73, cy + width * 0.35), (left + length * 0.46, cy + width * 0.38), (left + length * 0.24, cy + width * 0.27), (left + length * 0.12, cy + width * 0.12)]
pygame.draw.polygon(surf, body_main, P(inner))
pygame.draw.aalines(surf, fin_edge, True, P(body))
stripe_offsets = (-0.22, -0.1, 0.0, 0.1, 0.22)
for frac_y in stripe_offsets:
pts = []
for frac_x in (0.1, 0.22, 0.36, 0.5, 0.64, 0.78, 0.9):
x = left + length * frac_x
amp = 1.0 - abs(frac_x - 0.55) * 1.25
y = cy + width * frac_y * amp
pts.append((x, y))
pygame.draw.aalines(surf, stripe_c, False, P(pts))
top_fin = [(left + length * 0.54, cy - width * 0.34), (left + length * 0.7, cy - width * 1.18), (left + length * 0.82, cy - width * 0.3)]
bottom_fin = [(left + length * 0.54, cy + width * 0.34), (left + length * 0.7, cy + width * 1.18), (left + length * 0.82, cy + width * 0.3)]
tail_fin = [(left + length * 0.05, cy), (left - length * 0.1, cy - width * 0.34), (left + length * 0.12, cy - width * 0.1), (left - length * 0.1, cy + width * 0.34)]
for pts in (top_fin, bottom_fin, tail_fin):
pygame.draw.polygon(surf, fin_main, P(pts))
pygame.draw.aalines(surf, fin_edge, True, P(pts))
a, b, c = pts[:3]
for t in (0.3, 0.55, 0.78):
sx = a[0] * (1 - t) + c[0] * t
sy = a[1] * (1 - t) + c[1] * t
pygame.draw.aaline(surf, body_light, (int(b[0]), int(b[1])), (int(sx), int(sy)))
for sign in (-1, 1):
ex = left + length * 0.92
ey = cy + sign * width * 0.18
pygame.draw.ellipse(surf, eye_white, pygame.Rect(int(ex - width * 0.18), int(ey - width * 0.1), int(width * 0.36), int(width * 0.2)))
pygame.draw.circle(surf, eye_pupil, (int(ex + width * 0.02), int(ey)), max(1, int(width * 0.07)))
pygame.draw.circle(surf, (255, 255, 255, 220), (int(ex + width * 0.07), int(ey - width * 0.03)), max(1, int(width * 0.03)))
highlight = [(left + length * 0.18, cy), (left + length * 0.3, cy - width * 0.06), (left + length * 0.6, cy - width * 0.08), (left + length * 0.82, cy), (left + length * 0.6, cy + width * 0.08), (left + length * 0.3, cy + width * 0.06)]
pygame.draw.aalines(surf, body_light, True, P(highlight))
return surf
def generate_eggplant_fish_sprite(length, width):
"""Фиолетовая рыба-баклажан: округлое вытянутое тело, зелёные плавники и лимонная полоска по центру."""
length = max(16, int(length))
width = max(9, int(width))
margin = max(6, int(max(length, width) * 0.24))
surf_w = length + margin * 2
surf_h = max(width * 3, width + margin * 2)
surf = pygame.Surface((surf_w, surf_h), pygame.SRCALPHA)
cx, cy = (surf_w // 2, surf_h // 2)
left = margin
right = left + length
def P(points):
return [(int(x), int(y)) for x, y in points]
body_outline = (106, 56, 132, 255)
body_main = (167, 86, 185, 255)
body_light = (196, 110, 210, 255)
fin_green = (130, 220, 18, 235)
fin_green_dark = (28, 170, 86, 255)
stripe_c = (165, 245, 22, 230)
spot_c = (124, 54, 138, 180)
eye_white = (248, 248, 248, 255)
eye_pupil = (16, 16, 18, 255)
body = [(left + length * 0.06, cy), (left + length * 0.14, cy - width * 0.22), (left + length * 0.28, cy - width * 0.4), (left + length * 0.5, cy - width * 0.48), (left + length * 0.76, cy - width * 0.4), (left + length * 0.91, cy - width * 0.18), (right, cy), (left + length * 0.91, cy + width * 0.18), (left + length * 0.76, cy + width * 0.4), (left + length * 0.5, cy + width * 0.48), (left + length * 0.28, cy + width * 0.4), (left + length * 0.14, cy + width * 0.22)]
pygame.draw.polygon(surf, body_outline, P(body))
inner = [(left + length * 0.1, cy), (left + length * 0.18, cy - width * 0.17), (left + length * 0.3, cy - width * 0.31), (left + length * 0.5, cy - width * 0.38), (left + length * 0.74, cy - width * 0.31), (left + length * 0.88, cy - width * 0.15), (right - length * 0.02, cy), (left + length * 0.88, cy + width * 0.15), (left + length * 0.74, cy + width * 0.31), (left + length * 0.5, cy + width * 0.38), (left + length * 0.3, cy + width * 0.31), (left + length * 0.18, cy + width * 0.17)]
pygame.draw.polygon(surf, body_main, P(inner))
pygame.draw.aalines(surf, body_outline, True, P(body))
midline = [(left + length * 0.18, cy), (left + length * 0.32, cy - width * 0.02), (left + length * 0.48, cy), (left + length * 0.64, cy + width * 0.02), (left + length * 0.79, cy)]
pygame.draw.lines(surf, stripe_c, False, P(midline), max(1, int(width * 0.1)))
spots = [(0.24, -0.18), (0.36, -0.1), (0.48, -0.18), (0.62, -0.11), (0.77, -0.05), (0.28, 0.16), (0.42, 0.2), (0.56, 0.12), (0.68, 0.2), (0.82, 0.1)]
for fx, fy in spots:
pygame.draw.circle(surf, spot_c, (int(left + length * fx), int(cy + width * fy)), max(1, int(width * 0.07)))
tail = [(left + length * 0.08, cy), (left - length * 0.14, cy - width * 0.4), (left + length * 0.02, cy - width * 0.06), (left - length * 0.14, cy + width * 0.4)]
top_fin = [(left + length * 0.62, cy - width * 0.28), (left + length * 0.73, cy - width * 0.95), (left + length * 0.84, cy - width * 0.16)]
bottom_fin = [(left + length * 0.62, cy + width * 0.28), (left + length * 0.73, cy + width * 0.95), (left + length * 0.84, cy + width * 0.16)]
mid_top = [(left + length * 0.36, cy - width * 0.18), (left + length * 0.46, cy - width * 0.58), (left + length * 0.57, cy - width * 0.12)]
mid_bottom = [(left + length * 0.36, cy + width * 0.18), (left + length * 0.46, cy + width * 0.58), (left + length * 0.57, cy + width * 0.12)]
for pts in (tail, top_fin, bottom_fin, mid_top, mid_bottom):
pygame.draw.polygon(surf, fin_green, P(pts))
pygame.draw.aalines(surf, fin_green_dark, True, P(pts))
for sign in (-1, 1):
ex = left + length * 0.92
ey = cy + sign * width * 0.22
pygame.draw.ellipse(surf, eye_white, pygame.Rect(int(ex - width * 0.16), int(ey - width * 0.09), int(width * 0.32), int(width * 0.18)))
pygame.draw.circle(surf, eye_pupil, (int(ex + width * 0.01), int(ey)), max(1, int(width * 0.06)))
pygame.draw.circle(surf, (255, 255, 255, 220), (int(ex + width * 0.05), int(ey - width * 0.02)), max(1, int(width * 0.03)))
belly = [(left + length * 0.22, cy + width * 0.14), (left + length * 0.42, cy + width * 0.24), (left + length * 0.69, cy + width * 0.2)]
pygame.draw.aalines(surf, body_light, False, P(belly))
return surf
def generate_subnautica_creature_sprite(kind, length, width):
"""Графика оставшихся уникальных существ и морского дракона."""
if kind == 'sea_dragon':
return generate_detailed_sea_dragon_sprite(length, width)
if kind == 'chbos_whale':
return generate_chbos_whale_sprite(length, width)
if kind == 'zucchini_fish':
return generate_zucchini_fish_sprite(length, width)
if kind == 'eggplant_fish':
return generate_eggplant_fish_sprite(length, width)
raise ValueError('Неизвестный вид фауны: ' + str(kind))
def generate_weapon_sprite(barrel_length, color, accent_color=None):
if accent_color is None:
accent_color = tuple((max(0, c - 90) for c in color))
grip_width = 8
grip_height = 14
body_width = 24
body_height = 11
width = body_width + barrel_length + 8
height = body_height + grip_height + 8
surf = pygame.Surface((width, height), pygame.SRCALPHA)
grip_x = 6
grip_y = 4 + body_height - 1
grip_points = [(grip_x + 3, grip_y), (grip_x + 3 + grip_width, grip_y), (grip_x + 3 + grip_width - 3, grip_y + grip_height), (grip_x, grip_y + grip_height)]
pygame.draw.polygon(surf, (35, 35, 40), grip_points)
pygame.draw.polygon(surf, (10, 10, 15), grip_points, 1)
pygame.draw.arc(surf, (25, 25, 30), (grip_x + 6, grip_y + 1, 12, 10), 0, math.pi, 2)
body_x = 6
body_y = 4
pygame.draw.rect(surf, color, (body_x, body_y, body_width, body_height), border_radius=3)
darker = tuple((max(0, c - 50) for c in color))
pygame.draw.rect(surf, darker, (body_x + 2, body_y + 1, body_width - 4, 2))
pygame.draw.rect(surf, accent_color, (body_x, body_y, body_width, body_height), 2, border_radius=3)
barrel_x = body_x + body_width - 2
barrel_height = 5
barrel_y = body_y + (body_height - barrel_height) // 2
pygame.draw.rect(surf, (55, 55, 62), (barrel_x, barrel_y, barrel_length, barrel_height))
pygame.draw.rect(surf, accent_color, (barrel_x, barrel_y, barrel_length, barrel_height), 1)
muzzle_x = barrel_x + barrel_length - 2
pygame.draw.rect(surf, color, (muzzle_x, barrel_y - 1, 3, barrel_height + 2))
pygame.draw.rect(surf, (255, 255, 255), (muzzle_x, barrel_y - 1, 3, barrel_height + 2), 1)
pygame.draw.rect(surf, accent_color, (body_x + 6, body_y - 2, 2, 3))
pygame.draw.rect(surf, accent_color, (body_x + body_width - 10, body_y - 2, 2, 3))
return surf.convert_alpha()
def _fit_size(size, minimum=18):
return max(minimum, int(size))
def generate_tech_item_sprite(name, size, color, is_weapon=False, weapon_type=''):
size = _fit_size(size, 18)
surf = pygame.Surface((size + 12, size + 12), pygame.SRCALPHA)
cx = surf.get_width() // 2
cy = surf.get_height() // 2
dark = _shade(color, -65)
light = _shade(color, 45)
lower_name = name.lower()
def rounded_panel(rect, fill, border):
pygame.draw.rect(surf, fill, rect, border_radius=5)
pygame.draw.rect(surf, border, rect, 2, border_radius=5)
inner = rect.inflate(-6, -6)
if inner.width > 0 and inner.height > 0:
pygame.draw.rect(surf, _shade(fill, 18), inner, border_radius=4)
if is_weapon or 'нож' in lower_name or 'клинок' in lower_name or ('резак' in lower_name) or ('пушка' in lower_name) or ('винтовка' in lower_name):
length = max(14, size + 4)
return generate_weapon_sprite(length, color, dark)
if 'аптеч' in lower_name:
rect = pygame.Rect(5, 6, size, size - 2)
rounded_panel(rect, (235, 238, 238), (170, 175, 175))
pygame.draw.rect(surf, (215, 45, 55), (cx - 3, cy - 9, 6, 18), border_radius=2)
pygame.draw.rect(surf, (215, 45, 55), (cx - 9, cy - 3, 18, 6), border_radius=2)
elif 'батар' in lower_name or 'энергояч' in lower_name:
rect = pygame.Rect(5, 7, size - 2, max(14, size - 4))
rounded_panel(rect, _shade(color, -10), dark)
tip = pygame.Rect(rect.right - 3, rect.y + rect.h // 3, 4, rect.h // 3)
pygame.draw.rect(surf, light, tip, border_radius=2)
inner = rect.inflate(-8, -8)
pygame.draw.rect(surf, color, inner, border_radius=3)
pygame.draw.line(surf, (255, 255, 255), (inner.x + 4, inner.y + inner.h - 4), (inner.right - 4, inner.y + 4), 2)
elif 'баллон' in lower_name or 'костюм' in lower_name or 'ребриз' in lower_name or ('ласты' in lower_name) or ('компас' in lower_name):
body = pygame.Rect(cx - size // 4, 6, size // 2, size - 4)
pygame.draw.rect(surf, color, body, border_radius=6)
pygame.draw.rect(surf, dark, body, 2, border_radius=6)
pygame.draw.rect(surf, light, (body.x + 3, body.y + 3, body.w - 6, max(3, body.h // 4)), border_radius=4)
if 'компас' in lower_name:
pygame.draw.circle(surf, (250, 250, 245), (cx, cy + 2), max(7, size // 4))
pygame.draw.circle(surf, dark, (cx, cy + 2), max(7, size // 4), 2)
pygame.draw.line(surf, (210, 40, 45), (cx, cy + 2), (cx, cy - 7), 2)
pygame.draw.line(surf, (60, 90, 160), (cx, cy + 2), (cx - 5, cy + 8), 2)
elif 'ласты' in lower_name:
pygame.draw.polygon(surf, color, [(cx - 10, cy + 2), (cx - 3, cy - 2), (cx - 2, cy + 10), (cx - 10, cy + 12)])
pygame.draw.polygon(surf, _shade(color, 25), [(cx + 3, cy - 2), (cx + 10, cy + 2), (cx + 10, cy + 12), (cx + 2, cy + 10)])
pygame.draw.polygon(surf, dark, [(cx - 10, cy + 2), (cx - 3, cy - 2), (cx - 2, cy + 10), (cx - 10, cy + 12)], 1)
pygame.draw.polygon(surf, dark, [(cx + 3, cy - 2), (cx + 10, cy + 2), (cx + 10, cy + 12), (cx + 2, cy + 10)], 1)
elif 'ракета' in lower_name or 'пузыр' in lower_name or 'антисеп' in lower_name or ('кислот' in lower_name) or ('смаз' in lower_name) or ('аэрог' in lower_name):
if 'ракета' in lower_name:
body = pygame.Rect(cx - 4, 6, 8, size)
pygame.draw.rect(surf, color, body, border_radius=4)
pygame.draw.polygon(surf, light, [(cx, 2), (cx - 6, 10), (cx + 6, 10)])
pygame.draw.polygon(surf, dark, [(cx - 4, size + 2), (cx - 10, size + 8), (cx - 4, size + 8)])
pygame.draw.polygon(surf, dark, [(cx + 4, size + 2), (cx + 10, size + 8), (cx + 4, size + 8)])
pygame.draw.circle(surf, (255, 200, 100), (cx, size + 7), 4)
else:
pygame.draw.circle(surf, color, (cx, cy), max(7, size // 3))
pygame.draw.circle(surf, dark, (cx, cy), max(7, size // 3), 2)
pygame.draw.circle(surf, light, (cx - 3, cy - 3), max(2, size // 8))
elif 'провод' in lower_name or 'микросх' in lower_name or 'комплект' in lower_name or ('модуль' in lower_name) or ('скриж' in lower_name) or ('чип' in lower_name) or ('гидролок' in lower_name) or ('дрон' in lower_name):
rect = pygame.Rect(5, 7, size, size - 2)
rounded_panel(rect, _shade(color, -10), dark)
for i in range(4):
px = rect.x + 5 + i * max(3, rect.w // 5)
pygame.draw.line(surf, light, (px, rect.y - 1), (px, rect.y + 3), 2)
pygame.draw.line(surf, light, (px, rect.bottom - 3), (px, rect.bottom + 1), 2)
inner = rect.inflate(-8, -8)
pygame.draw.rect(surf, color, inner, border_radius=4)
pygame.draw.rect(surf, (240, 245, 255), inner, 1, border_radius=4)
pygame.draw.circle(surf, light, inner.center, max(2, inner.w // 6))
else:
rect = pygame.Rect(5, 7, size, size - 2)
rounded_panel(rect, color, dark)
pygame.draw.line(surf, light, (rect.x + 5, rect.y + rect.h - 6), (rect.right - 5, rect.y + 6), 2)
pygame.draw.line(surf, light, (rect.x + 5, rect.y + rect.h - 3), (rect.right - 8, rect.y + 8), 2)
return surf.convert_alpha()
def generate_building_sprite(name, size, color, wall=False):
size = _fit_size(size, 20)
width = size + 12
height = size + 10
surf = pygame.Surface((width, height), pygame.SRCALPHA)
dark = _shade(color, -60)
light = _shade(color, 35)
lower_name = name.lower()
def draw_window(rect, tint=(180, 235, 245)):
pygame.draw.rect(surf, tint, rect, border_radius=3)
pygame.draw.rect(surf, (95, 145, 165), rect, 1, border_radius=3)
if wall or 'окно' in lower_name or 'люк' in lower_name or ('переборк' in lower_name):
panel = pygame.Rect(4, 4, width - 8, height - 8)
pygame.draw.rect(surf, _shade(color, -8), panel, border_radius=4)
pygame.draw.rect(surf, dark, panel, 2, border_radius=4)
inset = panel.inflate(-10, -10)
if 'окно' in lower_name:
draw_window(inset)
elif 'люк' in lower_name:
pygame.draw.circle(surf, light, panel.center, max(8, min(panel.w, panel.h) // 3), 3)
pygame.draw.circle(surf, dark, panel.center, max(8, min(panel.w, panel.h) // 3 + 3), 1)
else:
pygame.draw.line(surf, light, (panel.x + 4, panel.y + 4), (panel.right - 4, panel.bottom - 4), 2)
pygame.draw.line(surf, light, (panel.right - 4, panel.y + 4), (panel.x + 4, panel.bottom - 4), 2)
elif any((key in lower_name for key in ('панель', 'прожектор', 'мачта', 'энергопередатчик'))):
if 'панель' in lower_name:
board = pygame.Rect(8, 4, width - 16, height - 14)
pygame.draw.rect(surf, color, board, border_radius=3)
pygame.draw.rect(surf, dark, board, 2, border_radius=3)
for i in range(1, 4):
x = board.x + i * board.w // 4
pygame.draw.line(surf, light, (x, board.y + 2), (x, board.bottom - 2), 1)
for i in range(1, 3):
y = board.y + i * board.h // 3
pygame.draw.line(surf, light, (board.x + 2, y), (board.right - 2, y), 1)
pygame.draw.line(surf, (180, 180, 180), board.midbottom, (width // 2, height - 2), 3)
elif 'прожектор' in lower_name or 'мачта' in lower_name:
pygame.draw.line(surf, (175, 180, 185), (width // 2, height - 2), (width // 2, 9), 3)
pygame.draw.circle(surf, (240, 235, 170), (width // 2, 8), 6)
pygame.draw.circle(surf, dark, (width // 2, 8), 6, 2)
pygame.draw.polygon(surf, (245, 245, 180, 70), [(width // 2, 10), (width // 2 - 14, height - 4), (width // 2 + 14, height - 4)])
else:
pygame.draw.line(surf, light, (width // 2, height - 3), (width // 2, 10), 3)
pygame.draw.circle(surf, color, (width // 2, 9), 6)
pygame.draw.circle(surf, dark, (width // 2, 9), 6, 2)
elif any((key in lower_name for key in ('реактор', 'станция', 'изготов', 'консоль', 'сканир'))):
body = pygame.Rect(5, 5, width - 10, height - 10)
pygame.draw.rect(surf, _shade(color, -10), body, border_radius=5)
pygame.draw.rect(surf, dark, body, 2, border_radius=5)
screen = pygame.Rect(body.x + 5, body.y + 5, body.w - 10, max(8, body.h // 2 - 1))
draw_window(screen, tint=(140, 225, 245))
pygame.draw.rect(surf, light, (body.x + 8, body.bottom - 8, body.w - 16, 3), border_radius=2)
if 'реактор' in lower_name:
pygame.draw.circle(surf, (235, 220, 95), (body.centerx, body.bottom - 10), 4)
pygame.draw.circle(surf, (120, 95, 30), (body.centerx, body.bottom - 10), 4, 1)
elif any((key in lower_name for key in ('комната', 'отсек', 'купол', 'обсерват', 'шахта', 'циклоп', 'мотыл', 'краб', 'нептун'))):
hull = pygame.Rect(4, 6, width - 8, height - 12)
pygame.draw.rect(surf, color, hull, border_radius=max(6, hull.h // 3))
pygame.draw.rect(surf, dark, hull, 2, border_radius=max(6, hull.h // 3))
stripe = pygame.Rect(hull.x + 4, hull.centery - 2, hull.w - 8, 4)
pygame.draw.rect(surf, light, stripe, border_radius=2)
if any((key in lower_name for key in ('комната', 'купол', 'обсерват'))):
for i in range(3):
wx = hull.x + 6 + i * max(6, hull.w // 4)
draw_window(pygame.Rect(wx, hull.y + 5, 6, 6))
elif 'краб' in lower_name:
pygame.draw.line(surf, dark, (hull.x + 8, hull.bottom - 2), (hull.x + 3, height - 1), 2)
pygame.draw.line(surf, dark, (hull.right - 8, hull.bottom - 2), (hull.right - 3, height - 1), 2)
elif 'мотыл' in lower_name:
pygame.draw.polygon(surf, light, [(hull.right - 2, hull.centery), (hull.right + 4, hull.centery - 5), (hull.right + 4, hull.centery + 5)])
elif 'циклоп' in lower_name:
hull.inflate_ip(4, 0)
elif any((key in lower_name for key in ('шкаф', 'контейнер', 'урна', 'полка', 'горшок', 'грядка', 'аквариум', 'лестниц', 'кровать', 'стул', 'скамья', 'стол', 'автомат'))):
body = pygame.Rect(6, 6, width - 12, height - 10)
pygame.draw.rect(surf, color, body, border_radius=4)
pygame.draw.rect(surf, dark, body, 2, border_radius=4)
if 'аквариум' in lower_name:
draw_window(body.inflate(-8, -8))
pygame.draw.circle(surf, (245, 210, 80), (body.x + 10, body.centery), 2)
elif 'лестниц' in lower_name:
x1 = width // 2 - 5
x2 = width // 2 + 5
pygame.draw.line(surf, dark, (x1, 4), (x1, height - 4), 2)
pygame.draw.line(surf, dark, (x2, 4), (x2, height - 4), 2)
for y in range(8, height - 4, 5):
pygame.draw.line(surf, light, (x1, y), (x2, y), 2)
elif any((key in lower_name for key in ('кровать', 'стул', 'скамья', 'стол'))):
pygame.draw.rect(surf, _shade(color, 25), (body.x + 4, body.y + 4, body.w - 8, body.h // 3), border_radius=3)
else:
body = pygame.Rect(6, 6, width - 12, height - 10)
pygame.draw.rect(surf, color, body, border_radius=4)
pygame.draw.rect(surf, dark, body, 2, border_radius=4)
draw_window(body.inflate(-10, -10))
return surf.convert_alpha()
def _shade(color, amount):
if amount >= 0:
return tuple((min(255, c + amount) for c in color))
return tuple((max(0, c + amount) for c in color))
def _rock_outline(size, rnd, squash=0.9):
cx = size / 2.0
cy = size / 2.0
radius = size * 0.46
num_points = rnd.randint(7, 10)
points = []
for i in range(num_points):
angle = i / num_points * math.tau
r = radius * rnd.uniform(0.72, 1.05)
px = cx + math.cos(angle) * r
py = cy + math.sin(angle) * r * squash
points.append((px, py))
return (cx, cy, points)
def generate_rock_sprite(size, base_color=(110, 110, 110), seed=None):
rnd = random.Random(seed) if seed is not None else random
surf = pygame.Surface((size, size), pygame.SRCALPHA)
cx, cy, points = _rock_outline(size, rnd)
dark = _shade(base_color, -55)
light = _shade(base_color, 40)
very_light = _shade(base_color, 75)
pygame.draw.polygon(surf, dark, points)
body = [(cx + (p[0] - cx) * 0.94, cy + (p[1] - cy) * 0.94 - size * 0.05) for p in points]
pygame.draw.polygon(surf, base_color, body)
highlight = [(cx + (p[0] - cx) * 0.6, cy + (p[1] - cy) * 0.5 - size * 0.1) for p in points]
pygame.draw.polygon(surf, light, highlight)
sparkle = [(cx + (p[0] - cx) * 0.35, cy + (p[1] - cy) * 0.3 - size * 0.14) for p in points]
pygame.draw.polygon(surf, very_light, sparkle)
for _ in range(rnd.randint(1, 3)):
a = rnd.uniform(0, math.tau)
r1 = size * 0.46 * rnd.uniform(0.1, 0.35)
r2 = size * 0.46 * rnd.uniform(0.45, 0.75)
x1 = cx + math.cos(a) * r1
y1 = cy + math.sin(a) * r1
x2 = cx + math.cos(a) * r2
y2 = cy + math.sin(a) * r2
pygame.draw.line(surf, dark, (x1, y1), (x2, y2), 1)
pygame.draw.polygon(surf, _shade(base_color, -85), points, 1)
return surf.convert_alpha()
def generate_ore_sprite(size, base_color, vein_colors, vein_count=5, seed=None):
rnd = random.Random(seed) if seed is not None else random
surf = pygame.Surface((size, size), pygame.SRCALPHA)
cx, cy, points = _rock_outline(size, rnd)
dark = _shade(base_color, -55)
light = _shade(base_color, 35)
pygame.draw.polygon(surf, dark, points)
pygame.draw.polygon(surf, base_color, [(cx + (p[0] - cx) * 0.94, cy + (p[1] - cy) * 0.94 - size * 0.04) for p in points])
pygame.draw.polygon(surf, light, [(cx + (p[0] - cx) * 0.6, cy + (p[1] - cy) * 0.5 - size * 0.09) for p in points])
used_positions = []
for _ in range(vein_count):
placed = False
for _try in range(12):
a = rnd.uniform(0, math.tau)
d = rnd.uniform(0, size * 0.3)
vx = cx + math.cos(a) * d
vy = cy + math.sin(a) * d
too_close = False
for ux, uy in used_positions:
if (vx - ux) ** 2 + (vy - uy) ** 2 < (size * 0.18) ** 2:
too_close = True
break
if not too_close:
used_positions.append((vx, vy))
placed = True
break
if not placed:
continue
vein_size = rnd.uniform(size * 0.1, size * 0.2)
color = rnd.choice(vein_colors)
color_light = _shade(color, 60)
color_dark = _shade(color, -60)
shard = [(vx, vy - vein_size), (vx + vein_size * 0.7, vy - vein_size * 0.2), (vx + vein_size * 0.5, vy + vein_size * 0.8), (vx - vein_size * 0.5, vy + vein_size * 0.8), (vx - vein_size * 0.7, vy - vein_size * 0.2)]
pygame.draw.polygon(surf, color_dark, shard)
inner = [(vx + (x - vx) * 0.72, vy + (y - vy) * 0.72 - vein_size * 0.1) for x, y in shard]
pygame.draw.polygon(surf, color, inner)
pygame.draw.circle(surf, color_light, (int(vx - vein_size * 0.2), int(vy - vein_size * 0.35)), max(1, int(vein_size * 0.25)))
pygame.draw.polygon(surf, _shade(base_color, -85), points, 1)
return surf.convert_alpha()
def generate_crystal_sprite(size, color=(0, 255, 255), sparkle=True, seed=None):
surf = pygame.Surface((size, size), pygame.SRCALPHA)
cx = size / 2.0
cy = size / 2.0
r = size * 0.46
top = (cx, cy - r)
upper_left = (cx - r * 0.75, cy - r * 0.15)
upper_right = (cx + r * 0.75, cy - r * 0.15)
left = (cx - r * 0.85, cy + r * 0.15)
right = (cx + r * 0.85, cy + r * 0.15)
bottom = (cx, cy + r)
dark = _shade(color, -70)
mid = color
light = _shade(color, 60)
bright = _shade(color, 130)
pygame.draw.polygon(surf, dark, [left, bottom, right, upper_right, upper_left])
pygame.draw.polygon(surf, mid, [top, upper_left, left, bottom])
pygame.draw.polygon(surf, dark, [top, upper_right, right, bottom])
pygame.draw.polygon(surf, light, [top, upper_left, (cx, cy - r * 0.05), upper_right])
pygame.draw.polygon(surf, bright, [top, (cx - r * 0.3, cy - r * 0.15), (cx - r * 0.1, cy + r * 0.05), (cx, cy - r * 0.05)])
pygame.draw.polygon(surf, _shade(color, -110), [top, upper_right, right, bottom, left, upper_left], 1)
if sparkle:
sx = int(cx - r * 0.35)
sy = int(cy - r * 0.45)
pygame.draw.line(surf, (255, 255, 255), (sx - 3, sy), (sx + 3, sy), 1)
pygame.draw.line(surf, (255, 255, 255), (sx, sy - 3), (sx, sy + 3), 1)
return surf.convert_alpha()
def generate_seaweed_sprite(size, seed=None):
rnd = random.Random(seed) if seed is not None else random
surf = pygame.Surface((size, size), pygame.SRCALPHA)
cx = size / 2.0
dark = (5, 90, 20)
mid = (30, 160, 50)
light = (100, 220, 90)
bright = (180, 245, 140)
strands = rnd.randint(3, 4)
for i in range(strands):
offset_x = (i - (strands - 1) / 2) * size * 0.18
phase = rnd.uniform(0, math.tau)
amplitude = size * rnd.uniform(0.08, 0.14)
height = size * rnd.uniform(0.65, 0.85)
points = []
steps = 8
for t in range(steps + 1):
prog = t / steps
x = cx + offset_x + math.sin(prog * math.pi * 2.2 + phase) * amplitude * (1 - prog * 0.3)
y = size * 0.92 - prog * height
points.append((x, y))
width = max(2, int(size * 0.12))
for j in range(len(points) - 1):
pygame.draw.line(surf, dark, points[j], points[j + 1], width + 2)
for j in range(len(points) - 1):
pygame.draw.line(surf, mid, points[j], points[j + 1], width)
for j in range(len(points) - 1):
pygame.draw.line(surf, light, points[j], points[j + 1], max(1, width // 3))
tip_x, tip_y = points[-1]
pygame.draw.circle(surf, light, (int(tip_x), int(tip_y)), max(1, width // 3))
pygame.draw.circle(surf, bright, (int(tip_x), int(tip_y)), max(1, width // 5))
return surf.convert_alpha()
def generate_lumpy_sprite(size, color, seed=None, shine=False):
rnd = random.Random(seed) if seed is not None else random
surf = pygame.Surface((size, size), pygame.SRCALPHA)
cx = size / 2.0
cy = size / 2.0
r = size * 0.42
dark = _shade(color, -50)
light = _shade(color, 35)
bright = _shade(color, 70)
blobs = rnd.randint(4, 6)
for i in range(blobs):
a = i / blobs * math.tau + rnd.uniform(-0.4, 0.4)
d = r * rnd.uniform(0.15, 0.45)
bx = cx + math.cos(a) * d
by = cy + math.sin(a) * d
br = r * rnd.uniform(0.55, 0.85)
pygame.draw.circle(surf, dark, (int(bx), int(by)), int(br))
for i in range(blobs):
a = i / blobs * math.tau + rnd.uniform(-0.4, 0.4)
d = r * rnd.uniform(0.15, 0.45)
bx = cx + math.cos(a) * d
by = cy + math.sin(a) * d - size * 0.05
br = r * rnd.uniform(0.5, 0.75)
pygame.draw.circle(surf, color, (int(bx), int(by)), int(br))
pygame.draw.circle(surf, light, (int(cx - r * 0.3), int(cy - r * 0.4)), max(1, int(r * 0.35)))
if shine:
pygame.draw.circle(surf, bright, (int(cx - r * 0.35), int(cy - r * 0.45)), max(1, int(r * 0.15)))
return surf.convert_alpha()
_HIGH_RES_RESOURCE_NAMES = {'Камень', 'Титан', 'Медная руда', 'Водоросль', 'Железная руда', 'Золотая руда', 'Алмаз', 'Кварц', 'Уголь', 'Сера', 'Глина', 'Песок', 'Известняк', 'Нефть', 'Соль', 'Серебряная руда', 'Свинец', 'Литий', 'Магнетит', 'Уранинит', 'Рубин', 'Гелевый мешок', 'Сталь', 'Медный провод', 'Стекло', 'Порох', 'Топливо', 'Бетон', 'Металлолом', 'Кислотный гриб', 'Образец водоросли', 'Гроздь семян водоросли', 'Пещерная сера', 'Образец пластинчатого коралла', 'Образец коралловой трубы', 'Зуб сталкера', 'Масляный сок', 'Глубинный гриб', 'Никелевая руда', 'Кианит', 'Ионный куб', 'Газовый пузырь'}
def _stable_preview_seed(name):
return 10000 + sum(((index + 1) * ord(ch) for index, ch in enumerate(name))) % 50000
def generate_highres_resource_preview(entity, size=280):
"""Redraw a collectible resource at scanner resolution instead of enlarging its tiny icon."""
name = getattr(entity, 'name', '')
if name not in _HIGH_RES_RESOURCE_NAMES:
return None
size = max(96, int(size))
color = tuple(getattr(entity, 'color', (150, 150, 150)))[:3]
seed = _stable_preview_seed(name)
if name in {'Водоросль', 'Образец водоросли'}:
return generate_seaweed_sprite(size, seed=seed)
if name in {'Алмаз', 'Кварц', 'Соль', 'Литий', 'Магнетит', 'Уранинит', 'Рубин', 'Пещерная сера', 'Кианит', 'Ионный куб', 'Зуб сталкера', 'Стекло'}:
return generate_crystal_sprite(size, color, sparkle=True, seed=seed)
if 'руда' in name or name in {'Сера', 'Свинец'}:
rock_base = _shade(color, -85)
if max(rock_base) < 45:
rock_base = (82, 84, 88)
veins = [color, _shade(color, 50), _shade(color, -35)]
return generate_ore_sprite(size, rock_base, veins, vein_count=8, seed=seed)
if name in {'Камень', 'Титан', 'Уголь', 'Известняк', 'Бетон', 'Металлолом'}:
return generate_rock_sprite(size, color, seed=seed)
if name in {'Сталь', 'Медный провод', 'Топливо'}:
return generate_tech_item_sprite(name, size - 12, color)
return generate_lumpy_sprite(size, color, seed=seed, shine=name in {'Нефть', 'Гелевый мешок', 'Кислотный гриб', 'Гроздь семян водоросли', 'Масляный сок', 'Глубинный гриб', 'Газовый пузырь'})
def generate_subnautica_flora_sprite(kind, size, seed=0):
"""Создаёт стилизованный top-down спрайт канонической флоры Subnautica.
Флора является отдельным декоративным слоем мира и не подменяет
собираемые ресурсы. Благодаря этому растения не попадают в инвентарь
сами по себе и не вмешиваются в существующую систему респавна ресурсов.
"""
rnd = random.Random(seed)
size = max(12, int(size))
canvas = max(size + 10, int(size * 1.35))
surf = pygame.Surface((canvas, canvas), pygame.SRCALPHA)
cx = canvas // 2
cy = canvas // 2
r = max(4, size // 2)
def pt(x, y):
return (int(x), int(y))
def line(color, a, b, width=1):
pygame.draw.line(surf, color, pt(*a), pt(*b), max(1, int(width)))
def circle(color, pos, radius, width=0):
pygame.draw.circle(surf, color, pt(*pos), max(1, int(radius)), width)
def ellipse(color, rect, width=0):
pygame.draw.ellipse(surf, color, pygame.Rect(*(int(v) for v in rect)), width)
def polygon(color, points, width=0):
pygame.draw.polygon(surf, color, [pt(x, y) for x, y in points], width)
def glow(pos, radius, color, alpha=55):
g = pygame.Surface((canvas, canvas), pygame.SRCALPHA)
for mul, a in ((2.1, alpha // 3), (1.55, alpha // 2), (1.15, alpha)):
pygame.draw.circle(g, (*color, max(1, int(a))), pt(*pos), max(1, int(radius * mul)))
surf.blit(g, (0, 0))
if kind in ('acid_mushroom', 'deep_shroom'):
deep = kind == 'deep_shroom'
cap = (110, 72, 190) if not deep else (56, 54, 92)
rim = (190, 120, 245) if not deep else (110, 102, 165)
spots = (235, 185, 255) if not deep else (160, 155, 220)
glow((cx, cy), r * 0.55, rim, 42)
ellipse((45, 38, 60), (cx - r * 0.76, cy - r * 0.42, r * 1.52, r * 0.88))
ellipse(cap, (cx - r * 0.7, cy - r * 0.5, r * 1.4, r * 0.86))
ellipse(rim, (cx - r * 0.62, cy - r * 0.42, r * 1.24, r * 0.65), 2)
for _ in range(7):
a = rnd.uniform(0, math.tau)
d = rnd.uniform(0.1, 0.48) * r
circle(spots, (cx + math.cos(a) * d, cy + math.sin(a) * d * 0.62), max(1, r * 0.07))
circle((65, 45, 80), (cx, cy + r * 0.03), r * 0.16)
elif kind == 'creepvine':
for strand in range(5):
phase = rnd.uniform(0, math.tau)
pts = []
for i in range(9):
t = i / 8
ang = phase + t * 1.25 + strand * 0.16
rad = r * (0.12 + t * 0.82)
pts.append((cx + math.cos(ang) * rad, cy + math.sin(ang) * rad))
for a, b in zip(pts, pts[1:]):
line((20, 75, 30), a, b, max(3, r * 0.16))
line((78, 158, 52), a, b, max(1, r * 0.09))
tip = pts[-1]
for j in range(3):
a = phase + 2.0 + j * 2.0
p = (tip[0] + math.cos(a) * r * 0.12, tip[1] + math.sin(a) * r * 0.12)
glow(p, r * 0.08, (255, 185, 35), 32)
circle((255, 188, 35), p, r * 0.07)
circle((255, 235, 110), (p[0] - 1, p[1] - 1), r * 0.025)
elif kind == 'blood_grass':
for i in range(18):
a = rnd.uniform(0, math.tau)
length = rnd.uniform(r * 0.48, r * 0.95)
bend = rnd.uniform(-0.22, 0.22)
base = (cx + math.cos(a) * r * 0.1, cy + math.sin(a) * r * 0.1)
tip = (cx + math.cos(a + bend) * length, cy + math.sin(a + bend) * length)
line((85, 25, 28), base, tip, max(2, r * 0.1))
line((170, 48, 55), base, tip, max(1, r * 0.045))
elif kind == 'blue_palm':
circle((60, 95, 115), (cx, cy), r * 0.22)
for i in range(8):
a = i / 8 * math.tau + rnd.uniform(-0.08, 0.08)
mid = (cx + math.cos(a) * r * 0.48, cy + math.sin(a) * r * 0.48)
tip = (cx + math.cos(a) * r * 0.88, cy + math.sin(a) * r * 0.88)
line((75, 190, 120), (cx, cy), mid, max(2, r * 0.11))
line((30, 92, 155), mid, tip, max(3, r * 0.16))
circle((55, 145, 190), tip, r * 0.1)
circle((160, 225, 95), (cx, cy), r * 0.11)
elif kind in ('writhing_weed', 'ghost_weed'):
ghost = kind == 'ghost_weed'
base = (65, 110, 115) if ghost else (60, 70, 145)
stem = (90, 190, 110) if ghost else (145, 75, 185)
tipc = (235, 80, 90) if ghost else (155, 245, 120)
circle(base, (cx, cy), r * 0.25)
for i in range(9 if not ghost else 6):
a = i / (9 if not ghost else 6) * math.tau + rnd.uniform(-0.2, 0.2)
length = rnd.uniform(r * 0.45, r * 0.95)
tip = (cx + math.cos(a) * length, cy + math.sin(a) * length)
line((45, 45, 70), (cx, cy), tip, max(3, r * 0.13))
line(stem, (cx, cy), tip, max(1, r * 0.07))
glow(tip, r * 0.055, tipc, 28)
circle(tipc, tip, r * 0.05)
elif kind in ('spotted_reeds', 'spotted_dockleaf'):
dock = kind == 'spotted_dockleaf'
leaf_c = (84, 145, 72) if not dock else (84, 112, 125)
spot_c = (230, 235, 190) if not dock else (220, 205, 95)
count = 7 if not dock else 5
for i in range(count):
a = i / count * math.tau + rnd.uniform(-0.18, 0.18)
length = rnd.uniform(r * 0.52, r * 0.92)
half = r * (0.13 if not dock else 0.18)
tip = (cx + math.cos(a) * length, cy + math.sin(a) * length)
nx, ny = (-math.sin(a) * half, math.cos(a) * half)
polygon((45, 72, 50), [(cx, cy), (tip[0] + nx, tip[1] + ny), tip, (tip[0] - nx, tip[1] - ny)])
polygon(leaf_c, [(cx, cy), (tip[0] + nx * 0.72, tip[1] + ny * 0.72), tip, (tip[0] - nx * 0.72, tip[1] - ny * 0.72)])
for frac in (0.45, 0.7):
p = (cx + (tip[0] - cx) * frac, cy + (tip[1] - cy) * frac)
circle(spot_c, p, max(1, r * 0.035))
elif kind in ('redwort', 'violet_beau'):
violet = kind == 'violet_beau'
stem_c = (110, 58, 62) if not violet else (85, 70, 120)
tip_c = (220, 68, 62) if not violet else (185, 105, 235)
for i in range(10):
a = i / 10 * math.tau + rnd.uniform(-0.12, 0.12)
length = rnd.uniform(r * 0.38, r * 0.86)
tip = (cx + math.cos(a) * length, cy + math.sin(a) * length)
line(stem_c, (cx, cy), tip, max(2, r * 0.09))
circle(tip_c, tip, r * 0.09)
circle((70, 55, 75), (cx, cy), r * 0.16)
elif kind in ('tiger_plant', 'spiked_horn_grass'):
tiger = kind == 'tiger_plant'
base_c = (95, 40, 25) if tiger else (135, 112, 72)
stem_c = (195, 60, 40) if tiger else (190, 125, 65)
spike_c = (245, 210, 85) if tiger else (180, 225, 85)
circle(base_c, (cx, cy), r * 0.22)
for i in range(8):
a = i / 8 * math.tau + rnd.uniform(-0.13, 0.13)
length = rnd.uniform(r * 0.48, r * 0.92)
tip = (cx + math.cos(a) * length, cy + math.sin(a) * length)
line((70, 40, 28), (cx, cy), tip, max(4, r * 0.15))
line(stem_c, (cx, cy), tip, max(2, r * 0.09))
for frac in (0.45, 0.72):
p = (cx + (tip[0] - cx) * frac, cy + (tip[1] - cy) * frac)
pa = a + math.pi / 2
q = (p[0] + math.cos(pa) * r * 0.12, p[1] + math.sin(pa) * r * 0.12)
line(spike_c, p, q, max(1, r * 0.035))
elif kind in ('mushroom_tree', 'jellyshroom'):
jelly = kind == 'jellyshroom'
outer = (70, 105, 150) if not jelly else (96, 55, 145)
inner = (95, 165, 175) if not jelly else (205, 95, 210)
bright = (135, 205, 190) if not jelly else (245, 155, 250)
if jelly:
glow((cx, cy), r * 0.7, (180, 80, 235), 46)
for i in range(6):
a = i / 6 * math.tau + rnd.uniform(-0.12, 0.12)
rr = r * rnd.uniform(0.48, 0.75)
p = (cx + math.cos(a) * rr * 0.35, cy + math.sin(a) * rr * 0.35)
circle((45, 55, 72), p, rr * 0.55)
circle(outer, p, rr * 0.5)
circle(inner, (p[0] - rr * 0.06, p[1] - rr * 0.06), rr * 0.38)
circle(bright, (p[0] - rr * 0.14, p[1] - rr * 0.14), rr * 0.12)
circle((35, 34, 50), (cx, cy), r * 0.22)
if jelly:
circle((235, 175, 250), (cx, cy), r * 0.13, 2)
elif kind == 'pygmy_fan':
for i in range(7):
a = i / 7 * math.tau
p1 = (cx + math.cos(a - 0.28) * r * 0.18, cy + math.sin(a - 0.28) * r * 0.18)
p2 = (cx + math.cos(a) * r * 0.88, cy + math.sin(a) * r * 0.88)
p3 = (cx + math.cos(a + 0.28) * r * 0.18, cy + math.sin(a + 0.28) * r * 0.18)
polygon((95, 55, 145), [p1, p2, p3])
line((210, 120, 245), (cx, cy), p2, max(1, r * 0.035))
glow((cx, cy), r * 0.22, (175, 95, 235), 28)
circle((185, 110, 240), (cx, cy), r * 0.15)
elif kind == 'bulb_bush':
for i in range(9):
a = i / 9 * math.tau + rnd.uniform(-0.15, 0.15)
d = r * rnd.uniform(0.25, 0.72)
p = (cx + math.cos(a) * d, cy + math.sin(a) * d)
br = r * rnd.uniform(0.18, 0.3)
glow(p, br * 0.7, (90, 120, 240), 25)
circle((58, 63, 125), p, br + 2)
circle((96, 110, 205), p, br)
circle((155, 185, 245), (p[0] - br * 0.25, p[1] - br * 0.25), br * 0.24)
elif kind in ('bloodvine', 'bloodroot'):
root = kind == 'bloodroot'
strands = 7 if root else 5
for i in range(strands):
a = i / strands * math.tau + rnd.uniform(-0.18, 0.18)
length = rnd.uniform(r * 0.55, r * 0.98)
mid = (cx + math.cos(a + 0.22) * length * 0.5, cy + math.sin(a + 0.22) * length * 0.5)
tip = (cx + math.cos(a) * length, cy + math.sin(a) * length)
line((50, 45, 58), (cx, cy), mid, max(5, r * 0.18 if root else r * 0.13))
line((206, 215, 205), (cx, cy), mid, max(3, r * 0.11 if root else r * 0.08))
line((196, 205, 200), mid, tip, max(2, r * 0.08))
if not root or i % 2 == 0:
p = (cx + (tip[0] - cx) * 0.72, cy + (tip[1] - cy) * 0.72)
glow(p, r * 0.09, (220, 55, 55), 34)
circle((180, 35, 45), p, r * 0.08)
circle((245, 90, 75), (p[0] - 1, p[1] - 1), r * 0.03)
elif kind == 'gabe_feather':
for i in range(6):
a = i / 6 * math.tau + rnd.uniform(-0.12, 0.12)
tip = (cx + math.cos(a) * r * 0.92, cy + math.sin(a) * r * 0.92)
line((80, 65, 105), (cx, cy), tip, max(3, r * 0.12))
for frac in (0.38, 0.55, 0.72, 0.86):
p = (cx + (tip[0] - cx) * frac, cy + (tip[1] - cy) * frac)
side = a + math.pi / 2
length = r * (0.18 * (1 - frac * 0.35))
q1 = (p[0] + math.cos(side) * length, p[1] + math.sin(side) * length)
q2 = (p[0] - math.cos(side) * length, p[1] - math.sin(side) * length)
line((170, 100, 185), p, q1, max(1, r * 0.035))
line((170, 100, 185), p, q2, max(1, r * 0.035))
elif kind == 'membraintree':
glow((cx, cy), r * 0.62, (70, 155, 245), 35)
circle((40, 70, 95), (cx, cy), r * 0.72)
circle((110, 175, 215, 145), (cx, cy), r * 0.65)
for _ in range(8):
a = rnd.uniform(0, math.tau)
d = rnd.uniform(0, r * 0.42)
p = (cx + math.cos(a) * d, cy + math.sin(a) * d)
circle((215, 80, 185), p, r * 0.1)
circle((245, 165, 225), (p[0] - 1, p[1] - 1), r * 0.035)
circle((205, 235, 250), (cx - r * 0.18, cy - r * 0.2), r * 0.18, 2)
elif kind == 'anchor_pod':
glow((cx, cy - r * 0.05), r * 0.56, (60, 210, 220), 45)
circle((40, 80, 85), (cx, cy - r * 0.05), r * 0.6)
circle((72, 170, 175), (cx, cy - r * 0.05), r * 0.53)
circle((115, 220, 205), (cx - r * 0.15, cy - r * 0.2), r * 0.2)
for i in range(7):
a = i / 7 * math.tau
start = (cx + math.cos(a) * r * 0.43, cy - r * 0.05 + math.sin(a) * r * 0.43)
end = (cx + math.cos(a) * r * 0.9, cy + math.sin(a) * r * 0.9)
line((100, 38, 70), start, end, max(2, r * 0.065))
elif kind == 'cave_bush':
for i in range(13):
a = i / 13 * math.tau + rnd.uniform(-0.12, 0.12)
length = rnd.uniform(r * 0.4, r * 0.9)
tip = (cx + math.cos(a) * length, cy + math.sin(a) * length)
line((65, 45, 85), (cx, cy), tip, max(3, r * 0.11))
line((155, 85, 190), (cx, cy), tip, max(1, r * 0.055))
circle((205, 120, 225), tip, r * 0.055)
elif kind == 'rouge_cradle':
glow((cx, cy), r * 0.38, (255, 190, 55), 38)
circle((55, 80, 125), (cx, cy), r * 0.62)
for i in range(10):
a = i / 10 * math.tau
outer = (cx + math.cos(a) * r * 0.82, cy + math.sin(a) * r * 0.82)
line((135, 45, 45), (cx, cy), outer, max(2, r * 0.09))
circle((120, 50, 50), (cx, cy), r * 0.42, 2)
circle((245, 190, 60), (cx, cy), r * 0.24)
circle((255, 235, 135), (cx - r * 0.06, cy - r * 0.06), r * 0.08)
elif kind == 'drooping_stinger':
glow((cx, cy), r * 0.42, (110, 220, 145), 24)
for i in range(7):
a = i / 7 * math.tau + rnd.uniform(-0.15, 0.15)
d = rnd.uniform(r * 0.25, r * 0.68)
p = (cx + math.cos(a) * d, cy + math.sin(a) * d)
circle((40, 85, 58, 190), p, r * 0.22)
circle((105, 190, 105, 180), (p[0] - 2, p[1] - 2), r * 0.15)
line((80, 180, 105), p, (cx, cy), max(1, r * 0.04))
elif kind == 'sulfur_plant':
circle((115, 72, 40), (cx, cy), r * 0.56)
circle((185, 105, 42), (cx, cy), r * 0.46)
polygon((245, 220, 105), [(cx - r * 0.15, cy - r * 0.06), (cx, cy - r * 0.44), (cx + r * 0.16, cy - r * 0.05), (cx + r * 0.42, cy + r * 0.12), (cx, cy + r * 0.28), (cx - r * 0.42, cy + r * 0.12)])
circle((50, 35, 28), (cx, cy), r * 0.17)
elif kind == 'sea_crown':
glow((cx, cy), r * 0.42, (70, 190, 255), 42)
for i in range(8):
a = i / 8 * math.tau
tip = (cx + math.cos(a) * r * 0.9, cy + math.sin(a) * r * 0.9)
line((115, 210, 80), (cx, cy), tip, max(3, r * 0.11))
circle((225, 215, 70), tip, r * 0.06)
circle((60, 115, 155), (cx, cy), r * 0.42)
circle((95, 185, 225), (cx, cy), r * 0.34)
circle((185, 235, 245), (cx - r * 0.09, cy - r * 0.11), r * 0.09)
elif kind == 'regress_shell':
for i, frac in enumerate((0.78, 0.6, 0.43, 0.27)):
circle((130 + 20 * i, 45 + 12 * i, 42 + 10 * i), (cx, cy), r * frac, max(2, int(r * 0.09)))
for i in range(8):
a = i / 8 * math.tau
tip = (cx + math.cos(a) * r * 0.88, cy + math.sin(a) * r * 0.88)
line((220, 145, 70), (cx, cy), tip, max(1, r * 0.035))
circle((245, 235, 205), tip, r * 0.04)
elif kind == 'eye_stalk':
for i in range(5):
a = i / 5 * math.tau + rnd.uniform(-0.2, 0.2)
length = rnd.uniform(r * 0.55, r * 0.95)
tip = (cx + math.cos(a) * length, cy + math.sin(a) * length)
mid = (cx + math.cos(a + 0.28) * length * 0.52, cy + math.sin(a + 0.28) * length * 0.52)
line((58, 32, 82), (cx, cy), mid, max(4, r * 0.14))
line((125, 60, 155), (cx, cy), mid, max(2, r * 0.08))
line((120, 58, 155), mid, tip, max(2, r * 0.07))
glow(tip, r * 0.08, (245, 205, 70), 34)
circle((245, 210, 75), tip, r * 0.07)
circle((70, 55, 45), tip, r * 0.025)
elif kind == 'furled_papyrus':
for i in range(7):
a = i / 7 * math.tau + rnd.uniform(-0.14, 0.14)
tip = (cx + math.cos(a) * r * 0.78, cy + math.sin(a) * r * 0.78)
line((75, 105, 72), (cx, cy), tip, max(3, r * 0.1))
q = (tip[0] + math.cos(a + 1.8) * r * 0.18, tip[1] + math.sin(a + 1.8) * r * 0.18)
line((165, 165, 95), tip, q, max(2, r * 0.07))
circle((205, 190, 110), q, r * 0.045)
else:
circle((55, 92, 72), (cx, cy), r * 0.18)
for i in range(8):
a = i / 8 * math.tau + rnd.uniform(-0.15, 0.15)
tip = (cx + math.cos(a) * r * 0.86, cy + math.sin(a) * r * 0.86)
line((85, 155, 95), (cx, cy), tip, max(2, r * 0.08))
circle((145, 205, 125), tip, r * 0.06)
return surf.convert_alpha()
def generate_diver_sprite(size=30, suit_color=(30, 100, 180), accent_color=(255, 200, 50), tank_color=(200, 205, 215)):
width = int(size * 2.4)
height = int(size * 1.7)
surf = pygame.Surface((width, height), pygame.SRCALPHA)
center_x = width // 2
center_y = height // 2
body_length = int(size * 1.05)
body_width = int(size * 0.62)
flipper_length = int(size * 0.7)
flipper_width = int(size * 0.24)
flipper_x = center_x - body_length // 2 - flipper_length + 6
for sign in (-1, 1):
flipper_y = center_y + sign * (body_width // 2 + flipper_width // 2 - 1) - flipper_width // 2
pygame.draw.ellipse(surf, (18, 18, 28), (flipper_x, flipper_y, flipper_length, flipper_width))
pygame.draw.ellipse(surf, (70, 70, 90), (flipper_x, flipper_y, flipper_length, flipper_width), 1)
tank_length = int(size * 0.6)
tank_width = int(size * 0.2)
tank_x = center_x - tank_length // 2 - 2
for sign in (-1, 1):
tank_y = center_y + sign * (body_width // 2 + tank_width // 2 + 1) - tank_width // 2
pygame.draw.rect(surf, tank_color, (tank_x, tank_y, tank_length, tank_width), border_radius=3)
pygame.draw.rect(surf, (90, 95, 110), (tank_x, tank_y, tank_length, tank_width), 1, border_radius=3)
pygame.draw.line(surf, (255, 255, 255), (tank_x + 3, tank_y + 1), (tank_x + tank_length - 3, tank_y + 1), 1)
pygame.draw.ellipse(surf, suit_color, (center_x - body_length // 2, center_y - body_width // 2, body_length, body_width))
pygame.draw.line(surf, accent_color, (center_x - body_length // 3, center_y), (center_x + body_length // 3, center_y), 2)
pygame.draw.ellipse(surf, accent_color, (center_x - body_length // 2, center_y - body_width // 2, body_length, body_width), 2)
arm_length = int(size * 0.5)
arm_width = int(size * 0.16)
for sign in (-1, 1):
arm_y = center_y + sign * (body_width // 2 + arm_width // 2 - 2) - arm_width // 2
arm_x = center_x + body_length // 4
pygame.draw.ellipse(surf, suit_color, (arm_x, arm_y, arm_length, arm_width))
pygame.draw.ellipse(surf, accent_color, (arm_x, arm_y, arm_length, arm_width), 1)
head_radius = int(size * 0.3)
head_x = center_x + body_length // 2 - 1
head_y = center_y
pygame.draw.circle(surf, (235, 195, 160), (head_x, head_y), head_radius)
pygame.draw.circle(surf, (140, 100, 70), (head_x, head_y), head_radius, 1)
mask_width = int(head_radius * 1.6)
mask_height = int(head_radius * 1.3)
mask_x = head_x - mask_width // 2 + 2
mask_y = head_y - mask_height // 2
pygame.draw.ellipse(surf, (70, 180, 220), (mask_x, mask_y, mask_width, mask_height))
pygame.draw.ellipse(surf, (20, 60, 90), (mask_x, mask_y, mask_width, mask_height), 2)
pygame.draw.ellipse(surf, (200, 240, 255), (mask_x + 2, mask_y + 2, max(2, mask_width // 3), max(2, mask_height // 4)))
pygame.draw.line(surf, (255, 200, 0), (head_x - 2, head_y - head_radius + 2), (center_x - 2, center_y - body_width // 2 - 3), 2)
return surf.convert_alpha()
def point_in_polygon(x, y, points):
"""Return True when a point is inside an arbitrary polygon."""
inside = False
j = len(points) - 1
for i in range(len(points)):
xi, yi = points[i]
xj, yj = points[j]
intersects = (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi + 1e-09) + xi
if intersects:
inside = not inside
j = i
return inside
def make_blob_points(cx, cy, radius_x, radius_y, seed, point_count=22, jitter=0.14):
"""Create a stable irregular outline for a biome."""
rnd = random.Random(seed)
points = []
for i in range(point_count):
angle = i / point_count * math.tau
radius_mul = rnd.uniform(1.0 - jitter, 1.0 + jitter)
wave = 1.0 + math.sin(angle * 3 + seed * 0.17) * 0.05 + math.sin(angle * 5 + seed * 0.31) * 0.035
px = cx + math.cos(angle) * radius_x * radius_mul * wave
py = cy + math.sin(angle) * radius_y * radius_mul * wave
px = max(0, min(MAP_SIZE - 1, px))
py = max(0, min(MAP_SIZE - 1, py))
points.append((px, py))
return points
def make_corridor_polygon(x1, y1, x2, y2, width):
"""Create a thick polygon corridor between two points."""
dx = x2 - x1
dy = y2 - y1
distance = math.hypot(dx, dy)
if distance <= 0.001:
return make_blob_points(x1, y1, width, width, seed=int(x1 + y1), point_count=12, jitter=0.08)
ux = dx / distance
uy = dy / distance
nx = -uy
ny = ux
half = width / 2.0
sx = x1 - ux * half
sy = y1 - uy * half
ex = x2 + ux * half
ey = y2 + uy * half
return [(sx + nx * half, sy + ny * half), (sx - nx * half, sy - ny * half), (ex - nx * half, ey - ny * half), (ex + nx * half, ey + ny * half)]
class Biome:
def __init__(self, name, color, x1, y1, x2, y2, objects, animals, passable=True, polygon=None, map_label=None, cave_enabled=False, priority=10, clip_biome=None):
self.name = name
self.color = color
self.objects = objects
self.animals = animals
self.flora = []
self.passable = passable
self.map_label = map_label
self.cave_enabled = cave_enabled
self.priority = priority
self.clip_biome = clip_biome
if polygon:
self.polygon = [(float(px), float(py)) for px, py in polygon]
xs = [p[0] for p in self.polygon]
ys = [p[1] for p in self.polygon]
self.rect = (int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys)))
else:
self.polygon = None
self.rect = (x1, y1, x2, y2)
def contains(self, x, y):
x1, y1, x2, y2 = self.rect
if not (x1 <= x <= x2 and y1 <= y <= y2):
return False
if self.polygon is not None:
if not point_in_polygon(x, y, self.polygon):
return False
if self.clip_biome is not None:
if not self.clip_biome.contains(x, y):
return False
return True
def center(self):
if self.polygon:
x = sum((p[0] for p in self.polygon)) / len(self.polygon)
y = sum((p[1] for p in self.polygon)) / len(self.polygon)
return (x, y)
x1, y1, x2, y2 = self.rect
return ((x1 + x2) / 2.0, (y1 + y2) / 2.0)
def area(self):
if self.polygon:
result = 0.0
count = len(self.polygon)
for i in range(count):
x1, y1 = self.polygon[i]
x2, y2 = self.polygon[(i + 1) % count]
result += x1 * y2 - x2 * y1
return abs(result) / 2.0
x1, y1, x2, y2 = self.rect
return (x2 - x1 + 1) * (y2 - y1 + 1)
def random_point(self, rng=None):
if rng is None:
rng = random
x1, y1, x2, y2 = self.rect
for _ in range(400):
x = rng.uniform(x1, x2)
y = rng.uniform(y1, y2)
if self.contains(x, y):
return (x, y)
return self.center()
def map_points(self):
if self.polygon:
return self.polygon
x1, y1, x2, y2 = self.rect
return [(x1, y1), (x2 + 1, y1), (x2 + 1, y2 + 1), (x1, y2 + 1)]
def shift(self, dx, dy):
x1, y1, x2, y2 = self.rect
self.rect = (x1 + dx, y1 + dy, x2 + dx, y2 + dy)
if self.polygon:
self.polygon = [(x + dx, y + dy) for x, y in self.polygon]
def _bio_normalize_kind(value):
return str(value).lower().replace(' ', '_').replace('-', '_')
def _bio_stable_seed(value):
return sum(((i + 1) * ord(c) for i, c in enumerate(str(value))))
def _bio_light_art(original, species, is_plant=False, seed=0):
"""Рендерит ТОЛЬКО светящиеся участки, строго отдельно от тела.
На выходе картинка с полями для ореола, которая может независимо
поворачиваться вместе с исходной рыбой.
"""
if original is None:
return None
bounds = original.get_bounding_rect(min_alpha=125)
if bounds.width == 0 or bounds.height == 0:
return None
kind = _bio_normalize_kind(species)
w, h = original.get_size()
pad = max(12, min(33, int(max(bounds.width, bounds.height) * 0.14)))
dims = (w + pad * 2, h + pad * 2)
aura = pygame.Surface(dims, pygame.SRCALPHA)
core = pygame.Surface(dims, pygame.SRCALPHA)
cx, cy = (pad + bounds.centerx, pad + bounds.centery)
def pos(u, v):
return (round(pad + bounds.left + bounds.width * u), round(pad + bounds.top + bounds.height * v))
def dot(x, y, radius, color, white=None, intensity=1.0):
x, y = (int(x), int(y))
r = max(1, int(radius))
for m, a in ((5, 9), (3.2, 19), (2.05, 42), (1.35, 65)):
pygame.draw.circle(aura, (*color, min(255, round(a * intensity))), (x, y), max(1, round(r * m)))
pygame.draw.circle(core, (*color, min(255, round(205 * intensity))), (x, y), r + 1)
if white is None:
white = tuple((min(255, round(c * 0.4 + 255 * 0.6)) for c in color))
pygame.draw.circle(core, (*white, min(255, round(249 * intensity))), (x, y), r)
def orb(u, v, relative_radius, color, intensity=1.0):
x, y = pos(u, v)
dot(x, y, max(1, round(min(bounds.width, bounds.height) * relative_radius)), color, intensity=intensity)
def path(vertices, color, thickness=1, intensity=1.0):
if len(vertices) < 2:
return
points = [pos(x, y) for x, y in vertices]
thickness = max(1, round(thickness))
for broad, alpha in ((6, 14), (3, 32)):
pygame.draw.lines(aura, (*color, round(alpha * intensity)), False, points, thickness + broad)
pygame.draw.lines(core, (*color, round(205 * intensity)), False, points, thickness + 1)
pale = tuple((min(255, round(c * 0.55 + 255 * 0.45)) for c in color))
pygame.draw.lines(core, (*pale, round(225 * intensity)), False, points, max(1, thickness // 2))
def luminous_oval(u, v, rw, rh, color, fill=False, intensity=1.0):
x, y = pos(u, v)
rx = max(2, round(bounds.width * rw))
ry = max(2, round(bounds.height * rh))
rectangle = pygame.Rect(x - rx, y - ry, rx * 2, ry * 2)
broad = rectangle.inflate(max(4, rx // 2), max(4, ry // 2))
pygame.draw.ellipse(aura, (*color, round(26 * intensity)), broad)
pygame.draw.ellipse(aura, (*color, round(56 * intensity)), rectangle)
pygame.draw.ellipse(core, (*color, round((105 if fill else 220) * intensity)), rectangle, 0 if fill else max(1, min(rx, ry) // 3))
if not fill:
inner = rectangle.inflate(-max(2, rx // 3), -max(2, ry // 3))
if inner.width > 1 and inner.height > 1:
pygame.draw.ellipse(core, (245, 250, 255, round(75 * intensity)), inner, 1)
def color_pixels(predicate, glow_color, region=None, stride=1, max_points=300):
"""Выделить точно окрашенный орган из уже нарисованного спрайта.
Сохраняет форму глазных склер, семенных гроздьев, капюшонов и узоров.
"""
x0, y0, x1, y1 = (bounds.left, bounds.top, bounds.right, bounds.bottom)
if region is not None:
ux0, vy0, ux1, vy1 = region
x0 = max(x0, int(bounds.left + bounds.width * ux0))
x1 = min(x1, int(bounds.left + bounds.width * ux1))
y0 = max(y0, int(bounds.top + bounds.height * vy0))
y1 = min(y1, int(bounds.top + bounds.height * vy1))
chosen = []
for yy in range(y0, y1, stride):
for xx in range(x0, x1, stride):
r, g, b, a = original.get_at((xx, yy))
if a >= 170 and predicate(r, g, b):
px, py = (xx + pad, yy + pad)
chosen.append((px, py))
pygame.draw.rect(core, (*glow_color, 245), (px, py, stride, stride))
if chosen:
step = max(1, len(chosen) // max_points)
for i in range(0, len(chosen), step):
px, py = chosen[i]
pygame.draw.circle(aura, (*glow_color, 25), (px, py), max(3, min(7, round(min(bounds.size) / 10))))
return len(chosen)
if is_plant:
if kind in ('blood_grass', 'redwort', 'tiger_plant', 'spiked_horn_grass', 'mushroom_tree', 'gabe_feather', 'regress_shell', 'furled_papyrus'):
return None
if kind in ('гроздь_семян_водоросли', 'creepvine_seed_cluster'):
luminous_oval(0.5, 0.5, 0.36, 0.34, (255, 208, 32), fill=True, intensity=1.15)
for u, v in ((0.3, 0.37), (0.65, 0.36), (0.55, 0.67)):
orb(u, v, 0.1, (255, 220, 72), 1.1)
elif kind in ('кислотный_гриб', 'acid_mushroom_item'):
luminous_oval(0.5, 0.5, 0.36, 0.29, (190, 104, 242), fill=True, intensity=0.6)
elif kind in ('глубинный_гриб', 'deep_shroom_item'):
luminous_oval(0.5, 0.5, 0.34, 0.27, (127, 109, 220), fill=True, intensity=0.65)
elif kind in ('масляный_сок', 'blood_oil'):
for u, v in ((0.38, 0.45), (0.59, 0.38), (0.52, 0.64)):
orb(u, v, 0.12, (229, 53, 68), 0.9)
elif kind in ('гелевый_мешок', 'gel_sack'):
for u, v in ((0.3, 0.4), (0.58, 0.31), (0.63, 0.63), (0.39, 0.69)):
orb(u, v, 0.085, (200, 120, 255), 0.76)
elif kind == 'creepvine':
found = color_pixels(lambda r, g, b: r > 202 and g > 125 and (14 < b < 175) and (r > g * 0.94) and (g > b + 40), (255, 218, 66), stride=1, max_points=170)
if not found:
for u, v in ((0.33, 0.32), (0.38, 0.35), (0.66, 0.6), (0.68, 0.55)):
orb(u, v, 0.045, (255, 195, 24), 1.15)
elif kind in ('acid_mushroom', 'deep_shroom'):
col = (176, 100, 235) if kind == 'acid_mushroom' else (133, 102, 207)
luminous_oval(0.5, 0.45, 0.31, 0.29, col)
for u, v in ((0.32, 0.37), (0.52, 0.32), (0.68, 0.45), (0.45, 0.6)):
orb(u, v, 0.04, col, 0.55)
elif kind == 'jellyshroom':
for u, v in ((0.3, 0.39), (0.51, 0.29), (0.71, 0.4), (0.38, 0.65), (0.65, 0.7)):
luminous_oval(u, v, 0.12, 0.09, (195, 96, 242), fill=True)
elif kind == 'bulb_bush':
for u, v in ((0.28, 0.34), (0.53, 0.25), (0.72, 0.38), (0.35, 0.62), (0.64, 0.7), (0.5, 0.5)):
luminous_oval(u, v, 0.105, 0.09, (65, 145, 255), fill=True)
orb(u - 0.03, v - 0.025, 0.035, (120, 225, 255))
elif kind in ('bloodvine', 'bloodroot'):
for u, v in ((0.21, 0.37), (0.42, 0.23), (0.77, 0.35), (0.69, 0.74), (0.35, 0.78)):
orb(u, v, 0.07, (230, 42, 62))
elif kind == 'eye_stalk':
for u, v in ((0.22, 0.35), (0.53, 0.18), (0.81, 0.43), (0.65, 0.83), (0.23, 0.72)):
orb(u, v, 0.06, (255, 209, 45))
elif kind == 'sea_crown':
luminous_oval(0.5, 0.5, 0.25, 0.25, (62, 175, 255), fill=True)
for i in range(8):
a = i * math.tau / 8
orb(0.5 + 0.39 * math.cos(a), 0.5 + 0.39 * math.sin(a), 0.026, (235, 220, 105))
elif kind == 'drooping_stinger':
for u, v in ((0.3, 0.35), (0.52, 0.29), (0.69, 0.46), (0.38, 0.68), (0.6, 0.7)):
luminous_oval(u, v, 0.1, 0.09, (93, 232, 128), fill=True, intensity=0.8)
elif kind in ('ghost_weed', 'writhing_weed'):
color = (123, 220, 251) if kind == 'ghost_weed' else (146, 236, 123)
for i in range(7):
a = math.tau * i / 7
orb(0.5 + 0.38 * math.cos(a), 0.5 + 0.38 * math.sin(a), 0.028, color, 0.7)
elif kind in ('membraintree', 'anchor_pod'):
color = (255, 107, 217) if kind == 'membraintree' else (85, 231, 232)
luminous_oval(0.5, 0.48, 0.3, 0.3, color, fill=True, intensity=0.64)
for u, v in ((0.42, 0.35), (0.61, 0.43), (0.5, 0.59)):
orb(u, v, 0.05, color)
elif kind == 'rouge_cradle':
luminous_oval(0.5, 0.5, 0.2, 0.2, (255, 207, 76), fill=True)
elif kind == 'pygmy_fan':
for i in range(7):
a = math.tau * i / 7
path(((0.5, 0.5), (0.5 + 0.4 * math.cos(a), 0.5 + 0.4 * math.sin(a))), (183, 105, 238), 1, 0.65)
elif kind in ('spotted_reeds', 'spotted_dockleaf'):
col = (210, 228, 155) if kind == 'spotted_reeds' else (223, 207, 99)
for i in range(8):
a = math.tau * i / 8
for rad in (0.21, 0.32):
orb(0.5 + rad * math.cos(a), 0.5 + rad * math.sin(a), 0.025, col, 0.6)
elif kind in ('blue_palm', 'cave_bush', 'violet_beau', 'sulfur_plant'):
col = {'blue_palm': (73, 158, 240), 'cave_bush': (191, 130, 221), 'violet_beau': (189, 105, 235), 'sulfur_plant': (235, 210, 83)}[kind]
if kind == 'sulfur_plant':
luminous_oval(0.5, 0.5, 0.2, 0.12, col, fill=True, intensity=0.44)
else:
for i in range(8):
a = i * math.tau / 8
orb(0.5 + 0.36 * math.cos(a), 0.5 + 0.36 * math.sin(a), 0.028, col, 0.57)
else:
return None
elif kind == 'sea_dragon':
for u, v in ((0.92, 0.35), (0.92, 0.65)):
orb(u, v, 0.045, (255, 172, 50))
for i in range(7):
u = 0.15 + i * 0.092
for vv in (0.3, 0.7):
orb(u, vv, 0.02, (255, 104, 38), 0.72)
elif kind == 'eggplant_fish':
luminous_oval(0.76, 0.49, 0.07, 0.21, (93, 194, 255))
for u in (0.29, 0.43, 0.58):
orb(u, 0.32, 0.03, (138, 176, 252), 0.6)
elif kind == 'zucchini_fish':
orb(0.79, 0.46, 0.053, (194, 234, 113), 0.82)
else:
return None
result = pygame.Surface(dims, pygame.SRCALPHA)
result.blit(aura, (0, 0))
result.blit(core, (0, 0))
return result.convert_alpha()
def generate_bioluminescence_sprite(original, species, is_plant=False, seed=0):
return _bio_light_art(original, species, is_plant, seed)
class FloraType:
def __init__(self, name, kind, size, seed=0):
self.name = name
self.kind = kind
self.size = int(size)
self.seed = int(seed)
self.image = generate_subnautica_flora_sprite(kind, self.size, seed=self.seed)
self.glow_image = generate_bioluminescence_sprite(self.image, kind, is_plant=True, seed=self.seed)
class ObjectType:
def __init__(self, name, color, size, shape='circle', image_path=None, volume=1, is_buildable=False, is_wall=False, edible=False, hunger_value=0, thirst_value=0, is_weapon=False, damage=0, weapon_range=0, weapon_type=''):
self.name = name
self.color = color
self.size = size
self.shape = shape
self.volume = volume
self.is_buildable = is_buildable
self.is_wall = is_wall
self.edible = edible
self.hunger_value = hunger_value
self.thirst_value = thirst_value
self.is_weapon = is_weapon
self.damage = damage
self.weapon_range = weapon_range
self.weapon_type = weapon_type
self.image = None
if image_path and os.path.exists(image_path):
self.image = load_image_with_proportions(image_path, size)
class AnimalType:
def __init__(self, name, color, size, shape='circle', speed=0.05, behavior='neutral', image_path=None, volume=1, hp=10, damage=0, edible=False, hunger_value=0, thirst_value=0, fish_style=False, fish_length=None, fish_width=None, body_color=None, spine_color=None, belly_color=None, fin_color=None, spot_colors=None, tail_color=(255, 165, 0), sprite_kind=None, schooling=False, fear_distance=None, zigzag_strength=0.0, speed_variation=0.12, flee_speed_boost=1.65):
self.name = name
self.color = color
self.size = size
self.shape = shape
self.speed = speed
self.behavior = behavior
self.volume = volume
self.hp = hp
self.damage = damage
self.edible = edible
self.hunger_value = hunger_value
self.thirst_value = thirst_value
self.fish_style = fish_style or sprite_kind is not None
self.schooling = schooling
self.sprite_kind = sprite_kind
self.fear_distance = float(fear_distance) if fear_distance is not None else 2.7 if behavior == 'prey' else 0.0
self.zigzag_strength = float(zigzag_strength)
self.speed_variation = float(speed_variation)
self.flee_speed_boost = float(flee_speed_boost)
self.fish_length = float(fish_length if fish_length is not None else max(20, size * 3))
self.fish_width = float(fish_width if fish_width is not None else max(6, self.fish_length / 6))
self.is_weapon = False
self.is_buildable = False
self.weapon_range = 0
self.weapon_type = ''
self.image = None
if image_path and os.path.exists(image_path):
self.image = load_image_with_proportions(image_path, size)
elif sprite_kind is not None:
length = fish_length if fish_length is not None else max(20, size * 3)
width = fish_width if fish_width is not None else max(6, length // 6)
self.image = generate_subnautica_creature_sprite(sprite_kind, length, width)
elif fish_style:
length = fish_length if fish_length is not None else max(20, size * 3)
width = fish_width if fish_width is not None else max(6, length // 6)
body = body_color if body_color is not None else color
spine = spine_color if spine_color is not None else tuple((max(0, c - 60) for c in body))
belly = belly_color if belly_color is not None else tuple((min(255, c + 40) for c in body))
fins = fin_color if fin_color is not None else spine
spots = spot_colors if spot_colors is not None else [spine]
self._hd_fish_palette = (body, spine, belly, fins, spots, tail_color)
self.image = generate_pike_style_sprite(length, width, body, spine, belly, fins, spots, tail_color)
self.glow_image = generate_bioluminescence_sprite(self.image, self.sprite_kind or self.name)
self.glow_rotated_cache = {}
self.rotated_cache = {}
def get_rotated_glow(self, angle):
if self.glow_image is None:
return None
angle = quantize_angle(angle)
result = self.glow_rotated_cache.get(angle)
if result is None:
result = pygame.transform.rotate(self.glow_image, angle)
self.glow_rotated_cache[angle] = result
return result
def get_rotated(self, angle):
if self.image is None:
return None
angle = quantize_angle(angle)
image = self.rotated_cache.get(angle)
if image is None:
image = pygame.transform.rotate(self.image, angle)
self.rotated_cache[angle] = image
return image
class Recipe:
def __init__(self, name, ingredients, result, result_count=1, station='Изготовитель', category='Разное'):
self.name = name
self.ingredients = ingredients
self.result = result
self.result_count = max(1, int(result_count))
self.station = station
self.category = category
def can_craft(self, inventory_counts):
for item, needed in self.ingredients.items():
if inventory_counts.get(item, 0) < needed:
return False
return True
def craft(self, inventory, inventory_counts):
if not self.can_craft(inventory_counts):
return None
for needed_item, needed_count in self.ingredients.items():
removed = 0
for index, item in enumerate(inventory):
if removed >= needed_count:
break
if item == needed_item:
inventory[index] = None
removed += 1
inventory_counts[needed_item] -= needed_count
if inventory_counts[needed_item] <= 0:
del inventory_counts[needed_item]
return self.result
def init_assets():
global rock_type
global titan_type
global copper_type
global seaweed_type
global iron_type
global gold_type
global diamond_type
global quartz_type
global coal_type
global sulfur_type
global clay_type
global sand_type
global limestone_type
global oil_type
global salt_type
global silver_type
global lead_type
global lithium_type
global magnetite_type
global uraninite_type
global ruby_type
global gel_sack_type
global kyanite_type
global steel_type
global provod_type
global glass_type
global gunpowder_type
global fuel_type
global concrete_type
global zucchini_fish_type
global eggplant_fish_type
global sea_dragon_type
global chbos_whale_type
global wall_type
global floor_type
global habitat_builder_type
global fins_type, reinforced_suit, water_filtration_suit, rebreather_type
global first_aid_kit, flashlight_type, seaglide_type, flare_type
global fire_extinguisher, air_bladder, scanner_type, pathfinder_tool
global repair_tool, repair_kit, propulsion_cannon, repulsion_cannon, stasis_rifle
global laser_cutter, creature_decoy, scanner_range, beacon_type
global spotlight_type, floodlight_type, solar_panel_type, grav_trap
global exterior_growbed, plant_pot, indoor_growbed, plant_shelf
global composite_pot, chic_pot
global bed_type, bench_type, chair_type, vending_machine, coffee_machine
global water_filtration_machine, medical_fabricator, scanner_room_type
global radio_type
global seamoth_type, cyclops_type
global START_ITEM_REGISTRY
global mad_moss_item, antibiotic_syringe
global laser_pistol
global underwater_rifle
global plasma_cannon
global lang_cannon
global schweer_cannon
global diamond_cutter
global flamethrower
global golden_pistol
global recipes
global biomes
global flora_types
global surface_flora_profiles
global cave_flora_profiles
rock_type = ObjectType('Камень', (100, 100, 100), 20, 'rect')
titan_type = ObjectType('Титан', (205, 224, 216), 35, 'rect')
copper_type = ObjectType('Медная руда', (211, 127, 34), 19, 'rect')
mad_moss_item = ObjectType('Бешеный мох', (65, 179, 79), 24, 'circle', edible=True, hunger_value=1, thirst_value=4)
mad_moss_item.image = pygame.transform.smoothscale(generate_mad_moss_sprite(150), (26, 26)).convert_alpha()
antibiotic_syringe = ObjectType('Шприц с антибиотиком', (108, 232, 205), 26, 'rect')
antibiotic_syringe.image = generate_tech_item_sprite('Шприц с антибиотиком', 28, (108, 232, 205))
seaweed_type = ObjectType('Водоросль', (0, 200, 0), 18, 'circle', edible=True, hunger_value=5, thirst_value=5)
iron_type = ObjectType('Железная руда', (169, 169, 169), 25, 'rect')
gold_type = ObjectType('Золотая руда', (255, 215, 0), 30, 'rect')
diamond_type = ObjectType('Алмаз', (0, 255, 255), 40, 'rect')
quartz_type = ObjectType('Кварц', (255, 255, 255), 15, 'rect')
coal_type = ObjectType('Уголь', (50, 50, 50), 22, 'rect')
sulfur_type = ObjectType('Сера', (255, 255, 0), 18, 'rect')
clay_type = ObjectType('Глина', (139, 69, 19), 12, 'rect')
sand_type = ObjectType('Песок', (194, 178, 128), 10, 'rect')
limestone_type = ObjectType('Известняк', (200, 200, 200), 16, 'rect')
oil_type = ObjectType('Нефть', (30, 30, 30), 20, 'rect')
salt_type = ObjectType('Соль', (225, 235, 245), 14, 'rect')
silver_type = ObjectType('Серебряная руда', (195, 205, 215), 18, 'rect')
lead_type = ObjectType('Свинец', (80, 90, 105), 20, 'rect')
lithium_type = ObjectType('Литий', (220, 210, 235), 17, 'rect')
magnetite_type = ObjectType('Магнетит', (65, 65, 78), 20, 'rect')
uraninite_type = ObjectType('Уранинит', (110, 190, 45), 22, 'rect')
ruby_type = ObjectType('Рубин', (230, 40, 65), 21, 'rect')
gel_sack_type = ObjectType('Гелевый мешок', (155, 70, 190), 18, 'circle')
steel_type = ObjectType('Сталь', (180, 180, 200), 18, 'rect')
provod_type = ObjectType('Медный провод', (211, 127, 34), 12, 'circle')
glass_type = ObjectType('Стекло', (200, 230, 255), 14, 'rect')
gunpowder_type = ObjectType('Порох', (60, 60, 60), 12, 'circle')
fuel_type = ObjectType('Топливо', (90, 60, 20), 16, 'rect')
concrete_type = ObjectType('Бетон', (170, 170, 160), 20, 'rect')
steel_type.image = generate_tech_item_sprite('Сталь', steel_type.size, steel_type.color)
provod_type.image = generate_tech_item_sprite('Медный провод', provod_type.size, provod_type.color)
glass_type.image = generate_crystal_sprite(glass_type.size, (190, 225, 245), sparkle=True, seed=214)
gunpowder_type.image = generate_lumpy_sprite(gunpowder_type.size, (65, 65, 68), seed=215)
fuel_type.image = generate_tech_item_sprite('Топливо', fuel_type.size, fuel_type.color)
concrete_type.image = generate_rock_sprite(concrete_type.size, (170, 170, 160), seed=216)
metal_salvage_type = ObjectType('Металлолом', (125, 135, 145), 24, 'rect')
acid_mushroom_item = ObjectType('Кислотный гриб', (145, 80, 205), 15, 'circle')
creepvine_sample_type = ObjectType('Образец водоросли', (55, 165, 70), 18, 'circle')
creepvine_seed_type = ObjectType('Гроздь семян водоросли', (235, 185, 40), 17, 'circle')
cave_sulfur_type = ObjectType('Пещерная сера', (245, 220, 65), 16, 'circle')
table_coral_type = ObjectType('Образец пластинчатого коралла', (220, 85, 105), 17, 'rect')
coral_tube_type = ObjectType('Образец коралловой трубы', (185, 115, 85), 17, 'rect')
blood_oil_type = ObjectType('Масляный сок', (165, 35, 45), 19, 'circle')
deep_shroom_item = ObjectType('Глубинный гриб', (70, 62, 105), 16, 'circle')
nickel_type = ObjectType('Никелевая руда', (125, 160, 150), 20, 'rect')
kyanite_type = ObjectType('Кианит', (70, 125, 245), 22, 'rect')
ion_cube_type = ObjectType('Ионный куб', (65, 245, 150), 22, 'rect')
rock_type.image = generate_rock_sprite(20, (115, 115, 120), seed=101)
titan_type.image = generate_rock_sprite(35, (205, 222, 218), seed=102)
copper_type.image = generate_ore_sprite(19, (95, 95, 100), [(211, 127, 34), (240, 160, 60), (170, 95, 20)], vein_count=4, seed=103)
seaweed_type.image = generate_seaweed_sprite(18, seed=104)
iron_type.image = generate_ore_sprite(25, (115, 115, 120), [(190, 190, 195), (140, 140, 145), (235, 235, 240)], vein_count=5, seed=105)
gold_type.image = generate_ore_sprite(30, (95, 85, 70), [(255, 215, 0), (255, 235, 120), (220, 180, 0)], vein_count=6, seed=106)
diamond_type.image = generate_crystal_sprite(40, (0, 240, 255), sparkle=True, seed=107)
quartz_type.image = generate_crystal_sprite(15, (225, 240, 255), sparkle=False, seed=108)
coal_type.image = generate_rock_sprite(22, (45, 45, 50), seed=109)
sulfur_type.image = generate_ore_sprite(18, (105, 95, 70), [(255, 255, 0), (255, 240, 80), (220, 220, 40)], vein_count=4, seed=110)
clay_type.image = generate_lumpy_sprite(12, (139, 69, 19), seed=111)
sand_type.image = generate_lumpy_sprite(10, (194, 178, 128), seed=112)
limestone_type.image = generate_rock_sprite(16, (215, 215, 205), seed=113)
oil_type.image = generate_lumpy_sprite(20, (28, 28, 32), seed=114, shine=True)
salt_type.image = generate_crystal_sprite(14, (225, 240, 255), sparkle=False, seed=201)
silver_type.image = generate_ore_sprite(18, (95, 100, 110), [(205, 215, 225), (170, 180, 195)], vein_count=4, seed=202)
lead_type.image = generate_ore_sprite(20, (70, 75, 82), [(105, 115, 130), (65, 70, 85)], vein_count=4, seed=203)
lithium_type.image = generate_crystal_sprite(17, (225, 215, 240), sparkle=True, seed=204)
magnetite_type.image = generate_crystal_sprite(20, (65, 65, 80), sparkle=True, seed=205)
uraninite_type.image = generate_crystal_sprite(22, (105, 190, 45), sparkle=True, seed=206)
ruby_type.image = generate_crystal_sprite(21, (235, 40, 65), sparkle=True, seed=207)
gel_sack_type.image = generate_lumpy_sprite(18, (145, 65, 180), seed=208, shine=True)
metal_salvage_type.image = generate_rock_sprite(24, (125, 135, 145), seed=220)
acid_mushroom_item.image = generate_lumpy_sprite(15, (145, 80, 205), seed=221, shine=True)
creepvine_sample_type.image = generate_seaweed_sprite(18, seed=222)
creepvine_seed_type.image = generate_lumpy_sprite(17, (235, 185, 40), seed=223, shine=True)
cave_sulfur_type.image = generate_crystal_sprite(16, (245, 220, 65), seed=224)
table_coral_type.image = generate_lumpy_sprite(17, (220, 85, 105), seed=225)
coral_tube_type.image = generate_lumpy_sprite(17, (185, 115, 85), seed=226)
blood_oil_type.image = generate_lumpy_sprite(19, (165, 35, 45), seed=228, shine=True)
deep_shroom_item.image = generate_lumpy_sprite(16, (70, 62, 105), seed=229, shine=True)
nickel_type.image = generate_ore_sprite(20, (90, 105, 102), [(125, 175, 160)], seed=230)
kyanite_type.image = generate_crystal_sprite(22, (70, 125, 245), seed=231)
ion_cube_type.image = generate_crystal_sprite(22, (65, 245, 150), seed=232)
for glowing_item in (seaweed_type, gel_sack_type, acid_mushroom_item, creepvine_sample_type, creepvine_seed_type, blood_oil_type, deep_shroom_item, table_coral_type, coral_tube_type):
glowing_item.glow_image = generate_bioluminescence_sprite(glowing_item.image, glowing_item.name, is_plant=True)
flora_types = {'acid_mushroom': FloraType('Кислотный гриб', 'acid_mushroom', 22, 3001), 'deep_shroom': FloraType('Глубинный гриб', 'deep_shroom', 20, 3002), 'creepvine': FloraType('Ползучая водоросль', 'creepvine', 68, 3003), 'blood_grass': FloraType('Кровавая трава', 'blood_grass', 24, 3004), 'blue_palm': FloraType('Синяя пальма', 'blue_palm', 38, 3005), 'writhing_weed': FloraType('Извивающаяся трава', 'writhing_weed', 28, 3006), 'spotted_reeds': FloraType('Пятнистый тростник', 'spotted_reeds', 28, 3007), 'redwort': FloraType('Краснотравник', 'redwort', 22, 3008), 'violet_beau': FloraType('Фиолетовая красавица', 'violet_beau', 24, 3009), 'tiger_plant': FloraType('Растение-тигр', 'tiger_plant', 28, 3010), 'spiked_horn_grass': FloraType('Шипорогая трава', 'spiked_horn_grass', 26, 3011), 'mushroom_tree': FloraType('Грибное дерево', 'mushroom_tree', 76, 3012), 'jellyshroom': FloraType('Медузогриб', 'jellyshroom', 66, 3013), 'pygmy_fan': FloraType('Карликовый веер', 'pygmy_fan', 28, 3014), 'bulb_bush': FloraType('Луковичный куст', 'bulb_bush', 48, 3015), 'spotted_dockleaf': FloraType('Пятнистый щавель', 'spotted_dockleaf', 28, 3016), 'bloodvine': FloraType('Кровавая лоза', 'bloodvine', 62, 3017), 'bloodroot': FloraType('Кровавый корень', 'bloodroot', 68, 3018), 'ghost_weed': FloraType('Призрачная трава', 'ghost_weed', 36, 3019), 'gabe_feather': FloraType('Перо Гейба', 'gabe_feather', 30, 3020), 'membraintree': FloraType('Мембранное дерево', 'membraintree', 42, 3021), 'anchor_pod': FloraType('Якорная капсула', 'anchor_pod', 58, 3022), 'cave_bush': FloraType('Пещерный куст', 'cave_bush', 30, 3023), 'rouge_cradle': FloraType('Красная колыбель', 'rouge_cradle', 30, 3024), 'drooping_stinger': FloraType('Свисающая жгучка', 'drooping_stinger', 40, 3025), 'sulfur_plant': FloraType('Серное растение', 'sulfur_plant', 26, 3026), 'sea_crown': FloraType('Морская корона', 'sea_crown', 30, 3027), 'regress_shell': FloraType('Регрессирующая раковина', 'regress_shell', 26, 3028), 'eye_stalk': FloraType('Глазастый стебель', 'eye_stalk', 32, 3029), 'furled_papyrus': FloraType('Свёрнутый папирус', 'furled_papyrus', 28, 3030)}
zucchini_fish_type = AnimalType('Рыба-кабачок', (132, 208, 22), 13, speed=0.07, behavior='prey', hp=8, edible=True, hunger_value=44, thirst_value=4, fish_length=57, fish_width=15, sprite_kind='zucchini_fish', schooling=True, fear_distance=2.8, zigzag_strength=0.17, speed_variation=0.17, flee_speed_boost=1.8)
eggplant_fish_type = AnimalType('Рыба-баклажан', (166, 86, 186), 12, speed=0.075, behavior='prey', hp=6, edible=True, hunger_value=30, thirst_value=2, fish_length=33, fish_width=15, sprite_kind='eggplant_fish', schooling=True, fear_distance=2.9, zigzag_strength=0.29, speed_variation=0.17, flee_speed_boost=1.9)
sea_dragon_type = AnimalType('Морской дракон-левиафан', (60, 120, 75), 240, speed=0.065, behavior='predator', hp=800, damage=30, edible=False, fish_length=300, fish_width=35, sprite_kind='sea_dragon')
chbos_whale_type = AnimalType(WHALE_NAME, (30, 122, 185), 240, speed=WHALE_SPEED / 60.0, behavior='predator', hp=WHALE_HP, damage=0, edible=True, hunger_value=90, thirst_value=90, fish_length=sea_dragon_type.fish_length, fish_width=96, sprite_kind='chbos_whale', volume=12, speed_variation=0.0)
def tech(name, color=(90, 155, 185), size=20, weapon=False, damage=0, weapon_range=0, weapon_type=''):
obj = ObjectType(name, color, size, 'rect', is_weapon=weapon, damage=damage, weapon_range=weapon_range, weapon_type=weapon_type)
obj.image = generate_tech_item_sprite(name, size, color, is_weapon=weapon, weapon_type=weapon_type)
return obj
def buildable(name, color=(105, 145, 155), wall=False, size=30):
obj = ObjectType(name, color, size, 'rect', is_buildable=True, is_wall=wall)
obj.image = generate_building_sprite(name, size, color, wall=wall)
obj.requires_habitat_builder = True
return obj
titanium_ingot = tech('Титановый слиток', (185, 205, 205))
fiber_mesh = tech('Сетчатое волокно', (120, 170, 110))
silicone_rubber = tech('Силиконовая резина', (170, 185, 175))
bleach_type = tech('Антисептик', (225, 235, 220))
lubricant_type = tech('Смазка', (205, 180, 55))
enameled_glass = tech('Эмалевое стекло', (180, 230, 245))
plasteel_ingot = tech('Пласталевый слиток', (150, 175, 190))
hydrochloric_acid = tech('Соляная кислота', (125, 220, 145))
polyaniline_type = tech('Полианилин', (95, 120, 200))
synthetic_fibers = tech('Синтетические волокна', (165, 150, 190))
aerogel_type = tech('Аэрогель', (130, 220, 235))
benzene_type = tech('Бензол', (185, 80, 115))
copper_wire = tech('Медный провод', (205, 120, 40))
wiring_kit = tech('Комплект проводов', (180, 190, 205))
advanced_wiring_kit = tech('Расширенный комплект проводов', (205, 170, 80))
battery_type = tech('Батарея', (235, 210, 70))
power_cell_type = tech('Энергоячейка', (80, 190, 225), 22)
computer_chip = tech('Микросхема', (80, 185, 125))
reactor_rod = tech('Топливный стержень реактора', (135, 215, 75), 22)
ion_battery = tech('Ионная батарея', (80, 245, 150))
ion_power_cell = tech('Ионная энергоячейка', (80, 245, 180), 22)
fins_type = tech('Ласты', (90, 170, 190))
standard_tank = tech('Обычный кислородный баллон', (175, 185, 195), 24)
high_capacity_tank = tech('Кислородный баллон высокой ёмкости', (120, 170, 205), 24)
lightweight_tank = tech('Лёгкий баллон высокой ёмкости', (115, 195, 205), 24)
ultra_tank = tech('Баллон сверхвысокой ёмкости', (100, 150, 220), 24)
rebreather_type = tech('Ребризер', (65, 115, 145))
radiation_suit = tech('Противорадиационный костюм', (220, 170, 65), 24)
reinforced_suit = tech('Укреплённый костюм ныряльщика', (70, 85, 100), 24)
water_filtration_suit = tech('Фильтрационный костюм', (80, 140, 165), 24)
compass_type = tech('Компас', (110, 185, 205))
first_aid_kit = tech('Аптечка', (225, 230, 230))
fire_extinguisher = tech('Огнетушитель', (220, 70, 50), 22)
floating_air_pump = tech('Плавучий воздушный насос', (120, 180, 205))
pipe_type = tech('Труба', (185, 195, 205))
survival_knife = tech('Нож', (165, 175, 185), weapon=True, damage=10, weapon_range=1.7, weapon_type='нож')
thermoblade = tech('Термоклинок', (245, 120, 50), weapon=True, damage=20, weapon_range=1.7, weapon_type='нож')
scanner_type = tech('Сканер', (80, 175, 215))
repair_tool = tech('Ремонтный инструмент', (85, 165, 205))
repair_kit = tech('Ремкомплект', (235, 155, 70), 22)
flashlight_type = tech('Фонарик', (220, 220, 180))
habitat_builder_type = tech('Строитель', (95, 190, 205))
air_bladder = tech('Воздушный пузырь', (230, 175, 70))
pathfinder_tool = tech('Инструмент исследователя', (100, 205, 175))
propulsion_cannon = tech('Пропульсионная пушка', (110, 145, 205), 24, True, 4, 7, 'импульс')
repulsion_cannon = tech('Репульсионная пушка', (150, 110, 210), 24, True, 8, 8, 'импульс')
stasis_rifle = tech('Стазис-винтовка', (75, 145, 220), 28, True, 1, 9, 'стазис')
laser_cutter = tech('Лазерный резак', (225, 110, 70), 24, True, 8, 2.5, 'лазер')
flare_type = tech('Сигнальная ракета', (245, 90, 70))
beacon_type = buildable('Радиомаяк', (235, 185, 65), size=24)
creature_decoy = tech('Приманка для существ', (190, 110, 85))
seaglide_type = tech('Морской глайдер', (70, 150, 190), 28)
mobile_vehicle_bay = buildable('Переносной сборщик транспорта', (95, 150, 175), size=34)
grav_trap = buildable('Грави-ловушка', (115, 120, 205), size=26)
waterproof_locker = buildable('Водостойкий контейнер', (150, 165, 170), size=26)
seamoth_type = buildable('Мотылёк', (220, 180, 70), size=42)
prawn_suit_type = buildable('Костюм КРАБ', (205, 115, 65), size=44)
cyclops_type = buildable('Циклоп', (85, 130, 155), size=58)
seamoth_type.image = generate_detailed_seamoth_vehicle_sprite(86)
cyclops_type.image = generate_detailed_cyclops_vehicle_sprite(180)
neptune_platform = buildable('Пусковая платформа «Нептуна»', (120, 135, 145), size=48)
neptune_gantry = buildable('Башня подъёмника «Нептуна»', (135, 140, 150), size=48)
neptune_boosters = buildable('Ускорители «Нептуна»', (180, 115, 65), size=48)
neptune_fuel = buildable('Топливные баки «Нептуна»', (165, 95, 70), size=48)
neptune_cockpit = buildable('Кабина «Нептуна»', (95, 155, 180), size=48)
for _deployable in (beacon_type, mobile_vehicle_bay, grav_trap, waterproof_locker, seamoth_type, prawn_suit_type, cyclops_type, neptune_platform, neptune_gantry, neptune_boosters, neptune_fuel, neptune_cockpit):
_deployable.requires_habitat_builder = False
purple_tablet = tech('Фиолетовая скрижаль', (165, 90, 220))
orange_tablet = tech('Оранжевая скрижаль', (235, 145, 55))
blue_tablet = tech('Синяя скрижаль', (70, 145, 235))
multipurpose_room = buildable('Многоцелевая комната', (95, 145, 160), size=38)
hatch_type = buildable('Люк', (130, 165, 175))
window_type = buildable('Окно', (125, 195, 215))
foundation_type = buildable('Фундамент', (115, 125, 130), size=36)
i_compartment = buildable('Прямой отсек', (100, 145, 155), size=34)
l_compartment = buildable('Угловой отсек', (100, 145, 155), size=34)
t_compartment = buildable('T-образный отсек', (100, 145, 155), size=34)
x_compartment = buildable('Крестообразный отсек', (100, 145, 155), size=34)
reinforcement_type = buildable('Усиление корпуса', (95, 105, 115), wall=True)
glass_i_compartment = buildable('Прямой стеклянный отсек', (130, 205, 225), size=34)
glass_l_compartment = buildable('Угловой стеклянный отсек', (130, 205, 225), size=34)
observatory_type = buildable('Обсерватория', (120, 205, 225), size=38)
vertical_connector = buildable('Вертикальный соединитель', (105, 150, 160), size=32)
moonpool_type = buildable('Стыковочная шахта', (75, 135, 160), size=42)
scanner_room_type = buildable('Комната сканирования', (85, 155, 170), size=40)
large_room_type = buildable('Большая комната', (90, 135, 150), size=46)
multipurpose_dome = buildable('Стеклянный купол многоцелевой комнаты', (145, 215, 230), size=38)
large_room_dome = buildable('Стеклянный купол большой комнаты', (145, 215, 230), size=44)
spotlight_type = buildable('Прожектор', (235, 230, 160), size=25)
solar_panel_type = buildable('Солнечная панель', (65, 110, 180), size=30)
power_transmitter = buildable('Энергопередатчик', (110, 175, 195), size=24)
thermal_plant = buildable('Теплоэлектростанция', (180, 105, 70), size=30)
exterior_growbed = buildable('Наружная грядка', (105, 125, 80), size=32)
floodlight_type = buildable('Осветительная мачта', (230, 225, 170), size=26)
base_air_pump = buildable('Стационарный воздушный насос', (120, 175, 195), size=26)
ladder_type = buildable('Лестница', (150, 155, 160), size=25)
bulkhead_type = buildable('Переборка', (125, 135, 140), wall=True)
vehicle_upgrade_console = buildable('Консоль улучшения транспорта', (75, 145, 185), size=28)
water_filtration_machine = buildable('Водоочистная станция', (110, 165, 185), size=30)
alien_containment = buildable('Большой аквариум', (100, 175, 195), size=34)
bioreactor = buildable('Биореактор', (100, 165, 95), size=32)
nuclear_reactor = buildable('Ядерный реактор', (145, 165, 80), size=34)
large_partition_door = buildable('Дверь переборки большой комнаты', (125, 135, 145), wall=True)
large_partition = buildable('Переборка большой комнаты', (115, 125, 135), wall=True)
modification_station = buildable('Модификационная станция', (100, 150, 195), size=30)
fabricator_type = buildable('Изготовитель', (95, 165, 190), size=28)
aquarium_type = buildable('Аквариум', (95, 175, 200), size=28)
locker_type = buildable('Шкаф', (125, 145, 155), size=28)
wall_locker = buildable('Настенный шкаф', (135, 150, 160), wall=True, size=26)
plant_pot = buildable('Горшок для растений', (130, 100, 75), size=24)
indoor_growbed = buildable('Внутренняя грядка', (120, 105, 75), size=30)
plant_shelf = buildable('Стеллаж для растений', (130, 115, 85), size=26)
radio_type = buildable('Радио', (95, 135, 170), size=24)
composite_pot = buildable('Композитный горшок', (115, 95, 80), size=24)
chic_pot = buildable('Роскошный горшок', (145, 115, 95), size=24)
medical_fabricator = buildable('Изготовитель аптечек', (210, 220, 220), size=26)
battery_charger = buildable('Зарядное устройство батарей', (100, 160, 185), size=28)
power_cell_charger = buildable('Зарядное устройство энергоячеек', (90, 150, 180), size=30)
centrifuge = buildable('Центрифуга', (120, 150, 165), size=28)
bench_type = buildable('Скамья', (130, 115, 100), size=28)
picture_frame = buildable('Фоторамка', (100, 110, 120), wall=True, size=22)
wall_shelf = buildable('Настенная полка', (120, 120, 125), wall=True, size=24)
bed_type = buildable('Кровать', (145, 125, 115), size=30)
desk_type = buildable('Рабочий стол', (115, 120, 125), size=30)
chair_type = buildable('Офисный стул', (95, 100, 105), size=24)
vending_machine = buildable('Торговый автомат', (130, 145, 155), size=28)
coffee_machine = buildable('Кофейный торговый автомат', (120, 105, 95), size=26)
trash_can = buildable('Урна', (100, 110, 115), size=24)
nuclear_waste = buildable('Утилизатор ядерных отходов', (145, 155, 75), size=26)
seamoth_depth1 = tech('Модуль глубины «Мотылька» – Мод. 1')
seamoth_depth2 = tech('Модуль глубины «Мотылька» – Мод. 2')
seamoth_depth3 = tech('Модуль глубины «Мотылька» – Мод. 3')
seamoth_solar = tech('Солнечная зарядка «Мотылька»')
vehicle_storage = tech('Модуль хранилища')
seamoth_perimeter = tech('Система периметральной защиты «Мотылька»')
hull_reinforcement = tech('Усиление корпуса транспорта')
seamoth_torpedo = tech('Торпедная установка «Мотылька»')
seamoth_sonar = tech('Гидролокатор «Мотылька»')
vortex_torpedo = tech('Вихревая торпеда')
gas_torpedo = tech('Газовая торпеда')
vehicle_engine_eff = tech('Модуль эффективности двигателя')
prawn_depth1 = tech('Модуль глубины костюма КРАБ – Мод. 1')
prawn_depth2 = tech('Модуль глубины костюма КРАБ – Мод. 2')
prawn_jet = tech('Улучшение реактивного ранца костюма КРАБ')
prawn_drill = tech('Бур костюма КРАБ')
prawn_thermal = tech('Тепловой реактор костюма КРАБ')
prawn_propulsion = tech('Пропульсионная пушка костюма КРАБ')
prawn_grapple = tech('Рука-захват костюма КРАБ')
prawn_torpedo = tech('Торпедная установка костюма КРАБ')
scanner_hud = tech('Чип интерфейса комнаты сканирования')
scanner_range = tech('Улучшение дальности сканирования')
scanner_speed = tech('Улучшение скорости сканирования')
camera_drone = tech('Дрон-камера')
cyclops_engine_eff = tech('Модуль эффективности двигателя «Циклопа»')
cyclops_depth1 = tech('Модуль глубины «Циклопа» – Мод. 1')
cyclops_depth2 = tech('Модуль глубины «Циклопа» – Мод. 2')
cyclops_depth3 = tech('Модуль глубины «Циклопа» – Мод. 3')
cyclops_shield = tech('Генератор щита «Циклопа»')
cyclops_sonar = tech('Гидролокатор «Циклопа»')
cyclops_repair = tech('Ремонтный модуль стыковочного отсека «Циклопа»')
cyclops_decoy = tech('Улучшение камеры приманок «Циклопа»')
cyclops_fire = tech('Система пожаротушения «Циклопа»')
cyclops_thermal = tech('Модуль теплового реактора «Циклопа»')
wall_type = ObjectType('Стена', (150, 75, 0), 30, 'rect', is_buildable=True, is_wall=True)
wall_type.image = generate_building_sprite('Стена', 30, (150, 75, 0), wall=True)
floor_type = ObjectType('Пол', (100, 150, 100), 30, 'rect', is_buildable=True, is_wall=False)
floor_type.image = generate_building_sprite('Пол', 30, (100, 150, 100), wall=False)
laser_pistol = ObjectType('Лазерный пистолет', (255, 50, 50), 22, 'rect', is_weapon=True, damage=8, weapon_range=5, weapon_type='лазер')
laser_pistol.image = generate_weapon_sprite(14, (255, 50, 50), (120, 0, 0))
underwater_rifle = ObjectType('Подводное ружьё', (50, 150, 255), 28, 'rect', is_weapon=True, damage=12, weapon_range=8, weapon_type='ружьё')
underwater_rifle.image = generate_weapon_sprite(24, (50, 150, 255), (0, 50, 130))
plasma_cannon = ObjectType('Плазменная пушка', (200, 50, 200), 26, 'rect', is_weapon=True, damage=20, weapon_range=4, weapon_type='плазма')
plasma_cannon.image = generate_weapon_sprite(18, (200, 50, 200), (90, 0, 110))
lang_cannon = ObjectType('Дальняя пушка', (10, 50, 200), 30, 'rect', is_weapon=True, damage=1, weapon_range=15, weapon_type='ружьё')
lang_cannon.image = generate_weapon_sprite(34, (10, 50, 200), (0, 15, 90))
schweer_cannon = ObjectType('Тяжёлая пушка', (238, 51, 32), 34, 'rect', is_weapon=True, damage=50, weapon_range=10, weapon_type='ружьё')
schweer_cannon.image = generate_weapon_sprite(38, (238, 51, 32), (110, 15, 10))
diamond_cutter = ObjectType('Алмазный резак', (150, 255, 255), 30, 'rect', is_weapon=True, damage=35, weapon_range=6, weapon_type='лазер')
diamond_cutter.image = generate_weapon_sprite(22, (150, 255, 255), (0, 130, 150))
flamethrower = ObjectType('Огнемёт', (255, 120, 20), 32, 'rect', is_weapon=True, damage=15, weapon_range=3, weapon_type='огонь')
flamethrower.image = generate_weapon_sprite(30, (255, 120, 20), (120, 40, 5))
golden_pistol = ObjectType('Золотой пистолет', (255, 215, 0), 24, 'rect', is_weapon=True, damage=14, weapon_range=7, weapon_type='лазер')
golden_pistol.image = generate_weapon_sprite(16, (255, 215, 0), (140, 100, 0))
fabricator_type.crafting_station_name = 'Изготовитель'
modification_station.crafting_station_name = 'Модификационная станция'
mobile_vehicle_bay.crafting_station_name = 'Переносной сборщик транспорта'
vehicle_upgrade_console.crafting_station_name = 'Консоль улучшения транспорта'
scanner_room_type.crafting_station_name = 'Комната сканирования'
cyclops_type.crafting_station_name = 'Циклоп'
F = 'Изготовитель'
H = 'Строитель'
M = 'Модификационная станция'
V = 'Переносной сборщик транспорта'
U = 'Консоль улучшения транспорта'
S = 'Комната сканирования'
C = 'Циклоп'
recipes = [Recipe('Титан x4', {metal_salvage_type: 1}, titan_type, 4, F, 'Ресурсы'), Recipe('Титановый слиток', {titan_type: 10}, titanium_ingot, 1, F, 'Основные материалы'), Recipe('Сетчатое волокно', {creepvine_sample_type: 2}, fiber_mesh, 1, F, 'Основные материалы'), Recipe('Силиконовая резина x2', {creepvine_seed_type: 1}, silicone_rubber, 2, F, 'Основные материалы'), Recipe('Стекло', {quartz_type: 2}, glass_type, 1, F, 'Основные материалы'), Recipe('Антисептик', {salt_type: 1, coral_tube_type: 1}, bleach_type, 1, F, 'Основные материалы'), Recipe('Смазка', {creepvine_seed_type: 1}, lubricant_type, 1, F, 'Основные материалы'), Recipe('Эмалевое стекло', {glass_type: 1, quartz_type: 1}, enameled_glass, 1, F, 'Основные материалы'), Recipe('Пласталевый слиток', {titanium_ingot: 1, lithium_type: 2}, plasteel_ingot, 1, F, 'Основные материалы'), Recipe('Соляная кислота', {deep_shroom_item: 3, salt_type: 1}, hydrochloric_acid, 1, F, 'Доп. материалы'), Recipe('Бензол', {blood_oil_type: 3}, benzene_type, 1, F, 'Доп. материалы'), Recipe('Синтетические волокна', {benzene_type: 1, fiber_mesh: 1}, synthetic_fibers, 1, F, 'Доп. материалы'), Recipe('Аэрогель', {ruby_type: 1, gel_sack_type: 1}, aerogel_type, 1, F, 'Доп. материалы'), Recipe('Полианилин', {gold_type: 1, hydrochloric_acid: 1}, polyaniline_type, 1, F, 'Доп. материалы'), Recipe('Медный провод', {copper_type: 2}, copper_wire, 1, F, 'Электроника'), Recipe('Батарея', {acid_mushroom_item: 2, copper_type: 1}, battery_type, 1, F, 'Электроника'), Recipe('Энергоячейка', {battery_type: 2, silicone_rubber: 1}, power_cell_type, 1, F, 'Электроника'), Recipe('Микросхема', {table_coral_type: 2, gold_type: 1, copper_wire: 1}, computer_chip, 1, F, 'Электроника'), Recipe('Комплект проводов', {silver_type: 2}, wiring_kit, 1, F, 'Электроника'), Recipe('Расширенный комплект проводов', {wiring_kit: 1, gold_type: 2, computer_chip: 1}, advanced_wiring_kit, 1, F, 'Электроника'), Recipe('Топливный стержень реактора', {uraninite_type: 3, lead_type: 1, titan_type: 1, glass_type: 1}, reactor_rod, 1, F, 'Электроника'), Recipe('Ионная батарея', {ion_cube_type: 1, gold_type: 1, silver_type: 1}, ion_battery, 1, F, 'Электроника'), Recipe('Ионная энергоячейка', {ion_battery: 2, silicone_rubber: 1}, ion_power_cell, 1, F, 'Электроника'), Recipe('Ласты', {silicone_rubber: 2}, fins_type, 1, F, 'Снаряжение'), Recipe('Обычный кислородный баллон', {titan_type: 3}, standard_tank, 1, F, 'Снаряжение'), Recipe('Баллон высокой ёмкости', {standard_tank: 1, glass_type: 2, titan_type: 4, silver_type: 1}, high_capacity_tank, 1, F, 'Снаряжение'), Recipe('Ребризер', {wiring_kit: 1, fiber_mesh: 1}, rebreather_type, 1, F, 'Снаряжение'), Recipe('Противорадиационный костюм', {fiber_mesh: 2, lead_type: 2}, radiation_suit, 1, F, 'Снаряжение'), Recipe('Укреплённый костюм ныряльщика', {synthetic_fibers: 1, diamond_type: 2, titan_type: 2}, reinforced_suit, 1, F, 'Снаряжение'), Recipe('Фильтрационный костюм', {synthetic_fibers: 1, aerogel_type: 1}, water_filtration_suit, 1, F, 'Снаряжение'), Recipe('Компас', {copper_wire: 1, wiring_kit: 1}, compass_type, 1, F, 'Снаряжение'), Recipe('Аптечка', {fiber_mesh: 1}, first_aid_kit, 1, F, 'Снаряжение'), Recipe('Шприц с антибиотиком', {bleach_type: 1, gel_sack_type: 1, glass_type: 1}, antibiotic_syringe, 1, F, 'Снаряжение'), Recipe('Огнетушитель', {titan_type: 3}, fire_extinguisher, 1, F, 'Снаряжение'), Recipe('Плавучий воздушный насос', {titan_type: 2}, floating_air_pump, 1, F, 'Снаряжение'), Recipe('Труба', {titan_type: 2}, pipe_type, 5, F, 'Снаряжение'), Recipe('Нож', {silicone_rubber: 1, titan_type: 1}, survival_knife, 1, F, 'Инструменты'), Recipe('Сканер', {battery_type: 1, titan_type: 1}, scanner_type, 1, F, 'Инструменты'), Recipe('Ремонтный инструмент', {silicone_rubber: 1, cave_sulfur_type: 1, titan_type: 1}, repair_tool, 1, F, 'Инструменты'), Recipe('Ремкомплект', {titan_type: 2, silicone_rubber: 1, cave_sulfur_type: 1}, repair_kit, 1, F, 'Инструменты'), Recipe('Фонарик', {battery_type: 1, glass_type: 1}, flashlight_type, 1, F, 'Инструменты'), Recipe('Строитель', {computer_chip: 1, wiring_kit: 1, battery_type: 1}, habitat_builder_type, 1, F, 'Инструменты'), Recipe('Воздушный пузырь', {silicone_rubber: 1, gel_sack_type: 1, titan_type: 1}, air_bladder, 1, F, 'Инструменты'), Recipe('Инструмент исследователя', {creepvine_sample_type: 1, copper_wire: 1, titan_type: 1}, pathfinder_tool, 1, F, 'Инструменты'), Recipe('Пропульсионная пушка', {wiring_kit: 1, battery_type: 1, titan_type: 1}, propulsion_cannon, 1, F, 'Инструменты'), Recipe('Стазис-винтовка', {computer_chip: 1, battery_type: 1, magnetite_type: 2}, stasis_rifle, 1, F, 'Инструменты'), Recipe('Лазерный резак', {diamond_type: 2, battery_type: 1, titan_type: 1, cave_sulfur_type: 1}, laser_cutter, 1, F, 'Инструменты'), Recipe('Сигнальная ракета', {cave_sulfur_type: 1}, flare_type, 5, F, 'Инструменты'), Recipe('Радиомаяк', {copper_wire: 1, titan_type: 1}, beacon_type, 1, F, 'Размещаемое'), Recipe('Приманка для существ', {titan_type: 3, wiring_kit: 1}, creature_decoy, 1, F, 'Размещаемое'), Recipe('Морской глайдер', {battery_type: 1, lubricant_type: 1, copper_wire: 1, titan_type: 1}, seaglide_type, 1, F, 'Размещаемое'), Recipe('Переносной сборщик транспорта', {titanium_ingot: 1, lubricant_type: 1, power_cell_type: 1}, mobile_vehicle_bay, 1, F, 'Размещаемое'), Recipe('Грави-ловушка', {battery_type: 1, copper_wire: 1, titan_type: 1}, grav_trap, 1, F, 'Размещаемое'), Recipe('Водостойкий контейнер', {titan_type: 4}, waterproof_locker, 1, F, 'Размещаемое'), Recipe('Фиолетовая скрижаль', {ion_cube_type: 1, diamond_type: 2}, purple_tablet, 1, F, 'Предтечи'), Recipe('Оранжевая скрижаль', {ion_cube_type: 1, nickel_type: 2}, orange_tablet, 1, F, 'Предтечи'), Recipe('Синяя скрижаль', {ion_cube_type: 1, kyanite_type: 2}, blue_tablet, 1, F, 'Предтечи'), Recipe('Термоклинок', {survival_knife: 1, battery_type: 1}, thermoblade, 1, M, 'Модификации'), Recipe('Лёгкий баллон высокой ёмкости', {high_capacity_tank: 1, plasteel_ingot: 1}, lightweight_tank, 1, M, 'Модификации'), Recipe('Баллон сверхвысокой ёмкости', {high_capacity_tank: 1, lithium_type: 4}, ultra_tank, 1, M, 'Модификации'), Recipe('Репульсионная пушка', {propulsion_cannon: 1, computer_chip: 1, magnetite_type: 2}, repulsion_cannon, 1, M, 'Модификации'), Recipe('Модуль глубины «Мотылька» – Мод. 2', {seamoth_depth1: 1, plasteel_ingot: 1, magnetite_type: 2, enameled_glass: 1}, seamoth_depth2, 1, M, 'Модификации'), Recipe('Модуль глубины «Мотылька» – Мод. 3', {seamoth_depth2: 1, plasteel_ingot: 1, ruby_type: 3}, seamoth_depth3, 1, M, 'Модификации'), Recipe('Модуль глубины костюма КРАБ – Мод. 2', {prawn_depth1: 1, titan_type: 5, lithium_type: 2, kyanite_type: 3}, prawn_depth2, 1, M, 'Модификации'), Recipe('Модуль глубины «Циклопа» – Мод. 2', {cyclops_depth1: 1, plasteel_ingot: 1, nickel_type: 3}, cyclops_depth2, 1, M, 'Модификации'), Recipe('Модуль глубины «Циклопа» – Мод. 3', {cyclops_depth2: 1, plasteel_ingot: 1, kyanite_type: 3}, cyclops_depth3, 1, M, 'Модификации'), Recipe('Мотылёк', {titanium_ingot: 1, power_cell_type: 1, glass_type: 2, lubricant_type: 1, lead_type: 1}, seamoth_type, 1, V, 'Транспорт'), Recipe('Костюм КРАБ', {plasteel_ingot: 2, aerogel_type: 2, enameled_glass: 1, diamond_type: 2, lead_type: 2}, prawn_suit_type, 1, V, 'Транспорт'), Recipe('Циклоп', {plasteel_ingot: 3, enameled_glass: 3, lubricant_type: 1, advanced_wiring_kit: 1, lead_type: 3}, cyclops_type, 1, V, 'Транспорт'), Recipe('Пусковая платформа «Нептуна»', {titanium_ingot: 2, computer_chip: 1, lead_type: 4}, neptune_platform, 1, V, 'Нептун'), Recipe('Башня подъёмника «Нептуна»', {plasteel_ingot: 1, copper_wire: 1, lubricant_type: 1}, neptune_gantry, 1, V, 'Нептун'), Recipe('Ускорители «Нептуна»', {plasteel_ingot: 1, nickel_type: 3, wiring_kit: 1, aerogel_type: 1}, neptune_boosters, 1, V, 'Нептун'), Recipe('Топливные баки «Нептуна»', {plasteel_ingot: 1, ion_power_cell: 4, kyanite_type: 4, cave_sulfur_type: 2}, neptune_fuel, 1, V, 'Нептун'), Recipe('Кабина «Нептуна»', {cyclops_shield: 1, plasteel_ingot: 1, enameled_glass: 1, computer_chip: 1}, neptune_cockpit, 1, V, 'Нептун'), Recipe('Модуль эффективности двигателя', {computer_chip: 1, polyaniline_type: 1}, vehicle_engine_eff, 1, U, 'Улучшения транспорта'), Recipe('Солнечная зарядка «Мотылька»', {advanced_wiring_kit: 1, enameled_glass: 1}, seamoth_solar, 1, U, 'Улучшения транспорта'), Recipe('Модуль хранилища', {titan_type: 3, lithium_type: 1}, vehicle_storage, 1, U, 'Улучшения транспорта'), Recipe('Система периметральной защиты «Мотылька»', {polyaniline_type: 1, wiring_kit: 1}, seamoth_perimeter, 1, U, 'Улучшения транспорта'), Recipe('Усиление корпуса транспорта', {titan_type: 3, lithium_type: 1, diamond_type: 1}, hull_reinforcement, 1, U, 'Улучшения транспорта'), Recipe('Торпедная установка «Мотылька»', {titan_type: 3, lithium_type: 1, aerogel_type: 1}, seamoth_torpedo, 1, U, 'Улучшения транспорта'), Recipe('Гидролокатор «Мотылька»', {copper_wire: 1, magnetite_type: 2}, seamoth_sonar, 1, U, 'Улучшения транспорта'), Recipe('Вихревая торпеда', {titan_type: 1, magnetite_type: 1}, vortex_torpedo, 2, U, 'Боеприпасы'), Recipe('Модуль глубины «Мотылька» – Мод. 1', {titanium_ingot: 1, glass_type: 2}, seamoth_depth1, 1, U, 'Улучшения транспорта'), Recipe('Улучшение реактивного ранца костюма КРАБ', {nickel_type: 2, cave_sulfur_type: 1, titan_type: 1, lithium_type: 1}, prawn_jet, 1, U, 'КРАБ'), Recipe('Бур костюма КРАБ', {titan_type: 5, lithium_type: 1, diamond_type: 4}, prawn_drill, 1, U, 'КРАБ'), Recipe('Тепловой реактор костюма КРАБ', {kyanite_type: 2, polyaniline_type: 2, wiring_kit: 1}, prawn_thermal, 1, U, 'КРАБ'), Recipe('Пропульсионная пушка костюма КРАБ', {computer_chip: 1, titan_type: 5, lithium_type: 1, magnetite_type: 2}, prawn_propulsion, 1, U, 'КРАБ'), Recipe('Рука-захват костюма КРАБ', {advanced_wiring_kit: 1, titan_type: 5, lithium_type: 1, benzene_type: 1}, prawn_grapple, 1, U, 'КРАБ'), Recipe('Торпедная установка костюма КРАБ', {titan_type: 5, lithium_type: 1, aerogel_type: 1}, prawn_torpedo, 1, U, 'КРАБ'), Recipe('Модуль глубины костюма КРАБ – Мод. 1', {plasteel_ingot: 1, nickel_type: 3, ruby_type: 2}, prawn_depth1, 1, U, 'КРАБ'), Recipe('Чип интерфейса комнаты сканирования', {computer_chip: 1, magnetite_type: 1}, scanner_hud, 1, S, 'Комната сканирования'), Recipe('Улучшение дальности сканирования', {copper_type: 1, magnetite_type: 1}, scanner_range, 1, S, 'Комната сканирования'), Recipe('Улучшение скорости сканирования', {silver_type: 1, gold_type: 1}, scanner_speed, 1, S, 'Комната сканирования'), Recipe('Дрон-камера', {computer_chip: 1, battery_type: 1, glass_type: 1, titan_type: 1}, camera_drone, 1, S, 'Комната сканирования'), Recipe('Модуль эффективности двигателя «Циклопа»', {computer_chip: 1, benzene_type: 1, polyaniline_type: 1}, cyclops_engine_eff, 1, C, 'Циклоп'), Recipe('Модуль глубины «Циклопа» – Мод. 1', {plasteel_ingot: 1, ruby_type: 3}, cyclops_depth1, 1, C, 'Циклоп'), Recipe('Генератор щита «Циклопа»', {advanced_wiring_kit: 1, polyaniline_type: 1, power_cell_type: 1}, cyclops_shield, 1, C, 'Циклоп'), Recipe('Гидролокатор «Циклопа»', {computer_chip: 1, magnetite_type: 3}, cyclops_sonar, 1, C, 'Циклоп'), Recipe('Ремонтный модуль стыковочного отсека «Циклопа»', {repair_tool: 1, copper_wire: 1}, cyclops_repair, 1, C, 'Циклоп'), Recipe('Улучшение камеры приманок «Циклопа»', {titan_type: 3, lithium_type: 1}, cyclops_decoy, 1, C, 'Циклоп'), Recipe('Система пожаротушения «Циклопа»', {aerogel_type: 2, cave_sulfur_type: 2}, cyclops_fire, 1, C, 'Циклоп'), Recipe('Модуль теплового реактора «Циклопа»', {kyanite_type: 4, polyaniline_type: 2, wiring_kit: 1}, cyclops_thermal, 1, C, 'Циклоп'), Recipe('Многоцелевая комната', {titan_type: 6}, multipurpose_room, 1, H, 'Детали базы'), Recipe('Люк', {quartz_type: 1, titan_type: 2}, hatch_type, 1, H, 'Детали базы'), Recipe('Окно', {glass_type: 1}, window_type, 1, H, 'Детали базы'), Recipe('Фундамент', {titan_type: 2, lead_type: 2}, foundation_type, 1, H, 'Детали базы'), Recipe('Прямой отсек', {titan_type: 2}, i_compartment, 1, H, 'Детали базы'), Recipe('Угловой отсек', {titan_type: 2}, l_compartment, 1, H, 'Детали базы'), Recipe('T-образный отсек', {titan_type: 3}, t_compartment, 1, H, 'Детали базы'), Recipe('Крестообразный отсек', {titan_type: 3}, x_compartment, 1, H, 'Детали базы'), Recipe('Усиление корпуса', {lithium_type: 1, titan_type: 3}, reinforcement_type, 1, H, 'Детали базы'), Recipe('Прямой стеклянный отсек', {glass_type: 2}, glass_i_compartment, 1, H, 'Детали базы'), Recipe('Угловой стеклянный отсек', {glass_type: 2}, glass_l_compartment, 1, H, 'Детали базы'), Recipe('Обсерватория', {enameled_glass: 2, titan_type: 1}, observatory_type, 1, H, 'Детали базы'), Recipe('Вертикальный соединитель', {titan_type: 2}, vertical_connector, 1, H, 'Детали базы'), Recipe('Стыковочная шахта', {titanium_ingot: 2, lubricant_type: 1, lead_type: 2}, moonpool_type, 1, H, 'Детали базы'), Recipe('Комната сканирования', {titan_type: 5, copper_type: 2, gold_type: 1, table_coral_type: 1}, scanner_room_type, 1, H, 'Детали базы'), Recipe('Большая комната', {plasteel_ingot: 2}, large_room_type, 1, H, 'Детали базы'), Recipe('Стеклянный купол многоцелевой комнаты', {enameled_glass: 2, titan_type: 2, lithium_type: 1}, multipurpose_dome, 1, H, 'Детали базы'), Recipe('Стеклянный купол большой комнаты', {enameled_glass: 4, titan_type: 4, lithium_type: 2}, large_room_dome, 1, H, 'Детали базы'), Recipe('Прожектор', {glass_type: 1, titan_type: 2}, spotlight_type, 1, H, 'Внешние модули'), Recipe('Солнечная панель', {quartz_type: 2, titan_type: 2, copper_type: 1}, solar_panel_type, 1, H, 'Внешние модули'), Recipe('Энергопередатчик', {gold_type: 1, titan_type: 1}, power_transmitter, 1, H, 'Внешние модули'), Recipe('Теплоэлектростанция', {titan_type: 5, magnetite_type: 2, aerogel_type: 1}, thermal_plant, 1, H, 'Внешние модули'), Recipe('Наружная грядка', {titan_type: 2}, exterior_growbed, 1, H, 'Внешние модули'), Recipe('Осветительная мачта', {glass_type: 1, titan_type: 1}, floodlight_type, 1, H, 'Внешние модули'), Recipe('Стационарный воздушный насос', {titan_type: 2}, base_air_pump, 1, H, 'Внешние модули'), Recipe('Лестница', {titan_type: 2}, ladder_type, 1, H, 'Внутренние элементы'), Recipe('Переборка', {titan_type: 3, silicone_rubber: 1}, bulkhead_type, 1, H, 'Внутренние элементы'), Recipe('Консоль улучшения транспорта', {titan_type: 3, computer_chip: 1, copper_wire: 1}, vehicle_upgrade_console, 1, H, 'Внутренние элементы'), Recipe('Водоочистная станция', {titan_type: 3, copper_wire: 1, aerogel_type: 1}, water_filtration_machine, 1, H, 'Внутренние элементы'), Recipe('Большой аквариум', {glass_type: 5, titan_type: 2}, alien_containment, 1, H, 'Внутренние элементы'), Recipe('Биореактор', {titan_type: 3, wiring_kit: 1, lubricant_type: 1}, bioreactor, 1, H, 'Внутренние элементы'), Recipe('Ядерный реактор', {plasteel_ingot: 3, advanced_wiring_kit: 1, lead_type: 3}, nuclear_reactor, 1, H, 'Внутренние элементы'), Recipe('Дверь переборки большой комнаты', {titan_type: 1}, large_partition_door, 1, H, 'Внутренние элементы'), Recipe('Переборка большой комнаты', {titan_type: 1}, large_partition, 1, H, 'Внутренние элементы'), Recipe('Модификационная станция', {computer_chip: 1, titan_type: 1, diamond_type: 1, lead_type: 1}, modification_station, 1, H, 'Внутренние модули'), Recipe('Изготовитель', {titan_type: 1, gold_type: 1, table_coral_type: 1}, fabricator_type, 1, H, 'Внутренние модули'), Recipe('Аквариум', {glass_type: 2, titan_type: 1}, aquarium_type, 1, H, 'Внутренние модули'), Recipe('Шкаф', {quartz_type: 1, titan_type: 2}, locker_type, 1, H, 'Внутренние модули'), Recipe('Настенный шкаф', {titan_type: 2}, wall_locker, 1, H, 'Внутренние модули'), Recipe('Горшок для растений', {titan_type: 2}, plant_pot, 1, H, 'Внутренние модули'), Recipe('Внутренняя грядка', {titan_type: 2}, indoor_growbed, 1, H, 'Внутренние модули'), Recipe('Стеллаж для растений', {titan_type: 1}, plant_shelf, 1, H, 'Внутренние модули'), Recipe('Радио', {titan_type: 1, copper_type: 1}, radio_type, 1, H, 'Внутренние модули'), Recipe('Композитный горшок', {titan_type: 2}, composite_pot, 1, H, 'Внутренние модули'), Recipe('Роскошный горшок', {titan_type: 2}, chic_pot, 1, H, 'Внутренние модули'), Recipe('Изготовитель аптечек', {computer_chip: 1, fiber_mesh: 1, silver_type: 1, titan_type: 1}, medical_fabricator, 1, H, 'Внутренние модули'), Recipe('Зарядное устройство батарей', {wiring_kit: 1, copper_wire: 1, titan_type: 1}, battery_charger, 1, H, 'Внутренние модули'), Recipe('Зарядное устройство энергоячеек', {advanced_wiring_kit: 1, ruby_type: 2, titan_type: 2}, power_cell_charger, 1, H, 'Внутренние модули'), Recipe('Центрифуга', {titan_type: 2, wiring_kit: 1}, centrifuge, 1, H, 'Внутренние модули'), Recipe('Скамья', {titan_type: 2}, bench_type, 1, H, 'Мебель'), Recipe('Фоторамка', {copper_wire: 1}, picture_frame, 1, H, 'Мебель'), Recipe('Настенная полка', {titan_type: 1}, wall_shelf, 1, H, 'Мебель'), Recipe('Кровать', {titan_type: 2, fiber_mesh: 1}, bed_type, 1, H, 'Мебель'), Recipe('Рабочий стол', {titan_type: 1}, desk_type, 1, H, 'Мебель'), Recipe('Офисный стул', {titan_type: 1}, chair_type, 1, H, 'Мебель'), Recipe('Торговый автомат', {glass_type: 1, titan_type: 2}, vending_machine, 1, H, 'Мебель'), Recipe('Кофейный торговый автомат', {titan_type: 2}, coffee_machine, 1, H, 'Мебель'), Recipe('Урна', {titan_type: 2}, trash_can, 1, H, 'Мебель'), Recipe('Утилизатор ядерных отходов', {titan_type: 2}, nuclear_waste, 1, H, 'Мебель'), Recipe('2D-стена', {rock_type: 2, limestone_type: 2}, wall_type, 1, F, '2D-совместимость'), Recipe('2D-пол', {limestone_type: 1, sand_type: 2, clay_type: 1}, floor_type, 1, F, '2D-совместимость')]
biomes = []
AURORA_HULL = (78, 72, 62)
AURORA_WALL = (102, 94, 82)
AURORA_NOSE = (60, 55, 46)
AURORA_ENGINE = (58, 48, 42)
AURORA_REACTOR = (72, 48, 54)
AURORA_FLOOR = (86, 80, 70)
AURORA_ADMIN = (70, 82, 92)
AURORA_CARGO = (93, 78, 58)
AURORA_DOCK = (60, 78, 92)
AURORA_LIVING = (92, 72, 67)
AURORA_LAB = (66, 78, 82)
AURORA_BLACKBOX = (50, 58, 68)
AURORA_SHIFT_X = 550
def aurora_rect(rect):
x1, y1, x2, y2 = rect
return (x1 + AURORA_SHIFT_X, y1, x2 + AURORA_SHIFT_X, y2)
def add_aurora_floor(name, rect, color=AURORA_FLOOR, objects=None, priority=120):
if objects is None:
objects = []
biome = Biome(name, color, *aurora_rect(rect), list(objects), [], passable=True, cave_enabled=False, priority=priority)
biome.aurora_style = True
biome.location_kind = 'aurora_room'
biomes.append(biome)
return biome
def add_aurora_wall(name, rect, color=AURORA_WALL, priority=170):
biome = Biome(name, color, *aurora_rect(rect), [], [], passable=False, cave_enabled=False, priority=priority)
biome.aurora_style = True
biome.location_kind = 'aurora_wall'
biomes.append(biome)
return biome
def add_aurora_passage(name, rect, color=AURORA_FLOOR, priority=185):
return add_aurora_floor(name, rect, color=color, objects=[], priority=priority)
add_aurora_floor('Внутренние отсеки Авроры', (196, 381, 299, 649), AURORA_FLOOR, priority=100)
for rect in [(247, 351, 253, 356), (241, 357, 259, 362), (234, 363, 266, 368), (227, 369, 273, 374), (221, 375, 279, 380)]:
add_aurora_floor('Разорванная носовая часть Авроры', rect, AURORA_FLOOR, priority=105)
AURORA_ROOMS = [('Входной коридор Авроры / Airlock', (238, 370, 260, 392), AURORA_ADMIN), ('Коридор администрации Авроры', (214, 390, 260, 404), AURORA_ADMIN), ('Кабинет администрации Авроры', (200, 402, 234, 426), AURORA_ADMIN), ('Обрушенный коридор Авроры к грузовому отсеку', (238, 402, 263, 434), AURORA_HULL), ('Коридор грузового отсека Авроры', (235, 426, 265, 448), AURORA_CARGO), ('Главный грузовой отсек Авроры', (202, 444, 292, 500), AURORA_CARGO), ('Грузовой подъёмник Авроры', (260, 486, 287, 511), AURORA_CARGO), ('Нижний грузовой отсек Авроры', (218, 498, 288, 522), AURORA_CARGO), ('Четырёхсторонний коридор Авроры', (234, 516, 266, 541), AURORA_FLOOR), ('Отсек «Мотылёк» Авроры', (197, 518, 232, 561), AURORA_DOCK), ('Коридор двигательного отсека Авроры', (263, 518, 282, 551), AURORA_ENGINE), ('Двигательный отсек / реактор Авроры', (273, 538, 298, 586), AURORA_REACTOR), ('Раздевалка Авроры', (232, 538, 263, 563), AURORA_LIVING), ('Отсек костюмов КРАБ / PRAWN Bay Авроры', (197, 562, 272, 611), AURORA_DOCK), ('Пролом корпуса PRAWN Bay Авроры', (270, 582, 293, 610), AURORA_HULL), ('Жилой коридор Авроры', (198, 607, 270, 646), AURORA_LIVING), ('Столовая Авроры', (199, 611, 220, 632), AURORA_LIVING), ('Каюта №1 Авроры', (222, 611, 235, 625), AURORA_LIVING), ('Каюта капитана Авроры', (237, 611, 254, 625), AURORA_LIVING), ('Каюта №3 Авроры', (256, 611, 269, 625), AURORA_LIVING), ('Каюта №4 Авроры', (222, 628, 235, 643), AURORA_LIVING), ('Каюта №6 Авроры', (237, 628, 249, 643), AURORA_LIVING), ('Каюта №7 Авроры', (251, 628, 265, 643), AURORA_LIVING), ('Подводный технический проход Авроры', (266, 604, 282, 636), AURORA_DOCK), ('Верхняя комната чёрного ящика Авроры', (270, 618, 292, 634), AURORA_BLACKBOX), ('Нижняя комната чёрного ящика Авроры', (270, 634, 292, 648), AURORA_BLACKBOX), ('Большая катушка данных Авроры', (258, 634, 274, 648), AURORA_BLACKBOX), ('Лаборатория Авроры', (280, 603, 299, 635), AURORA_LAB), ('Обрушенный коридор лаборатории Авроры', (286, 584, 299, 605), AURORA_HULL), ('Выходной коридор Авроры', (291, 570, 299, 594), AURORA_FLOOR)]
for room_name, room_rect, room_color in AURORA_ROOMS:
# Находки размещаются отдельно по клеткам, а не случайно по площади.
# Уникальные пушки вернутся в свои отсеки вместе с остальным грузом.
aurora_room = add_aurora_floor(room_name, room_rect, room_color, objects=[], priority=125)
aurora_room.aurora_loot_room = True
# Внутренние переборки: каждое помещение обрамлено металлической стеной.
# Двери образуются приоритетными проходимыми накладками.
# Все координаты ниже локальные (до смещения корпуса на восток).
for room_index, (room_name, room_rect, room_color) in enumerate(AURORA_ROOMS):
x1, y1, x2, y2 = room_rect
if x2 - x1 < 5 or y2 - y1 < 5:
continue
for edge in ((x1, y1, x2, y1), (x1, y2, x2, y2),
(x1, y1 + 1, x1, y2 - 1), (x2, y1 + 1, x2, y2 - 1)):
add_aurora_wall('Переборка: ' + room_name, edge, priority=158)
# Широкие дверные проёмы с каждой стороны: проходы соединяют
# помещения между собой и с общими коридорами.
mid_x = (x1 + x2) // 2
mid_y = (y1 + y2) // 2
for edge, passage in (
('север', (mid_x - 1, y1, mid_x + 1, y1)),
('юг', (mid_x - 1, y2, mid_x + 1, y2)),
('запад', (x1, mid_y - 1, x1, mid_y + 1)),
('восток', (x2, mid_y - 1, x2, mid_y + 1))):
px1, py1, px2, py2 = passage
# Не открываем дверные проёмы за пределы основной Авроры.
if px1 <= 196 or px2 >= 299 or py2 >= 649:
continue
add_aurora_passage('Дверь (' + edge + '): ' + room_name,
passage, color=room_color, priority=165)
for rect in [(195, 380, 195, 650), (300, 380, 300, 569), (300, 606, 300, 650), (195, 650, 300, 650), (195, 380, 220, 380), (280, 380, 300, 380)]:
add_aurora_wall('Корпус Авроры', rect, AURORA_HULL)
for rect in [(220, 375, 226, 375), (226, 369, 233, 369), (233, 363, 240, 363), (240, 357, 246, 357), (254, 357, 260, 357), (260, 363, 267, 363), (267, 369, 274, 369), (274, 375, 280, 375)]:
add_aurora_wall('Нос Авроры', rect, AURORA_NOSE)
for rect in [(196, 438, 299, 439), (196, 513, 299, 514), (196, 560, 299, 561), (196, 605, 299, 606)]:
add_aurora_wall('Внутренняя переборка Авроры', rect)
for name, rect, color in [('Верхний вход Авроры', (244, 350, 256, 382), AURORA_FLOOR), ('Дверь грузового отсека Авроры', (245, 437, 256, 441), AURORA_CARGO), ('Проход к нижнему грузовому отсеку Авроры', (245, 512, 258, 516), AURORA_CARGO), ('Проход к PRAWN Bay Авроры', (238, 559, 258, 563), AURORA_DOCK), ('Проход в жилые отсеки Авроры', (204, 604, 218, 608), AURORA_LIVING), ('Проход к чёрному ящику Авроры', (264, 604, 278, 608), AURORA_BLACKBOX), ('Нижний аварийный вход Авроры', (296, 588, 305, 602), AURORA_LAB)]:
add_aurora_passage(name, rect, color=color)
def _ring_xy(radius, angle):
return (500 + math.cos(angle) * radius, 500 + math.sin(angle) * radius)
def _ring_disk(radius, seed=0):
return [_ring_xy(radius * (1.0 + 0.012 * math.sin(j * 3.1 + seed)), j * math.tau / 72) for j in range(72)]
def _ring_sector(inner_radius, outer_radius, a0, a1, seed):
a0 -= 0.009
a1 += 0.009
n = max(7, int((a1 - a0) * 55))
outer = []
inner = []
for j in range(n + 1):
angle = a0 + (a1 - a0) * j / n
wave = 3.0 * math.sin(6 * angle + seed * 0.31) + 2.5 * math.sin(13 * angle + seed * 0.17)
outer.append(_ring_xy(outer_radius + wave, angle))
inner.append(_ring_xy(max(1, inner_radius + wave * 0.45), angle))
return outer + list(reversed(inner))
def _ring_add(name, color, poly, resources=(), fauna=(), cave=False, priority=10, label=''):
b = Biome(name, color, 0, 0, 0, 0, list(resources), list(fauna), passable=True, polygon=poly, map_label=label, cave_enabled=cave, priority=priority)
depth_ranges = {'Мелководье': '0–100 м', 'Глубоководье': '100–500 м', 'Центральное море': '300–1000+ м', 'Побережье': 'суша', 'Острова': 'суша', 'Коралловый риф': '0–300 м', 'Кельповый лес': '0–100 м', 'Био-люминесцентная зона': '300–1000 м', 'Озёра (внешние)': '0–100 м', 'Реки': '0–100 м', 'Притоки': '0–100 м', 'Водопады': '0–100 м'}
b.depth_label = 'суша' if name in LAND_BIOMES else depth_ranges.get(name)
biomes.append(b)
return b
biomes[:] = [b for b in biomes if getattr(b, 'aurora_style', False)]
_RING_RES = {'Мелководье': [(titan_type, 3), (quartz_type, 4), (sand_type, 3), (salt_type, 2)], 'Глубоководье': [(quartz_type, 4), (silver_type, 2), (lithium_type, 2), (lead_type, 2)], 'Центральное море': [(quartz_type, 2), (gold_type, 1), (iron_type, 1)], 'Побережье': [(sand_type, 6), (quartz_type, 3), (titan_type, 2), (limestone_type, 3)], 'Острова': [(rock_type, 4), (iron_type, 3), (silver_type, 2), (lithium_type, 2)], 'Коралловый риф': [(table_coral_type, 4), (coral_tube_type, 3), (quartz_type, 3), (ruby_type, 1)], 'Леса': [(rock_type, 4), (copper_type, 3), (limestone_type, 2), (clay_type, 2)], 'Кельповый лес': [(seaweed_type, 6), (creepvine_seed_type, 3), (salt_type, 2), (copper_type, 2)], 'Био-люминесцентная зона': [(quartz_type, 4), (ruby_type, 2), (nickel_type, 2), (gel_sack_type, 2)], 'Горы': [(rock_type, 6), (lithium_type, 4), (magnetite_type, 3), (diamond_type, 2)], 'Озёра (внешние)': [(quartz_type, 3), (salt_type, 3), (titan_type, 2)], 'Реки': [(quartz_type, 3), (salt_type, 2), (copper_type, 1)], 'Притоки': [(quartz_type, 1)], 'Водопады': [(quartz_type, 2), (silver_type, 1)]}
_ring_add('Центральное море', (7, 22, 86), _ring_disk(222, 15), _RING_RES['Центральное море'], cave=True, priority=7, label='ЦЕНТРАЛЬНОЕ МОРЕ')
SECTORS = 16
for i in range(SECTORS):
a0 = -math.pi + i * math.tau / SECTORS
a1 = -math.pi + (i + 1) * math.tau / SECTORS
_ring_add('Глубоководье', (4, 84 + i % 3 * 8, 161), _ring_sector(212, 272, a0, a1, 42), _RING_RES['Глубоководье'], cave=i == 6, priority=8)
_ring_add('Мелководье', (15, 173 + i % 3 * 6, 213), _ring_sector(263, 311, a0, a1, 21), _RING_RES['Мелководье'], cave=i == 2, priority=10)
_ring_add('Побережье', (213, 189 + i % 4 * 6, 122), _ring_sector(301, 337, a0, a1, 27), _RING_RES['Побережье'], cave=i == 10, priority=12)
forest_color = (31 + 4 * (i % 4), 106 + 9 * (i % 5), 53 + 3 * (i % 3))
_ring_add('Леса', forest_color, _ring_sector(326, 465, a0, a1, 34), _RING_RES['Леса'], cave=i in (3, 5, 12), priority=6)
for j, deg in enumerate((-164, -96, -18, 34, 108, 161)):
a = math.radians(deg)
x, y = _ring_xy(283 if j % 2 else 312, a)
_ring_add('Острова', (84, 105, 118), make_blob_points(x, y, 42, 31, 118 + j, point_count=22, jitter=0.15), _RING_RES['Острова'], cave=j == 1, priority=17)
for j, deg in enumerate((-150, -103, -51, 8, 76, 136)):
a = math.radians(deg)
x, y = _ring_xy(235 + 9 * (j % 3), a)
_ring_add('Коралловый риф', (143 + j % 2 * 15, 43, 167), make_blob_points(x, y, 32 + 3 * (j % 3), 22 + 3 * (j % 3), 280 + j, point_count=28, jitter=0.12), _RING_RES['Коралловый риф'], cave=j == 3, priority=19)
for j, deg in enumerate((-170, -68, -9, 63, 151)):
x, y = _ring_xy(285, math.radians(deg))
_ring_add('Кельповый лес', (10, 110 + j * 5, 72), make_blob_points(x, y, 38, 29, 350 + j, point_count=26, jitter=0.14), _RING_RES['Кельповый лес'], cave=j == 1, priority=20)
for j, deg in enumerate((-146, -83, -22, 30, 94, 168)):
x, y = _ring_xy(219 + 8 * (j % 2), math.radians(deg))
_ring_add('Био-люминесцентная зона', (58, 30, 170 + j * 8), make_blob_points(x, y, 31, 23, 440 + j, point_count=26, jitter=0.17), _RING_RES['Био-люминесцентная зона'], cave=j in (0, 4), priority=21)
for j, deg in enumerate((-166, -111, -80, -40, 17, 63, 118, 155)):
x, y = _ring_xy(395 + j % 3 * 8, math.radians(deg))
_ring_add('Горы', (135 + j % 3 * 16, 143, 151), make_blob_points(x, y, 48 + 7 * (j % 3), 55 + j % 2 * 7, 510 + j, point_count=28, jitter=0.24), _RING_RES['Горы'], cave=j in (0, 2, 5), priority=23)
for j, deg in enumerate((-155, -92, -29, 34, 92, 148)):
a = math.radians(deg)
lx, ly = _ring_xy(407 - 8 * (j % 2), a)
_ring_add('Озёра (внешние)', (9, 108, 191), make_blob_points(lx, ly, 30 + j % 3 * 4, 24 + j % 3 * 4, 610 + j, point_count=26, jitter=0.17), _RING_RES['Озёра (внешние)'], priority=30)
points = [_ring_xy(r, a + 0.055 * math.sin(r * 0.039 + j)) for r in (402, 378, 350, 326, 300, 278)]
for q, (p1, p2) in enumerate(zip(points, points[1:])):
_ring_add('Реки', (5, 145 + q * 7, 221), make_corridor_polygon(*p1, *p2, 12), _RING_RES['Реки'], priority=33)
for sign in (-1, 1):
tributary_angle = a + sign * 0.17
p0 = _ring_xy(414, tributary_angle)
p1 = _ring_xy(361, a + sign * 0.055)
_ring_add('Притоки', (10, 110, 196), make_corridor_polygon(*p0, *p1, 6), _RING_RES['Притоки'], priority=34)
wx, wy = points[-2]
_ring_add('Водопады', (124, 231, 255), make_blob_points(wx, wy, 9, 13, 690 + j, point_count=14, jitter=0.07), _RING_RES['Водопады'], priority=36)
for j, (x, y) in enumerate(((468, 505), (541, 493), (516, 542))):
_ring_add('Острова', (100, 116, 105), make_blob_points(x, y, 15, 13, 750 + j, point_count=22, jitter=0.15), _RING_RES['Острова'], priority=24)
extra_resource_profiles = {'Мелководье': [(acid_mushroom_item, 3), (table_coral_type, 2)], 'Глубоководье': [(nickel_type, 2), (gel_sack_type, 1)], 'Центральное море': [(ion_cube_type, 1)], 'Побережье': [(metal_salvage_type, 3), (sand_type, 3)], 'Острова': [(metal_salvage_type, 2), (nickel_type, 2)], 'Коралловый риф': [(table_coral_type, 3), (coral_tube_type, 4)], 'Леса': [(metal_salvage_type, 1), (clay_type, 2)], 'Кельповый лес': [(creepvine_sample_type, 4), (creepvine_seed_type, 3)], 'Био-люминесцентная зона': [(blood_oil_type, 2), (deep_shroom_item, 3), (ion_cube_type, 1)], 'Горы': [(nickel_type, 3), (kyanite_type, 1)], 'Озёра (внешние)': [(salt_type, 2)], 'Реки': [(copper_type, 2)]}
for _biome in biomes:
if _biome.cave_enabled:
_biome.objects.extend(extra_resource_profiles.get(_biome.name, ()))
SURFACE_FAUNA = {'Мелководье': [(zucchini_fish_type, 8)], 'Глубоководье': [(zucchini_fish_type, 7)], 'Центральное море': [(zucchini_fish_type, 16), (chbos_whale_type, 10)], 'Побережье': [], 'Острова': [], 'Коралловый риф': [(eggplant_fish_type, 12), (zucchini_fish_type, 4)], 'Леса': [], 'Кельповый лес': [(zucchini_fish_type, 5)], 'Био-люминесцентная зона': [(eggplant_fish_type, 14)], 'Горы': [], 'Озёра (внешние)': [(zucchini_fish_type, 4)], 'Реки': [(zucchini_fish_type, 5)], 'Притоки': [(zucchini_fish_type, 2)], 'Водопады': []}
dense_fish_additions = {}
small_fish_types = {zucchini_fish_type, eggplant_fish_type}
weak_predator_types = set()
for biome_name, fauna in list(SURFACE_FAUNA.items()):
merged = OrderedDict()
for animal_type, count in fauna:
new_count = count
if animal_type in small_fish_types:
new_count = max(new_count + 2, int(round(new_count * 1.35)))
elif animal_type in weak_predator_types:
new_count = max(new_count + 1, int(round(new_count * 1.2)))
merged[animal_type] = merged.get(animal_type, 0) + new_count
for animal_type, add_count in dense_fish_additions.get(biome_name, []):
merged[animal_type] = merged.get(animal_type, 0) + add_count
SURFACE_FAUNA[biome_name] = list(merged.items())
for surface_biome in biomes:
if getattr(surface_biome, 'aurora_style', False):
continue
surface_biome.animals = [] if is_land_biome(surface_biome) else list(SURFACE_FAUNA.get(surface_biome.name, ()))
F = flora_types
surface_flora_profiles = {'Мелководье': [(F['acid_mushroom'], 4.0), (F['blue_palm'], 1.0), (F['writhing_weed'], 2.0)], 'Глубоководье': [(F['ghost_weed'], 1.0), (F['eye_stalk'], 0.7)], 'Центральное море': [(F['ghost_weed'], 0.45), (F['sea_crown'], 0.1)], 'Побережье': [], 'Острова': [], 'Коралловый риф': [(F['spotted_reeds'], 2.2), (F['violet_beau'], 1.8), (F['redwort'], 2.2), (F['blue_palm'], 1.2)], 'Леса': [], 'Кельповый лес': [(F['creepvine'], 6.8), (F['membraintree'], 0.6)], 'Био-люминесцентная зона': [(F['bloodvine'], 2.5), (F['deep_shroom'], 2.8), (F['ghost_weed'], 2.5), (F['anchor_pod'], 1.2)], 'Горы': [], 'Озёра (внешние)': [(F['acid_mushroom'], 2.3), (F['writhing_weed'], 1.3)], 'Реки': [(F['writhing_weed'], 1.4)], 'Притоки': [(F['blue_palm'], 0.6)], 'Водопады': [(F['blue_palm'], 0.7)]}
cave_flora_profiles = {'Мелководье': [(F['sulfur_plant'], 2.0), (F['acid_mushroom'], 2.0)], 'Глубоководье': [(F['drooping_stinger'], 1.5), (F['ghost_weed'], 1.0)], 'Центральное море': [(F['sea_crown'], 0.5), (F['ghost_weed'], 1.8)], 'Побережье': [(F['sulfur_plant'], 1.0), (F['cave_bush'], 0.9)], 'Острова': [(F['cave_bush'], 2.0), (F['eye_stalk'], 1.0)], 'Коралловый риф': [(F['jellyshroom'], 2.0), (F['redwort'], 1.4)], 'Леса': [(F['cave_bush'], 2.0), (F['drooping_stinger'], 1.0)], 'Кельповый лес': [(F['eye_stalk'], 2.0), (F['cave_bush'], 1.2)], 'Био-люминесцентная зона': [(F['deep_shroom'], 3.0), (F['ghost_weed'], 3.0)], 'Горы': [(F['cave_bush'], 2.0), (F['jellyshroom'], 1.0)], 'Озёра (внешние)': [(F['acid_mushroom'], 1.0)], 'Реки': [(F['eye_stalk'], 0.5)]}
for surface_biome in biomes:
if getattr(surface_biome, 'aurora_style', False):
continue
surface_biome.flora = [] if is_land_biome(surface_biome) else list(surface_flora_profiles.get(surface_biome.name, ()))
def find_surface_biome(name, wreck_id):
candidates = [b for b in biomes if b.name == name and (not getattr(b, 'aurora_style', False))]
if not candidates:
return None
stable_index = sum(((i + 1) * ord(ch) for i, ch in enumerate(str(wreck_id))))
return candidates[stable_index % len(candidates)]
def wreck_point_in_biome(parent, original_x, original_z, seed):
cx, cy = parent.center()
x1, y1, x2, y2 = parent.rect
rx = max(8.0, (x2 - x1) * 0.4)
ry = max(8.0, (y2 - y1) * 0.4)
angle = math.atan2(-original_z, original_x)
rnd = random.Random(seed)
distance = rnd.uniform(0.28, 0.78)
px = cx + math.cos(angle) * rx * distance
py = cy + math.sin(angle) * ry * distance
for _ in range(40):
if parent.contains(px, py):
return (int(px), int(py))
px = (px + cx) / 2.0
py = (py + cy) / 2.0
return (int(cx), int(cy))
def add_wreck_piece(name, rect, passable, priority):
wreck_biome = Biome(name, AURORA_FLOOR if passable else AURORA_HULL, *rect, [(titan_type, 2), (quartz_type, 1)] if passable else [], [], passable=passable, cave_enabled=False, priority=priority)
wreck_biome.aurora_style = True
wreck_biome.location_kind = 'aurora_wreck'
biomes.append(wreck_biome)
return wreck_biome
def add_wreck_structure(wreck_id, parent_name, original_x, original_z, large=True):
parent = find_surface_biome(parent_name, wreck_id)
if parent is None:
return
seed = 910000 + sum((ord(ch) for ch in str(wreck_id))) * 37 + abs(int(original_x)) * 3 + abs(int(original_z)) * 5
cx, cy = wreck_point_in_biome(parent, original_x, original_z, seed)
if large:
width = 14
height = 10
else:
width = 7
height = 5
x1 = max(1, cx - width // 2)
y1 = max(1, cy - height // 2)
x2 = min(MAP_SIZE - 2, x1 + width - 1)
y2 = min(MAP_SIZE - 2, y1 + height - 1)
label = f'Обломок Авроры №{wreck_id}'
floor = add_wreck_piece(label, (x1 + 1, y1 + 1, x2 - 1, y2 - 1), True, 82 if large else 78)
floor.original_subnautica_xz = (original_x, original_z)
floor.wreck_id = str(wreck_id)
floor.parent_surface_biome = parent_name
floor.is_large_wreck = bool(large)
door_a = (x1 + x2) // 2
if door_a - 1 >= x1:
add_wreck_piece(label + ' — северная стенка L', (x1, y1, door_a - 1, y1), False, 90)
if door_a + 1 <= x2:
add_wreck_piece(label + ' — северная стенка R', (door_a + 1, y1, x2, y1), False, 90)
if large:
door_b = max(x1 + 1, min(x2 - 1, door_a + 2))
if door_b - 1 >= x1:
add_wreck_piece(label + ' — южная стенка L', (x1, y2, door_b - 1, y2), False, 90)
if door_b + 1 <= x2:
add_wreck_piece(label + ' — южная стенка R', (door_b + 1, y2, x2, y2), False, 90)
else:
add_wreck_piece(label + ' — южная стенка', (x1, y2, x2, y2), False, 90)
add_wreck_piece(label + ' — левая стенка', (x1, y1 + 1, x1, y2 - 1), False, 90)
add_wreck_piece(label + ' — правая стенка', (x2, y1 + 1, x2, y2 - 1), False, 90)
if large:
rnd = random.Random(seed + 17)
for debris_index in range(3):
dx = rnd.choice((-1, 1)) * rnd.randint(width // 2 + 2, width + 5)
dy = rnd.randint(-height, height)
debris_x = max(1, min(MAP_SIZE - 3, cx + dx))
debris_y = max(1, min(MAP_SIZE - 3, cy + dy))
if parent.contains(debris_x, debris_y):
add_wreck_piece(label + f' — фрагмент {debris_index + 1}', (debris_x, debris_y, debris_x + rnd.randint(1, 3), debris_y + rnd.randint(1, 2)), False, 84)
LARGE_WRECKS = [('1', 'Побережье', 320, 431), ('2', 'Побережье', -630, -2), ('3', 'Глубоководье', 910, 600), ('4', 'Центральное море', -128, 884), ('5', 'Горы', 701, 1224), ('6', 'Побережье', -1448, 732), ('7', 'Глубоководье', -1148, -706), ('8', 'Био-люминесцентная зона', -1208, -390), ('9', 'Леса', -672, 750), ('10', 'Коралловый риф', -290, -773), ('11', 'Коралловый риф', -865, -1390), ('12', 'Побережье', -393, 630), ('13', 'Побережье', -15, -624), ('14', 'Побережье', -418, -261), ('15', 'Мелководье', -33, -398), ('16', 'Мелководье', 343, -190), ('17', 'Кельповый лес', 64, 376), ('18', 'Кельповый лес', -317, 223), ('19', 'Горы', 1074, 1335), ('20', 'Коралловый риф', -781, -730), ('43', 'Побережье', -1214, 11), ('44', 'Побережье', -1625, 100), ('45', 'Глубоководье', 1319, 567)]
for wreck_id, parent_name, original_x, original_z in LARGE_WRECKS:
add_wreck_structure(wreck_id, parent_name, original_x, original_z, large=True)
SMALL_WRECKS = [('S1', 'Мелководье', 20, 300), ('S2', 'Мелководье', 90, 120), ('S3', 'Мелководье', 65, -85), ('S4', 'Мелководье', -160, -225), ('S5', 'Мелководье', 175, -253), ('S6', 'Мелководье', 310, -119), ('G1', 'Побережье', 165, -520), ('G2', 'Побережье', 322, 222), ('G3', 'Побережье', 270, 70), ('G4', 'Побережье', -370, 475), ('G5', 'Побережье', -284, 606), ('G6', 'Побережье', -520, -215)]
for wreck_id, parent_name, original_x, original_z in SMALL_WRECKS:
add_wreck_structure(wreck_id, parent_name, original_x, original_z, large=False)
add_wreck_structure('CZ', 'Острова', 527, -43, large=False)
registry = {}
def register_start_item(variable_name, value):
if not isinstance(value, (ObjectType, AnimalType)):
return
registry.setdefault(value.name, value)
if variable_name:
registry.setdefault(variable_name, value)
for variable_name, value in list(globals().items()):
register_start_item(variable_name, value)
for variable_name, value in list(locals().items()):
register_start_item(variable_name, value)
for recipe in recipes:
register_start_item(None, recipe.result)
for ingredient in recipe.ingredients:
register_start_item(None, ingredient)
for biome in biomes:
for entry in biome.objects:
if isinstance(entry, (tuple, list)) and entry:
register_start_item(None, entry[0])
for entry in biome.animals:
if isinstance(entry, (tuple, list)) and entry:
register_start_item(None, entry[0])
START_ITEM_REGISTRY = registry
def generate_mad_moss_sprite(size=180, seed=413):
"""Объёмная розетка мха из десятков листочков, побегов, спор и мицелия.
Спрайт высокого разрешения масштабируется без дорогой регенерации
в каждом кадре. Контуры колонии разрастаются во все стороны.
"""
size = max(40, int(size))
surf = pygame.Surface((size, size), pygame.SRCALPHA)
draw = pygame.draw
rng = random.Random(seed)
center = size / 2
radius = size * 0.34
def point(x, y):
return (round(x), round(y))
for i in range(45):
a = math.tau * i / 45 + rng.uniform(-0.16, 0.16)
r1 = radius * rng.uniform(0.21, 0.43)
r2 = radius * rng.uniform(0.75, 1.19)
bend = rng.uniform(-0.28, 0.28)
p0 = point(center + math.cos(a) * r1, center + math.sin(a) * r1)
p1 = point(center + math.cos(a + bend) * r2 * 0.7, center + math.sin(a + bend) * r2 * 0.7)
p2 = point(center + math.cos(a) * r2, center + math.sin(a) * r2)
draw.lines(surf, (16, 65, 37, 215), False, [p0, p1, p2], max(1, size // 130))
draw.circle(surf, (127, 218, 102, 235), p2, max(1, round(size * rng.uniform(0.009, 0.019))))
for layer in range(3):
count = (25, 35, 47)[layer]
for i in range(count):
a = i * math.tau / count + rng.uniform(-0.14, 0.14)
r = radius * rng.uniform(0.2, 0.89) * (1.0 - layer * 0.07)
x = center + math.cos(a) * r
y = center + math.sin(a) * r
rr = size * rng.uniform(0.045, 0.093) * (1.0 - layer * 0.13)
shadow = ((19, 75, 39, 244), (27, 113, 52, 250), (48, 146, 58, 247))[layer]
top = ((42, 135, 49, 242), (74, 181, 55, 250), (151, 226, 83, 254))[layer]
draw.circle(surf, shadow, point(x + rr * 0.16, y + rr * 0.16), round(rr * 1.1))
draw.circle(surf, top, point(x - rr * 0.1, y - rr * 0.12), max(1, round(rr * 0.78)))
if i % 2 == 0:
draw.ellipse(surf, (190, 247, 123, 240), (round(x - rr * 0.4), round(y - rr * 0.58), max(1, round(rr * 0.45)), max(1, round(rr * 0.55))))
draw.circle(surf, (20, 93, 47, 252), point(center, center), round(radius * 0.37))
draw.circle(surf, (81, 185, 61, 250), point(center - 1, center - 1), round(radius * 0.27))
for i in range(33):
a = math.tau * i / 33 + rng.uniform(-0.1, 0.1)
r = radius * rng.uniform(0.3, 0.85)
p0 = point(center + math.cos(a) * radius * 0.11, center + math.sin(a) * radius * 0.11)
p1 = point(center + math.cos(a) * r, center + math.sin(a) * r)
draw.aaline(surf, (198, 244, 118, 220), p0, p1)
if i % 3 == 0:
draw.circle(surf, (236, 251, 167, 245), p1, max(1, size // 125))
return surf
_LAND_TREE_SCANNER_TYPES = {}
def _land_tree_scanner_type(species_index):
species_index = int(species_index)
result = _LAND_TREE_SCANNER_TYPES.get(species_index)
if result is None:
result = ObjectType(TREE_SPECIES[species_index], (71, 155, 71), 66, 'circle')
result.image = generate_tree_sprite(species_index).convert_alpha()
_LAND_TREE_SCANNER_TYPES[species_index] = result
return result
class MadMossColony:
"""Независимая лесная колония. Маленькая конечная колония не растёт.
Каждый удар по крупной колонии заменяет её ДВУМЯ меньшими.
Подбирать можно только конечные колонии диаметром ровно 0.2 клетки.
"""
__slots__ = ('x', 'y', 'diameter', 'heading', 'turn_timer', 'home_biome', 'terminal', 'age', 'variant')
def __init__(self, x, y, home_biome, diameter=1.15, terminal=False):
self.x, self.y = (float(x), float(y))
self.home_biome = home_biome
self.diameter = max(MAD_MOSS_MIN_DIAMETER, float(diameter))
self.terminal = bool(terminal or self.diameter <= MAD_MOSS_MIN_DIAMETER + 1e-06)
if self.terminal:
self.diameter = MAD_MOSS_MIN_DIAMETER
self.heading = random.uniform(-math.pi, math.pi)
self.turn_timer = random.randint(15, 70)
self.age = random.randrange(200)
self.variant = random.randint(0, 5)
def update(self, world):
self.age += 1
self.turn_timer -= 1
if self.turn_timer <= 0:
self.heading = random.uniform(-math.pi, math.pi)
self.turn_timer = random.randint(18, 75)
speed = MAD_MOSS_SPEED
nx = self.x + math.cos(self.heading) * speed
ny = self.y + math.sin(self.heading) * speed
if 0 <= nx < MAP_SIZE and 0 <= ny < MAP_SIZE and (world.get_biome_at(nx, ny) is self.home_biome) and self.home_biome.passable and (not world.get_object_at(nx, ny)) and (not getattr(world, 'creature_blocked', lambda x, y: False)(int(nx), int(ny))):
self.x, self.y = (nx, ny)
else:
self.heading += math.pi + random.uniform(-0.55, 0.55)
self.turn_timer = random.randint(10, 32)
if not self.terminal:
self.diameter = min(MAD_MOSS_MAX_DIAMETER, self.diameter + MAD_MOSS_GROWTH_PER_FRAME)
_MAD_MOSS_SPRITE_CACHE = {}
def get_mad_moss_sprite(pixel_diameter, variant=0):
"""Кэш размера/цвета; в игре рисуется без пересоздания 100+ деталей."""
diameter = max(4, int(round(pixel_diameter)))
key = (diameter, int(variant) % 6)
sprite = _MAD_MOSS_SPRITE_CACHE.get(key)
if sprite is None:
source = generate_mad_moss_sprite(180, 413 + key[1] * 89)
sprite = pygame.transform.smoothscale(source, (diameter, diameter)).convert_alpha()
if len(_MAD_MOSS_SPRITE_CACHE) > 280:
_MAD_MOSS_SPRITE_CACHE.clear()
_MAD_MOSS_SPRITE_CACHE[key] = sprite
return sprite
def generate_walker_sprite(size=30, stride=0):
"""Вид сверху, пешеход в снаряжении: голова, ранец, руки и две ноги.
В отличие от пловца, здесь нет баллонов по бокам и ласт, зато видны
ботинки. Значение stride (-1, 0, +1) даёт простую анимацию походки.
Герой смотрит вправо; Game поворачивает спрайт вслед за курсором.
"""
w, h = (int(size * 2.0), int(size * 1.8))
surf = pygame.Surface((w, h), pygame.SRCALPHA)
cx, cy = (w // 2, h // 2)
dark = (26, 34, 42)
suit = (44, 108, 173)
suit_hi = (81, 163, 210)
boots = (43, 44, 52)
pygame.draw.ellipse(surf, (0, 0, 0, 58), (cx - 20, cy - 14, 42, 29))
for side in (-1, 1):
step = int(stride * side * 5)
yy = cy + side * 10
pygame.draw.line(surf, dark, (cx - 5, yy), (cx - 16 + step, yy), 6)
pygame.draw.ellipse(surf, boots, (cx - 23 + step, yy - 4, 10, 8))
pygame.draw.line(surf, (137, 147, 154), (cx - 21 + step, yy - 3), (cx - 18 + step, yy - 3), 1)
for side in (-1, 1):
yy = cy + side * 18
hand_shift = -stride * side * 3
pygame.draw.line(surf, (24, 64, 106), (cx + 1, cy + side * 9), (cx + 10 + hand_shift, yy), 7)
pygame.draw.circle(surf, (191, 199, 187), (cx + 10 + int(hand_shift), yy), 4)
pygame.draw.ellipse(surf, dark, (cx - 20, cy - 12, 30, 24))
pygame.draw.ellipse(surf, suit, (cx - 14, cy - 11, 31, 22))
pygame.draw.rect(surf, (80, 91, 91), (cx - 20, cy - 10, 13, 20), border_radius=3)
pygame.draw.line(surf, (248, 203, 83), (cx - 6, cy - 7), (cx + 9, cy - 7), 2)
pygame.draw.line(surf, suit_hi, (cx - 4, cy + 6), (cx + 9, cy + 6), 2)
pygame.draw.circle(surf, (20, 28, 40), (cx + 17, cy), 12)
pygame.draw.circle(surf, (221, 193, 92), (cx + 17, cy), 10)
pygame.draw.circle(surf, (36, 111, 144), (cx + 19, cy), 8)
pygame.draw.ellipse(surf, (123, 224, 237), (cx + 15, cy - 6, 8, 4))
pygame.draw.arc(surf, (244, 237, 188), (cx + 7, cy - 10, 20, 20), -1.3, 1.3, 2)
return surf.convert_alpha()
def generate_tree_sprite(species, variant=0):
"""Крупное дерево сверху, без внешних текстур. Все шесть видов различимы."""
seed = 41921 + species * 1301 + variant * 719
rng = random.Random(seed)
size = 66
surf = pygame.Surface((size, size), pygame.SRCALPHA)
c = size // 2
pygame.draw.ellipse(surf, (0, 0, 0, 47), (c - 23, c - 18, 50, 49))
pygame.draw.circle(surf, (76, 55, 40), (c + 2, c + 3), 7)
pygame.draw.circle(surf, (131, 92, 53), (c + 1, c + 2), 4)
if species in (0, 1):
base = (25, 85, 58) if species == 0 else (23, 74, 64)
light = (69, 143, 74) if species == 0 else (59, 126, 110)
for ring, arms in ((22, 11), (16, 9), (9, 7)):
for i in range(arms):
a = math.tau * i / arms + rng.uniform(-0.1, 0.1)
end = (c + math.cos(a) * ring, c + math.sin(a) * ring)
side = 0.28 if species == 0 else 0.2
pygame.draw.polygon(surf, base, [(c, c), (c + math.cos(a - side) * (ring - 2), c + math.sin(a - side) * (ring - 2)), end, (c + math.cos(a + side) * (ring - 2), c + math.sin(a + side) * (ring - 2))])
pygame.draw.circle(surf, light, (c - 3, c - 4), 6)
pygame.draw.circle(surf, (125, 177, 102), (c - 4, c - 6), 3)
elif species in (2, 3, 4):
colors = {2: ((28, 91, 48), (67, 144, 63), (110, 178, 84)), 3: ((52, 124, 68), (115, 178, 95), (177, 215, 127)), 4: ((102, 41, 70), (182, 65, 83), (223, 122, 98))}[species]
for i in range(13):
a = i * math.tau / 13 + rng.uniform(-0.22, 0.22)
r = rng.uniform(9, 19)
px = c + int(math.cos(a) * r)
py = c + int(math.sin(a) * r)
pygame.draw.circle(surf, colors[i % 2], (px, py), rng.randint(10, 15))
pygame.draw.circle(surf, colors[1], (c, c), 16)
for i in range(7):
a = rng.random() * math.tau
r = rng.uniform(0, 13)
pygame.draw.circle(surf, colors[2], (round(c + math.cos(a) * r), round(c + math.sin(a) * r)), rng.randint(3, 7))
if species == 3:
pygame.draw.line(surf, (230, 229, 213), (c - 2, c - 2), (c + 5, c + 5), 2)
else:
for i in range(9):
a = i * math.tau / 9 + rng.uniform(-0.09, 0.09)
r = 25 + rng.randint(-4, 3)
ex = c + math.cos(a) * r
ey = c + math.sin(a) * r
pygame.draw.line(surf, (39, 97, 52), (c, c), (ex, ey), 6)
pygame.draw.line(surf, (109, 180, 77), (c - 2, c - 2), (ex - 3, ey - 3), 2)
for t in (0.4, 0.65, 0.8):
bx, by = (c + math.cos(a) * r * t, c + math.sin(a) * r * t)
for sign in (-1, 1):
pygame.draw.line(surf, (74, 146, 63), (bx, by), (bx + math.cos(a + sign * 0.9) * 8, by + math.sin(a + sign * 0.9) * 8), 2)
pygame.draw.circle(surf, (185, 142, 67), (c, c), 5)
return surf.convert_alpha()
class GolemScanEntity:
"""Не является предметом: картинка только в большом экране сканера."""
def __init__(self, kind, bacterium=False):
self.kind = kind
self.bacterium = bacterium
self.name = 'Големогенные бактерии — ' + {'stone': 'каменные', 'water': 'водяные', 'wood': 'деревянные', 'dung': 'говняные'}[kind] if bacterium else GOLEM_DATA[kind]['name']
self.size = 1 if bacterium else int(30 * GOLEM_DATA[kind]['diameter'])
self.color = GOLEM_DATA[kind]['color']
self.image = None
GOLEM_SCAN_TYPES = {k: GolemScanEntity(k) for k in GOLEM_DATA}
GOLEM_BACTERIA_SCAN_TYPES = {k: GolemScanEntity(k, True) for k in GOLEM_DATA}
_GOLEM_SPRITE_CACHE = {}
def generate_golem_sprite(kind, pixel_size=200, shattered=False):
"""Высокодетализированный голем в Pygame: вид сверху, голова направо."""
n = 240
surf = pygame.Surface((n, n), pygame.SRCALPHA)
d = pygame.draw
seed = {'stone': 13, 'water': 29, 'wood': 41, 'dung': 73}[kind]
rnd = random.Random(seed)
base = GOLEM_DATA[kind]['color']
edge = tuple((max(0, v - 50) for v in base))
light = tuple((min(255, v + 48) for v in base))
gl = {'stone': (116, 240, 218), 'water': (165, 255, 255), 'wood': (139, 251, 111), 'dung': (250, 207, 72)}[kind]
for side in (-1, 1):
yy = 120 + side * 48
d.line(surf, edge, (84, yy), (45, yy + side * 45), 25)
d.line(surf, base, (84, yy), (44, yy + side * 45), 18)
d.circle(surf, light, (44, yy + side * 45), 14)
d.line(surf, edge, (145, yy), (183, yy + side * 32), 26)
d.line(surf, base, (145, yy), (181, yy + side * 32), 18)
d.circle(surf, light, (185, yy + side * 33), 13)
if kind == 'stone':
for x, y, w, h in [(66, 80, 78, 80), (105, 65, 67, 75), (88, 116, 79, 60), (137, 94, 46, 62), (48, 103, 49, 46)]:
pts = [(x, y + 9), (x + 12, y), (x + w - 11, y + 2), (x + w, y + h // 3), (x + w - 8, y + h - 3), (x + w // 2, y + h), (x + 2, y + h - 12)]
d.polygon(surf, edge, pts)
d.polygon(surf, base, [(xx - 4 if xx > x + w // 2 else xx + 3, yy - 3 if yy > y + h // 2 else yy + 2) for xx, yy in pts])
d.line(surf, light, (x + 10, y + 9), (x + w - 10, y + 8), 3)
for i in range(22):
x = rnd.randint(60, 176)
y = rnd.randint(80, 160)
d.line(surf, edge, (x, y), (x + rnd.randint(-12, 12), y + 9), 2)
elif kind == 'water':
d.ellipse(surf, (15, 79, 123, 180), (49, 62, 148, 117))
d.ellipse(surf, (39, 160, 221, 224), (58, 69, 132, 104))
d.ellipse(surf, (105, 214, 252, 125), (74, 73, 96, 55))
for i in range(24):
x = rnd.randrange(65, 185)
y = rnd.randrange(80, 162)
r = rnd.randrange(2, 8)
d.circle(surf, (167, 236, 255, 170), (x, y), r, 1)
for i in range(5):
x = 72 + i * 22
d.arc(surf, (181, 246, 255), (x, 119, 35, 23), 0.3, 2.7, 2)
elif kind == 'wood':
d.ellipse(surf, edge, (58, 59, 141, 129))
d.ellipse(surf, base, (64, 65, 128, 118))
for off in (-28, -12, 8, 28):
d.arc(surf, light, (78 + off, 72, 72, 94), 0.85, 5.65, 3)
for i in range(17):
x = rnd.randrange(75, 182)
y = rnd.randrange(76, 167)
d.line(surf, edge, (x, y), (x - 5, y + rnd.randrange(8, 24)), 2)
for x, y in [(65, 85), (134, 68), (170, 155), (103, 176)]:
d.circle(surf, (50, 137, 65), (x, y), 15)
d.circle(surf, (91, 179, 80), (x - 3, y - 4), 10)
else:
for x, y, r in [(101, 132, 51), (132, 108, 55), (159, 137, 36), (88, 108, 32), (123, 81, 29)]:
d.circle(surf, edge, (x + 3, y + 3), r + 2)
d.circle(surf, base, (x, y), r)
d.circle(surf, light, (x - 9, y - 10), max(8, r // 2))
for i in range(24):
x = rnd.randint(76, 175)
y = rnd.randint(77, 159)
d.circle(surf, (75, 48, 24), (x, y), rnd.randint(2, 5))
for x, y in [(38, 52), (185, 66), (193, 177)]:
d.circle(surf, (15, 20, 19), (x, y), 3)
d.line(surf, (90, 130, 105), (x - 5, y), (x + 5, y), 1)
nodes = [(87, 120), (106, 91), (132, 112), (151, 133), (117, 152), (171, 94)]
for a, b in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (2, 4), (2, 5)]:
d.line(surf, gl, nodes[a], nodes[b], 2)
for x, y in nodes:
d.circle(surf, tuple((max(0, v - 90) for v in gl)), (x, y), 5)
d.circle(surf, gl, (x, y), 3)
for ey in (102, 145):
d.circle(surf, edge, (174, ey), 12)
d.circle(surf, gl, (176, ey), 7)
d.circle(surf, (253, 254, 241), (179, ey - 3), 2)
if shattered:
surf.set_alpha(112)
cropped = surf.subsurface(surf.get_bounding_rect(min_alpha=1)).copy()
pixel_size = max(8, int(pixel_size))
return pygame.transform.smoothscale(cropped, (pixel_size, pixel_size)).convert_alpha()
def generate_golem_bacterium_preview(kind, size=260):
"""Условное изображение под микроскопом: реальный диаметр 1 мкм."""
surf = pygame.Surface((280, 280), pygame.SRCALPHA)
d = pygame.draw
color = GOLEM_DATA[kind]['color']
d.ellipse(surf, (40, 78, 105, 35), (27, 27, 226, 226), 3)
for i, (x, y) in enumerate([(132, 116), (74, 178), (192, 181)]):
w = 64 if i == 0 else 42
d.ellipse(surf, tuple((max(0, c - 40) for c in color)), (x - w // 2, y - w // 2, w, w))
d.ellipse(surf, color, (x - w // 2 + 4, y - w // 2 + 4, w - 8, w - 8))
d.circle(surf, (229, 249, 184), (x - 7, y - 8), max(3, w // 8))
for a in range(10):
theta = math.tau * a / 10 + i
x1 = x + math.cos(theta) * w * 0.47
y1 = y + math.sin(theta) * w * 0.47
d.line(surf, (126, 230, 197), (x1, y1), (x1 + math.cos(theta) * 14, y1 + math.sin(theta) * 14), 2)
return pygame.transform.smoothscale(surf, (size, size)).convert_alpha()
class GolemColony:
"""Невесомые особи -> заражение субстрата -> живой голем -> обломки."""
__slots__ = ('kind', 'x', 'y', 'anchor_x', 'anchor_y', 'phase', 'mass', 'hp', 'max_hp', 'heading', 'heading_timer', 'next_attack', 'revive_at', 'last_update', 'seed')
def __init__(self, kind, x, y, phase='spore', seed=0):
self.kind = kind
self.x = float(x)
self.y = float(y)
self.anchor_x = float(x)
self.anchor_y = float(y)
self.phase = phase
self.mass = GOLEM_FORMATION_MASS if phase == 'active' else 0.0
self.max_hp = GOLEM_DATA[kind]['hp']
self.hp = self.max_hp if phase == 'active' else 0
self.heading = random.Random(seed + 31).uniform(-math.pi, math.pi)
self.heading_timer = 50
self.next_attack = 0
self.revive_at = 0
self.last_update = 0
self.seed = seed
def _substrate_here(self, world, x, y):
ix, iy = (int(x), int(y))
b = world.get_biome_at(ix, iy)
if b is None or not b.passable or getattr(b, 'aurora_style', False):
return False
if self.kind == 'water':
return world.is_water_at(ix, iy)
if not is_land_biome(b):
return False
if self.kind == 'wood':
return (ix, iy) in world.tree_grid
if self.kind == 'dung':
return (ix, iy) in world.golem_dung_piles
if self.kind == 'stone':
obj = world.get_object_at(ix, iy)
return b.name in ('Горы', 'Острова', 'Побережье') or obj is rock_type or obj is limestone_type
return False
def update(self, world, player_x, player_y, frame, antibiotics_visible=False):
elapsed = max(1, min(1200, frame - self.last_update)) if self.last_update else 1
self.last_update = frame
rng = random
if self.phase == 'spore':
wind = frame * 0.0018
vx = (math.cos(wind) + 0.45 * math.cos(self.heading)) * GOLEM_WIND_SPEED
vy = (math.sin(wind * 0.83) + 0.45 * math.sin(self.heading)) * GOLEM_WIND_SPEED
self.x = min(MAP_SIZE - 2, max(1, self.x + vx * min(elapsed, 30)))
self.y = min(MAP_SIZE - 2, max(1, self.y + vy * min(elapsed, 30)))
if self._substrate_here(world, self.x, self.y):
self.phase = 'breeding'
self.anchor_x, self.anchor_y = (self.x, self.y)
self.mass = max(0, self.mass)
elif self.phase == 'breeding':
if not self._substrate_here(world, self.anchor_x, self.anchor_y):
self.phase = 'spore'
self.mass = 0
else:
self.mass += min(elapsed, 1200) * 0.29
if self.mass >= GOLEM_FORMATION_MASS:
self.phase = 'active'
self.hp = self.max_hp
self.x, self.y = (self.anchor_x, self.anchor_y)
self.heading_timer = 0
elif self.phase == 'rubble':
if frame >= self.revive_at:
if self._substrate_here(world, self.anchor_x, self.anchor_y):
self.phase = 'breeding'
self.mass = 70.0
else:
self.phase = 'spore'
self.mass = 0.0
elif self.phase == 'active':
self.heading_timer -= 1
dx = player_x - self.x
dy = player_y - self.y
dist = math.hypot(dx, dy)
if dist < 5.5 and dist > 0.1:
angle = math.atan2(dy, dx)
if antibiotics_visible and dist < 3.5:
angle += math.pi
elif self.hp < self.max_hp * 0.35 and dist < 2.5:
angle += math.pi * 0.8
else:
angle = self.heading
if self.heading_timer <= 0:
self.heading = rng.uniform(-math.pi, math.pi)
self.heading_timer = rng.randrange(60, 180)
speed = GOLEM_DATA[self.kind]['speed']
if self.kind == 'water' and dist < 4:
speed *= 1.35
nx = self.x + math.cos(angle) * speed
ny = self.y + math.sin(angle) * speed
b = world.get_biome_at(nx, ny)
permitted = b is not None and b.passable and (not getattr(b, 'aurora_style', False)) and (not (world.get_object_at(nx, ny) is not None and world.get_object_at(nx, ny).is_wall)) and (world.is_water_at(nx, ny) if self.kind == 'water' else is_land_biome(b))
blocker = getattr(world, 'creature_blocked', None)
if permitted and (blocker is None or not blocker(int(nx), int(ny))):
self.x, self.y = (nx, ny)
else:
self.heading += math.pi * 0.69
self.heading_timer = 12
def hit(self, damage, frame):
if self.phase != 'active':
return False
self.hp = max(0, self.hp - max(0, damage))
if self.hp <= 0:
self.phase = 'rubble'
self.revive_at = frame + random.randint(GOLEM_REASSEMBLE_MIN, GOLEM_REASSEMBLE_MAX)
self.anchor_x, self.anchor_y = (self.x, self.y)
return True
class World:
def __init__(self):
self.biomes = biomes
self.object_grid = {}
self.flora_grid = {}
self.tree_grid = {}
self.moss_colonies = []
self.golem_colonies = []
self.golem_dung_piles = set()
self.animals = []
self.biome_chunks = {}
self.biome_cache = {}
self.animal_chunks = {}
self._build_biome_index()
self._generate_objects()
self._generate_animals()
self._generate_flora()
self._generate_trees()
self._generate_mad_moss()
self._generate_golem_colonies()
self.rebuild_animal_index()
def _build_biome_index(self):
chunk = WORLD_CHUNK_SIZE
max_chunk = (MAP_SIZE - 1) // chunk
declaration_order = {id(biome): index for index, biome in enumerate(self.biomes)}
for biome in self.biomes:
x1, y1, x2, y2 = biome.rect
cx1 = max(0, x1 // chunk)
cy1 = max(0, y1 // chunk)
cx2 = min(max_chunk, x2 // chunk)
cy2 = min(max_chunk, y2 // chunk)
for cy in range(cy1, cy2 + 1):
for cx in range(cx1, cx2 + 1):
key = (cx, cy)
self.biome_chunks.setdefault(key, []).append(biome)
for candidates in self.biome_chunks.values():
candidates.sort(key=lambda biome: (biome.priority, declaration_order[id(biome)]), reverse=True)
def get_biome_at(self, x, y):
x = int(x)
y = int(y)
if not (0 <= x < MAP_SIZE and 0 <= y < MAP_SIZE):
return None
cache_key = (x, y)
if cache_key in self.biome_cache:
return self.biome_cache[cache_key]
chunk_key = (x // WORLD_CHUNK_SIZE, y // WORLD_CHUNK_SIZE)
result = None
for biome in self.biome_chunks.get(chunk_key, ()):
if biome.contains(x, y):
result = biome
break
if len(self.biome_cache) >= MAX_BIOME_CACHE:
self.biome_cache.clear()
self.biome_cache[cache_key] = result
return result
def is_water_at(self, x, y):
return not is_land_biome(self.get_biome_at(x, y)) and self.get_biome_at(x, y) is not None
def _generate_trees(self):
"""Леса густые, но не перекрывают реки и входы в пещеры.
Деревья не занимают object_grid: доступны постройки, минералы,
сканер и предметы, а при рисовании игрок всегда поверх кроны.
"""
self.tree_grid.clear()
for index, biome in enumerate(self.biomes):
if biome.name != 'Леса':
continue
count = min(3000, int(biome.area() / 400.0 * FOREST_TREES_PER_400_CELLS))
rnd = random.Random(880083 + 7937 * index)
for _ in range(count):
x, y = biome.random_point(rnd)
x, y = (int(x), int(y))
key = (x, y)
if key in self.tree_grid or key in self.object_grid:
continue
if self.get_biome_at(x, y) is not biome:
continue
species = rnd.randrange(len(TREE_SPECIES))
variant = rnd.randrange(6)
self.tree_grid[key] = (species, variant)
def _generate_mad_moss(self):
"""Мох растёт только в фактически видимых лесных биомах."""
self.moss_colonies.clear()
for index, biome in enumerate(self.biomes):
if biome.name != 'Леса' or not biome.passable:
continue
quantity = min(38, max(1, int(biome.area() / 400 * MAD_MOSS_PER_400_FOREST)))
rnd = random.Random(9613 + index * 661)
for _ in range(quantity):
if len(self.moss_colonies) >= MAD_MOSS_MAX_COLONIES:
return
for attempt in range(80):
x, y = biome.random_point(rnd)
if self.get_biome_at(x, y) is biome and (int(x), int(y)) not in self.object_grid:
self.moss_colonies.append(MadMossColony(x, y, biome, diameter=rnd.uniform(0.75, 1.25)))
break
def _generate_golem_colonies(self):
"""Ветер приносит микробы к реальным субстратам по всей карте.
Часть популяций уже построила големов, остальные будут размножаться.
Никакой рыбной фауны на суше при этом не появляется.
"""
self.golem_colonies.clear()
self.golem_dung_piles.clear()
if not GOLEM_ENABLED:
return
forest_trees = {}
for (x, y), tree in self.tree_grid.items():
biome = self.get_biome_at(x, y)
if biome is not None:
forest_trees.setdefault(biome, []).append((x, y))
for i, biome in enumerate(self.biomes):
if len(self.golem_colonies) >= GOLEM_MAX_COLONIES:
break
if not biome.passable or getattr(biome, 'aurora_style', False):
continue
k = biome.name
if k == 'Леса':
kinds = ('wood', 'dung', 'stone')
elif k in ('Горы', 'Острова', 'Побережье'):
kinds = ('stone', 'dung')
elif not is_land_biome(biome):
kinds = ('water',)
else:
continue
rnd = random.Random(800517 + i * 1777)
count = max(1, min(8, int(biome.area() / 2300) + 1))
if k == 'Центральное море':
count = min(count, 5)
for j in range(count):
if len(self.golem_colonies) >= GOLEM_MAX_COLONIES:
break
kind = kinds[j % len(kinds)]
spot = None
for attempt in range(42):
if kind == 'wood' and forest_trees.get(biome):
x, y = rnd.choice(forest_trees[biome])
else:
x, y = biome.random_point(rnd)
x, y = (int(x), int(y))
if self.get_biome_at(x, y) is not biome:
continue
if self.get_object_at(x, y) is not None and self.get_object_at(x, y).is_wall:
continue
if kind == 'dung':
self.golem_dung_piles.add((x, y))
candidate = GolemColony(kind, x, y, 'active' if j % 3 == 0 else 'spore', seed=i * 997 + j)
if candidate._substrate_here(self, x, y):
spot = candidate
break
if spot is not None:
if spot.phase == 'spore':
spot.x += rnd.uniform(-0.15, 0.15)
spot.y += rnd.uniform(-0.15, 0.15)
self.golem_colonies.append(spot)
def get_object_at(self, x, y):
return self.object_grid.get((int(x), int(y)))
def remove_object_at(self, x, y):
return self.object_grid.pop((int(x), int(y)), None)
def _generate_objects(self):
object_grid = self.object_grid
for biome in self.biomes:
x1, y1, x2, y2 = biome.rect
area = biome.area()
for object_type, density in biome.objects:
# Увеличенный запас строительных материалов в мире.
# Редкие сюжетные предметы, оружие и декор не размножаем.
building_materials = {
'Камень', 'Титан', 'Медная руда', 'Железная руда',
'Золотая руда', 'Кварц', 'Серебряная руда', 'Свинец',
'Литий', 'Известняк', 'Песок', 'Глина', 'Уголь',
'Алмаз', 'Соль', 'Металлолом', 'Кислотный гриб',
'Образец водоросли', 'Гроздь семян водоросли',
'Образец пластинчатого коралла', 'Образец коралловой трубы',
'Пещерная сера', 'Магнетит', 'Рубин', 'Никелевая руда',
'Кианит', 'Уранинит'
}
multiplier = 2.1 if object_type.name in building_materials else 1.0
count = int(area / 400 * density * multiplier)
for _ in range(count):
x = random.randint(x1, x2)
y = random.randint(y1, y2)
key = (x, y)
effective_biome = self.get_biome_at(x, y)
if effective_biome is biome and biome.passable and (key not in object_grid):
object_grid[key] = object_type
self._spawn_weapons_in_large_wrecks()
def _spawn_weapons_in_large_wrecks(self):
weapon_types = [laser_pistol, underwater_rifle, plasma_cannon, lang_cannon, schweer_cannon, diamond_cutter, flamethrower, golden_pistol]
large_wrecks = [biome for biome in self.biomes if getattr(biome, 'is_large_wreck', False)]
rng = random
for group_start in range(0, len(large_wrecks), 3):
group = large_wrecks[group_start:group_start + 3]
if len(group) < 3:
break
wreck = rng.choice(group)
weapon = rng.choice(weapon_types)
x1, y1, x2, y2 = wreck.rect
candidates = []
for y in range(y1, y2 + 1):
for x in range(x1, x2 + 1):
key = (x, y)
if self.get_biome_at(x, y) is wreck and key not in self.object_grid:
candidates.append(key)
if candidates:
self.object_grid[rng.choice(candidates)] = weapon
def _generate_animals(self):
append = self.animals.append
for biome in self.biomes:
if is_land_biome(biome):
continue
for animal_type, count in biome.animals:
if animal_type.schooling:
count = int(count * FISH_COUNT_MULTIPLIER)
for _ in range(count):
spawn = None
for _attempt in range(100):
x, y = biome.random_point()
if self.get_biome_at(int(x), int(y)) is biome:
spawn = (x, y)
break
if spawn is None:
continue
append(Animal(spawn[0], spawn[1], animal_type, biome))
def _generate_flora(self):
"""Детерминированно размещает декоративную флору поверхности."""
self.flora_grid.clear()
for biome_index, biome in enumerate(self.biomes):
if is_land_biome(biome):
continue
if not getattr(biome, 'flora', None):
continue
if not biome.passable:
continue
area = max(1.0, biome.area())
for flora_index, (flora_type, density) in enumerate(biome.flora):
count = int(area / 400.0 * float(density))
if density > 0 and count <= 0:
count = 1
count = min(count, 2200)
seed = 310000 + biome_index * 10007 + flora_index * 379 + sum(((i + 1) * ord(ch) for i, ch in enumerate(biome.name)))
rnd = random.Random(seed)
placed = 0
attempts = 0
max_attempts = max(40, count * 12)
while placed < count and attempts < max_attempts:
attempts += 1
rx, ry = biome.random_point(rnd)
x = int(rx)
y = int(ry)
key = (x, y)
if self.get_biome_at(x, y) is not biome:
continue
if key in self.object_grid or key in self.flora_grid:
continue
self.flora_grid[key] = flora_type
placed += 1
@staticmethod
def _animal_chunk_key(x, y):
return (int(x) // ANIMAL_CHUNK_SIZE, int(y) // ANIMAL_CHUNK_SIZE)
def rebuild_animal_index(self):
self.animal_chunks.clear()
for order, animal in enumerate(self.animals):
animal.world_order = order
key = self._animal_chunk_key(animal.x, animal.y)
self.animal_chunks.setdefault(key, set()).add(animal)
def get_animals_near(self, x, y, radius):
"""Возвращает животных только из соседних пространственных чанков."""
chunk = ANIMAL_CHUNK_SIZE
min_cx = int(math.floor(x - radius)) // chunk
max_cx = int(math.floor(x + radius)) // chunk
min_cy = int(math.floor(y - radius)) // chunk
max_cy = int(math.floor(y + radius)) // chunk
result = []
append = result.append
for cy in range(min_cy, max_cy + 1):
for cx in range(min_cx, max_cx + 1):
for animal in self.animal_chunks.get((cx, cy), ()):
if abs(animal.x - x) <= radius and abs(animal.y - y) <= radius:
append(animal)
result.sort(key=lambda animal: animal.world_order)
return result
def update_animal_index(self, animal, old_x, old_y):
old_key = self._animal_chunk_key(old_x, old_y)
new_key = self._animal_chunk_key(animal.x, animal.y)
if old_key == new_key:
return
old_bucket = self.animal_chunks.get(old_key)
if old_bucket is not None:
old_bucket.discard(animal)
if not old_bucket:
self.animal_chunks.pop(old_key, None)
self.animal_chunks.setdefault(new_key, set()).add(animal)
def remove_animal(self, animal):
key = self._animal_chunk_key(animal.x, animal.y)
bucket = self.animal_chunks.get(key)
if bucket is not None:
bucket.discard(animal)
if not bucket:
self.animal_chunks.pop(key, None)
try:
self.animals.remove(animal)
except ValueError:
pass
class Animal:
def __init__(self, x, y, animal_type, home_biome):
self.x = x
self.y = y
self.type = animal_type
self.home_biome = home_biome
self.swim_heading = random.uniform(0.0, math.tau)
self.cruise_heading = self.swim_heading
self.swim_phase = random.uniform(0.0, math.tau)
self.speed_phase = random.uniform(0.0, math.tau)
self.phase_rate = random.uniform(0.075, 0.135)
self.swim_turn_sign = random.choice((-1, 1))
self.fleeing = False
self.stuck_frames = 0
self.move_timer = random.randint(28, 75)
self.dx = math.cos(self.swim_heading) * animal_type.speed
self.dy = math.sin(self.swim_heading) * animal_type.speed
self.active = False
self.rotated_image = None
self.rotated_glow = None
self.hp = animal_type.hp
self.max_hp = animal_type.hp
self.attack_timer = 0
self.attack_cooldown = 60
self.is_companion = False
self.companion_orbit_angle = random.uniform(0.0, math.tau)
self.companion_orbit_radius = random.uniform(1.8, 2.7)
self.companion_target = None
self.companion_attack_timer = 0
self.companion_attack_cooldown = 28
self.companion_state = 'свободно плавает'
self.pet_combat_target = None
self._update_rotation()
def _update_rotation(self):
if self.type.image is None:
return
if self.dx == 0 and self.dy == 0:
angle = 0
elif self.type.fish_style:
angle = -math.degrees(math.atan2(self.dy, self.dx))
else:
angle = math.degrees(math.atan2(self.dy, self.dx) + math.pi / 2)
self.visual_angle = angle
self.rotated_image = self.type.get_rotated(angle)
self.rotated_glow = self.type.get_rotated_glow(angle)
@staticmethod
def _angle_difference(target, current):
return math.atan2(math.sin(target - current), math.cos(target - current))
def _can_swim_to(self, x, y, world):
"""Одна проверка для стен, границ, пещер и стартовой капсулы."""
if not (0 <= x < MAP_SIZE and 0 <= y < MAP_SIZE):
return False
cave_lookup = getattr(world, 'cave_lookup', None)
if cave_lookup is not None:
if cave_lookup(x, y) is None:
return False
else:
if not self.home_biome.contains(x, y):
return False
if is_land_biome(self.home_biome):
if world.get_biome_at(x, y) is not self.home_biome:
return False
elif not world.is_water_at(x, y):
return False
obj = world.get_object_at(int(x), int(y))
if obj is not None and obj.is_wall:
return False
extra_wall = getattr(world, 'creature_blocked', None)
return extra_wall is None or not extra_wall(int(x), int(y))
def _find_open_heading(self, desired, speed, world):
"""При упоре в препятствие выбираем направление со свободным путём.
Сканирование проводится ТОЛЬКО при упоре, а не для каждой рыбы
каждый кадр, поэтому большая популяция не тормозит игру.
"""
sign = self.swim_turn_sign
offsets = (0.0, sign * 0.36, -sign * 0.36, sign * 0.72, -sign * 0.72, sign * 1.1, -sign * 1.1, sign * 1.55, -sign * 1.55, sign * 2.15, -sign * 2.15, math.pi)
best_angle = None
best_score = -float('inf')
for offset in offsets:
a = desired + offset
ux, uy = (math.cos(a), math.sin(a))
if not self._can_swim_to(self.x + ux * speed, self.y + uy * speed, world):
continue
openness = 0
for lookahead in (0.35, 0.8, 1.45):
if self._can_swim_to(self.x + ux * lookahead, self.y + uy * lookahead, world):
openness += 1
else:
break
score = 0.65 * math.cos(offset) + 0.65 * openness
if score > best_score:
best_score = score
best_angle = a
return best_angle
def update(self, player_x, player_y, world):
self.active = abs(self.x - player_x) <= VIEW_RADIUS and abs(self.y - player_y) <= VIEW_RADIUS
if self.hp <= 0:
return
dx_player = player_x - self.x
dy_player = player_y - self.y
distance_sq = dx_player * dx_player + dy_player * dy_player
flee = self.active and self.type.behavior == 'prey' and (1e-05 < distance_sq < self.type.fear_distance ** 2)
if self.fleeing and (not flee):
self.move_timer = max(12, self.move_timer)
self.fleeing = flee
self.swim_phase += self.phase_rate
self.speed_phase += 0.055
self.move_timer -= 1
if self.move_timer <= 0:
self.cruise_heading += random.uniform(-1.1, 1.1)
self.move_timer = random.randint(30, 85)
self.phase_rate = random.uniform(0.075, 0.135)
detection_range = PREDATOR_DETECTION_RANGE_BY_NAME.get(self.type.name, PREDATOR_DETECTION_RANGE_DEFAULT)
player_detected = self.active and self.type.behavior == 'predator' and (distance_sq <= detection_range * detection_range)
if flee:
target_heading = math.atan2(-dy_player, -dx_player)
target_heading += math.sin(self.swim_phase * 1.8) * (0.1 + 0.25 * self.type.zigzag_strength)
elif player_detected and distance_sq > 0.01:
target_heading = math.atan2(dy_player, dx_player)
else:
target_heading = self.cruise_heading + self.type.zigzag_strength * math.sin(self.swim_phase)
max_turn = 0.48 if flee else 0.28 if self.type.behavior == 'predator' else 0.14
diff = self._angle_difference(target_heading, self.swim_heading)
self.swim_heading += max(-max_turn, min(max_turn, diff))
speed_wave = math.sin(self.speed_phase)
speed_factor = max(0.55, 1.0 + self.type.speed_variation * speed_wave)
if flee:
speed_factor *= self.type.flee_speed_boost * (1.0 + 0.12 * math.sin(self.swim_phase * 2.3))
speed = max(0.002, self.type.speed * speed_factor)
heading = self.swim_heading
nx = self.x + math.cos(heading) * speed
ny = self.y + math.sin(heading) * speed
if not self._can_swim_to(nx, ny, world):
alternate = self._find_open_heading(target_heading, speed, world)
if alternate is not None:
heading = alternate
nx = self.x + math.cos(heading) * speed
ny = self.y + math.sin(heading) * speed
self.swim_heading = heading
if not flee and self.type.behavior != 'predator':
self.cruise_heading = heading
self.move_timer = random.randint(20, 48)
self.stuck_frames = 0
else:
self.stuck_frames += 1
if self.stuck_frames >= 12:
relocated = False
for radius in (0.25, 0.65, 1.15, 2.0):
for i in range(16):
a = i * math.tau / 16.0
tx = self.x + math.cos(a) * radius
ty = self.y + math.sin(a) * radius
if self._can_swim_to(tx, ty, world):
self.x, self.y = (tx, ty)
self.swim_heading = a
self.cruise_heading = a
self.move_timer = 35
self.stuck_frames = 0
relocated = True
break
if relocated:
break
self.dx = self.dy = 0.0
self._update_rotation()
return
else:
self.stuck_frames = 0
self.x, self.y = (nx, ny)
self.dx = math.cos(heading) * speed
self.dy = math.sin(heading) * speed
new_visual = -math.degrees(heading) if self.type.fish_style else math.degrees(heading + math.pi / 2)
if abs(self._angle_difference(math.radians(new_visual), math.radians(getattr(self, 'visual_angle', 9999.0)))) > 0.045:
self._update_rotation()
class CaveWorldProxy:
def __init__(self, object_grid):
self.object_grid = object_grid
self.cave_lookup = None
def get_object_at(self, x, y):
return self.object_grid.get((int(x), int(y)))
def remove_object_at(self, x, y):
return self.object_grid.pop((int(x), int(y)), None)
class NightWaterSpark:
"""Ночной планктон/малёк: только декоративная точка без физики и лута.
Координаты задаются в клетках мира; крошечные световые ореолы
формируются уже на экране. Не влияет на спавн полноценных рыб.
"""
__slots__ = ('x', 'y', 'vx', 'vy', 'phase', 'blink_speed', 'wander', 'jitter', 'radius', 'color', 'fry', 'flee_distance', 'flee_speed')
def __init__(self, rng, x, y):
self.x = float(x)
self.y = float(y)
self.fry = rng.random() < NIGHT_FRY_CHANCE
angle = rng.uniform(0.0, math.tau)
speed = rng.uniform(0.018, 0.065) if self.fry else rng.uniform(0.002, 0.022)
self.vx = math.cos(angle) * speed
self.vy = math.sin(angle) * speed
self.wander = rng.uniform(0.0015, 0.012 if self.fry else 0.006)
self.phase = rng.uniform(0, math.tau)
self.blink_speed = rng.uniform(0.06, 0.27)
self.jitter = rng.uniform(0.3, 1.5 if self.fry else 1.1)
roll = rng.random()
if self.fry:
self.radius = rng.uniform(1.65, 2.45) if roll < 0.9 else rng.uniform(2.55, 3.25)
else:
self.radius = rng.uniform(0.65, 1.4) if roll < 0.89 else rng.uniform(1.45, 2.35)
palette = ((88, 204, 255), (113, 229, 255), (66, 158, 255), (155, 240, 255), (79, 195, 242), (58, 147, 239), (73, 235, 208), (114, 255, 208), (190, 157, 255), (245, 186, 255), (255, 215, 130), (156, 255, 158))
self.color = palette[rng.randrange(6) if rng.random() < 0.79 else rng.randrange(6, len(palette))]
if rng.random() < NIGHT_FLEE_CHANCE + (0.1 if self.fry else 0.0):
self.flee_distance = rng.uniform(1.6, 5.7 if self.fry else 3.9)
self.flee_speed = rng.uniform(0.065, 0.145) if self.fry else rng.uniform(0.024, 0.065)
else:
self.flee_distance = 0.0
self.flee_speed = 0.0
class Game:
def __init__(self):
pygame.init()
display_flags = pygame.FULLSCREEN
if hasattr(pygame, 'SCALED'):
display_flags |= pygame.SCALED
self.screen = pygame.display.set_mode((SCREEN_SIZE, SCREEN_SIZE), display_flags)
pygame.display.set_caption('2D Subnautica — каноническая фауна и флора')
self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont('Arial', 20)
self.large_font = pygame.font.SysFont('Arial', 32)
self.small_font = pygame.font.SysFont('Arial', 15)
self.tiny_font = pygame.font.SysFont('Arial', 13)
self.screen.fill((8, 18, 30))
loading = self.large_font.render('Генерация мира...', True, (200, 230, 255))
self.screen.blit(loading, loading.get_rect(center=(SCREEN_SIZE // 2, SCREEN_SIZE // 2)))
pygame.display.flip()
init_assets()
self.world = World()
# Аврора лежит на востоке карты (примерно x=750..850).
# Выбираем безопасное мелководье к западу, лицом к Авроре.
candidates = []
for xx in range(690, 450, -2):
for yy in range(490, 550, 2):
biome = self.world.get_biome_at(xx, yy)
if (biome is not None and biome.name == 'Мелководье'
and all(self.world.get_biome_at(xx+dx, yy+dy) is biome for dx in (-4, 4) for dy in (-3, 3))
and self.world.is_water_at(xx, yy)):
candidates.append((xx, yy))
if not candidates:
for xx in range(700, 300, -4):
for yy in range(420, 620, 4):
biome = self.world.get_biome_at(xx, yy)
if biome is not None and biome.name == 'Мелководье' and self.world.is_water_at(xx, yy):
candidates.append((xx, yy))
self.player_x, self.player_y = (candidates[0] if candidates else (MAP_SIZE // 2, MAP_SIZE // 2))
capsule_x1 = int(self.player_x) - START_CAPSULE_WIDTH // 2
capsule_y1 = int(self.player_y) - START_CAPSULE_HEIGHT // 2
capsule_x2 = capsule_x1 + START_CAPSULE_WIDTH - 1
capsule_y2 = capsule_y1 + START_CAPSULE_HEIGHT - 1
self.start_capsule_rect = (capsule_x1, capsule_y1, capsule_x2, capsule_y2)
self.start_capsule_inner_rect = (capsule_x1 + 1, capsule_y1 + 1, capsule_x2 - 1, capsule_y2 - 1)
door_y = capsule_y2
door_x1 = capsule_x1 + START_CAPSULE_WIDTH // 2 - 1
self.start_capsule_door_cells = {(door_x1, door_y), (door_x1 + 1, door_y)}
for cy in range(capsule_y1, capsule_y2 + 1):
for cx in range(capsule_x1, capsule_x2 + 1):
self.world.object_grid.pop((cx, cy), None)
self.world.flora_grid.pop((cx, cy), None)
self.world.animals = [animal for animal in self.world.animals if not (capsule_x1 <= int(animal.x) <= capsule_x2 and capsule_y1 <= int(animal.y) <= capsule_y2)]
self.world.rebuild_animal_index()
self.world.creature_blocked = self.is_start_capsule_wall
# Старт игрока в центре каюты №6 Авроры. Координаты помещений
# смещены при построении карты на AURORA_SHIFT_X = 550.
self.player_x, self.player_y = 793.0, 635.0
# Убираем возможный предмет из стартовой клетки.
self.world.object_grid.pop((int(self.player_x), int(self.player_y)), None)
self.in_cave = False
self.cave_tunnels = []
self.cave_animals = []
self.cave_object_grid = {}
self.cave_flora_grid = {}
self.cave_world = CaveWorldProxy(self.cave_object_grid)
self.cave_world.cave_lookup = self.get_cave_tunnel_at
self.cave_entrances = []
self.cave_tunnel_chunks = {}
self.cave_tunnel_cache = {}
self.surface_cave_entrance_cells = {}
self.cave_exit_cells = {}
self._init_caves()
# Счётчики предметов/флоры поддерживаются при подборе и респавне:
# щелчок по биому больше не сканирует всю карту.
self._install_biome_map_index()
self.hostile_projectiles = []
self.special_explosions = []
self.plant_attack_timers = {}
self.running = True
self.state = 'playing'
self.settings_open = False
self.setting_plankton_enabled = NIGHT_PLANKTON_ENABLED
self.setting_seabed_details_enabled = True
self.setting_water_darkening_enabled = True
# Выключатель всех NPC в настройках: одиночный режим.
# Состояние бригады сохраняется при включении/выключении в рамках запуска игры.
self.setting_workers_enabled = True
self.player_hp = 25
self.max_player_hp = 25
self.player_attack_damage = 5
self.player_attack_range = 1.5
self.max_hunger = 100
self.max_thirst = 100
self.player_hunger = self.max_hunger
self.player_thirst = self.max_thirst
self.hunger_timer = 0
self.thirst_timer = 0
self.equipped_weapon = None
self.cooldown_animals = set()
self.mouse_world_x = 0
self.mouse_world_y = 0
self.player_angle = 0.0
self.inventory = [None] * INVENTORY_SIZE
self.inventory_counts = {}
self.active_slot_index = -1
self._stored_companion_hp = {}
self._load_start_inventory()
self.pet_list_page = 0
self.pets_per_page = 9
self.selected_pet = None
self.surface_dropped_items = {}
self.cave_dropped_items = {}
self.craft_page = 0
self.craft_station_index = 0
self.recipes_per_page = 9
self.max_craft_page = max(0, (len(recipes) - 1) // self.recipes_per_page)
self.notification = ''
self.notification_timer = 0
self.flashlight_on = False
self.vehicle_headlights_on = VEHICLE_HEADLIGHTS_DEFAULT
self.seaglide_on = False
self.active_flare_timer = 0
self.speed_boost_timer = 0
self.damage_resist_timer = 0
self.damage_vignette_flash = 0
self.damage_vignette_surface = None
self.scanner_highlight_timer = 0
self.scanner_highlight_cells = set()
self.scanned_entries = set()
self.researched_flora = set()
self.scanner_target = None
self.scanner_scroll_offset = 0
self.scanner_scroll_max = 0
self.scanner_preview_cache = {}
self.planted_flora = {}
self.planting_target = None
self.planting_page = 0
self.plants_per_page = 9
self.active_decoys = []
self.base_effect_tick = 0
self.diver_sprite = generate_diver_sprite(size=CELL_SIZE, suit_color=(30, 100, 180), accent_color=(255, 200, 50), tank_color=(200, 205, 215))
self.walker_sprites = (generate_walker_sprite(CELL_SIZE, -1), generate_walker_sprite(CELL_SIZE, 0), generate_walker_sprite(CELL_SIZE, 1))
self.player_mode = 'swimming'
self.walk_animation = 0
self.diver_rotated_cache = {}
self.weapon_rotated_cache = {}
self.vehicle_rotated_cache = {}
self.inventory_image_cache = {}
self.tree_sprite_cache = {}
self.active_vehicle = None
self.vehicle_max_hp = {seamoth_type: 50, cyclops_type: 400}
self.vehicle_hp = dict(self.vehicle_max_hp)
self.vehicle_wrecks = []
self.vehicle_torpedoes = []
self.vehicle_torpedo_cooldown = 0
self.inventory_overlay = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE), pygame.SRCALPHA).convert_alpha()
self.inventory_overlay.fill((0, 0, 0, 180))
self.crafting_overlay = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE), pygame.SRCALPHA).convert_alpha()
self.crafting_overlay.fill((0, 0, 0, 200))
self.camera_zoom = 1.0
self._render_view_radius = VIEW_RADIUS
self._render_canvas_size = SCREEN_SIZE
self._world_render_surface = None
self._blue_edge_haze = self._make_blue_edge_haze()
self._night_edge_vignette = self._make_night_edge_vignette()
self._hd_source_cache = {}
self._hd_glow_source_cache = {}
self._hd_sprite_cache = OrderedDict()
self._hd_floor_cache = OrderedDict()
self.map_zoom = 1.0
self.map_min_zoom = 0.3
self.map_max_zoom = 20.0
self.map_base_scale = SCREEN_SIZE / MAP_SIZE
self.map_center_x = self.player_x
self.map_center_y = self.player_y
self.selected_biome = None
self.map_panel_scroll = 0
self._map_biome_species_cache = None
self.whale_quests = []
self.whale_pending_punishment = False
self.whale_punishment_remaining = 0
self.whale_quest_serial = 0
self.whale_popup_queue = []
self.whale_quest_panel_page = 0
pass
self.day_time = 0.3
self.day_cycle_seconds = 180.0
self.day_speed = 1.0 / (self.day_cycle_seconds * 60.0)
self.elapsed_game_days = 0.0
self.ecosystem_refresh_interval_days = 4.0
self.next_ecosystem_refresh_day = self.ecosystem_refresh_interval_days
self._capture_ecosystem_targets()
self.day_overlay = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE)).convert()
self.lighting_overlay = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE), pygame.SRCALPHA).convert_alpha()
self._radial_light_cache = {}
self._beam_light_cache = {}
self._emitter_glow_cache = {}
self._day_overlay_key = None
self.frame_count = 0
self.force_midnight_preview = False
self._bio_planted_scaled_cache = {}
self._night_rng = random.Random(56021026)
self._night_sparks = []
self._night_sparks_visible = 0
self._night_spark_surface = None
self._seabed_tile_cache = {}
self._seabed_theme_cache = {}
self._cave_floor_tile_cache = {}
self._cave_wall_tile_cache = {}
self._cave_theme_cache = {}
pygame.mouse.set_visible(True)
@staticmethod
def _increment_target(counter, key, amount=1):
counter[key] = counter.get(key, 0) + amount
def _capture_ecosystem_targets(self):
"""Запоминает исходную численность ресурсов и дикой фауны.
Целевые значения снимаются уже после создания стартовой капсулы и
пещер, поэтому восстановление не пытается вернуть то, что генератор
намеренно убрал из стартовой зоны. Питомцы в целевую популяцию не
входят: приручение существа уменьшает дикую популяцию, и позже
недостающий дикий экземпляр может восстановиться.
"""
self.surface_resource_targets = {}
self.cave_resource_targets = {}
self.surface_fauna_targets = {}
self.cave_fauna_targets = {}
for (x, y), obj in self.world.object_grid.items():
if getattr(obj, 'is_buildable', False) or getattr(obj, 'is_weapon', False):
continue
biome = self.world.get_biome_at(x, y)
if biome is None:
continue
self._increment_target(self.surface_resource_targets, (biome, obj))
for (x, y), obj in self.cave_object_grid.items():
if getattr(obj, 'is_buildable', False):
continue
tunnel = self.get_cave_tunnel_at(x, y)
if tunnel is None:
continue
self._increment_target(self.cave_resource_targets, (tunnel, obj))
for animal in self.world.animals:
if getattr(animal, 'is_companion', False):
continue
home = getattr(animal, 'home_biome', None)
if home is None:
continue
self._increment_target(self.surface_fauna_targets, (home, animal.type))
for animal in self.cave_animals:
if getattr(animal, 'is_companion', False):
continue
home = getattr(animal, 'home_biome', None)
if home is None:
continue
self._increment_target(self.cave_fauna_targets, (home, animal.type))
def _surface_cell_in_start_capsule(self, x, y):
x1, y1, x2, y2 = self.start_capsule_rect
return x1 <= x <= x2 and y1 <= y <= y2
def _spawn_missing_surface_resource(self, biome, object_type):
"""Пытается разместить один ресурс в случайной точке его биома."""
for _attempt in range(160):
rx, ry = biome.random_point()
x = int(rx)
y = int(ry)
key = (x, y)
if self.world.get_biome_at(x, y) is not biome:
continue
if not biome.passable:
continue
if self._surface_cell_in_start_capsule(x, y):
continue
if key in self.surface_cave_entrance_cells:
continue
if self.world.get_object_at(x, y) is not None:
continue
if self.surface_dropped_items.get(key):
continue
self.world.object_grid[key] = object_type
return True
return False
def _spawn_missing_cave_resource(self, tunnel, object_type):
"""Пытается разместить один ресурс в случайной точке пещерной секции."""
for _attempt in range(160):
rx, ry = tunnel.random_point()
x = int(rx)
y = int(ry)
key = (x, y)
if self.get_cave_tunnel_at(x, y) is not tunnel:
continue
if key in self.cave_exit_cells:
continue
if key in self.cave_object_grid:
continue
if self.cave_dropped_items.get(key):
continue
self.cave_object_grid[key] = object_type
return True
return False
def _spawn_missing_surface_animal(self, biome, animal_type):
"""Возвращает одну дикую рыбу/существо в случайную точку биома."""
for _attempt in range(120):
x, y = biome.random_point()
if self.world.get_biome_at(int(x), int(y)) is not biome:
continue
if self._surface_cell_in_start_capsule(int(x), int(y)):
continue
self.world.animals.append(Animal(x, y, animal_type, biome))
return True
return False
def _spawn_missing_cave_animal(self, tunnel, animal_type):
"""Возвращает одно дикое существо в случайную точку пещерной секции."""
for _attempt in range(120):
x, y = tunnel.random_point()
if self.get_cave_tunnel_at(int(x), int(y)) is not tunnel:
continue
self.cave_animals.append(Animal(x, y, animal_type, tunnel))
return True
return False
def _count_current_surface_resources(self):
counts = {}
for (x, y), obj in self.world.object_grid.items():
if getattr(obj, 'is_buildable', False):
continue
biome = self.world.get_biome_at(x, y)
key = (biome, obj)
if key in self.surface_resource_targets:
self._increment_target(counts, key)
for (x, y), stack in self.surface_dropped_items.items():
biome = self.world.get_biome_at(x, y)
for item in stack:
key = (biome, item)
if key in self.surface_resource_targets:
self._increment_target(counts, key)
return counts
def _count_current_cave_resources(self):
counts = {}
for (x, y), obj in self.cave_object_grid.items():
if getattr(obj, 'is_buildable', False):
continue
tunnel = self.get_cave_tunnel_at(x, y)
key = (tunnel, obj)
if key in self.cave_resource_targets:
self._increment_target(counts, key)
for (x, y), stack in self.cave_dropped_items.items():
tunnel = self.get_cave_tunnel_at(x, y)
for item in stack:
key = (tunnel, item)
if key in self.cave_resource_targets:
self._increment_target(counts, key)
return counts
def _count_current_fauna(self, animals, targets):
counts = {}
for animal in animals:
if getattr(animal, 'is_companion', False):
continue
home = getattr(animal, 'home_biome', None)
key = (home, animal.type)
if key in targets:
self._increment_target(counts, key)
return counts
def refresh_ecosystem(self):
"""Восполняет недостающую фауну и ресурсы до стартовых значений."""
restored_resources = 0
restored_fauna = 0
surface_resource_counts = self._count_current_surface_resources()
cave_resource_counts = self._count_current_cave_resources()
for (biome, object_type), target_count in self.surface_resource_targets.items():
missing = max(0, target_count - surface_resource_counts.get((biome, object_type), 0))
for _ in range(missing):
if self._spawn_missing_surface_resource(biome, object_type):
restored_resources += 1
for (tunnel, object_type), target_count in self.cave_resource_targets.items():
missing = max(0, target_count - cave_resource_counts.get((tunnel, object_type), 0))
for _ in range(missing):
if self._spawn_missing_cave_resource(tunnel, object_type):
restored_resources += 1
surface_fauna_counts = self._count_current_fauna(self.world.animals, self.surface_fauna_targets)
cave_fauna_counts = self._count_current_fauna(self.cave_animals, self.cave_fauna_targets)
surface_added = False
for (biome, animal_type), target_count in self.surface_fauna_targets.items():
missing = max(0, target_count - surface_fauna_counts.get((biome, animal_type), 0))
for _ in range(missing):
if self._spawn_missing_surface_animal(biome, animal_type):
restored_fauna += 1
surface_added = True
for (tunnel, animal_type), target_count in self.cave_fauna_targets.items():
missing = max(0, target_count - cave_fauna_counts.get((tunnel, animal_type), 0))
for _ in range(missing):
if self._spawn_missing_cave_animal(tunnel, animal_type):
restored_fauna += 1
if surface_added:
self.world.rebuild_animal_index()
refresh_day = int(round(self.next_ecosystem_refresh_day))
if restored_fauna or restored_resources:
self.notification = f'Обновление экосистемы — день {refresh_day}: восстановлено рыб/существ {restored_fauna}, ресурсов {restored_resources}'
else:
self.notification = f'Обновление экосистемы — день {refresh_day}: пополнение не требуется'
self.notification_timer = 240
def _whale_quest_spec(self, kind):
specs = {'fasting': (20, 'НЕ ЕСТЬ 20 ЧАСОВ', 'Не ешьте 20 игровых часов; пить воду разрешено.'), 'marathon': (3, 'ПРОПЛЫТЬ 200 КЛЕТОК', 'За 3 игровых часа проплывите 200 клеток без транспорта.'), 'empty_inventory': (4, 'ВЫБРОСИТЬ ВЕСЬ ИНВЕНТАРЬ', 'За 4 игровых часа выбросьте P все вещи и существа. Инвентарь должен стать пустым.'), 'whale_hunt': (4, 'УНИЧТОЖИТЬ ЛЮБОГО ЧБОС-КИТА', 'За 4 игровых часа уничтожьте одного чёрно-бело-оранжево-синего кита.'), 'fauna_catalogue': (48, 'СОБРАТЬ ВСЮ ФАУНУ', 'За 2 игровых дня положите в инвентарь каждый обитающий в мире вид фауны хотя бы раз.')}
return specs[kind]
def _whale_visible_remaining(self, quest):
hours = max(0.0, (quest['deadline_day'] - self.elapsed_game_days) * 24)
return '%02d:%02d' % (int(hours), min(59, int(hours % 1 * 60)))
def _whale_quest_progress(self, quest):
kind = quest['kind']
if kind == 'marathon':
return 'Проплыто: %d/200 клеток' % int(quest['swum'])
if kind == 'fauna_catalogue':
seen, target = (quest['seen_fauna'], quest['fauna_target'])
return 'Найдено видов: %d/%d' % (len(seen & target), len(target))
if kind == 'empty_inventory':
return 'Предметов осталось: %d' % sum((item is not None for item in self.inventory))
if kind == 'fasting':
return 'Еда запрещена, вода разрешена'
if kind == 'whale_hunt':
return 'Уничтожено китов: 0/1'
return 'Выполните указанное задание'
def _queue_whale_popup(self, heading, quest, extra=''):
hours = quest['duration_hours']
duration = '2 игровых дня' if hours == 48 else '%d игровых часов' % hours
self.whale_popup_queue.append({'heading': heading, 'lines': [quest['title'], quest['instruction'], 'Срок задания: ' + duration, extra or self._whale_quest_progress(quest), 'ПРОВАЛ = 20 диких драконов-левиафанов в воде!'], 'until': None})
if len(self.whale_popup_queue) > 14:
self.whale_popup_queue = self.whale_popup_queue[-14:]
def _start_whale_quest(self, kind=None):
"""Волна навязывает 1-2 разных задания; действующие не заменяет."""
pool = ('fasting', 'marathon', 'empty_inventory', 'whale_hunt', 'fauna_catalogue')
available = [item for item in pool if not any((q['kind'] == item for q in self.whale_quests))]
if kind is not None:
available = [kind] if kind in available and kind in pool else []
if not available:
return
how_many = 1 if kind is not None else min(len(available), 2 if random.random() < 0.4 else 1)
for quest_kind in random.sample(available, how_many):
hours, title, instruction = self._whale_quest_spec(quest_kind)
self.whale_quest_serial += 1
quest = {'id': self.whale_quest_serial, 'kind': quest_kind, 'title': title, 'instruction': instruction, 'start_day': self.elapsed_game_days, 'deadline_day': self.elapsed_game_days + hours / 24, 'duration_hours': hours, 'swum': 0.0, 'seen_fauna': set(), 'fauna_target': frozenset((a.type.name for a in self.world.animals + self.cave_animals if not getattr(a, 'is_companion', False))), 'dropped_any': False}
self.whale_quests.append(quest)
self._queue_whale_popup('НОВЫЙ ОБЯЗАТЕЛЬНЫЙ КВЕСТ!', quest)
if quest_kind == 'empty_inventory' and (not any(self.inventory)):
self._finish_whale_quest(quest)
self.notification = 'ЧБОС-КИТ: НОВЫЕ КВЕСТЫ! СМОТРИТЕ ИНВЕНТАРЬ (R)'
self.notification_timer = 180
def _finish_whale_quest(self, quest):
if quest not in self.whale_quests:
return
self.whale_quests.remove(quest)
self._queue_whale_popup('КВЕСТ ВЫПОЛНЕН!', quest, 'Требование выполнено. Вы пережили кита!')
self.notification = 'ЧБОС-КИТ: КВЕСТ ВЫПОЛНЕН!'
self.notification_timer = 150
def _fail_whale_quest(self, quest, reason):
if quest not in self.whale_quests:
return
self.whale_quests.remove(quest)
self.whale_pending_punishment = True
self.whale_punishment_remaining += WHALE_PUNISHMENT_DRAGONS
self._queue_whale_popup('КВЕСТ ПРОВАЛЕН!', quest, 'Причина: ' + reason)
self.notification = 'ЧБОС-КИТ: ПРОВАЛ! +20 ДРАКОНОВ!'
self.notification_timer = 200
def _whale_quest_on_food(self):
for quest in list(self.whale_quests):
if quest['kind'] == 'fasting':
self._fail_whale_quest(quest, 'вы поели')
def _whale_quest_on_catch(self, animal_type, slot_index):
if isinstance(animal_type, AnimalType):
for quest in list(self.whale_quests):
if quest['kind'] == 'fauna_catalogue' and animal_type.name in quest['fauna_target']:
quest['seen_fauna'].add(animal_type.name)
if quest['seen_fauna'] >= quest['fauna_target']:
self._finish_whale_quest(quest)
def _whale_quest_on_drop(self):
for quest in list(self.whale_quests):
if quest['kind'] == 'empty_inventory':
quest['dropped_any'] = True
if not any(self.inventory):
self._finish_whale_quest(quest)
def _whale_quest_on_whale_death(self, animal):
if animal.type.name == WHALE_NAME and animal.hp <= 0 and (not getattr(animal, '_whale_kill_recorded', False)):
animal._whale_kill_recorded = True
for quest in list(self.whale_quests):
if quest['kind'] == 'whale_hunt':
self._finish_whale_quest(quest)
def _update_whale_quest(self, old_x, old_y):
for quest in list(self.whale_quests):
if quest['kind'] == 'marathon' and (not self._is_player_on_land()) and (self.active_vehicle is None):
step = math.hypot(self.player_x - old_x, self.player_y - old_y)
if step <= 1.5:
quest['swum'] += step
if quest['swum'] >= 200:
self._finish_whale_quest(quest)
continue
if self.elapsed_game_days >= quest['deadline_day']:
if quest['kind'] == 'fasting':
self._finish_whale_quest(quest)
else:
self._fail_whale_quest(quest, 'время истекло')
if self.whale_pending_punishment:
self._apply_whale_punishment()
def _apply_whale_punishment(self):
if not self.whale_pending_punishment or self._is_player_on_land():
return
created = 0
rng = random.Random(pygame.time.get_ticks() + self.whale_quest_serial * 1007)
used_cells = set()
remaining = self.whale_punishment_remaining
for i in range(remaining):
chosen = None
for attempt in range(500):
r = rng.uniform(2.0, 8.5) if attempt < 400 else rng.uniform(0.5, 12.0)
a = rng.uniform(0.0, math.tau)
x = self.player_x + math.cos(a) * r
y = self.player_y + math.sin(a) * r
if not (0 <= x < MAP_SIZE and 0 <= y < MAP_SIZE):
continue
if (int(x), int(y)) in used_cells:
continue
if self.in_cave:
home = self.get_cave_tunnel_at(x, y)
passable = home is not None and self.cave_is_passable(x, y)
else:
home = self.world.get_biome_at(x, y)
obj = self.world.get_object_at(int(x), int(y))
passable = home is not None and (not is_land_biome(home)) and (obj is None or not obj.is_wall) and (not self.is_start_capsule_wall(int(x), int(y)))
if passable:
chosen = (x, y, home)
break
if chosen is None:
break
x, y, home = chosen
used_cells.add((int(x), int(y)))
dragon = Animal(x, y, sea_dragon_type, home)
if self.in_cave:
self.cave_animals.append(dragon)
else:
self.world.animals.append(dragon)
created += 1
if created and (not self.in_cave):
self.world.rebuild_animal_index()
self.whale_punishment_remaining -= created
if self.whale_punishment_remaining <= 0:
self.whale_punishment_remaining = 0
self.whale_pending_punishment = False
self.notification = 'НАКАЗАНИЕ! 20 ДРАКОНОВ!'
self.notification_timer = 180
def _whale_wrap_text(self, text, font, max_width):
"""Переносит русские строки по ширине колонки без обрезания."""
result = []
for paragraph in str(text).split('\n'):
line = ''
for word in paragraph.split():
candidate = (line + ' ' + word).strip()
if font.size(candidate)[0] <= max_width:
line = candidate
else:
if line:
result.append(line)
line = word
while line and font.size(line)[0] > max_width:
cutoff = max(1, len(line) - 1)
while cutoff > 1 and font.size(line[:cutoff])[0] > max_width:
cutoff -= 1
result.append(line[:cutoff])
line = line[cutoff:]
result.append(line)
return result
def _draw_whale_quest_hud(self):
if self.state != 'playing':
return
count = len(self.whale_quests)
if not count and (not self.whale_pending_punishment):
return
status = 'КВЕСТЫ КИТА: %d | R: СПИСОК' % count
if self.whale_pending_punishment:
status += ' | ДРАКОНОВ: %d' % self.whale_punishment_remaining
rect = pygame.Rect(153, 53, SCREEN_SIZE - 165, 31)
pygame.draw.rect(self.screen, (20, 32, 44), rect, border_radius=6)
pygame.draw.rect(self.screen, (246, 149, 45), rect, 2, border_radius=6)
self.screen.blit(self.tiny_font.render(status, True, (255, 206, 112)), (160, 61))
def _draw_whale_quest_popup(self):
if self.state != 'playing' or self.settings_open or (not self.whale_popup_queue):
return
now = pygame.time.get_ticks()
popup = self.whale_popup_queue[0]
if popup['until'] is None:
popup['until'] = now + 6000
if now >= popup['until']:
self.whale_popup_queue.pop(0)
return
panel = pygame.Rect(65, 171, SCREEN_SIZE - 130, 240)
pygame.draw.rect(self.screen, (16, 28, 41), panel, border_radius=10)
pygame.draw.rect(self.screen, (252, 156, 38), panel, 3, border_radius=10)
header = self.small_font.render(popup['heading'], True, (255, 187, 93))
self.screen.blit(header, (panel.x + 15, panel.y + 12))
y = panel.y + 45
for i, line in enumerate(popup['lines']):
color = (255, 224, 140) if i == 0 else (232, 240, 242)
if i == len(popup['lines']) - 1:
color = (255, 125, 111)
for wrapped in self._whale_wrap_text(line, self.small_font, panel.width - 30):
if y >= panel.bottom - 23:
break
self.screen.blit(self.small_font.render(wrapped, True, color), (panel.x + 15, y))
y += 21
y += 6
def _inventory_grid_layout(self):
grid_width = INVENTORY_COLS * (SLOT_SIZE + SLOT_MARGIN) - SLOT_MARGIN
grid_height = INVENTORY_ROWS * (SLOT_SIZE + SLOT_MARGIN) - SLOT_MARGIN
return (25, (SCREEN_SIZE - grid_height) // 2, grid_width, grid_height)
def _draw_whale_quest_inventory_column(self):
x, y, w, h = (390, 112, SCREEN_SIZE - 405, 445)
panel = pygame.Rect(x, y, w, h)
pygame.draw.rect(self.screen, (20, 30, 42), panel, border_radius=8)
pygame.draw.rect(self.screen, (234, 138, 48), panel, 2, border_radius=8)
self.screen.blit(self.tiny_font.render('ЗАДАНИЯ ЧБОС-КИТА', True, (255, 183, 91)), (x + 10, y + 12))
quests_per_page = 2
pages = max(1, (len(self.whale_quests) + quests_per_page - 1) // quests_per_page)
self.whale_quest_panel_page = min(self.whale_quest_panel_page, pages - 1)
start = self.whale_quest_panel_page * quests_per_page
selected = self.whale_quests[start:start + quests_per_page]
if not selected:
self.screen.blit(self.tiny_font.render('Активных квестов нет', True, (183, 213, 208)), (x + 12, y + 55))
for i, quest in enumerate(selected):
top = y + 44 + i * 176
if i:
pygame.draw.line(self.screen, (93, 94, 103), (x + 9, top - 7), (x + w - 9, top - 7))
max_line_width = w - 22
yy = top
title_lines = self._whale_wrap_text(quest['title'], self.tiny_font, max_line_width)[:2]
for line in title_lines:
self.screen.blit(self.tiny_font.render(line, True, (255, 196, 106)), (x + 10, yy))
yy += 17
info = self._whale_wrap_text(quest['instruction'], self.tiny_font, max_line_width)
for line in info[:4]:
self.screen.blit(self.tiny_font.render(line, True, (222, 226, 236)), (x + 10, yy))
yy += 15
progress_lines = self._whale_wrap_text(self._whale_quest_progress(quest), self.tiny_font, max_line_width)
for line in progress_lines[:2]:
self.screen.blit(self.tiny_font.render(line, True, (150, 232, 220)), (x + 10, yy))
yy += 15
self.screen.blit(self.tiny_font.render('ОСТАЛОСЬ: ' + self._whale_visible_remaining(quest), True, (255, 167, 116)), (x + 10, max(top + 142, yy + 2)))
if self.whale_pending_punishment:
self.screen.blit(self.tiny_font.render('Наказание: %d драконов' % self.whale_punishment_remaining, True, (255, 110, 90)), (x + 10, y + h - 43))
self.screen.blit(self.tiny_font.render('PgUp/PgDn: %d/%d' % (self.whale_quest_panel_page + 1, pages), True, (180, 200, 221)), (x + 10, y + h - 23))
def _is_player_on_land(self):
return not self.in_cave and is_land_biome(self.world.get_biome_at(self.player_x, self.player_y))
def get_diver_rotated(self, angle):
angle = quantize_angle(angle)
walking = self._is_player_on_land()
self.player_mode = 'walking' if walking else 'swimming'
stride = (1 if self.walk_animation // 8 % 2 else -1) if walking and self.walk_animation > 0 else 0
cache_key = ('walk', stride, angle) if walking else ('swim', angle)
image = self.diver_rotated_cache.get(cache_key)
if image is None:
source = self.walker_sprites[stride + 1] if walking else self.diver_sprite
image = pygame.transform.rotate(source, -angle)
self.diver_rotated_cache[cache_key] = image
return image
def get_weapon_rotated(self, weapon, angle):
if weapon is None or weapon.image is None:
return None
angle = quantize_angle(angle)
key = (id(weapon), angle)
image = self.weapon_rotated_cache.get(key)
if image is None:
image = pygame.transform.rotate(weapon.image, -angle)
if len(self.weapon_rotated_cache) >= 720:
self.weapon_rotated_cache.clear()
self.weapon_rotated_cache[key] = image
return image
def get_vehicle_rotated(self, vehicle_type, angle):
if vehicle_type is None or vehicle_type.image is None:
return None
angle = quantize_angle(angle)
key = (id(vehicle_type), angle)
image = self.vehicle_rotated_cache.get(key)
if image is None:
image = pygame.transform.rotate(vehicle_type.image, -angle)
if len(self.vehicle_rotated_cache) >= 360:
self.vehicle_rotated_cache.clear()
self.vehicle_rotated_cache[key] = image
return image
def _remove_inventory_items_of_type(self, item_type, amount=1):
amount = max(0, int(amount))
if amount == 0:
return True
if self.inventory_counts.get(item_type, 0) < amount:
return False
remaining = amount
for index, item in enumerate(self.inventory):
if remaining <= 0:
break
if item is item_type:
self.inventory[index] = None
self._stored_companion_hp.pop(index, None)
if index == self.active_slot_index:
self.active_slot_index = -1
remaining -= 1
new_count = self.inventory_counts.get(item_type, 0) - amount
if new_count > 0:
self.inventory_counts[item_type] = new_count
else:
self.inventory_counts.pop(item_type, None)
return remaining == 0
def _nearest_vehicle_wreck(self, radius=1.7):
best = None
radius_sq = radius * radius
for wreck in self.vehicle_wrecks:
if wreck['in_cave'] != self.in_cave:
continue
dx = wreck['x'] - self.player_x
dy = wreck['y'] - self.player_y
dist_sq = dx * dx + dy * dy
if dist_sq <= radius_sq and (best is None or dist_sq < best[0]):
best = (dist_sq, wreck)
return best[1] if best is not None else None
def _preferred_inventory_vehicle(self):
if 0 <= self.active_slot_index < len(self.inventory):
selected = self.inventory[self.active_slot_index]
if selected in self.vehicle_max_hp:
return selected
for item in self.inventory:
if item in self.vehicle_max_hp:
return item
return None
def handle_vehicle_action(self):
if self.state not in ('playing', 'inventory'):
return
if self.active_vehicle is not None:
name = self.active_vehicle.name
self.active_vehicle = None
self.state = 'playing'
self.notification = f'Вы покинули: {name}'
self.notification_timer = 60
return
if self._is_player_on_land():
self.notification = 'Подлодки работают только в воде. Вернитесь к берегу.'
self.notification_timer = 100
return
wreck = self._nearest_vehicle_wreck()
if wreck is not None:
vehicle_type = wreck['type']
kits_needed = 2 if vehicle_type is cyclops_type else 1
kits_have = self.inventory_counts.get(repair_kit, 0)
if kits_have < kits_needed:
self.notification = f'Для ремонта {vehicle_type.name} нужно ремкомплектов: {kits_needed} (есть {kits_have})'
self.notification_timer = 100
return
if not self._remove_inventory_items_of_type(repair_kit, kits_needed):
return
if not self.add_to_inventory(vehicle_type):
for _ in range(kits_needed):
self.add_to_inventory(repair_kit)
self.notification = 'Нет места для восстановленного транспорта'
self.notification_timer = 90
return
self.vehicle_hp[vehicle_type] = self.vehicle_max_hp[vehicle_type]
self.vehicle_wrecks.remove(wreck)
self.active_vehicle = vehicle_type
self.state = 'playing'
self.notification = f'{vehicle_type.name} восстановлен за {kits_needed} ремкомплект(а) и снова готов к работе'
self.notification_timer = 120
return
vehicle_type = self._preferred_inventory_vehicle()
if vehicle_type is None:
self.notification = 'В инвентаре нет Мотылька или Циклопа'
self.notification_timer = 75
return
if self.vehicle_hp.get(vehicle_type, 0) <= 0:
self.notification = f'{vehicle_type.name} уничтожен. Вернитесь к месту крушения для ремонта'
self.notification_timer = 90
return
self.active_vehicle = vehicle_type
self.state = 'playing'
self.notification = f'Управление: {vehicle_type.name} | WASD — движение | ЛКМ — торпеда | F — выйти'
self.notification_timer = 120
def vehicle_take_damage(self, damage):
if self.active_vehicle is None:
self.player_take_damage(damage)
return
vehicle_type = self.active_vehicle
damage = max(1, int(round(damage)))
self.vehicle_hp[vehicle_type] = max(0, self.vehicle_hp.get(vehicle_type, self.vehicle_max_hp[vehicle_type]) - damage)
hp = self.vehicle_hp[vehicle_type]
self.notification = f'{vehicle_type.name}: -{damage} HP, осталось {hp}'
self.notification_timer = 45
if hp <= 0:
self._destroy_active_vehicle()
def _destroy_active_vehicle(self):
vehicle_type = self.active_vehicle
if vehicle_type is None:
return
self.vehicle_wrecks.append({'type': vehicle_type, 'x': float(self.player_x), 'y': float(self.player_y), 'in_cave': bool(self.in_cave), 'angle': float(self.player_angle)})
self._remove_inventory_items_of_type(vehicle_type, 1)
self.vehicle_hp[vehicle_type] = 0
self.active_vehicle = None
kits_needed = 2 if vehicle_type is cyclops_type else 1
self.notification = f'{vehicle_type.name} уничтожен! Игрок не получил урона. Вернитесь сюда с ремкомплектами: {kits_needed}'
self.notification_timer = 180
def fire_vehicle_torpedo(self, target_x, target_y):
vehicle_type = self.active_vehicle
if vehicle_type is None or self.state != 'playing':
return
if self.vehicle_torpedo_cooldown > 0:
return
dx = target_x - self.player_x
dy = target_y - self.player_y
length = math.hypot(dx, dy)
if length <= 0.001:
radians = math.radians(self.player_angle)
dx, dy = (math.cos(radians), math.sin(radians))
else:
dx /= length
dy /= length
if vehicle_type is cyclops_type:
speed, damage, cooldown = (0.34, 45, 26)
else:
speed, damage, cooldown = (0.44, 30, 18)
self.vehicle_torpedoes.append({'x': self.player_x + dx * 0.9, 'y': self.player_y + dy * 0.9, 'vx': dx * speed, 'vy': dy * speed, 'damage': damage, 'ttl': 85, 'in_cave': bool(self.in_cave)})
self.vehicle_torpedo_cooldown = cooldown
def _update_vehicle_torpedoes(self):
if self.vehicle_torpedo_cooldown > 0:
self.vehicle_torpedo_cooldown -= 1
if not self.vehicle_torpedoes:
return
survivors = []
current_animals = self.get_current_animals()
for torpedo in self.vehicle_torpedoes:
if torpedo['in_cave'] != self.in_cave:
continue
torpedo['x'] += torpedo['vx']
torpedo['y'] += torpedo['vy']
torpedo['ttl'] -= 1
if torpedo['ttl'] <= 0:
continue
if not (0 <= torpedo['x'] < MAP_SIZE and 0 <= torpedo['y'] < MAP_SIZE):
continue
if self.in_cave and (not self.cave_is_passable(torpedo['x'], torpedo['y'])):
continue
hit = None
for animal in current_animals:
if animal.hp <= 0 or getattr(animal, 'is_companion', False):
continue
dx = animal.x - torpedo['x']
dy = animal.y - torpedo['y']
visual_radius = max(0.35, animal.type.size / max(1, CELL_SIZE) * 0.32)
if dx * dx + dy * dy <= visual_radius * visual_radius:
hit = animal
break
if hit is None and (not self.in_cave):
golems = self._near_golems(torpedo['x'], torpedo['y'], 0.9, ('active',))
if golems:
golem = min(golems, key=lambda g: (g.x - torpedo['x']) ** 2 + (g.y - torpedo['y']) ** 2)
self._damage_golem(golem, torpedo['damage'])
continue
if hit is None and (not self.in_cave):
mosses = self._moss_near(torpedo['x'], torpedo['y'], 0.55)
if mosses:
moss = min(mosses, key=lambda m: (m.x - torpedo['x']) ** 2 + (m.y - torpedo['y']) ** 2)
self._hit_mad_moss(moss, torpedo['damage'])
continue
if hit is not None:
hit.hp -= torpedo['damage']
self.notification = f'Торпеда поразила {hit.type.name}: HP {max(0, int(hit.hp))}'
self.notification_timer = 45
if hit.hp <= 0:
self.handle_animal_death(hit)
continue
survivors.append(torpedo)
self.vehicle_torpedoes = survivors
def draw_vehicle_wrecks(self, camera_x, camera_y):
for wreck in self.vehicle_wrecks:
if wreck['in_cave'] != self.in_cave:
continue
sx = int((wreck['x'] - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = int((wreck['y'] - camera_y) * CELL_SIZE + CELL_SIZE // 2)
if not (-220 <= sx <= self._render_canvas_size + 220 and -220 <= sy <= self._render_canvas_size + 220):
continue
image = self.get_vehicle_rotated(wreck['type'], wreck['angle'])
if image is not None:
wreck_image = image.copy()
wreck_image.set_alpha(115)
self.screen.blit(wreck_image, wreck_image.get_rect(center=(sx, sy)))
r = 13 if wreck['type'] is seamoth_type else 18
pygame.draw.circle(self.screen, (190, 45, 35), (sx, sy), r, 2)
pygame.draw.line(self.screen, (255, 80, 55), (sx - r, sy - r), (sx + r, sy + r), 3)
pygame.draw.line(self.screen, (255, 80, 55), (sx - r, sy + r), (sx + r, sy - r), 3)
def draw_vehicle_torpedoes(self, camera_x, camera_y):
for torpedo in self.vehicle_torpedoes:
if torpedo['in_cave'] != self.in_cave:
continue
sx = int((torpedo['x'] - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = int((torpedo['y'] - camera_y) * CELL_SIZE + CELL_SIZE // 2)
dx, dy = (torpedo['vx'], torpedo['vy'])
length = max(0.001, math.hypot(dx, dy))
nx, ny = (dx / length, dy / length)
tail = (int(sx - nx * 10), int(sy - ny * 10))
pygame.draw.line(self.screen, (85, 210, 235), tail, (sx, sy), 5)
pygame.draw.circle(self.screen, (240, 245, 210), (sx, sy), 3)
def _draw_hostile_attacks(self, camera_x, camera_y):
"""Рисует иглы, огненные шары, газовые пузырьки и взрывы."""
now = pygame.time.get_ticks()
for projectile in self.hostile_projectiles:
if projectile['in_cave'] != self.in_cave:
continue
sx = int((projectile['x'] - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = int((projectile['y'] - camera_y) * CELL_SIZE + CELL_SIZE // 2)
kind = projectile['kind']
if kind == 'needle':
length = max(7, int(CELL_SIZE * 0.28))
mag = max(0.001, math.hypot(projectile['vx'], projectile['vy']))
ux = projectile['vx'] / mag
uy = projectile['vy'] / mag
tail = (int(sx - ux * length), int(sy - uy * length))
pygame.draw.line(self.screen, (55, 75, 35), tail, (sx, sy), 5)
pygame.draw.line(self.screen, (205, 235, 125), tail, (sx, sy), 2)
pygame.draw.circle(self.screen, (240, 250, 180), (sx, sy), 2)
elif kind == 'quest_wave':
phase = self.frame_count % 24 / 24.0
r = 12 + int(6 * phase)
pygame.draw.circle(self.screen, (15, 74, 120), (sx, sy), r + 3, 3)
pygame.draw.circle(self.screen, (75, 218, 250), (sx, sy), r, 3)
pygame.draw.circle(self.screen, (250, 164, 44), (sx, sy), 5, 2)
pygame.draw.circle(self.screen, (240, 255, 255), (sx, sy), 2)
elif kind == 'fireball':
pygame.draw.circle(self.screen, (180, 35, 20), (sx, sy), 10)
pygame.draw.circle(self.screen, (255, 90, 25), (sx, sy), 7)
pygame.draw.circle(self.screen, (255, 220, 95), (sx, sy), 3)
else:
pygame.draw.circle(self.screen, (45, 115, 65), (sx, sy), 7)
pygame.draw.circle(self.screen, (155, 225, 120), (sx, sy), 5)
pygame.draw.circle(self.screen, (215, 250, 165), (sx - 1, sy - 2), 2)
survivors = []
for effect in self.special_explosions:
if effect['in_cave'] != self.in_cave:
survivors.append(effect)
continue
if now >= effect['expires_at']:
continue
duration = max(1, effect['expires_at'] - effect['created_at'])
progress = max(0.0, min(1.0, (now - effect['created_at']) / duration))
sx = int((effect['x'] - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = int((effect['y'] - camera_y) * CELL_SIZE + CELL_SIZE // 2)
max_radius = max(5, int(effect.get('radius', 1.0) * CELL_SIZE))
radius = max(3, int(max_radius * (0.35 + 0.65 * progress)))
if effect['kind'] in ('fireball',):
pygame.draw.circle(self.screen, (170, 40, 20), (sx, sy), radius)
pygame.draw.circle(self.screen, (255, 120, 35), (sx, sy), max(2, radius * 2 // 3))
pygame.draw.circle(self.screen, (255, 230, 120), (sx, sy), max(2, radius // 3))
else:
pygame.draw.circle(self.screen, (100, 190, 85), (sx, sy), radius, 3)
pygame.draw.circle(self.screen, (185, 235, 125), (sx, sy), max(2, radius // 2), 2)
survivors.append(effect)
self.special_explosions = survivors
def get_inventory_image(self, item):
if item.image is None:
return None
key = id(item)
cached = self.inventory_image_cache.get(key)
if cached is not None:
return cached
image = item.image
max_size = SLOT_SIZE - 6
scale = min(max_size / image.get_width(), max_size / image.get_height(), 1.0)
if scale < 1.0:
width = max(1, int(image.get_width() * scale))
height = max(1, int(image.get_height() * scale))
image = pygame.transform.smoothscale(image, (width, height))
self.inventory_image_cache[key] = image
return image
@staticmethod
def _move_point_inside_biome(biome, x, y, target_x, target_y):
"""Pull a point towards the biome center until it is inside."""
for _ in range(30):
if biome.contains(x, y):
return (x, y)
x = (x + target_x) / 2.0
y = (y + target_y) / 2.0
return (target_x, target_y)
def _find_cave_entrance_point(self, biome, angle, inset=18):
cx, cy = biome.center()
dx = math.cos(angle)
dy = math.sin(angle)
x1, y1, x2, y2 = biome.rect
max_distance = max(x2 - x1, y2 - y1) * 0.8 + 30
last_x = cx
last_y = cy
distance = 0.0
while distance <= max_distance:
px = cx + dx * distance
py = cy + dy * distance
if not biome.contains(px, py):
break
last_x = px
last_y = py
distance += 2.0
last_x -= dx * inset
last_y -= dy * inset
return self._move_point_inside_biome(biome, last_x, last_y, cx, cy)
def _init_caves(self):
"""Создаёт единую связанную подземную сеть.
Каждый поверхностный биом получает:
- один крупный центральный зал;
- три независимых выхода на поверхность;
- три боковых зала;
- локальные коридоры между ними.
После этого центральные залы всех биомов соединяются минимальным
остовным деревом и дополнительными перемычками. Поэтому из любой
пещеры можно под землёй добраться до любой другой, а у каждого
локального узла существенно больше двух направлений движения.
"""
self.cave_tunnels = []
self.cave_entrances = []
self.cave_object_grid.clear()
self.cave_flora_grid.clear()
self.cave_animals = []
self.cave_hubs = []
self.cave_connections = []
self.cave_tunnel_chunks.clear()
self.cave_tunnel_cache.clear()
self.surface_cave_entrance_cells.clear()
self.cave_exit_cells.clear()
surface_biomes = [biome for biome in self.world.biomes if biome.cave_enabled]
for biome_index, parent in enumerate(surface_biomes):
seed = 5000 + biome_index * 7919 + sum(((i + 1) * ord(ch) for i, ch in enumerate(parent.name)))
rnd = random.Random(seed)
cx, cy = parent.center()
x1, y1, x2, y2 = parent.rect
biome_w = max(20, x2 - x1)
biome_h = max(20, y2 - y1)
min_size = min(biome_w, biome_h)
cave_color = (max(15, parent.color[0] // 3), max(18, parent.color[1] // 3), max(24, parent.color[2] // 3))
deep_color = (max(10, cave_color[0] - 8), max(12, cave_color[1] - 8), max(18, cave_color[2] - 8))
hub_rx = max(15, min(30, biome_w * 0.105))
hub_ry = max(14, min(28, biome_h * 0.105))
hub_poly = make_blob_points(cx, cy, hub_rx, hub_ry, seed + 99, point_count=18, jitter=0.1)
cave_resources = [(rock_type, 3)]
for resource, density in parent.objects:
if resource in (seaweed_type, sand_type):
continue
cave_resources.append((resource, max(1, density // 2)))
if len(cave_resources) >= 6:
break
if parent.name in ('Био-люминесцентная зона', 'Горы', 'Центральное море'):
cave_resources.append((kyanite_type, 1))
hub = Biome(f'{parent.name}: центральный пещерный узел', deep_color, 0, 0, 0, 0, cave_resources, [], polygon=hub_poly, clip_biome=parent, priority=5)
hub.parent_surface_biome = parent
hub.cave_kind = 'hub'
self.cave_tunnels.append(hub)
hub_record = {'biome': parent, 'hub': hub, 'point': (cx, cy), 'color': cave_color, 'deep_color': deep_color, 'seed': seed}
self.cave_hubs.append(hub_record)
tunnel_width = max(8, min(15, min_size * 0.065))
entrance_base_angle = rnd.uniform(0, math.tau)
for entrance_index in range(3):
entrance_angle = entrance_base_angle + entrance_index * (math.tau / 3.0) + rnd.uniform(-0.16, 0.16)
entrance_x, entrance_y = self._find_cave_entrance_point(parent, entrance_angle, inset=max(10, int(tunnel_width * 1.7)))
entrance_x = int(entrance_x)
entrance_y = int(entrance_y)
inward_x = cx - entrance_x
inward_y = cy - entrance_y
inward_len = math.hypot(inward_x, inward_y)
if inward_len <= 0.001:
inward_x = 1.0
inward_y = 0.0
inward_len = 1.0
inward_x /= inward_len
inward_y /= inward_len
spawn_x = entrance_x + inward_x * max(5, tunnel_width * 0.8)
spawn_y = entrance_y + inward_y * max(5, tunnel_width * 0.8)
spawn_x, spawn_y = self._move_point_inside_biome(parent, spawn_x, spawn_y, cx, cy)
self.cave_entrances.append({'name': f'{parent.name}: выход {entrance_index + 1}', 'surface_rect': (entrance_x, entrance_y, entrance_x, entrance_y), 'cave_rect': (entrance_x, entrance_y, entrance_x, entrance_y), 'cave_spawn': (spawn_x + 0.5, spawn_y + 0.5), 'surface_spawn': (entrance_x + 0.5, entrance_y + 0.5), 'parent_biome': parent})
self.world.object_grid.pop((entrance_x, entrance_y), None)
self.world.flora_grid.pop((entrance_x, entrance_y), None)
side = -1 if entrance_index % 2 else 1
perp_x = -inward_y
perp_y = inward_x
bend = min_size * rnd.uniform(0.04, 0.1) * side
mid_x = entrance_x + (cx - entrance_x) * 0.48 + perp_x * bend
mid_y = entrance_y + (cy - entrance_y) * 0.48 + perp_y * bend
mid_x, mid_y = self._move_point_inside_biome(parent, mid_x, mid_y, cx, cy)
for segment_index, (a, b) in enumerate((((entrance_x, entrance_y), (mid_x, mid_y)), ((mid_x, mid_y), (cx, cy)))):
corridor_poly = make_corridor_polygon(a[0], a[1], b[0], b[1], tunnel_width)
corridor = Biome(f'{parent.name}: выходной тоннель {entrance_index + 1}.{segment_index + 1}', cave_color, 0, 0, 0, 0, [(rock_type, 2)], [], polygon=corridor_poly, clip_biome=parent, priority=2)
corridor.parent_surface_biome = parent
corridor.cave_kind = 'local_corridor'
self.cave_tunnels.append(corridor)
branch_base_angle = entrance_base_angle + math.pi / 3.0
branch_points = []
branch_radius = min_size * 0.24
for branch_index in range(3):
branch_angle = branch_base_angle + branch_index * (math.tau / 3.0) + rnd.uniform(-0.18, 0.18)
bx = cx + math.cos(branch_angle) * branch_radius
by = cy + math.sin(branch_angle) * branch_radius
bx, by = self._move_point_inside_biome(parent, bx, by, cx, cy)
branch_points.append((bx, by))
room_rx = max(9, min(20, hub_rx * 0.7))
room_ry = max(8, min(18, hub_ry * 0.7))
branch_poly = make_blob_points(bx, by, room_rx, room_ry, seed + 300 + branch_index * 17, point_count=14, jitter=0.13)
branch_room = Biome(f'{parent.name}: боковой зал {branch_index + 1}', deep_color, 0, 0, 0, 0, cave_resources[:4], [], polygon=branch_poly, clip_biome=parent, priority=4)
branch_room.parent_surface_biome = parent
branch_room.cave_kind = 'branch_room'
self.cave_tunnels.append(branch_room)
branch_corridor = Biome(f'{parent.name}: ответвление {branch_index + 1}', cave_color, 0, 0, 0, 0, [(rock_type, 2)], [], polygon=make_corridor_polygon(cx, cy, bx, by, max(7, tunnel_width * 0.8)), clip_biome=parent, priority=2)
branch_corridor.parent_surface_biome = parent
branch_corridor.cave_kind = 'branch_corridor'
self.cave_tunnels.append(branch_corridor)
for branch_index in range(3):
a = branch_points[branch_index]
b = branch_points[(branch_index + 1) % 3]
loop_corridor = Biome(f'{parent.name}: кольцевой тоннель {branch_index + 1}', cave_color, 0, 0, 0, 0, [(rock_type, 1)], [], polygon=make_corridor_polygon(a[0], a[1], b[0], b[1], max(6, tunnel_width * 0.62)), clip_biome=parent, priority=1)
loop_corridor.parent_surface_biome = parent
loop_corridor.cave_kind = 'local_loop'
self.cave_tunnels.append(loop_corridor)
if self.cave_hubs:
connected = {0}
global_edges = set()
while len(connected) < len(self.cave_hubs):
best = None
best_distance = None
for i in connected:
ax, ay = self.cave_hubs[i]['point']
for j in range(len(self.cave_hubs)):
if j in connected:
continue
bx, by = self.cave_hubs[j]['point']
distance = (ax - bx) ** 2 + (ay - by) ** 2
if best_distance is None or distance < best_distance:
best_distance = distance
best = (i, j)
if best is None:
break
i, j = best
global_edges.add(tuple(sorted((i, j))))
connected.add(j)
for i, hub_record in enumerate(self.cave_hubs):
ax, ay = hub_record['point']
candidates = []
for j, other in enumerate(self.cave_hubs):
if i == j:
continue
bx, by = other['point']
distance = (ax - bx) ** 2 + (ay - by) ** 2
candidates.append((distance, j))
candidates.sort()
for _, j in candidates[:2]:
global_edges.add(tuple(sorted((i, j))))
for edge_index, (i, j) in enumerate(sorted(global_edges)):
hub_a = self.cave_hubs[i]
hub_b = self.cave_hubs[j]
ax, ay = hub_a['point']
bx, by = hub_b['point']
link_width = 8
link_color = (18, 62, 70)
link = Biome(f"Межбиомный тоннель: {hub_a['biome'].name} ↔ {hub_b['biome'].name}", link_color, 0, 0, 0, 0, [(rock_type, 2), (quartz_type, 1)], [], polygon=make_corridor_polygon(ax, ay, bx, by, link_width), clip_biome=None, priority=1)
link.cave_kind = 'interbiome_corridor'
self.cave_tunnels.append(link)
self.cave_connections.append((i, j))
distance = math.hypot(bx - ax, by - ay)
if distance > 170:
mx = (ax + bx) / 2.0
my = (ay + by) / 2.0
junction = Biome(f'Межбиомная развилка {edge_index + 1}', (15, 48, 58), 0, 0, 0, 0, [(rock_type, 2), (quartz_type, 2)], [], polygon=make_blob_points(mx, my, 13, 11, 88000 + edge_index, point_count=14, jitter=0.1), clip_biome=None, priority=3)
junction.cave_kind = 'interbiome_junction'
self.cave_tunnels.append(junction)
for tunnel in self.cave_tunnels:
override = CAVE_TUNNEL_ECOSYSTEM_OVERRIDES.get(tunnel.name, {})
if 'flora' in override:
custom_flora = []
for flora_key, density in override.get('flora', {}).items():
flora_type = flora_types.get(flora_key)
try:
density = float(density)
except (TypeError, ValueError):
continue
if flora_type is not None and density > 0:
custom_flora.append((flora_type, density))
tunnel.flora = custom_flora
continue
parent = getattr(tunnel, 'parent_surface_biome', None)
if parent is None:
tunnel.flora = []
continue
profile = cave_flora_profiles.get(parent.name, ())
kind = getattr(tunnel, 'cave_kind', '')
if kind in ('hub', 'branch_room'):
scale = 1.0
elif kind in ('local_corridor', 'branch_corridor', 'local_loop'):
scale = 0.38
elif kind == 'interbiome_corridor':
scale = 0.16
elif kind == 'interbiome_junction':
scale = 0.3
else:
scale = 0.0
tunnel.flora = [(flora_type, density * scale) for flora_type, density in profile if density * scale > 0]
self._build_cave_spatial_index()
self._build_cave_entrance_index()
cave_exit_cells = set()
for entrance in self.cave_entrances:
ex1, ey1, ex2, ey2 = entrance['cave_rect']
for ey in range(ey1, ey2 + 1):
for ex in range(ex1, ex2 + 1):
cave_exit_cells.add((ex, ey))
for tunnel_index, tunnel in enumerate(self.cave_tunnels):
rnd = random.Random(70000 + tunnel_index * 101)
area = max(1, tunnel.area())
for object_type, density in tunnel.objects:
count = int(area / 400 * density)
if getattr(tunnel, 'cave_kind', '') == 'interbiome_corridor':
count = min(count, 12)
for _ in range(count):
for _attempt in range(50):
rx, ry = tunnel.random_point(rnd)
x = int(rx)
y = int(ry)
key = (x, y)
if key in cave_exit_cells:
continue
if key in self.cave_object_grid:
continue
if self.get_cave_tunnel_at(x, y) is not tunnel:
continue
self.cave_object_grid[key] = object_type
break
for tunnel_index, tunnel in enumerate(self.cave_tunnels):
if not getattr(tunnel, 'flora', None):
continue
area = max(1.0, tunnel.area())
for flora_index, (flora_type, density) in enumerate(tunnel.flora):
count = int(area / 400.0 * float(density))
if density > 0 and count <= 0 and (area >= 80):
count = 1
count = min(count, 420)
rnd = random.Random(470000 + tunnel_index * 6151 + flora_index * 487)
placed = 0
attempts = 0
max_attempts = max(30, count * 14)
while placed < count and attempts < max_attempts:
attempts += 1
rx, ry = tunnel.random_point(rnd)
x = int(rx)
y = int(ry)
key = (x, y)
if key in cave_exit_cells:
continue
if key in self.cave_object_grid:
continue
if key in self.cave_flora_grid:
continue
if self.get_cave_tunnel_at(x, y) is not tunnel:
continue
self.cave_flora_grid[key] = flora_type
placed += 1
cave_fauna_by_parent = {'Мелководье': [(zucchini_fish_type, 5)], 'Глубоководье': [(zucchini_fish_type, 5)], 'Центральное море': [(eggplant_fish_type, 7)], 'Побережье': [], 'Острова': [], 'Коралловый риф': [(eggplant_fish_type, 6)], 'Леса': [], 'Кельповый лес': [(zucchini_fish_type, 4)], 'Био-люминесцентная зона': [(eggplant_fish_type, 6)], 'Горы': [(sea_dragon_type, 1)]}
cave_rooms_by_parent = {}
for tunnel in self.cave_tunnels:
parent = getattr(tunnel, 'parent_surface_biome', None)
if parent is None:
continue
if getattr(tunnel, 'cave_kind', '') not in ('hub', 'branch_room'):
continue
cave_rooms_by_parent.setdefault(parent.name, []).append(tunnel)
for parent_name, species in cave_fauna_by_parent.items():
rooms = cave_rooms_by_parent.get(parent_name, ())
if not rooms:
continue
seed = 99000 + sum(((i + 1) * ord(ch) for i, ch in enumerate(parent_name)))
rnd = random.Random(seed)
for species_index, (animal_type, total_count) in enumerate(species):
for spawn_index in range(total_count):
room = rooms[(spawn_index + species_index) % len(rooms)]
spawn = None
for _attempt in range(40):
x, y = room.random_point(rnd)
if self.get_cave_tunnel_at(int(x), int(y)) is room:
spawn = (x, y)
break
if spawn is not None:
self.cave_animals.append(Animal(spawn[0], spawn[1], animal_type, room))
default_fish_by_parent = {'Мелководье': zucchini_fish_type, 'Глубоководье': zucchini_fish_type, 'Центральное море': eggplant_fish_type, 'Побережье': zucchini_fish_type, 'Острова': zucchini_fish_type, 'Коралловый риф': eggplant_fish_type, 'Леса': zucchini_fish_type, 'Кельповый лес': zucchini_fish_type, 'Био-люминесцентная зона': eggplant_fish_type, 'Горы': sea_dragon_type, 'Реки': zucchini_fish_type, 'Озёра (внешние)': zucchini_fish_type}
for tunnel_index, tunnel in enumerate(self.cave_tunnels):
override = CAVE_TUNNEL_ECOSYSTEM_OVERRIDES.get(tunnel.name, {})
if 'fauna' in override:
continue
parent = getattr(tunnel, 'parent_surface_biome', None)
fish_type = default_fish_by_parent.get(getattr(parent, 'name', None))
if fish_type is None:
fish_type = (zucchini_fish_type, eggplant_fish_type)[tunnel_index % 2]
fish_count = 3 if tunnel.area() >= 900 else 2
rnd = random.Random(126000 + tunnel_index * 733)
placed = 0
for _fish_index in range(fish_count):
for _attempt in range(80):
x, y = tunnel.random_point(rnd)
ix, iy = (int(x), int(y))
if self.get_cave_tunnel_at(ix, iy) is not tunnel:
continue
self.cave_animals.append(Animal(x, y, fish_type, tunnel))
placed += 1
break
cave_animal_registry = {'zucchini_fish': zucchini_fish_type, 'eggplant_fish': eggplant_fish_type, 'sea_dragon': sea_dragon_type, 'chbos_whale': chbos_whale_type, 'cucumber_fish': cucumber_fish_type, 'patisson_fish': patisson_fish_type, 'nut_fish': nut_fish_type, 'bolt_fish': bolt_fish_type}
for tunnel in self.cave_tunnels:
override = CAVE_TUNNEL_ECOSYSTEM_OVERRIDES.get(tunnel.name, {})
if 'fauna' not in override:
continue
self.cave_animals = [animal for animal in self.cave_animals if animal.home_biome is not tunnel]
for animal_key, amount in override.get('fauna', {}).items():
animal_type = cave_animal_registry.get(animal_key)
try:
amount = max(0, min(100, int(amount)))
except (TypeError, ValueError):
continue
if animal_type is None or amount <= 0:
continue
rnd = random.Random(191000 + sum(((i + 1) * ord(ch) for i, ch in enumerate(tunnel.name))) + len(animal_key) * 97)
placed = 0
for _spawn_index in range(amount):
for _attempt in range(80):
x, y = tunnel.random_point(rnd)
ix, iy = (int(x), int(y))
if self.get_cave_tunnel_at(ix, iy) is not tunnel:
continue
self.cave_animals.append(Animal(x, y, animal_type, tunnel))
placed += 1
break
cave_room_counts = {}
for animal in self.cave_animals:
room_counts = cave_room_counts.setdefault(animal.home_biome, {})
room_counts[animal.type] = room_counts.get(animal.type, 0) + 1
for tunnel in self.cave_tunnels:
counts = cave_room_counts.get(tunnel, {})
tunnel.animals = [(animal_type, count) for animal_type, count in counts.items()]
def _build_cave_spatial_index(self):
self.cave_tunnel_chunks.clear()
self.cave_tunnel_cache.clear()
chunk = CAVE_CHUNK_SIZE
max_chunk = (MAP_SIZE - 1) // chunk
for tunnel in self.cave_tunnels:
x1, y1, x2, y2 = tunnel.rect
cx1 = max(0, int(x1) // chunk)
cy1 = max(0, int(y1) // chunk)
cx2 = min(max_chunk, int(x2) // chunk)
cy2 = min(max_chunk, int(y2) // chunk)
for cy in range(cy1, cy2 + 1):
for cx in range(cx1, cx2 + 1):
self.cave_tunnel_chunks.setdefault((cx, cy), []).append(tunnel)
def _build_cave_entrance_index(self):
self.surface_cave_entrance_cells.clear()
self.cave_exit_cells.clear()
for entrance in self.cave_entrances:
sx1, sy1, sx2, sy2 = entrance['surface_rect']
for y in range(sy1, sy2 + 1):
for x in range(sx1, sx2 + 1):
self.surface_cave_entrance_cells[x, y] = entrance
cx1, cy1, cx2, cy2 = entrance['cave_rect']
for y in range(cy1, cy2 + 1):
for x in range(cx1, cx2 + 1):
self.cave_exit_cells[x, y] = entrance
@staticmethod
def _point_in_rect(x, y, rect):
x1, y1, x2, y2 = rect
return x1 <= x <= x2 and y1 <= y <= y2
def get_cave_tunnel_at(self, x, y):
if not (0 <= x < MAP_SIZE and 0 <= y < MAP_SIZE):
return None
integer_x = int(x)
integer_y = int(y)
cacheable = x == integer_x and y == integer_y
cache_key = (integer_x, integer_y)
if cacheable and cache_key in self.cave_tunnel_cache:
return self.cave_tunnel_cache[cache_key]
chunk_key = (integer_x // CAVE_CHUNK_SIZE, integer_y // CAVE_CHUNK_SIZE)
result = None
for tunnel in reversed(self.cave_tunnel_chunks.get(chunk_key, ())):
if tunnel.contains(x, y):
result = tunnel
break
if cacheable:
if len(self.cave_tunnel_cache) >= MAX_CAVE_CACHE:
self.cave_tunnel_cache.clear()
self.cave_tunnel_cache[cache_key] = result
return result
def cave_is_passable(self, x, y):
if self.get_cave_tunnel_at(x, y) is None:
return False
obj = self.cave_world.get_object_at(int(x), int(y))
return obj is None or not obj.is_wall
def get_surface_cave_entrance_at(self, x, y):
integer_x = int(x)
integer_y = int(y)
if x == integer_x and y == integer_y:
return self.surface_cave_entrance_cells.get((integer_x, integer_y))
for entrance in self.cave_entrances:
if self._point_in_rect(x, y, entrance['surface_rect']):
return entrance
return None
def get_cave_exit_at(self, x, y):
integer_x = int(x)
integer_y = int(y)
if x == integer_x and y == integer_y:
return self.cave_exit_cells.get((integer_x, integer_y))
for entrance in self.cave_entrances:
if self._point_in_rect(x, y, entrance['cave_rect']):
return entrance
return None
def get_current_animals(self):
if self.in_cave:
return self.cave_animals
return self.world.animals
def get_nearby_animals(self, radius):
if self.in_cave:
player_x = self.player_x
player_y = self.player_y
return [animal for animal in self.cave_animals if abs(animal.x - player_x) <= radius and abs(animal.y - player_y) <= radius]
return self.world.get_animals_near(self.player_x, self.player_y, radius)
def remove_current_animal(self, animal):
self._whale_quest_on_whale_death(animal)
if self.in_cave:
if animal in self.cave_animals:
self.cave_animals.remove(animal)
return
self.world.remove_animal(animal)
def _get_all_companions(self):
result = []
seen = set()
for animal in list(self.world.animals) + list(self.cave_animals):
if not getattr(animal, 'is_companion', False):
continue
marker = id(animal)
if marker in seen:
continue
seen.add(marker)
result.append(animal)
return result
def _get_visible_companions(self):
companions = []
for animal in self.get_current_animals():
if not getattr(animal, 'is_companion', False):
continue
if abs(animal.x - self.player_x) <= VIEW_RADIUS and abs(animal.y - self.player_y) <= VIEW_RADIUS:
companions.append(animal)
return companions
def _companion_follow_distance(self, animal):
length = float(getattr(animal.type, 'fish_length', max(20, animal.type.size * 3)))
return max(1.7, min(VIEW_RADIUS * 0.68, 1.35 + length / 55.0))
def _companion_orbit_distance(self, animal):
follow = self._companion_follow_distance(animal)
return max(1.5, min(VIEW_RADIUS * 0.72, follow * 0.92))
def _position_is_passable_for_companion(self, x, y):
if not (0 <= x < MAP_SIZE and 0 <= y < MAP_SIZE):
return False
if self.in_cave:
return self.cave_is_passable(x, y)
cell_x = int(x)
cell_y = int(y)
target_object = self.world.get_object_at(cell_x, cell_y)
if target_object is not None and target_object.is_wall:
return False
if self.is_start_capsule_wall(cell_x, cell_y):
return False
biome = self.world.get_biome_at(cell_x, cell_y)
if is_land_biome(biome):
return False
if self.is_start_capsule_passage(cell_x, cell_y):
return True
return biome is not None and biome.passable
def _find_nearby_passable_position(self, radius=1.25):
candidates = [(self.player_x, self.player_y)]
for ring in (radius, radius * 1.6):
for index in range(12):
angle = index / 12.0 * math.tau
candidates.append((self.player_x + math.cos(angle) * ring, self.player_y + math.sin(angle) * ring))
for x, y in candidates:
if self._position_is_passable_for_companion(x, y):
return (x, y)
return None
def _add_animal_to_current_layer(self, animal):
if self.in_cave:
self.cave_animals.append(animal)
else:
self.world.animals.append(animal)
self.world.rebuild_animal_index()
def _clear_companion_target(self, animal):
target = getattr(animal, 'companion_target', None)
if target is not None and getattr(target, 'pet_combat_target', None) is animal:
target.pet_combat_target = None
animal.companion_target = None
def _transfer_visible_companions(self, companions, target_in_cave):
if not companions:
return
if not target_in_cave and self._is_player_on_land():
return
for index, animal in enumerate(companions):
self._clear_companion_target(animal)
if target_in_cave:
self.world.remove_animal(animal)
if animal not in self.cave_animals:
self.cave_animals.append(animal)
animal.home_biome = self.get_cave_tunnel_at(self.player_x, self.player_y) or animal.home_biome
else:
if animal in self.cave_animals:
self.cave_animals.remove(animal)
if animal not in self.world.animals:
self.world.animals.append(animal)
animal.home_biome = self.world.get_biome_at(self.player_x, self.player_y) or animal.home_biome
angle = animal.companion_orbit_angle + index * 0.8
radius = min(self._companion_follow_distance(animal), VIEW_RADIUS * 0.6)
target_x = self.player_x + math.cos(angle) * radius
target_y = self.player_y + math.sin(angle) * radius
if self._position_is_passable_for_companion(target_x, target_y):
animal.x = target_x
animal.y = target_y
else:
animal.x = self.player_x
animal.y = self.player_y
animal.dx = 0.0
animal.dy = 0.0
animal.active = True
animal.companion_state = 'следует за игроком'
if not target_in_cave:
self.world.rebuild_animal_index()
def _find_companion_hunt_target(self, companion):
special_combatants = {'Морской дракон-левиафан'}
if companion.type.behavior != 'predator' and companion.type.name not in special_combatants:
return None
best = None
best_distance_sq = float('inf')
for animal in self.get_nearby_animals(VIEW_RADIUS):
if animal is companion or animal.hp <= 0:
continue
if getattr(animal, 'is_companion', False):
continue
if abs(animal.x - self.player_x) > VIEW_RADIUS or abs(animal.y - self.player_y) > VIEW_RADIUS:
continue
dx = animal.x - companion.x
dy = animal.y - companion.y
distance_sq = dx * dx + dy * dy
if distance_sq > VIEW_RADIUS * VIEW_RADIUS:
continue
if distance_sq < best_distance_sq:
best = animal
best_distance_sq = distance_sq
return best
def _move_companion_towards(self, animal, target_x, target_y, move_speed):
to_target_x = target_x - animal.x
to_target_y = target_y - animal.y
distance = math.hypot(to_target_x, to_target_y)
if distance <= 0.001:
animal.dx = 0.0
animal.dy = 0.0
return False
step = min(move_speed, distance)
velocity_x = to_target_x / distance * step
velocity_y = to_target_y / distance * step
new_x = animal.x + velocity_x
new_y = animal.y + velocity_y
moved = False
if self._position_is_passable_for_companion(new_x, new_y):
animal.x = new_x
animal.y = new_y
moved = True
elif self._position_is_passable_for_companion(new_x, animal.y):
animal.x = new_x
velocity_y = 0.0
moved = True
elif self._position_is_passable_for_companion(animal.x, new_y):
animal.y = new_y
velocity_x = 0.0
moved = True
else:
animal.companion_orbit_angle += 0.35
if moved:
animal.dx = velocity_x
animal.dy = velocity_y
animal._update_rotation()
else:
animal.dx = 0.0
animal.dy = 0.0
return moved
def _finish_companion_kill(self, companion, target):
target.hp = 0
self._whale_quest_on_whale_death(target)
if getattr(target, 'pet_combat_target', None) is companion:
target.pet_combat_target = None
companion.companion_target = None
if self.add_to_inventory(target.type, wild_catch=True):
self.remove_current_animal(target)
companion.companion_state = 'добыча отправлена в инвентарь'
self.notification = f'{companion.type.name} поймал {target.type.name}: добыча в инвентаре'
else:
companion.companion_state = 'инвентарь полон'
self.notification = f'{companion.type.name} убил {target.type.name}, но инвентарь полон'
self.notification_timer = 75
def _update_companion(self, animal, player_move_x, player_move_y):
dx_to_player = self.player_x - animal.x
dy_to_player = self.player_y - animal.y
distance_to_player = math.hypot(dx_to_player, dy_to_player)
if animal.companion_attack_timer > 0:
animal.companion_attack_timer -= 1
if abs(dx_to_player) > VIEW_RADIUS or abs(dy_to_player) > VIEW_RADIUS:
self._clear_companion_target(animal)
animal.active = False
animal.dx = 0.0
animal.dy = 0.0
animal.companion_state = 'ждёт вне видимости'
return
animal.active = True
player_move_length = math.hypot(player_move_x, player_move_y)
follow_distance = self._companion_follow_distance(animal)
orbit_distance = self._companion_orbit_distance(animal)
animal.companion_orbit_radius = orbit_distance
must_follow = player_move_length > 0.001 or distance_to_player > follow_distance * 1.35
if must_follow:
self._clear_companion_target(animal)
if player_move_length > 0.001:
move_x = player_move_x / player_move_length
move_y = player_move_y / player_move_length
elif distance_to_player > 0.001:
move_x = -dx_to_player / distance_to_player
move_y = -dy_to_player / distance_to_player
else:
move_x, move_y = (1.0, 0.0)
side_x = -move_y
side_y = move_x
animal.companion_orbit_angle = (animal.companion_orbit_angle + 0.012) % math.tau
lateral = math.sin(animal.companion_orbit_angle) * min(1.2, follow_distance * 0.22)
target_x = self.player_x - move_x * follow_distance + side_x * lateral
target_y = self.player_y - move_y * follow_distance + side_y * lateral
speed_scale = 0.75 + min(0.55, follow_distance / 10.0)
move_speed = max(0.22, min(1.15, animal.type.speed * 8.0 * speed_scale))
if distance_to_player > VIEW_RADIUS * 0.65:
move_speed *= 1.35
self._move_companion_towards(animal, target_x, target_y, move_speed)
animal.companion_state = 'следует за игроком'
return
target = getattr(animal, 'companion_target', None)
current_animals = self.get_current_animals()
target_valid = target is not None and target in current_animals and (target.hp > 0) and (not getattr(target, 'is_companion', False)) and (abs(target.x - self.player_x) <= VIEW_RADIUS) and (abs(target.y - self.player_y) <= VIEW_RADIUS)
if not target_valid:
self._clear_companion_target(animal)
target = self._find_companion_hunt_target(animal)
animal.companion_target = target
if target is not None:
if target.type.behavior == 'predator':
target.pet_combat_target = animal
hunt_dx = target.x - animal.x
hunt_dy = target.y - animal.y
hunt_distance = math.hypot(hunt_dx, hunt_dy)
animal.companion_state = f'охотится: {target.type.name}'
length = float(getattr(animal.type, 'fish_length', animal.type.size * 3))
uses_projectiles = animal.type.name in ('Морской дракон-левиафан')
attack_range = 3.5 if uses_projectiles else max(0.9, min(2.2, 0.75 + length / 150.0))
if hunt_distance > attack_range:
hunt_speed = max(0.28, min(1.25, animal.type.speed * 9.5))
self._move_companion_towards(animal, target.x, target.y, hunt_speed)
else:
animal.dx = 0.0
animal.dy = 0.0
if animal.companion_attack_timer <= 0 and (not uses_projectiles):
damage = max(1, int(animal.type.damage))
target.hp -= damage
animal.companion_attack_timer = animal.companion_attack_cooldown
animal.companion_state = f'атакует: {target.type.name}'
if target.hp <= 0:
self._finish_companion_kill(animal, target)
elif uses_projectiles:
animal.companion_state = f'готовит особую атаку: {target.type.name}'
return
animal.companion_orbit_angle = (animal.companion_orbit_angle + 0.028) % math.tau
target_x = self.player_x + math.cos(animal.companion_orbit_angle) * orbit_distance
target_y = self.player_y + math.sin(animal.companion_orbit_angle) * orbit_distance
orbit_speed = max(0.16, min(0.42, animal.type.speed * 3.4))
self._move_companion_towards(animal, target_x, target_y, orbit_speed)
animal.companion_state = 'плавает вокруг игрока'
def handle_animal_death(self, animal):
self._whale_quest_on_whale_death(animal)
if self.add_to_inventory(animal.type, wild_catch=not getattr(animal, 'is_companion', False)):
self.remove_current_animal(animal)
self.notification = f'{animal.type.name} убит и подобран!'
else:
self.notification = f'{animal.type.name} убит, но инвентарь полон!'
self.notification_timer = 60
def use_cave_entrance(self):
if self.state != 'playing':
return
px = int(self.player_x)
py = int(self.player_y)
if not self.in_cave:
entrance = self.get_surface_cave_entrance_at(px, py)
if entrance is None:
self.notification = 'Здесь нет входа в пещеру'
self.notification_timer = 45
return
visible_companions = self._get_visible_companions()
self.in_cave = True
self.player_x, self.player_y = entrance['cave_spawn']
self._transfer_visible_companions(visible_companions, target_in_cave=True)
self.map_center_x = self.player_x
self.map_center_y = self.player_y
self.selected_biome = None
self.notification = f"Вы вошли в пещеру: {entrance['name']}"
self.notification_timer = 90
return
exit_data = self.get_cave_exit_at(px, py)
if exit_data is None:
self.notification = 'Всплыть можно только у выхода на поверхность'
self.notification_timer = 60
return
visible_companions = self._get_visible_companions()
self.in_cave = False
self.player_x, self.player_y = exit_data['surface_spawn']
self._transfer_visible_companions(visible_companions, target_in_cave=False)
self.map_center_x = self.player_x
self.map_center_y = self.player_y
self.selected_biome = None
self.notification = f"Вы всплыли: {exit_data['name']}"
self.notification_timer = 90
def _start_inventory_registry(self):
"""Возвращает словарь доступных типов предметов по имени.
Поддерживаются:
1) отображаемое имя из игры, например "Мотылёк" или "Нож";
2) внутреннее имя переменной, если оно было доступно при init_assets().
START_ITEM_REGISTRY формируется в конце init_assets(), поэтому сюда
попадают и глобальные, и локальные игровые типы.
"""
registry = dict(START_ITEM_REGISTRY)
for variable_name, value in globals().items():
if isinstance(value, (ObjectType, AnimalType)):
registry.setdefault(value.name, value)
registry.setdefault(variable_name, value)
return registry
def _load_start_inventory(self):
"""Заполняет инвентарь согласно START_INVENTORY из начала файла."""
if not isinstance(START_INVENTORY, dict):
print("[START_INVENTORY] Ошибка: START_INVENTORY должен быть словарём, например {'Сканер': 1, 'Титан': 5}.")
return
registry = self._start_inventory_registry()
added_total = 0
for item_name, amount in START_INVENTORY.items():
try:
amount = int(amount)
except (TypeError, ValueError):
print(f"[START_INVENTORY] Пропущено '{item_name}': количество должно быть целым числом, получено {amount!r}.")
continue
if amount <= 0:
continue
item_type = registry.get(item_name)
if item_type is None:
print(f'[START_INVENTORY] Не найден предмет: {item_name!r}. Проверьте точное игровое название.')
continue
for _ in range(amount):
if not self.add_to_inventory(item_type):
print(f'[START_INVENTORY] Инвентарь заполнен. Не удалось добавить оставшиеся предметы, начиная с {item_type.name!r}.')
return
added_total += 1
if added_total:
print(f'[START_INVENTORY] Добавлено стартовых предметов: {added_total}/{INVENTORY_SIZE}.')
def add_to_inventory(self, item_type, wild_catch=False):
for index, slot in enumerate(self.inventory):
if slot is None:
self._stored_companion_hp.pop(index, None)
self.inventory[index] = item_type
self.inventory_counts[item_type] = self.inventory_counts.get(item_type, 0) + 1
if wild_catch and hasattr(self, 'whale_quests'):
self._whale_quest_on_catch(item_type, index)
return True
return False
def _remove_inventory_slot(self, index):
if not 0 <= index < INVENTORY_SIZE:
return None
item = self.inventory[index]
if item is None:
return None
self.inventory[index] = None
pass
self._stored_companion_hp.pop(index, None)
current_count = self.inventory_counts.get(item, 0) - 1
if current_count > 0:
self.inventory_counts[item] = current_count
else:
self.inventory_counts.pop(item, None)
if self.equipped_weapon is item:
self.equipped_weapon = None
self.active_slot_index = -1
return item
def _current_dropped_items(self):
return self.cave_dropped_items if self.in_cave else self.surface_dropped_items
def _spawn_animal_from_inventory_item(self, animal_type, companion=False):
spawn = self._find_nearby_passable_position(1.25)
if spawn is None:
return None
if self.in_cave:
home_biome = self.get_cave_tunnel_at(spawn[0], spawn[1])
else:
home_biome = self.world.get_biome_at(spawn[0], spawn[1])
if is_land_biome(home_biome):
return None
if home_biome is None and (not companion):
return None
if home_biome is None:
home_biome = self.get_cave_tunnel_at(self.player_x, self.player_y) if self.in_cave else self.world.get_biome_at(self.player_x, self.player_y)
if home_biome is None:
return None
animal = Animal(spawn[0], spawn[1], animal_type, home_biome)
if companion:
animal.is_companion = True
animal.hp = animal.max_hp
animal.companion_orbit_angle = random.uniform(0.0, math.tau)
animal.companion_orbit_radius = self._companion_orbit_distance(animal)
animal.companion_target = None
animal.companion_attack_timer = 0
animal.companion_state = 'следует за игроком'
self._add_animal_to_current_layer(animal)
return animal
def drop_selected_item(self):
if self.state != 'inventory':
return
if self.active_slot_index == -1:
self.notification = 'Выберите предмет в инвентаре'
self.notification_timer = 60
return
item = self.inventory[self.active_slot_index]
if item is None:
self.notification = 'Слот пуст'
self.notification_timer = 45
return
if isinstance(item, AnimalType):
spawned = self._spawn_animal_from_inventory_item(item, companion=False)
if spawned is None:
self.notification = 'Здесь нельзя выпустить это существо'
self.notification_timer = 60
return
self._remove_inventory_slot(self.active_slot_index)
self._whale_quest_on_drop()
self.notification = f'Выпущено: {item.name}'
self.notification_timer = 60
return
if item is mad_moss_item:
if self.in_cave:
self.notification = 'Бешеный мох можно выпускать только на поверхности суши'
self.notification_timer = 90
return
if len(self.world.moss_colonies) >= MAD_MOSS_MAX_COLONIES:
self.notification = 'Слишком много живых колоний мха'
self.notification_timer = 90
return
moss_spot = None
for distance in (0.4, 0.65, 0.15, 1.0):
for index in range(12):
angle = index / 12.0 * math.tau + random.uniform(-0.12, 0.12)
x = self.player_x + math.cos(angle) * distance
y = self.player_y + math.sin(angle) * distance
biome = self.world.get_biome_at(x, y)
if is_land_biome(biome) and biome.passable and (self.world.get_object_at(x, y) is None):
moss_spot = (x, y, biome)
break
if moss_spot is not None:
break
if moss_spot is None:
self.notification = 'Для живого мха нужна свободная суша поблизости'
self.notification_timer = 90
return
x, y, biome = moss_spot
self.world.moss_colonies.append(MadMossColony(x, y, biome, diameter=0.3, terminal=False))
self._remove_inventory_slot(self.active_slot_index)
self._whale_quest_on_drop()
self.notification = 'Бешеный мох выпущен: колония ожила и снова растёт'
self.notification_timer = 110
return
cell = (int(self.player_x), int(self.player_y))
dropped = self._current_dropped_items()
dropped.setdefault(cell, []).append(item)
self._remove_inventory_slot(self.active_slot_index)
self._whale_quest_on_drop()
self.notification = f'Выброшено: {item.name}'
self.notification_timer = 60
def spawn_selected_companion(self):
if self.state != 'inventory':
return
if self.active_slot_index == -1:
self.notification = 'Выберите рыбу или хищника'
self.notification_timer = 60
return
item = self.inventory[self.active_slot_index]
if item is None:
self.notification = 'Слот пуст'
self.notification_timer = 45
return
if not isinstance(item, AnimalType):
self.notification = 'Спутником может стать только существо'
self.notification_timer = 60
return
slot_index = self.active_slot_index
companion = self._spawn_animal_from_inventory_item(item, companion=True)
if companion is None:
self.notification = 'Здесь недостаточно места для существа'
self.notification_timer = 60
return
stored = self._stored_companion_hp.get(slot_index)
if stored is not None and stored[0] is item:
companion.hp = max(1, min(companion.max_hp, stored[1]))
pass
self._remove_inventory_slot(slot_index)
self.notification = f'{item.name} теперь ваш спутник'
self.notification_timer = 90
def player_take_damage(self, damage, source=None):
damage_multiplier = 1.0
if self.inventory_counts.get(reinforced_suit, 0) > 0:
damage_multiplier *= 0.75
if self.damage_resist_timer > 0:
damage_multiplier *= 0.5
final_damage = max(1, int(round(damage * damage_multiplier)))
self.player_hp -= final_damage
pass
self.damage_vignette_flash = max(self.damage_vignette_flash, 18)
self.notification = f'Получено {final_damage} урона! HP: {self.player_hp}'
self.notification_timer = 60
if self.player_hp <= 0:
self.player_hp = 0
self.notification = 'Игрок погиб! Игра окончена.'
self.notification_timer = 9999
self.running = False
def equip_weapon(self):
if self.state != 'playing':
return
if self.active_slot_index == -1:
self.notification = 'Выберите предмет в инвентаре'
self.notification_timer = 60
return
item = self.inventory[self.active_slot_index]
if item is None:
self.notification = 'Слот пуст'
self.notification_timer = 60
return
if not item.is_weapon:
self.notification = 'Этот предмет не является оружием'
self.notification_timer = 60
return
self.equipped_weapon = item
self.notification = f'Экипировано: {item.name}'
self.notification_timer = 60
def _consume_selected_item(self, item=None):
if self.active_slot_index == -1:
return False
if item is None:
item = self.inventory[self.active_slot_index]
if item is None:
return False
self.inventory[self.active_slot_index] = None
pass
self._stored_companion_hp.pop(self.active_slot_index, None)
current_count = self.inventory_counts.get(item, 0) - 1
if current_count > 0:
self.inventory_counts[item] = current_count
else:
self.inventory_counts.pop(item, None)
if self.equipped_weapon is item:
self.equipped_weapon = None
self.active_slot_index = -1
return True
def _get_current_object_grid(self):
return self.cave_object_grid if self.in_cave else self.world.object_grid
def _nearby_buildables(self, radius=3.0):
grid = self._get_current_object_grid()
result = []
radius_sq = radius * radius
for (x, y), obj in grid.items():
if not getattr(obj, 'is_buildable', False):
continue
dx = x - self.player_x
dy = y - self.player_y
dist_sq = dx * dx + dy * dy
if dist_sq <= radius_sq:
result.append((obj, x, y, dist_sq))
return result
def _nearest_resource_info(self, radius=8):
grid = self._get_current_object_grid()
radius_sq = radius * radius
best = None
highlights = []
counts = {}
for (x, y), obj in grid.items():
if getattr(obj, 'is_buildable', False) or getattr(obj, 'is_weapon', False):
continue
dx = x - self.player_x
dy = y - self.player_y
dist_sq = dx * dx + dy * dy
if dist_sq > radius_sq:
continue
highlights.append((x, y))
counts[obj.name] = counts.get(obj.name, 0) + 1
if best is None or dist_sq < best[0]:
best = (dist_sq, obj, x, y)
return (best, counts, highlights)
def _direction_hint(self, target_x, target_y):
dx = target_x - self.player_x
dy = target_y - self.player_y
parts = []
if abs(dx) >= 0.5:
parts.append('восток' if dx > 0 else 'запад')
if abs(dy) >= 0.5:
parts.append('юг' if dy > 0 else 'север')
return '-'.join(parts) if parts else 'здесь'
def _find_nearest_path_target(self):
points = []
if self.in_cave:
for (x, y), exit_data in self.cave_exit_cells.items():
points.append((x, y, 'выход на поверхность'))
else:
for (x, y), entrance in self.surface_cave_entrance_cells.items():
points.append((x, y, 'вход в пещеру'))
x1, y1, x2, y2 = self.start_capsule_rect
points.append(((x1 + x2) / 2, (y1 + y2) / 2, 'стартовая капсула'))
for (x, y), obj in self.world.object_grid.items():
if obj is beacon_type:
points.append((x, y, 'радиомаяк'))
if not points:
return None
best = None
for x, y, label in points:
dx = x - self.player_x
dy = y - self.player_y
dist_sq = dx * dx + dy * dy
if best is None or dist_sq < best[0]:
best = (dist_sq, x, y, label)
return best
def _scan_environment(self, passive=False):
radius = 8
if self.inventory_counts.get(scanner_range, 0) > 0:
radius += 3
best, counts, highlights = self._nearest_resource_info(radius)
self.scanner_highlight_cells = set(highlights[:24])
self.scanner_highlight_timer = 300
predator_count = 0
for animal in self.get_current_animals():
if animal.hp <= 0:
continue
dx = animal.x - self.player_x
dy = animal.y - self.player_y
if dx * dx + dy * dy <= radius * radius and animal.type.behavior == 'predator':
predator_count += 1
if best is None:
if not passive:
self.notification = f'Сканер: поблизости ничего не найдено. Хищников: {predator_count}'
self.notification_timer = 120
return
_, obj, x, y = best
direction = self._direction_hint(x, y)
details = ', '.join((f'{name}×{count}' for name, count in list(sorted(counts.items()))[:3]))
if passive:
self.notification = f'Комната сканирования: {obj.name} ({direction}), хищников: {predator_count}'
self.notification_timer = 90
else:
self.notification = f'Сканер: ближайший ресурс — {obj.name} ({direction}). Рядом: {details}. Хищников: {predator_count}'
self.notification_timer = 150
def _selected_inventory_item(self):
if not 0 <= self.active_slot_index < len(self.inventory):
return None
return self.inventory[self.active_slot_index]
def _is_scanner_selected(self):
return self._selected_inventory_item() is scanner_type
def _current_flora_grid(self):
return self.cave_flora_grid if self.in_cave else self.world.flora_grid
def _current_location_name(self, x, y):
if self.in_cave:
area = self.get_cave_tunnel_at(x, y)
else:
area = self.world.get_biome_at(x, y)
return area.name if area is not None else 'Неизвестная область'
def _scan_target_at_cursor(self):
"""Возвращает объект под прицелом сканера.
Приоритет отдаётся существам, потому что их спрайт может занимать
несколько клеток. Затем проверяются предметы/ресурсы, растения и
выброшенные предметы в клетке курсора.
"""
mx = float(self.mouse_world_x)
my = float(self.mouse_world_y)
if not self.in_cave:
matching = []
for g in self.world.golem_colonies:
if g.phase == 'spore':
scan_radius = 0.5
else:
scan_radius = max(0.6, GOLEM_DATA[g.kind]['diameter'] * 0.43)
dist_sq = (g.x + 0.5 - mx) ** 2 + (g.y + 0.5 - my) ** 2
if dist_sq <= scan_radius ** 2:
matching.append((dist_sq, g))
if matching:
_, g = min(matching, key=lambda item: item[0])
bacteria = g.phase == 'spore'
return {'kind': 'bacteria' if bacteria else 'golem', 'entity': GOLEM_BACTERIA_SCAN_TYPES[g.kind] if bacteria else GOLEM_SCAN_TYPES[g.kind], 'colony': g, 'x': g.x, 'y': g.y}
best_animal = None
for animal in self.get_current_animals():
if animal.hp <= 0:
continue
radius = max(0.55, min(3.8, animal.type.size / CELL_SIZE * 0.55))
dist_sq = (animal.x + 0.5 - mx) ** 2 + (animal.y + 0.5 - my) ** 2
if dist_sq <= radius * radius:
if best_animal is None or dist_sq < best_animal[0]:
best_animal = (dist_sq, animal)
if best_animal is not None:
animal = best_animal[1]
return {'kind': 'animal', 'entity': animal.type, 'animal': animal, 'x': animal.x, 'y': animal.y}
x = int(math.floor(mx))
y = int(math.floor(my))
if not (0 <= x < MAP_SIZE and 0 <= y < MAP_SIZE):
return None
obj = self._get_current_object_grid().get((x, y))
if obj is not None:
return {'kind': 'object', 'entity': obj, 'x': x, 'y': y}
flora = self._current_flora_grid().get((x, y))
if flora is not None:
return {'kind': 'flora', 'entity': flora, 'x': x, 'y': y}
if not self.in_cave:
nearest_moss = min((m for m in self.world.moss_colonies if (m.x + 0.5 - mx) ** 2 + (m.y + 0.5 - my) ** 2 <= max(0.2, m.diameter * 0.54) ** 2), key=lambda m: (m.x + 0.5 - mx) ** 2 + (m.y + 0.5 - my) ** 2, default=None)
if nearest_moss is not None:
return {'kind': 'moss', 'entity': mad_moss_item, 'colony': nearest_moss, 'x': nearest_moss.x, 'y': nearest_moss.y}
closest_tree = None
for tx in range(x - 1, x + 2):
for ty in range(y - 1, y + 2):
tree = self.world.tree_grid.get((tx, ty))
if tree is None:
continue
distance = (tx + 0.5 - mx) ** 2 + (ty + 0.5 - my) ** 2
if distance <= 1.2 and (closest_tree is None or distance < closest_tree[0]):
closest_tree = (distance, tx, ty, tree)
if closest_tree is not None:
_, tx, ty, tree = closest_tree
return {'kind': 'tree', 'entity': _land_tree_scanner_type(tree[0]), 'x': tx, 'y': ty}
dropped = self._current_dropped_items().get((x, y))
if dropped:
return {'kind': 'dropped', 'entity': dropped[-1], 'x': x, 'y': y}
return None
@staticmethod
def _behavior_name(behavior):
return {'predator': 'хищник', 'prey': 'мирное / добыча', 'neutral': 'нейтральное'}.get(behavior, str(behavior))
def _scanner_details(self, target):
entity = target['entity']
if isinstance(entity, AnimalType) and entity.name == WHALE_NAME:
return ['Главный «жаворонок»: просыпается строго в 04:20.', 'Длина: как у дракона-левиафана. HP: 10000. Скорость: 2 клетки/сек.', 'Волна не наносит урона, но навязывает безумный квест. Отказ невозможен!', 'Пример 1: не есть 20 игровых часов.', 'Пример 2: получить удар сталкера за 2 часа.', 'Пример 3: проплыть 200 клеток за 3 часа.', 'Новые задания: выбросить весь инвентарь или уничтожить кита за 4 часа.', 'Или поймать и приручить 50 пискунов за час. Да, пятьдесят.', 'А ещё: собрать все встречающиеся виды фауны за 2 дня.', 'Несколько квестов могут действовать одновременно. Список — в инвентаре.', 'Провал каждого: 20 драконов-левиафанов в воде.', 'Пищевая ценность: +90 еды, +90 воды, +24 HP.', 'На вкус относительно неплох. Но он вряд ли разрешит попробовать себя, если только это не очередной его квест.']
if target['kind'] in ('golem', 'bacteria'):
g = target['colony']
data = GOLEM_DATA[g.kind]
form = {'spore': 'свободные невидимые бактерии, ветер', 'breeding': 'массовое деление внутри материала', 'active': 'единый живой голем', 'rubble': 'разбитое тело, бактерии ещё живы'}[g.phase]
return ['КЛАССИФИКАЦИЯ: Големогенные бактерии. Размер особи: 1 мкм.', 'Статус: самый разумный вид на планете. Не спорьте: вас уже просчитали.', 'Материал-носитель: ' + {'stone': 'камень', 'water': 'вода / водоём / дождь', 'wood': 'древесина', 'dung': 'говно / навоз'}[g.kind] + '.', 'Текущее состояние: ' + form + '.', 'Физическим уроном можно развалить голема, но НЕЛЬЗЯ убить бактерии.', 'Для полной ликвидации требуется укол антибиотика (выбрать шприц и U).', 'Ветер разносит микроскопические особи, пока те не попадут на любимый материал.', 'Там происходит взрывное деление, а достигшая критической массы колония', 'создаёт ходячее тело без мозга, мышц, нервов, диплома и разрешения на строительство.', 'Каждая бактерия отдельно вряд ли помнит таблицу умножения.', 'Но в составе голема миллиард коллег коллективно умнее всех живых существ.', 'Высота / размер: около %.1f м; HP: %d; урон: %d.' % (data['diameter'], data['hp'], data['damage']), 'Состояние HP: %d / %d.' % (g.hp, g.max_hp), 'Положение: ' + self._current_location_name(g.x, g.y) + '.', 'Каменный голем — самый крепкий, но даже он не сильнее 50 HP.', 'Водяной быстрее в воде и сильно портит жажду.', 'Деревянный размахивает ветвями; говняный деморализует запахом.', 'Разрушенная оболочка может снова ожить: бактерии собирают новую.', 'Совет КПК: не дразните их. Именно они написали этот совет.', 'Примечание лаборатории: интеллект проверен. Испытуемые победили', 'учёных в шахматы, не имея рук. Шахматную доску тоже сделали сами.']
location = self._current_location_name(target['x'], target['y'])
lines = []
if target['kind'] == 'moss':
colony = target['colony']
lines.extend(['Класс: наземная подвижная флора', 'Среда обитания: леса', 'Скорость: 0.1 клетки за кадр', f'Диаметр колонии: {colony.diameter:.2f} клетки', 'Растёт во все стороны и меняет направление случайно.', 'При ударе большая колония распадается на две маленькие.', 'Собирать можно только фрагменты диаметром 0.2 клетки.', 'При употреблении: +1 еда, +4 вода.', f'Обнаружено: {location}'])
elif target['kind'] == 'tree':
lines.extend(['Класс: наземная флора / дерево', f'Вид: {entity.name}', 'Среда обитания: леса', 'Крона формируется из листвы или хвои.', f'Обнаружено: {location}'])
elif target['kind'] == 'flora':
lines.extend(['Класс: флора', f'Вид: {entity.kind}', f'Размер природного экземпляра: {entity.size}px', f'Обнаружено: {location}', 'Статус базы данных: исследовано', 'Практическое применение: после исследования этот вид можно высаживать в построенные грядки и горшки.'])
elif isinstance(entity, AnimalType):
lines.extend(['Класс: живое существо', f'Поведение: {self._behavior_name(entity.behavior)}', f'Здоровье: {entity.hp}', f'Урон при атаке: {entity.damage}', f'Скорость: {entity.speed:.3f}', f"Съедобность: {('да' if entity.edible else 'нет')}", f'Объём в инвентаре: {entity.volume}', f'Обнаружено: {location}'])
else:
if getattr(entity, 'is_buildable', False):
category = 'постройка / устройство'
elif getattr(entity, 'is_weapon', False):
category = 'оружие'
elif getattr(entity, 'edible', False):
category = 'съедобный предмет / ресурс'
else:
category = 'предмет / ресурс'
lines.extend([f'Класс: {category}', f"Размер: {getattr(entity, 'size', 0)}px", f"Объём в инвентаре: {getattr(entity, 'volume', 1)}", f'Обнаружено: {location}'])
if getattr(entity, 'is_weapon', False):
lines.append(f'Урон: {entity.damage}')
lines.append(f'Дальность: {entity.weapon_range}')
lines.append(f"Тип оружия: {entity.weapon_type or 'не указан'}")
if getattr(entity, 'edible', False):
lines.append(f'Питательность: +{entity.hunger_value} голода')
lines.append(f'Жидкость: +{entity.thirst_value} жажды')
if entity in self._planter_types():
lines.append('Контейнер для выращивания: ПКМ без выбранного сканера открывает посадку.')
return lines
def _scanner_preview_source(self, target):
entity = target['entity']
cache_key = (target['kind'], id(entity))
cached = self.scanner_preview_cache.get(cache_key)
if cached is not None:
return cached
source = None
if target['kind'] == 'golem':
source = generate_golem_sprite(entity.kind, 340, shattered=target['colony'].phase == 'rubble')
elif target['kind'] == 'bacteria':
source = generate_golem_bacterium_preview(entity.kind, 290)
elif target['kind'] == 'moss':
source = generate_mad_moss_sprite(270)
elif target['kind'] == 'tree':
source = pygame.transform.smoothscale(generate_tree_sprite(TREE_SPECIES.index(entity.name)), (260, 260))
elif isinstance(entity, FloraType):
source = generate_subnautica_flora_sprite(entity.kind, 260, seed=entity.seed)
elif isinstance(entity, AnimalType):
if entity.sprite_kind is not None:
length = 420
width = max(75, int(length * entity.fish_width / max(1.0, entity.fish_length)))
source = generate_subnautica_creature_sprite(entity.sprite_kind, length, width)
elif entity.image is not None:
source = entity.image.copy()
elif getattr(entity, 'is_buildable', False):
source = generate_building_sprite(entity.name, 220, entity.color, wall=getattr(entity, 'is_wall', False))
else:
source = generate_highres_resource_preview(entity, 280)
if source is None and getattr(entity, 'image', None) is not None:
source = entity.image.copy()
if source is None:
source = pygame.Surface((180, 180), pygame.SRCALPHA)
if getattr(entity, 'shape', 'rect') == 'circle':
pygame.draw.circle(source, entity.color, (90, 90), 70)
else:
pygame.draw.rect(source, entity.color, (25, 25, 130, 130), border_radius=18)
self.scanner_preview_cache[cache_key] = source
return source
@staticmethod
def _fit_surface(source, max_width, max_height):
sw, sh = source.get_size()
if sw <= 0 or sh <= 0:
return source
scale = min(max_width / sw, max_height / sh)
nw = max(1, int(sw * scale))
nh = max(1, int(sh * scale))
return pygame.transform.smoothscale(source, (nw, nh))
def scan_with_scanner(self):
if not self._is_scanner_selected():
return False
target = self._scan_target_at_cursor()
if target is None:
self.notification = 'Сканер: наведите прицел на предмет, ресурс, существо или растение'
self.notification_timer = 100
return False
entity = target['entity']
entry_key = (target['kind'], entity.name)
first_scan = entry_key not in self.scanned_entries
self.scanned_entries.add(entry_key)
if isinstance(entity, FloraType):
self.researched_flora.add(entity.kind)
target['first_scan'] = first_scan
target['details'] = self._scanner_details(target)
self.scanner_scroll_offset = 0
self.scanner_scroll_max = 0
self.scanner_target = target
self.state = 'scanner'
self.notification = ''
self.notification_timer = 0
return True
def _planter_types(self):
return (exterior_growbed, indoor_growbed, plant_pot, composite_pot, chic_pot, plant_shelf)
def _planter_capacity(self, planter):
if planter is exterior_growbed:
return 6
if planter is indoor_growbed:
return 4
if planter is plant_shelf:
return 2
return 1
def _planter_key(self, x, y):
return (bool(self.in_cave), int(x), int(y))
def open_planting_menu_at_cursor(self):
x = int(math.floor(self.mouse_world_x))
y = int(math.floor(self.mouse_world_y))
obj = self._get_current_object_grid().get((x, y))
if obj not in self._planter_types():
return False
researched = [flora for flora in flora_types.values() if flora.kind in self.researched_flora]
researched.sort(key=lambda flora: flora.name.lower())
if not researched:
self.notification = 'Сначала исследуйте растение сканером'
self.notification_timer = 100
return True
self.planting_target = {'x': x, 'y': y, 'planter': obj}
self.planting_page = 0
self.state = 'planting'
return True
def _researched_plant_list(self):
plants = [flora for flora in flora_types.values() if flora.kind in self.researched_flora]
plants.sort(key=lambda flora: flora.name.lower())
return plants
def plant_researched_flora(self, visible_index):
if self.state != 'planting' or self.planting_target is None:
return
plants = self._researched_plant_list()
index = self.planting_page * self.plants_per_page + visible_index
if not 0 <= index < len(plants):
return
x = self.planting_target['x']
y = self.planting_target['y']
planter = self.planting_target['planter']
key = self._planter_key(x, y)
planted = self.planted_flora.setdefault(key, [])
capacity = self._planter_capacity(planter)
if len(planted) >= capacity:
self.notification = 'В этой грядке/горшке больше нет свободных мест'
self.notification_timer = 100
return
flora = plants[index]
if flora.kind not in self.researched_flora:
self.notification = 'Этот вид ещё не исследован'
self.notification_timer = 100
return
planted.append(flora)
self.notification = f'Посажено: {flora.name} ({len(planted)}/{capacity})'
self.notification_timer = 100
def _draw_planted_flora_at(self, x, y, screen_x, screen_y):
plants = self.planted_flora.get(self._planter_key(x, y), ())
if not plants:
return
offsets = [(0, 1), (-8, -3), (8, -3), (-8, 7), (8, 7), (0, -8), (0, 9)]
for index, flora in enumerate(plants):
image = flora.image
if image is None:
continue
max_size = 20 if len(plants) <= 2 else 15
fitted = self._fit_surface(image, max_size, max_size)
ox, oy = offsets[index % len(offsets)]
rect = fitted.get_rect(center=(screen_x + CELL_SIZE // 2 + ox, screen_y + CELL_SIZE // 2 + oy))
self.screen.blit(fitted, rect)
def _draw_wrapped_lines(self, lines, x, y, max_width, color=(220, 235, 240), line_height=22):
cursor_y = y
for source_line in lines:
words = str(source_line).split()
current = ''
wrapped = []
for word in words:
test = word if not current else current + ' ' + word
if self.small_font.size(test)[0] <= max_width:
current = test
else:
if current:
wrapped.append(current)
current = word
if current:
wrapped.append(current)
if not wrapped:
wrapped = ['']
for line in wrapped:
surf = self.small_font.render(line, True, color)
self.screen.blit(surf, (x, cursor_y))
cursor_y += line_height
cursor_y += 3
return cursor_y
def draw_scanner_view(self):
if self.scanner_target is None:
self.state = 'playing'
return
target = self.scanner_target
entity = target['entity']
self.screen.fill((4, 20, 28))
margin = 28
header_h = 70
pygame.draw.rect(self.screen, (8, 55, 68), (margin, margin, SCREEN_SIZE - margin * 2, SCREEN_SIZE - margin * 2), border_radius=14)
pygame.draw.rect(self.screen, (60, 210, 220), (margin, margin, SCREEN_SIZE - margin * 2, SCREEN_SIZE - margin * 2), 2, border_radius=14)
title = self.large_font.render(entity.name, True, (170, 250, 250))
self.screen.blit(title, (margin + 22, margin + 16))
status_text = 'НОВАЯ ЗАПИСЬ' if target.get('first_scan') else 'ИССЛЕДОВАНО'
status = self.small_font.render(status_text, True, (130, 255, 170))
self.screen.blit(status, (SCREEN_SIZE - margin - status.get_width() - 22, margin + 25))
content_top = margin + header_h
preview_w = int((SCREEN_SIZE - margin * 2) * 0.46)
preview_rect = pygame.Rect(margin + 18, content_top + 10, preview_w - 28, SCREEN_SIZE - content_top - margin - 60)
pygame.draw.rect(self.screen, (3, 26, 35), preview_rect, border_radius=12)
pygame.draw.rect(self.screen, (35, 130, 150), preview_rect, 2, border_radius=12)
preview = self._fit_surface(self._scanner_preview_source(target), preview_rect.width - 30, preview_rect.height - 30)
preview_pos = preview.get_rect(center=preview_rect.center)
self.screen.blit(preview, preview_pos)
scan_y = preview_rect.top + self.frame_count * 3 % max(1, preview_rect.height)
pygame.draw.line(self.screen, (90, 245, 245), (preview_rect.left + 6, scan_y), (preview_rect.right - 6, scan_y), 1)
info_x = preview_rect.right + 24
info_width = SCREEN_SIZE - margin - info_x - 20
heading = self.font.render('Подробная информация', True, (255, 235, 150))
self.screen.blit(heading, (info_x, content_top + 14))
clip_rect = pygame.Rect(info_x, content_top + 42, info_width, SCREEN_SIZE - margin - 78 - (content_top + 42))
original_clip = self.screen.get_clip()
self.screen.set_clip(clip_rect)
text_end = self._draw_wrapped_lines(target.get('details', ()), info_x, content_top + 48 - self.scanner_scroll_offset, info_width)
self.screen.set_clip(original_clip)
self.scanner_scroll_max = max(0, text_end + self.scanner_scroll_offset - clip_rect.bottom + 12)
self.scanner_scroll_offset = min(self.scanner_scroll_offset, self.scanner_scroll_max)
footer = self.small_font.render('Колесо — листать | ESC или ПКМ — закрыть', True, (170, 210, 215))
self.screen.blit(footer, footer.get_rect(center=(SCREEN_SIZE // 2, SCREEN_SIZE - margin - 20)))
def draw_planting_menu(self):
if self.planting_target is None:
self.state = 'playing'
return
overlay = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 205))
self.screen.blit(overlay, (0, 0))
plants = self._researched_plant_list()
max_page = max(0, (len(plants) - 1) // self.plants_per_page)
self.planting_page = min(self.planting_page, max_page)
start = self.planting_page * self.plants_per_page
shown = plants[start:start + self.plants_per_page]
x = self.planting_target['x']
y = self.planting_target['y']
planter = self.planting_target['planter']
key = self._planter_key(x, y)
planted = self.planted_flora.get(key, ())
capacity = self._planter_capacity(planter)
title = self.large_font.render(f'{planter.name}: посадка растений', True, (150, 245, 190))
self.screen.blit(title, title.get_rect(center=(SCREEN_SIZE // 2, 60)))
occupancy = self.small_font.render(f'Занято мест: {len(planted)}/{capacity}. Доступны только исследованные сканером виды.', True, (225, 225, 225))
self.screen.blit(occupancy, occupancy.get_rect(center=(SCREEN_SIZE // 2, 96)))
start_y = 130
row_h = 45
for i, flora in enumerate(shown):
row_y = start_y + i * row_h
pygame.draw.rect(self.screen, (15, 55, 52), (105, row_y, SCREEN_SIZE - 210, row_h - 5), border_radius=8)
pygame.draw.rect(self.screen, (55, 140, 110), (105, row_y, SCREEN_SIZE - 210, row_h - 5), 1, border_radius=8)
number = self.font.render(f'{i + 1}.', True, (255, 235, 135))
self.screen.blit(number, (120, row_y + 9))
icon = self._fit_surface(flora.image, 30, 30)
self.screen.blit(icon, icon.get_rect(center=(170, row_y + 20)))
label = self.font.render(flora.name, True, (225, 245, 235))
self.screen.blit(label, (195, row_y + 9))
footer = self.small_font.render(f'1–9 — посадить | ← → — страница {self.planting_page + 1}/{max_page + 1} | ESC — закрыть', True, (205, 220, 220))
self.screen.blit(footer, footer.get_rect(center=(SCREEN_SIZE // 2, SCREEN_SIZE - 35)))
def _apply_repulsion(self, radius=5.0, push=1.6):
affected = 0
for animal in self.get_current_animals():
if animal.hp <= 0 or getattr(animal, 'is_companion', False):
continue
dx = animal.x - self.player_x
dy = animal.y - self.player_y
dist_sq = dx * dx + dy * dy
if dist_sq <= 0 or dist_sq > radius * radius:
continue
dist = math.sqrt(dist_sq)
old_x, old_y = (animal.x, animal.y)
candidate_x = animal.x + dx / dist * push
candidate_y = animal.y + dy / dist * push
if animal.home_biome.contains(candidate_x, candidate_y):
animal.x = candidate_x
animal.y = candidate_y
animal.attack_timer = max(animal.attack_timer, 60)
animal._update_rotation()
if not self.in_cave:
self.world.update_animal_index(animal, old_x, old_y)
affected += 1
return affected
def _use_special_item(self, item):
if item is antibiotic_syringe:
return self._inject_antibiotic()
if item is first_aid_kit:
heal = 12
self.player_hp = min(self.max_player_hp, self.player_hp + heal)
self._consume_selected_item(item)
self.notification = f'Аптечка использована. HP: {self.player_hp}/{self.max_player_hp}'
self.notification_timer = 90
return True
if item is flashlight_type:
self.flashlight_on = not self.flashlight_on
self.notification = 'Фонарик включён' if self.flashlight_on else 'Фонарик выключен'
self.notification_timer = 60
return True
if item is seaglide_type:
self.seaglide_on = not self.seaglide_on
self.notification = 'Морской глайдер активирован' if self.seaglide_on else 'Морской глайдер выключен'
self.notification_timer = 60
return True
if item is flare_type:
self.active_flare_timer = max(self.active_flare_timer, 18 * 60)
self._consume_selected_item(item)
self.notification = 'Ракета горит: ночью она не отпугивает хищников' if self._is_dark_night() else 'Ракета горит: часть хищников отступает'
self.notification_timer = 150
return True
if item is fire_extinguisher:
self.damage_resist_timer = max(self.damage_resist_timer, 20 * 60)
self._consume_selected_item(item)
self.notification = 'Огнетушитель использован: временно снижает получаемый урон'
self.notification_timer = 120
return True
if item is air_bladder:
self.speed_boost_timer = max(self.speed_boost_timer, 8 * 60)
self.player_thirst = min(self.max_thirst, self.player_thirst + 10)
self._consume_selected_item(item)
self.notification = 'Воздушный пузырь использован: краткий рывок и восстановление жажды'
self.notification_timer = 120
return True
if item is scanner_type:
self.notification = 'Сканер выбран: наведите прицел на цель и нажмите ПКМ'
self.notification_timer = 120
return True
if item is pathfinder_tool:
best = self._find_nearest_path_target()
if best is None:
self.notification = 'Инструмент исследователя: ориентир не найден'
else:
_, x, y, label = best
self.notification = f'Инструмент исследователя: ближайший ориентир — {label} ({self._direction_hint(x, y)})'
self.notification_timer = 120
return True
if item is repair_tool:
healed = min(self.max_player_hp - self.player_hp, 5)
if healed <= 0:
self.notification = 'Ремонтный инструмент: скафандр не требует обслуживания'
else:
self.player_hp += healed
self.notification = f'Ремонтный инструмент обслужил скафандр. HP +{healed}'
self.notification_timer = 90
return True
if item is propulsion_cannon:
grid = self._get_current_object_grid()
best = None
for (x, y), obj in grid.items():
if getattr(obj, 'is_buildable', False) or getattr(obj, 'is_weapon', False):
continue
dx = x - self.player_x
dy = y - self.player_y
dist_sq = dx * dx + dy * dy
if dist_sq <= 16 and (best is None or dist_sq < best[0]):
best = (dist_sq, x, y, obj)
if best is None:
self.notification = 'Пропульсионная пушка: поблизости нет подходящего предмета'
else:
_, x, y, obj = best
if self.add_to_inventory(obj):
grid.pop((x, y), None)
self.notification = f'Пропульсионная пушка притянула: {obj.name}'
else:
self.notification = 'Нет места в инвентаре'
self.notification_timer = 90
return True
if item is repulsion_cannon:
affected = self._apply_repulsion(radius=5.5, push=1.9)
self.notification = f'Репульсионная пушка отбросила существ: {affected}'
self.notification_timer = 90
return True
if item is stasis_rifle:
frozen = 0
for animal in self.get_current_animals():
if animal.hp <= 0 or getattr(animal, 'is_companion', False):
continue
dx = animal.x - self.player_x
dy = animal.y - self.player_y
dist_sq = dx * dx + dy * dy
if dist_sq <= 36:
animal.stasis_timer = max(getattr(animal, 'stasis_timer', 0), 6 * 60)
frozen += 1
if frozen >= 3:
break
self.notification = f'Стазис-винтовка обездвижила существ: {frozen}'
self.notification_timer = 90
return True
if item is laser_cutter:
hit = False
for animal in self.get_current_animals():
if animal.hp <= 0 or getattr(animal, 'is_companion', False):
continue
dx = animal.x - self.player_x
dy = animal.y - self.player_y
dist_sq = dx * dx + dy * dy
if dist_sq <= 9:
animal.hp -= 18
self.notification = f'Лазерный резак повредил {animal.type.name} (HP: {animal.hp})'
self.notification_timer = 90
if animal.hp <= 0:
self.handle_animal_death(animal)
hit = True
break
if not hit and (not self.in_cave):
golems = self._near_golems(self.player_x, self.player_y, 3.0, ('active',))
if golems:
self._damage_golem(min(golems, key=lambda g: (g.x - self.player_x) ** 2 + (g.y - self.player_y) ** 2), 18)
hit = True
if not hit and (not self.in_cave):
mosses = self._moss_near(self.player_x, self.player_y, 3.0)
if mosses:
self._hit_mad_moss(min(mosses, key=lambda m: (m.x - self.player_x) ** 2 + (m.y - self.player_y) ** 2), 18)
hit = True
if not hit:
self.notification = 'Лазерный резак: цель слишком далеко'
self.notification_timer = 90
return True
if item is creature_decoy:
self.active_decoys.append({'x': float(self.player_x), 'y': float(self.player_y), 'timer': 14 * 60, 'in_cave': self.in_cave})
self._consume_selected_item(item)
self.notification = 'Приманка для существ выброшена'
self.notification_timer = 90
return True
return False
def _base_light_bonus(self):
bonus = 0
if self.seaglide_on:
bonus += 12
if self.active_flare_timer > 0:
bonus += 28
return min(55, bonus)
def _update_base_effects(self):
self.frame_count += 1
if self.active_flare_timer > 0:
self.active_flare_timer -= 1
if self.speed_boost_timer > 0:
self.speed_boost_timer -= 1
if self.damage_resist_timer > 0:
self.damage_resist_timer -= 1
if self.scanner_highlight_timer > 0:
self.scanner_highlight_timer -= 1
if self.scanner_highlight_timer <= 0:
self.scanner_highlight_cells.clear()
if self.active_decoys:
for decoy in self.active_decoys:
decoy['timer'] -= 1
self.active_decoys = [d for d in self.active_decoys if d['timer'] > 0]
if self.frame_count % 15 == 0:
grid = self._get_current_object_grid()
traps = [(x, y) for (x, y), obj in list(grid.items()) if obj is grav_trap]
for tx, ty in traps:
moved = []
for (x, y), obj in list(grid.items()):
if (x, y) == (tx, ty):
continue
if getattr(obj, 'is_buildable', False) or getattr(obj, 'is_weapon', False):
continue
dx = tx - x
dy = ty - y
if dx * dx + dy * dy > 9:
continue
step_x = 0 if dx == 0 else 1 if dx > 0 else -1
step_y = 0 if dy == 0 else 1 if dy > 0 else -1
nx = x + step_x if abs(dx) >= abs(dy) else x
ny = y + step_y if abs(dy) > abs(dx) else y
if (nx, ny) == (tx, ty) or (nx, ny) not in grid:
moved.append(((x, y), (nx, ny), obj))
for old_pos, new_pos, obj in moved:
if old_pos in grid and new_pos not in grid:
grid.pop(old_pos, None)
grid[new_pos] = obj
if self.frame_count % 180 != 0:
return
nearby = self._nearby_buildables(radius=3.2)
if not nearby:
return
nearby_types = {obj for obj, _x, _y, _d in nearby}
if any((obj in nearby_types for obj in (bed_type, bench_type, chair_type))):
self.player_hp = min(self.max_player_hp, self.player_hp + 1)
if vending_machine in nearby_types:
self.player_hunger = min(self.max_hunger, self.player_hunger + 2)
if coffee_machine in nearby_types:
self.player_thirst = min(self.max_thirst, self.player_thirst + 3)
if water_filtration_machine in nearby_types:
self.player_thirst = min(self.max_thirst, self.player_thirst + 2)
if medical_fabricator in nearby_types and self.frame_count % 720 == 0:
if self.add_to_inventory(first_aid_kit):
self.notification = 'Изготовитель аптечек выдал новую аптечку'
self.notification_timer = 90
if scanner_room_type in nearby_types and self.frame_count % 540 == 0:
self._scan_environment(passive=True)
if radio_type in nearby_types and self.frame_count % 900 == 0:
best = self._find_nearest_path_target()
if best is not None:
_, x, y, label = best
self.notification = f'Радио: ориентир — {label} ({self._direction_hint(x, y)})'
self.notification_timer = 90
def use_item(self):
if self.state not in ('playing', 'inventory'):
return
if self.active_slot_index == -1:
self.notification = 'Выберите предмет в инвентаре'
self.notification_timer = 60
return
item = self.inventory[self.active_slot_index]
if item is None:
self.notification = 'Слот пуст'
self.notification_timer = 60
return
if item.edible:
self._whale_quest_on_food()
self.player_hunger = min(self.max_hunger, self.player_hunger + item.hunger_value)
self.player_thirst = min(self.max_thirst, self.player_thirst + item.thirst_value)
food_heal = 0 if item is mad_moss_item else 24 if item.name == WHALE_NAME else 2
self.player_hp = min(self.max_player_hp, self.player_hp + food_heal)
self._consume_selected_item(item)
self.notification = f'Использовано: {item.name}. HP +{food_heal}, Голод: {self.player_hunger:.0f}, Жажда: {self.player_thirst:.0f}'
self.notification_timer = 60
return
if self.state == 'inventory':
return
if self._use_special_item(item):
return
self.notification = 'У этого предмета пока нет активного применения'
self.notification_timer = 60
def _near_golems(self, x, y, radius=2.0, phases=None):
if self.in_cave:
return []
rr = radius * radius
return [g for g in self.world.golem_colonies if (phases is None or g.phase in phases) and (g.x - x) ** 2 + (g.y - y) ** 2 <= rr]
def _damage_golem(self, golem, damage):
if not golem.hit(damage, self.frame_count):
self.notification = 'Бактерии живы, но тело пока не собрано. Нужен антибиотик.'
elif golem.phase == 'rubble':
self.notification = GOLEM_DATA[golem.kind]['name'] + ' разрушен! Бактерии выжили и могут собрать его снова.'
else:
self.notification = '%s: %d/%d HP' % (GOLEM_DATA[golem.kind]['name'], golem.hp, golem.max_hp)
self.notification_timer = 130
self._map_biome_species_cache = None
def _inject_antibiotic(self):
targets = self._near_golems(self.player_x, self.player_y, 2.0, ('active', 'rubble', 'breeding'))
if not targets:
self.notification = 'Антибиотик: приблизьтесь к голему или заражённым обломкам (2 клетки)'
self.notification_timer = 110
return True
golem = min(targets, key=lambda g: (g.x - self.player_x) ** 2 + (g.y - self.player_y) ** 2)
self.world.golem_colonies.remove(golem)
self._consume_selected_item(antibiotic_syringe)
self._map_biome_species_cache = None
self.notification = GOLEM_DATA[golem.kind]['name'] + ': колония уничтожена антибиотиком. Воскреснуть больше некому!'
self.notification_timer = 180
return True
def _update_golems(self):
if self.in_cave or not GOLEM_ENABLED:
return
radius = max(16, self._render_view_radius + 7)
for g in self.world.golem_colonies:
if abs(g.x - self.player_x) > radius or abs(g.y - self.player_y) > radius:
continue
g.update(self.world, self.player_x, self.player_y, self.frame_count, self._selected_inventory_item() is antibiotic_syringe)
if g.phase != 'active' or self.frame_count < g.next_attack:
continue
d = math.hypot(g.x - self.player_x, g.y - self.player_y)
if d > 1.22:
continue
g.next_attack = self.frame_count + GOLEM_DAMAGE_COOLDOWN
damage = GOLEM_DATA[g.kind]['damage']
if g.kind == 'water':
self.player_thirst = max(0, self.player_thirst - 3)
elif g.kind == 'wood':
damage += 1 if self.frame_count % 3 == 0 else 0
elif g.kind == 'dung':
self.player_hunger = max(0, self.player_hunger - 4)
self._damage_player_or_vehicle(damage)
self.notification = '%s атакует! −%d HP' % (GOLEM_DATA[g.kind]['name'], damage)
self.notification_timer = 65
def _draw_golems(self, camera_x, camera_y):
if self.in_cave:
return
rr = self._render_view_radius + 3
for x, y in self.world.golem_dung_piles:
if abs(x - self.player_x) > rr or abs(y - self.player_y) > rr:
continue
sx = round((x - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = round((y - camera_y) * CELL_SIZE + CELL_SIZE // 2)
pygame.draw.ellipse(self.screen, (81, 63, 36), (sx - 9, sy - 6, 18, 12))
pygame.draw.ellipse(self.screen, (135, 94, 48), (sx - 6, sy - 6, 12, 8))
for g in self.world.golem_colonies:
if abs(g.x - self.player_x) > rr or abs(g.y - self.player_y) > rr:
continue
if g.phase == 'spore':
continue
if g.phase == 'breeding' and g.mass < 20:
continue
sx = round((g.x - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = round((g.y - camera_y) * CELL_SIZE + CELL_SIZE // 2)
mult = max(0.28, min(1.0, g.mass / GOLEM_FORMATION_MASS)) if g.phase == 'breeding' else 1.0
diameter = round(CELL_SIZE * GOLEM_DATA[g.kind]['diameter'] * mult)
cache_key = (g.kind, diameter, g.phase == 'rubble')
sprite = _GOLEM_SPRITE_CACHE.get(cache_key)
if sprite is None:
sprite = generate_golem_sprite(g.kind, diameter, g.phase == 'rubble')
if len(_GOLEM_SPRITE_CACHE) > 250:
_GOLEM_SPRITE_CACHE.clear()
_GOLEM_SPRITE_CACHE[cache_key] = sprite
self.screen.blit(sprite, sprite.get_rect(center=(sx, sy)))
if g.phase == 'active' and g.hp < g.max_hp:
bx = sx - 18
by = sy - diameter // 2 - 8
pygame.draw.rect(self.screen, (82, 32, 32), (bx, by, 36, 4))
pygame.draw.rect(self.screen, (55, 224, 151), (bx, by, round(36 * g.hp / g.max_hp), 4))
elif g.phase == 'rubble':
pygame.draw.circle(self.screen, (235, 181, 57), (sx, sy), max(7, diameter // 2 + 4), 1)
def _moss_near(self, x, y, radius=1.0):
if self.in_cave:
return []
rr = radius * radius
return [m for m in self.world.moss_colonies if (m.x - x) ** 2 + (m.y - y) ** 2 <= rr]
def _update_mad_moss(self):
if self.in_cave:
return
radius = self._render_view_radius + 3
for moss in self.world.moss_colonies:
if abs(moss.x - self.player_x) <= radius and abs(moss.y - self.player_y) <= radius:
moss.update(self.world)
def _hit_mad_moss(self, moss, damage=1):
"""Любой удар делит одну крупную колонию на две меньшие."""
if moss.terminal:
self.notification = 'Мох измельчён до 1/5 клетки: подберите E'
self.notification_timer = 95
return True
try:
self.world.moss_colonies.remove(moss)
except ValueError:
return False
child_diameter = max(MAD_MOSS_MIN_DIAMETER, moss.diameter / math.sqrt(2))
terminal = child_diameter <= MAD_MOSS_MIN_DIAMETER + 0.028
if terminal:
child_diameter = MAD_MOSS_MIN_DIAMETER
for sign in (-1, 1):
spread = max(0.11, child_diameter * 0.47)
angle = moss.heading + sign * math.pi * 0.5
x = moss.x + math.cos(angle) * spread
y = moss.y + math.sin(angle) * spread
if self.world.get_biome_at(x, y) is not moss.home_biome:
x, y = (moss.x, moss.y)
part = MadMossColony(x, y, moss.home_biome, child_diameter, terminal)
part.heading = moss.heading + sign * 0.95
self.world.moss_colonies.append(part)
self.notification = 'Бешеный мох разделился: 2 части' if not terminal else 'Мох разделился на 2 съедобные части (E)'
self.notification_timer = 80
return True
def _draw_mad_moss(self, camera_x, camera_y, surface=None, zoom=1.0):
if self.in_cave:
return
target = surface if surface is not None else self.screen
extra = self._render_view_radius + 3
for moss in self.world.moss_colonies:
if abs(moss.x - self.player_x) > extra or abs(moss.y - self.player_y) > extra:
continue
if zoom == 1.0:
sx = round((moss.x - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = round((moss.y - camera_y) * CELL_SIZE + CELL_SIZE // 2)
else:
sx = round(SCREEN_SIZE * 0.5 + (moss.x - self.player_x) * CELL_SIZE * zoom)
sy = round(SCREEN_SIZE * 0.5 + (moss.y - self.player_y) * CELL_SIZE * zoom)
diameter = moss.diameter * CELL_SIZE * zoom
sprite = get_mad_moss_sprite(diameter, moss.variant)
target.blit(sprite, sprite.get_rect(center=(sx, sy)))
def pick_up(self):
player_x = int(self.player_x)
player_y = int(self.player_y)
if not self.in_cave:
nearest = sorted(self._moss_near(self.player_x, self.player_y, 0.72), key=lambda m: (m.x - self.player_x) ** 2 + (m.y - self.player_y) ** 2)
if nearest:
moss = nearest[0]
if not moss.terminal:
self.notification = 'Сначала измельчите Бешеный мох ударом (Пробел)!'
self.notification_timer = 90
return
if self.add_to_inventory(mad_moss_item):
self.world.moss_colonies.remove(moss)
self.notification = 'Подобран Бешеный мох (+1 еда, +4 вода при поедании)'
else:
self.notification = 'Инвентарь полон — мох остался на земле'
self.notification_timer = 90
return
dropped = self._current_dropped_items()
dropped_stack = dropped.get((player_x, player_y))
if dropped_stack:
item = dropped_stack[-1]
if self.add_to_inventory(item):
dropped_stack.pop()
if not dropped_stack:
dropped.pop((player_x, player_y), None)
self.notification = f'Подобрано: {item.name}'
self.notification_timer = 40
else:
self.notification = 'Нет места в инвентаре!'
self.notification_timer = 60
return
if self.in_cave:
obj = self.cave_world.get_object_at(player_x, player_y)
else:
obj = self.world.get_object_at(player_x, player_y)
if obj is not None and (not obj.is_buildable):
if self.add_to_inventory(obj):
if self.in_cave:
self.cave_world.remove_object_at(player_x, player_y)
else:
self.world.remove_object_at(player_x, player_y)
self.notification = f'Подобрано: {obj.name}'
self.notification_timer = 40
else:
self.notification = 'Нет места в инвентаре!'
self.notification_timer = 60
return
animals = self.get_current_animals()
for animal in list(animals):
if getattr(animal, 'is_companion', False):
continue
if int(animal.x) != player_x or int(animal.y) != player_y:
continue
if animal.hp > 0 and animal.type.behavior == 'predator':
self.notification = 'Хищник жив, убейте его!'
self.notification_timer = 60
return
if self.add_to_inventory(animal.type, wild_catch=True):
self.remove_current_animal(animal)
self.notification = f'Подобран {animal.type.name}'
self.notification_timer = 60
else:
self.notification = 'Нет места в инвентаре!'
self.notification_timer = 60
return
def build(self):
if self.state != 'playing':
return
if self.active_slot_index == -1:
self.notification = 'Выберите предмет в инвентаре'
self.notification_timer = 60
return
item = self.inventory[self.active_slot_index]
if item is None:
self.notification = 'Слот пуст'
self.notification_timer = 60
return
if not item.is_buildable:
self.notification = 'Этот предмет нельзя установить'
self.notification_timer = 60
return
if getattr(item, 'requires_habitat_builder', False) and self.inventory_counts.get(habitat_builder_type, 0) <= 0:
self.notification = 'Для установки нужен Строитель'
self.notification_timer = 90
return
player_x = int(self.player_x)
player_y = int(self.player_y)
active_world = self.cave_world if self.in_cave else self.world
if active_world.get_object_at(player_x, player_y) is not None:
self.notification = 'Клетка занята'
self.notification_timer = 60
return
active_world.object_grid[player_x, player_y] = item
self.inventory[self.active_slot_index] = None
self.inventory_counts[item] -= 1
if self.inventory_counts[item] <= 0:
del self.inventory_counts[item]
self.active_slot_index = -1
self.notification = f'Установлено: {item.name}'
self.notification_timer = 60
if item.is_wall:
self.notification = 'Стена установлена под игроком'
self.notification_timer = 120
def destroy_build(self):
if self.state != 'playing':
return
player_x = int(self.player_x)
player_y = int(self.player_y)
active_world = self.cave_world if self.in_cave else self.world
obj = active_world.get_object_at(player_x, player_y)
if obj is None or not obj.is_buildable:
self.notification = 'Здесь нет постройки'
self.notification_timer = 60
return
active_world.remove_object_at(player_x, player_y)
self.planted_flora.pop((bool(self.in_cave), player_x, player_y), None)
if self.add_to_inventory(obj):
self.notification = f'Разрушено: {obj.name}'
else:
self.notification = 'Инвентарь полон. Предмет потерян.'
self.notification_timer = 60
def attack(self):
if self.state != 'playing':
return
player_x = self.player_x
player_y = self.player_y
max_distance_squared = self.player_attack_range ** 2
if not self.in_cave:
candidates = self._moss_near(player_x, player_y, self.player_attack_range)
if candidates:
moss = min(candidates, key=lambda m: (m.x - player_x) ** 2 + (m.y - player_y) ** 2)
self._hit_mad_moss(moss, self.player_attack_damage)
return
if not self.in_cave:
targets = self._near_golems(player_x, player_y, self.player_attack_range, ('active',))
if targets:
target = min(targets, key=lambda g: (g.x - player_x) ** 2 + (g.y - player_y) ** 2)
self._damage_golem(target, self.player_attack_damage)
return
for animal in list(self.get_current_animals()):
if animal.hp <= 0:
continue
if getattr(animal, 'is_companion', False):
continue
dx = animal.x - player_x
dy = animal.y - player_y
distance_squared = dx * dx + dy * dy
if distance_squared > max_distance_squared:
continue
animal.hp -= self.player_attack_damage
self.notification = f'Атакован {animal.type.name}, HP: {animal.hp}'
self.notification_timer = 30
if animal.hp <= 0:
self.handle_animal_death(animal)
break
def shoot(self, target_x, target_y):
if self.state != 'playing':
return
weapon = self.equipped_weapon
if weapon is None:
self.notification = 'Нет экипированного оружия!'
self.notification_timer = 60
return
target_dx = target_x - self.player_x
target_dy = target_y - self.player_y
target_distance_squared = target_dx ** 2 + target_dy ** 2
if target_distance_squared <= 0:
return
target_length = math.sqrt(target_distance_squared)
target_dx /= target_length
target_dy /= target_length
best_animal = None
best_distance_squared = float('inf')
max_range_squared = weapon.weapon_range ** 2
cos_threshold = math.cos(math.radians(30))
for animal in self.get_current_animals():
if animal.hp <= 0:
continue
if getattr(animal, 'is_companion', False):
continue
dx = animal.x - self.player_x
dy = animal.y - self.player_y
distance_squared = dx * dx + dy * dy
if distance_squared > max_range_squared:
continue
if distance_squared <= 0:
continue
distance = math.sqrt(distance_squared)
direction_x = dx / distance
direction_y = dy / distance
dot = direction_x * target_dx + direction_y * target_dy
if dot >= cos_threshold and distance_squared < best_distance_squared:
best_distance_squared = distance_squared
best_animal = animal
if not self.in_cave:
for moss in self._moss_near(self.player_x, self.player_y, weapon.weapon_range):
dx, dy = (moss.x - self.player_x, moss.y - self.player_y)
dist_sq = dx * dx + dy * dy
if not 0 < dist_sq < best_distance_squared:
continue
if (dx * target_dx + dy * target_dy) / math.sqrt(dist_sq) >= cos_threshold:
best_animal = None
best_distance_squared = dist_sq
best_moss = moss
if 'best_moss' in locals():
self._hit_mad_moss(best_moss, weapon.damage)
return
if not self.in_cave:
possible = self._near_golems(self.player_x, self.player_y, weapon.weapon_range, ('active',))
chosen = None
chosen_dist = best_distance_squared
for g in possible:
ddx = g.x - self.player_x
ddy = g.y - self.player_y
dd2 = ddx * ddx + ddy * ddy
if dd2 <= 0.01 or dd2 >= chosen_dist:
continue
if (ddx * target_dx + ddy * target_dy) / math.sqrt(dd2) >= cos_threshold:
chosen = g
chosen_dist = dd2
if chosen is not None:
self._damage_golem(chosen, weapon.damage)
return
if best_animal is None:
self.notification = 'Промах!'
self.notification_timer = 30
return
best_animal.hp -= weapon.damage
self.notification = f'{weapon.name} поразил {best_animal.type.name} (HP: {best_animal.hp})'
self.notification_timer = 60
if best_animal.hp <= 0:
self.handle_animal_death(best_animal)
def is_inside_start_capsule(self, x, y):
x1, y1, x2, y2 = self.start_capsule_rect
return x1 <= int(x) <= x2 and y1 <= int(y) <= y2
def is_inside_capsule_crafting_area(self, x, y):
x1, y1, x2, y2 = self.start_capsule_inner_rect
return x1 <= int(x) <= x2 and y1 <= int(y) <= y2
def is_start_capsule_wall(self, x, y):
x = int(x)
y = int(y)
if not self.is_inside_start_capsule(x, y):
return False
if (x, y) in self.start_capsule_door_cells:
return False
x1, y1, x2, y2 = self.start_capsule_rect
return x in (x1, x2) or y in (y1, y2)
def is_start_capsule_passage(self, x, y):
cell = (int(x), int(y))
return self.is_inside_capsule_crafting_area(*cell) or cell in self.start_capsule_door_cells
def get_available_crafting_stations(self):
"""Return crafting stations currently usable by the player."""
stations = []
player_x = int(self.player_x)
player_y = int(self.player_y)
active_world = self.cave_world if self.in_cave else self.world
obj = active_world.get_object_at(player_x, player_y)
if self.in_cave:
if obj is floor_type:
stations.append('Изготовитель')
elif (self.is_inside_capsule_crafting_area(player_x, player_y)
or (self.is_aurora_biome(self.world.get_biome_at(player_x, player_y))
and self.world.get_biome_at(player_x, player_y).passable)):
stations.append('Изготовитель')
elif obj is floor_type:
stations.append('Изготовитель')
station_name = getattr(obj, 'crafting_station_name', None)
if station_name and station_name not in stations:
stations.append(station_name)
if self.inventory_counts.get(habitat_builder_type, 0) > 0:
if 'Строитель' not in stations:
stations.append('Строитель')
return stations
def get_active_crafting_station(self):
stations = self.get_available_crafting_stations()
if not stations:
return None
self.craft_station_index %= len(stations)
return stations[self.craft_station_index]
def get_crafting_location_name(self):
return self.get_active_crafting_station()
def get_current_station_recipes(self):
station = self.get_active_crafting_station()
if station is None:
return []
return [recipe for recipe in recipes if recipe.station == station]
def can_craft_here(self):
return bool(self.get_available_crafting_stations())
def draw_start_capsule_cell(self, x, y, screen_x, screen_y):
"""Рисует детализированную клетку стартовой капсулы."""
if not self.is_inside_start_capsule(x, y):
return False
x1, y1, x2, y2 = self.start_capsule_rect
cell_rect = pygame.Rect(screen_x, screen_y, CELL_SIZE, CELL_SIZE)
inner_rect = cell_rect.inflate(-4, -4)
cell = (x, y)
if self.is_inside_capsule_crafting_area(x, y):
pygame.draw.rect(self.screen, (34, 58, 68), cell_rect)
pygame.draw.rect(self.screen, (54, 82, 92), inner_rect)
pygame.draw.rect(self.screen, (90, 118, 124), inner_rect, 1)
pygame.draw.line(self.screen, (42, 68, 78), (screen_x + CELL_SIZE // 2, screen_y + 3), (screen_x + CELL_SIZE // 2, screen_y + CELL_SIZE - 3), 1)
pygame.draw.line(self.screen, (42, 68, 78), (screen_x + 3, screen_y + CELL_SIZE // 2), (screen_x + CELL_SIZE - 3, screen_y + CELL_SIZE // 2), 1)
for rx, ry in ((6, 6), (CELL_SIZE - 7, 6), (6, CELL_SIZE - 7), (CELL_SIZE - 7, CELL_SIZE - 7)):
pygame.draw.circle(self.screen, (150, 170, 174), (screen_x + rx, screen_y + ry), 1)
if y == y1 + 1 and x in (x1 + 2, x2 - 2):
pygame.draw.rect(self.screen, (15, 45, 58), (screen_x + 7, screen_y + 5, CELL_SIZE - 14, 6), border_radius=2)
pygame.draw.rect(self.screen, (70, 220, 240), (screen_x + 9, screen_y + 7, CELL_SIZE - 18, 2))
if x == x1 + 1 and y in (y1 + 2, y2 - 2):
pygame.draw.rect(self.screen, (70, 78, 82), (screen_x + 4, screen_y + 6, 5, CELL_SIZE - 12), border_radius=2)
pygame.draw.circle(self.screen, (80, 255, 150), (screen_x + 6, screen_y + CELL_SIZE // 2), 2)
return True
if cell in self.start_capsule_door_cells:
pygame.draw.rect(self.screen, (44, 52, 58), cell_rect)
pygame.draw.rect(self.screen, (105, 116, 120), inner_rect, 2)
pygame.draw.line(self.screen, (230, 150, 45), (screen_x + 4, screen_y + 6), (screen_x + CELL_SIZE - 4, screen_y + CELL_SIZE - 6), 3)
pygame.draw.line(self.screen, (40, 45, 48), (screen_x + 4, screen_y + 12), (screen_x + CELL_SIZE - 4, screen_y + CELL_SIZE - 0), 3)
pygame.draw.circle(self.screen, (80, 235, 255), (screen_x + CELL_SIZE // 2, screen_y + 5), 2)
return True
pygame.draw.rect(self.screen, (126, 139, 143), cell_rect)
pygame.draw.rect(self.screen, (74, 86, 90), inner_rect)
pygame.draw.rect(self.screen, (177, 188, 190), cell_rect, 2)
pygame.draw.rect(self.screen, (36, 47, 52), inner_rect, 1)
pygame.draw.line(self.screen, (105, 118, 122), (screen_x + 4, screen_y + CELL_SIZE // 2), (screen_x + CELL_SIZE - 4, screen_y + CELL_SIZE // 2), 1)
pygame.draw.circle(self.screen, (210, 218, 220), (screen_x + 5, screen_y + 5), 1)
pygame.draw.circle(self.screen, (210, 218, 220), (screen_x + CELL_SIZE - 6, screen_y + CELL_SIZE - 6), 1)
if y == y1 and x in (x1 + 2, x1 + 5):
pygame.draw.ellipse(self.screen, (18, 45, 62), (screen_x + 6, screen_y + 8, CELL_SIZE - 12, 13))
pygame.draw.ellipse(self.screen, (72, 185, 215), (screen_x + 8, screen_y + 10, CELL_SIZE - 16, 9), 2)
pygame.draw.line(self.screen, (185, 235, 245), (screen_x + 11, screen_y + 12), (screen_x + CELL_SIZE - 12, screen_y + 12), 1)
if x in (x1, x2) and y in (y1 + 1, y2 - 1):
pygame.draw.rect(self.screen, (214, 116, 28), (screen_x + 3, screen_y + 5, 4, CELL_SIZE - 10))
if y == y2 and x in (x1 + 1, x2 - 1):
pygame.draw.circle(self.screen, (255, 190, 55), (screen_x + CELL_SIZE // 2, screen_y + 7), 2)
return True
def is_aurora_biome(self, biome):
"""Проверяет, нужно ли рисовать клетку металлическим стилем Авроры."""
if biome is None:
return False
if getattr(biome, 'aurora_style', False):
return True
name = biome.name
return 'Аврор' in name or 'Двигательный отсек' in name
def draw_aurora_cell(self, x, y, screen_x, screen_y, biome):
"""Reuse identical pre-rendered metal tiles, rather than redrawing primitives."""
cache = getattr(self, '_aurora_tile_cache', None)
if cache is None:
cache = self._aurora_tile_cache = {}
# Some details depend on cell coordinates; repeat them by a fixed period.
key = (id(biome), x % 187, y % 187, x % 11, y % 13)
tile = cache.get(key)
if tile is None:
tile = pygame.Surface((CELL_SIZE, CELL_SIZE)).convert()
original_screen = self.screen
try:
self.screen = tile
self._paint_aurora_cell(x, y, 0, 0, biome)
finally:
self.screen = original_screen
if len(cache) >= 2048:
cache.clear()
cache[key] = tile
self.screen.blit(tile, (screen_x, screen_y))
def _paint_aurora_cell(self, x, y, screen_x, screen_y, biome):
"""Рисует клетку Авроры в стиле стартовой капсулы:
непроходимые клетки — как наружный корпус,
проходимые — как внутренний металлический настил."""
cell_rect = pygame.Rect(screen_x, screen_y, CELL_SIZE, CELL_SIZE)
inner_rect = cell_rect.inflate(-4, -4)
if not biome.passable:
pygame.draw.rect(self.screen, (126, 139, 143), cell_rect)
pygame.draw.rect(self.screen, (74, 86, 90), inner_rect)
pygame.draw.rect(self.screen, (177, 188, 190), cell_rect, 2)
pygame.draw.rect(self.screen, (36, 47, 52), inner_rect, 1)
pygame.draw.line(self.screen, (105, 118, 122), (screen_x + 4, screen_y + CELL_SIZE // 2), (screen_x + CELL_SIZE - 4, screen_y + CELL_SIZE // 2), 1)
pygame.draw.circle(self.screen, (210, 218, 220), (screen_x + 5, screen_y + 5), 1)
pygame.draw.circle(self.screen, (210, 218, 220), (screen_x + CELL_SIZE - 6, screen_y + CELL_SIZE - 6), 1)
if (x * 7 + y * 3) % 11 == 0:
pygame.draw.ellipse(self.screen, (18, 45, 62), (screen_x + 6, screen_y + 8, CELL_SIZE - 12, 13))
pygame.draw.ellipse(self.screen, (72, 185, 215), (screen_x + 8, screen_y + 10, CELL_SIZE - 16, 9), 2)
pygame.draw.line(self.screen, (185, 235, 245), (screen_x + 11, screen_y + 12), (screen_x + CELL_SIZE - 12, screen_y + 12), 1)
if (x + y) % 13 == 0:
pygame.draw.circle(self.screen, (255, 190, 55), (screen_x + CELL_SIZE // 2, screen_y + 6), 2)
if (x * 3 + y * 5) % 17 == 0:
pygame.draw.rect(self.screen, (214, 116, 28), (screen_x + 3, screen_y + 5, 4, CELL_SIZE - 10))
return
# Цвет и узор зависят от конкретного отсека, а не от всей Авроры.
name = biome.name
base = tuple(int(c) for c in biome.color[:3])
if getattr(biome, 'location_kind', '') == 'aurora_wreck':
base = (91, 104, 113)
token = sum((i + 1) * ord(c) for i, c in enumerate(name))
def shade(mul, offset=0):
return tuple(max(0, min(255, int(c * mul + offset))) for c in base)
pygame.draw.rect(self.screen, shade(.43), cell_rect)
pygame.draw.rect(self.screen, shade(.71), inner_rect)
pygame.draw.rect(self.screen, shade(1.15), inner_rect, 1)
pattern = token % 6
if pattern == 0: # продольные рёбра
for delta in (9, 15, 21):
pygame.draw.line(self.screen, shade(.48),
(screen_x + delta, screen_y + 4),
(screen_x + delta, screen_y + CELL_SIZE - 4), 2)
elif pattern == 1: # диагональная штриховка
for delta in (0, 10, 20):
pygame.draw.line(self.screen, shade(.48),
(screen_x + delta + 3, screen_y + 4),
(screen_x + min(delta + 12, CELL_SIZE - 4), screen_y + 20), 1)
elif pattern == 2: # две прямоугольные панели
pygame.draw.rect(self.screen, shade(.52),
(screen_x + 5, screen_y + 5, CELL_SIZE - 10, 8), 1)
pygame.draw.rect(self.screen, shade(.54),
(screen_x + 5, screen_y + 17, CELL_SIZE - 10, 8), 1)
elif pattern == 3: # решётчатый настил
for delta in (8, 15, 22):
pygame.draw.line(self.screen, shade(.55),
(screen_x + 4, screen_y + delta),
(screen_x + CELL_SIZE - 4, screen_y + delta), 1)
pygame.draw.line(self.screen, shade(.55),
(screen_x + delta, screen_y + 4),
(screen_x + delta, screen_y + CELL_SIZE - 4), 1)
elif pattern == 4: # круглые крепления
pygame.draw.circle(self.screen, shade(.54),
(screen_x + 15, screen_y + 15), 8, 2)
pygame.draw.circle(self.screen, shade(1.25),
(screen_x + 15, screen_y + 15), 2)
else: # технические пластины
pygame.draw.line(self.screen, shade(.45),
(screen_x + 4, screen_y + 15),
(screen_x + CELL_SIZE - 4, screen_y + 15), 2)
pygame.draw.rect(self.screen, shade(.54),
(screen_x + 7, screen_y + 6, 16, 17), 1)
for ox, oy in ((5, 5), (CELL_SIZE - 6, 5), (5, CELL_SIZE - 6),
(CELL_SIZE - 6, CELL_SIZE - 6)):
pygame.draw.circle(self.screen, shade(1.35),
(screen_x + ox, screen_y + oy), 1)
if (x * 3 + y * 7 + token) % 17 == 0:
pygame.draw.line(self.screen, (88, 194, 202),
(screen_x + 9, screen_y + CELL_SIZE - 5),
(screen_x + CELL_SIZE - 9, screen_y + CELL_SIZE - 5), 2)
def toggle_inventory(self):
if self.state == 'playing':
self.state = 'inventory'
elif self.state == 'inventory':
self.state = 'playing'
self.notification = ''
else:
self.state = 'playing'
self.selected_biome = None
def toggle_crafting(self):
if self.state == 'crafting':
self.state = 'playing'
self.notification = ''
return
if not self.can_craft_here():
self.notification = 'Крафт доступен только в капсуле или на построенном полу'
self.notification_timer = 120
return
self.state = 'crafting'
self.craft_page = 0
self.craft_station_index = 0
self.selected_biome = None
def toggle_map(self):
if self.state == 'playing':
self.state = 'map'
self.map_center_x = self.player_x
self.map_center_y = self.player_y
self.selected_biome = None
elif self.state == 'map':
self.state = 'playing'
self.selected_biome = None
def toggle_pet_list(self):
if self.state == 'pets':
self.state = 'playing'
self.notification = ''
return
self.state = 'pets'
self.pet_list_page = 0
self.selected_pet = None
self.selected_biome = None
def select_pet_from_list(self, number):
"""Клавиши 1–9 выбирают питомца именно на текущей странице."""
pets = self._get_all_companions()
index = self.pet_list_page * self.pets_per_page + number - 1
if 1 <= number <= 9 and 0 <= index < len(pets):
self.selected_pet = pets[index]
else:
self.selected_pet = None
self.notification = 'На этой позиции нет питомца'
self.notification_timer = 70
def _get_selected_pet(self):
pet = self.selected_pet
if pet is not None and pet in self._get_all_companions() and (pet.hp > 0):
return pet
self.selected_pet = None
self.notification = 'Сначала выберите питомца клавишей 1–9'
self.notification_timer = 80
return None
def return_selected_pet_to_inventory(self):
"""E: отозвать выбранного спутника с поверхности или из пещеры."""
pet = self._get_selected_pet()
if pet is None:
return
free_slot = next((i for i, item in enumerate(self.inventory) if item is None), None)
if free_slot is None or not self.add_to_inventory(pet.type):
self.notification = 'Инвентарь заполнен — забрать питомца нельзя'
self.notification_timer = 110
return
self._stored_companion_hp[free_slot] = (pet.type, pet.hp)
self._clear_companion_target(pet)
for animal in list(self.world.animals) + list(self.cave_animals):
if getattr(animal, 'pet_combat_target', None) is pet:
animal.pet_combat_target = None
if pet in self.cave_animals:
self.cave_animals.remove(pet)
else:
self.world.remove_animal(pet)
self.selected_pet = None
self.pet_list_page = min(self.pet_list_page, max(0, (len(self._get_all_companions()) - 1) // self.pets_per_page))
self.notification = f'{pet.type.name} возвращён в инвентарь (HP {int(pet.hp)})'
self.notification_timer = 110
def heal_selected_pet(self):
"""U: одна аптечка восстанавливает до 35 HP выбранному питомцу."""
pet = self._get_selected_pet()
if pet is None:
return
if pet.hp >= pet.max_hp:
self.notification = f'У {pet.type.name} уже полное здоровье'
self.notification_timer = 80
return
if self.inventory_counts.get(first_aid_kit, 0) <= 0:
self.notification = 'Для лечения питомца нужна аптечка'
self.notification_timer = 100
return
if not self._remove_inventory_items_of_type(first_aid_kit, 1):
self.notification = 'Не удалось использовать аптечку'
self.notification_timer = 90
return
restored = min(35, pet.max_hp - pet.hp)
pet.hp += restored
self.notification = f'{pet.type.name}: +{int(restored)} HP ({int(pet.hp)}/{int(pet.max_hp)}), потрачена 1 аптечка'
self.notification_timer = 110
def map_screen_to_world(self, screen_x, screen_y):
scale = self.map_base_scale * self.map_zoom
center_x = SCREEN_SIZE / 2
center_y = SCREEN_SIZE / 2
world_x = (screen_x - center_x) / scale + self.map_center_x
world_y = (screen_y - center_y) / scale + self.map_center_y
return (world_x, world_y)
def map_select_biome(self, screen_x, screen_y):
self.map_panel_scroll = 0
self._map_biome_species_cache = None
world_x, world_y = self.map_screen_to_world(screen_x, screen_y)
self.selected_biome = None
if self.in_cave:
for tunnel in reversed(self.cave_tunnels):
if tunnel.contains(world_x, world_y):
self.selected_biome = tunnel
self.notification = f'Выбрано: {tunnel.name}'
self.notification_timer = 60
return
biome = self.world.get_biome_at(world_x, world_y)
if biome is not None:
self.selected_biome = biome
self.notification = f'Выбран биом: {biome.name}'
self.notification_timer = 60
return
self.notification = 'Космическая пустота'
self.notification_timer = 30
def update_map_pan(self):
keys = pygame.key.get_pressed()
pan = 12 / self.map_zoom
if keys[pygame.K_LEFT]:
self.map_center_x -= pan
if keys[pygame.K_RIGHT]:
self.map_center_x += pan
if keys[pygame.K_UP]:
self.map_center_y -= pan
if keys[pygame.K_DOWN]:
self.map_center_y += pan
self.map_center_x = max(0, min(MAP_SIZE, self.map_center_x))
self.map_center_y = max(0, min(MAP_SIZE, self.map_center_y))
def biome_screen_polygon(self, biome, scale, center_x, center_y):
points = []
for world_x, world_y in biome.map_points():
screen_x = center_x + (world_x - self.map_center_x) * scale
screen_y = center_y + (world_y - self.map_center_y) * scale
points.append((int(screen_x), int(screen_y)))
return points
def draw_map_biome_label(self, biome, scale, center_x, center_y):
if not biome.map_label or self.map_zoom < 0.55:
return
wx, wy = biome.center()
sx = int(center_x + (wx - self.map_center_x) * scale)
sy = int(center_y + (wy - self.map_center_y) * scale)
lines = biome.map_label.split('\n')
line_height = 16
start_y = sy - len(lines) * line_height // 2
for index, line in enumerate(lines):
shadow = self.tiny_font.render(line, True, (0, 0, 0))
label = self.tiny_font.render(line, True, (245, 245, 245))
rect = label.get_rect(center=(sx, start_y + index * line_height))
shadow_rect = rect.copy()
shadow_rect.x += 2
shadow_rect.y += 2
self.screen.blit(shadow, shadow_rect)
self.screen.blit(label, rect)
def draw_caves_on_world_map(self, scale, center_x, center_y):
overlay = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE), pygame.SRCALPHA)
base_alpha = 115 if self.in_cave else 65
current_tunnel = None
if self.in_cave:
current_tunnel = self.get_cave_tunnel_at(self.player_x, self.player_y)
for tunnel in self.cave_tunnels:
points = self.biome_screen_polygon(tunnel, scale, center_x, center_y)
if len(points) < 3:
continue
if tunnel is current_tunnel:
fill = (40, 245, 255, 165)
border = (210, 255, 255, 220)
else:
fill = (20, 205, 220, base_alpha)
border = (120, 245, 250, min(190, base_alpha + 60))
pygame.draw.polygon(overlay, fill, points)
pygame.draw.polygon(overlay, border, points, 1)
self.screen.blit(overlay, (0, 0))
for entrance in self.cave_entrances:
x1, y1, _, _ = entrance['surface_rect']
sx = int(center_x + (x1 - self.map_center_x) * scale)
sy = int(center_y + (y1 - self.map_center_y) * scale)
radius = max(3, int(5 * self.map_zoom))
radius = min(radius, 8)
pygame.draw.circle(self.screen, (15, 40, 45), (sx, sy), radius + 2)
pygame.draw.circle(self.screen, (90, 245, 255), (sx, sy), radius, 2)
def _draw_land_ecology_on_map(self, scale, center_x, center_y):
"""Обозначает наземные живые объекты при приближении карты.
Не прорисовываем тысячи деревьев: равномерная выборка и обрезка
по экрану ограничивают время отрисовки даже в больших лесах.
"""
if self.in_cave or self.map_zoom < 0.9:
return
def draw_point(x, y, color, radius):
px = int(center_x + (x - self.map_center_x) * scale)
py = int(center_y + (y - self.map_center_y) * scale)
if 6 <= px < SCREEN_SIZE - 6 and 6 <= py < SCREEN_SIZE - 6:
pygame.draw.circle(self.screen, color, (px, py), radius)
trees = self.world.tree_grid
tree_step = max(1, len(trees) // 600)
for index, (x, y) in enumerate(trees):
if index % tree_step == 0:
draw_point(x, y, (64, 170, 67), 2)
land_plants = self.world.flora_grid
plant_step = max(1, len(land_plants) // 750)
for index, (x, y) in enumerate(land_plants):
if index % plant_step == 0 and is_land_biome(self.world.get_biome_at(x, y)):
draw_point(x, y, (177, 235, 90), 2)
for animal in self.world.animals:
if animal.hp > 0 and (not getattr(animal, 'is_companion', False)) and is_land_biome(self.world.get_biome_at(animal.x, animal.y)):
draw_point(animal.x, animal.y, (255, 193, 82), 2)
for moss in self.world.moss_colonies:
draw_point(moss.x, moss.y, (230, 244, 112), 3)
for golem in self.world.golem_colonies:
if golem.phase in ('active', 'rubble', 'breeding'):
col = GOLEM_DATA[golem.kind]['color']
if golem.phase == 'rubble':
col = tuple((v // 2 for v in col))
draw_point(golem.x, golem.y, col, 4 if golem.phase == 'active' else 2)
def draw_map(self):
self.screen.fill((4, 6, 9))
scale = self.map_base_scale * self.map_zoom
center_x = SCREEN_SIZE // 2
center_y = SCREEN_SIZE // 2
ordered_biomes = sorted(self.world.biomes, key=lambda biome: biome.priority)
for biome in ordered_biomes:
points = self.biome_screen_polygon(biome, scale, center_x, center_y)
if len(points) < 3:
continue
xs = [p[0] for p in points]
ys = [p[1] for p in points]
if max(xs) < -30 or min(xs) > SCREEN_SIZE + 30 or max(ys) < -30 or (min(ys) > SCREEN_SIZE + 30):
continue
pygame.draw.polygon(self.screen, biome.color, points)
if biome is self.selected_biome:
border_color = (255, 235, 80)
border_width = 3
else:
border_color = (max(0, biome.color[0] - 50), max(0, biome.color[1] - 50), max(0, biome.color[2] - 50))
border_width = 1
pygame.draw.polygon(self.screen, border_color, points, border_width)
self.draw_caves_on_world_map(scale, center_x, center_y)
# Plants and animals are listed only in the left biome information panel.
for biome in self.world.biomes:
if biome.cave_enabled:
self.draw_map_biome_label(biome, scale, center_x, center_y)
player_screen_x = int(center_x + (self.player_x - self.map_center_x) * scale)
player_screen_y = int(center_y + (self.player_y - self.map_center_y) * scale)
if -20 <= player_screen_x <= SCREEN_SIZE + 20 and -20 <= player_screen_y <= SCREEN_SIZE + 20:
pygame.draw.circle(self.screen, (0, 90, 230), (player_screen_x, player_screen_y), 7)
pygame.draw.circle(self.screen, (255, 255, 255), (player_screen_x, player_screen_y), 7, 2)
map_title = 'КАРТА МИРА — ПЕЩЕРНЫЙ УРОВЕНЬ' if self.in_cave else 'АСТЕРОИД-КОЛЬЦО'
title = self.large_font.render(map_title, True, (255, 255, 255))
self.screen.blit(title, title.get_rect(center=(SCREEN_SIZE // 2, 26)))
zoom_text = self.small_font.render(f'Зум: {self.map_zoom:.2f}x', True, (255, 255, 255))
self.screen.blit(zoom_text, (10, 10))
if self.selected_biome:
self.draw_biome_info_panel()
controls = self.tiny_font.render('M/ESC: закрыть | +/-: зум | стрелки: движение | клик: биом', True, (220, 220, 220))
self.screen.blit(controls, controls.get_rect(center=(SCREEN_SIZE // 2, SCREEN_SIZE - 15)))
def draw_cave_map(self):
self.screen.fill((8, 8, 12))
scale = self.map_base_scale * self.map_zoom
center_x = SCREEN_SIZE // 2
center_y = SCREEN_SIZE // 2
for tunnel in self.cave_tunnels:
x1, y1, x2, y2 = tunnel.rect
sx1 = center_x + (x1 - self.map_center_x) * scale
sy1 = center_y + (y1 - self.map_center_y) * scale
sx2 = center_x + (x2 + 1 - self.map_center_x) * scale
sy2 = center_y + (y2 + 1 - self.map_center_y) * scale
rect = pygame.Rect(int(sx1), int(sy1), max(2, int(sx2 - sx1)), max(2, int(sy2 - sy1)))
pygame.draw.rect(self.screen, tunnel.color, rect)
border = (255, 230, 90) if tunnel is self.selected_biome else (85, 95, 110)
pygame.draw.rect(self.screen, border, rect, 3 if tunnel is self.selected_biome else 1)
for entrance in self.cave_entrances:
x1, y1, x2, y2 = entrance['cave_rect']
sx1 = center_x + (x1 - self.map_center_x) * scale
sy1 = center_y + (y1 - self.map_center_y) * scale
sx2 = center_x + (x2 + 1 - self.map_center_x) * scale
sy2 = center_y + (y2 + 1 - self.map_center_y) * scale
rect = pygame.Rect(int(sx1), int(sy1), max(4, int(sx2 - sx1)), max(4, int(sy2 - sy1)))
pygame.draw.rect(self.screen, (20, 80, 110), rect)
pygame.draw.rect(self.screen, (180, 235, 255), rect, 2)
player_x = center_x + (self.player_x - self.map_center_x) * scale
player_y = center_y + (self.player_y - self.map_center_y) * scale
pygame.draw.circle(self.screen, (0, 120, 230), (int(player_x), int(player_y)), 8)
pygame.draw.circle(self.screen, (255, 255, 255), (int(player_x), int(player_y)), 8, 2)
title = self.large_font.render('КАРТА ПЕЩЕР', True, (255, 255, 255))
self.screen.blit(title, title.get_rect(center=(SCREEN_SIZE // 2, 26)))
zoom_text = self.small_font.render(f'Зум: {self.map_zoom:.2f}x', True, (255, 255, 255))
self.screen.blit(zoom_text, (10, 10))
if self.selected_biome:
self.draw_biome_info_panel()
controls = self.tiny_font.render('M/ESC: закрыть | +/-: зум | стрелки: движение | клик: секция', True, (220, 220, 220))
self.screen.blit(controls, controls.get_rect(center=(SCREEN_SIZE // 2, SCREEN_SIZE - 15)))
def _map_real_biome_population(self, biome):
"""O(1) для ресурсов/растений; небольшой проход только по подвижной фауне.
Индекс создаётся однажды при старте и автоматически поддерживает
подбор, выпадение, строительство, респавн и уничтожение предметов.
"""
from collections import Counter
cave = self.in_cave and biome in self.cave_tunnels
lookup = self.get_cave_tunnel_at if cave else self.world.get_biome_at
if cave:
resources = Counter(self.cave_object_grid.biome_counts.get(biome, ()))
flora = Counter(self.cave_flora_grid.biome_counts.get(biome, ()))
animals = self.cave_animals
else:
resources = Counter(self.world.object_grid.biome_counts.get(biome, ()))
flora = Counter(self.world.flora_grid.biome_counts.get(biome, ()))
flora.update(self.world.tree_grid.biome_counts.get(biome, ()))
animals = self.world.animals
fauna = Counter()
x1, y1, x2, y2 = biome.rect
def belongs(x, y):
# Прямоугольный фильтр отсекает почти все вызовы геометрии.
return (x1 <= x <= x2 and y1 <= y <= y2
and lookup(x, y) is biome)
if not cave:
for moss in self.world.moss_colonies:
if belongs(moss.x, moss.y):
flora['Бешеный мох'] += 1
for golem in self.world.golem_colonies:
if belongs(golem.x, golem.y):
if golem.phase in ('active', 'rubble'):
fauna[GOLEM_DATA[golem.kind]['name']] += 1
elif golem.phase == 'breeding':
fauna['Големогенные бактерии (' + golem.kind + ')'] += 1
for gx, gy in self.world.golem_dung_piles:
if belongs(gx, gy):
resources['Навоз (субстрат големов)'] += 1
for animal in animals:
if animal.hp > 0 and not getattr(animal, 'is_companion', False):
if belongs(animal.x, animal.y):
fauna[animal.type.name] += 1
return (resources, flora, fauna)
def _map_info_panel_area(self):
return pygame.Rect(10, 70, 270, SCREEN_SIZE - 100)
def draw_biome_info_panel(self):
"""Рисование панели за O(видимых строк); строки и текст кешируются."""
biome = self.selected_biome
if biome is None:
return
previous = self._map_biome_species_cache
if previous is None or previous[0] is not biome:
counts = self._map_real_biome_population(biome)
self._map_biome_species_cache = (biome, counts)
self._map_info_render_cache = None
self.map_panel_scroll = 0
else:
counts = previous[1]
area = self._map_info_panel_area()
render_cache = getattr(self, '_map_info_render_cache', None)
if render_cache is None or render_cache[0] is not biome:
rows = self._build_biome_panel_lines(biome, counts, area.width - 24)
# pygame.font.render вызывается только при выборе, не ежесекундно.
drawn = []
for message, color, bold in rows:
font = self.small_font if bold else self.tiny_font
drawn.append(font.render(message, True, color))
render_cache = (biome, drawn)
self._map_info_render_cache = render_cache
drawn = render_cache[1]
self.screen.fill((12, 21, 26), area)
pygame.draw.rect(self.screen, (239, 212, 80), area, 2)
px, py = area.x + 10, area.y
caption = self.small_font.render(biome.name, True, (255, 245, 177))
if caption.get_width() > area.width - 24:
caption = self.tiny_font.render(biome.name, True, (255, 245, 177))
# Длинные заголовки отсеков тоже обрезаются по границам панели.
heading_clip = pygame.Rect(px, py + 6, area.width - 22, 25)
old_clip = self.screen.get_clip()
self.screen.set_clip(heading_clip)
self.screen.blit(caption, (px, py + 8))
self.screen.set_clip(old_clip)
pygame.draw.line(self.screen, (93, 115, 107), (px, py + 34),
(area.right - 10, py + 34))
row_h = 19
visible_top, visible_bottom = py + 43, area.bottom - 13
visible_h = visible_bottom - visible_top
max_scroll = max(0, len(drawn) * row_h - visible_h)
self.map_panel_scroll = max(0, min(self.map_panel_scroll, max_scroll))
first = max(0, self.map_panel_scroll // row_h)
last = min(len(drawn), (self.map_panel_scroll + visible_h) // row_h + 2)
self.screen.set_clip(pygame.Rect(px, visible_top, area.width - 24, visible_h))
try:
for i in range(first, last):
self.screen.blit(drawn[i], (px, visible_top + i * row_h - self.map_panel_scroll))
finally:
self.screen.set_clip(old_clip)
if max_scroll:
marker_h = max(18, round(visible_h * visible_h / (max_scroll + visible_h)))
marker_y = visible_top + round((visible_h - marker_h) * self.map_panel_scroll / max_scroll)
pygame.draw.rect(self.screen, (65, 79, 75), (area.right - 7, visible_top, 3, visible_h))
pygame.draw.rect(self.screen, (174, 206, 170), (area.right - 7, marker_y, 3, marker_h))
def get_camera_origin(self):
"""Исходное положение камеры (используется логикой игры)."""
return (self.player_x - VIEW_RADIUS, self.player_y - VIEW_RADIUS)
def get_render_camera_origin(self):
"""Камера отрисовки: радиус меняется с масштабом изображения."""
return (self.player_x - self._render_view_radius, self.player_y - self._render_view_radius)
def screen_to_world(self, mouse_x, mouse_y):
"""Точное наведение, взаимодействие и сканирование с учётом зума."""
tile_pixels = CELL_SIZE * self.camera_zoom
return (self.player_x + 0.5 + (mouse_x - SCREEN_SIZE / 2) / tile_pixels, self.player_y + 0.5 + (mouse_y - SCREEN_SIZE / 2) / tile_pixels)
@staticmethod
def _make_blue_edge_haze():
"""Голубая дымка: максимальна по краям, прозрачна в центре."""
haze = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE), pygame.SRCALPHA)
fade_width = int(SCREEN_SIZE * 0.39)
for inset in range(fade_width):
fraction = 1.0 - inset / max(1, fade_width - 1)
alpha = round(195 * fraction ** 2.15)
if alpha < 1:
continue
length = SCREEN_SIZE - 2 * inset
if length <= 0:
break
pygame.draw.rect(haze, (50, 167, 224, alpha), pygame.Rect(inset, inset, length, length), 1)
return haze.convert_alpha()
def _draw_blue_edge_haze(self):
if self.camera_zoom >= 1.0:
return
amount = (1.0 - self.camera_zoom) / (1.0 - CAMERA_ZOOM_MIN)
self._blue_edge_haze.set_alpha(round(255 * max(0.0, min(1.0, amount))))
self.screen.blit(self._blue_edge_haze, (0, 0))
def _change_camera_zoom(self, wheel_steps):
if not wheel_steps:
return
new_zoom = self.camera_zoom * CAMERA_ZOOM_STEP ** wheel_steps
self.camera_zoom = max(CAMERA_ZOOM_MIN, min(CAMERA_ZOOM_MAX, new_zoom))
self.mouse_world_x, self.mouse_world_y = self.screen_to_world(*pygame.mouse.get_pos())
@staticmethod
def _make_night_edge_vignette():
"""Непрозрачный чёрный край с мягким сходом к центру."""
surface = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE), pygame.SRCALPHA)
width = max(1, int(SCREEN_SIZE * NIGHT_EDGE_FADE))
for inset in range(width):
t = 1.0 - inset / max(1, width - 1)
alpha = round(255 * t ** 2.0)
if alpha > 0:
pygame.draw.rect(surface, (0, 0, 0, alpha), (inset, inset, SCREEN_SIZE - 2 * inset, SCREEN_SIZE - 2 * inset), 1)
return surface.convert_alpha()
def _draw_night_edge_vignette(self):
if not NIGHT_EDGE_VIGNETTE or not getattr(self, 'setting_water_darkening_enabled', True):
return
brightness = self._solar_brightness()
if self.in_cave:
strength = 0.76
else:
strength = max(0.0, min(1.0, (0.74 - brightness) / 0.74))
strength = strength ** 1.55
if strength <= 0.002:
return
self._night_edge_vignette.set_alpha(round(strength * 255))
self.screen.blit(self._night_edge_vignette, (0, 0))
def _hd_source(self, entity):
"""Не увеличиваем пиксели: создаём фигуры заново из исходных данных.
Первый запрос для вида выполняется один раз, затем результат
переиспользуется всеми экземплярами данного вида.
"""
key = id(entity)
if key in self._hd_source_cache:
return self._hd_source_cache[key]
base = getattr(entity, 'image', None)
if base is None:
self._hd_source_cache[key] = None
return None
factor = ZOOM_DETAIL_SOURCE_FACTOR
source = None
try:
if isinstance(entity, FloraType):
source = generate_subnautica_flora_sprite(entity.kind, round(entity.size * factor), seed=entity.seed)
elif isinstance(entity, AnimalType):
if getattr(entity, 'fastener_hd_image', None) is not None:
source = entity.fastener_hd_image
elif entity.sprite_kind is not None:
source = generate_subnautica_creature_sprite(entity.sprite_kind, max(12, round(entity.fish_length * factor)), max(6, round(entity.fish_width * factor)))
elif getattr(entity, '_hd_fish_palette', None) is not None:
state = random.getstate()
try:
random.seed(_stable_preview_seed(entity.name))
source = generate_pike_style_sprite(max(12, round(entity.fish_length * factor)), max(6, round(entity.fish_width * factor)), *entity._hd_fish_palette)
finally:
random.setstate(state)
elif isinstance(entity, ObjectType):
high_size = max(100, round(entity.size * factor * 1.4))
if getattr(entity, 'is_buildable', False):
source = generate_building_sprite(entity.name, high_size, entity.color, wall=getattr(entity, 'is_wall', False))
elif entity.name in _HIGH_RES_RESOURCE_NAMES:
source = generate_highres_resource_preview(entity, high_size)
else:
source = generate_tech_item_sprite(entity.name, high_size, entity.color, is_weapon=getattr(entity, 'is_weapon', False), weapon_type=getattr(entity, 'weapon_type', ''))
except (ValueError, TypeError, pygame.error):
source = None
if source is None:
source = pygame.transform.smoothscale(base, (max(2, round(base.get_width() * factor)), max(2, round(base.get_height() * factor))))
self._hd_source_cache[key] = source
return source
def _hd_image(self, entity, zoom, angle=None, glow=False):
"""Кэшированный экранный LOD без изменения игровых размеров объекта."""
base = entity.glow_image if glow else entity.image
if base is None:
return None
bucket = round(zoom / 0.04) * 0.04
rotation = round(angle / ZOOM_DETAIL_ROTATION_STEP) * ZOOM_DETAIL_ROTATION_STEP % 360 if angle is not None else None
key = (id(entity), bucket, rotation, glow)
result = self._hd_sprite_cache.get(key)
if result is not None:
self._hd_sprite_cache.move_to_end(key)
return result
src = self._hd_source(entity)
if src is None:
return None
if glow:
src = self._hd_glow_source_cache.get(id(entity))
if src is None:
src = generate_bioluminescence_sprite(self._hd_source(entity), entity.kind if isinstance(entity, FloraType) else entity.sprite_kind or entity.name, is_plant=isinstance(entity, FloraType), seed=getattr(entity, 'seed', 0))
self._hd_glow_source_cache[id(entity)] = src
if src is None:
return None
sz = (max(1, round(base.get_width() * bucket)), max(1, round(base.get_height() * bucket)))
result = pygame.transform.smoothscale(src, sz)
if rotation is not None:
result = pygame.transform.rotate(result, rotation)
self._hd_sprite_cache[key] = result
if len(self._hd_sprite_cache) > ZOOM_DETAIL_ROTATED_CACHE_MAX:
self._hd_sprite_cache.popitem(last=False)
return result
def _hd_micro_tile(self, x, y, tile_px, cave=False, biome=None):
"""Рисует НОВЫЕ мини-детали дна на итоговой сетке пикселей."""
variant = self._stable_visual_hash('zoom_details', x, y) % 48
theme_id = 'cave' if cave else biome.name if biome else 'none'
key = (theme_id, variant, tile_px)
cached = self._hd_floor_cache.get(key)
if cached is not None:
self._hd_floor_cache.move_to_end(key)
return cached
seed = self._stable_visual_hash('zoom_micro', theme_id, variant)
rng = random.Random(seed)
surf = pygame.Surface((tile_px, tile_px), pygame.SRCALPHA)
scale = tile_px / CELL_SIZE
if cave:
palette = ((86, 100, 120), (106, 112, 132), (43, 47, 60), (162, 174, 191))
else:
theme = self._surface_theme_for_biome(biome)
if not theme:
return None
palette = tuple(theme['pebble_palette']) + (theme['highlight'], theme['shadow'])
for i in range(22):
px = rng.randint(1, tile_px - 2)
py = rng.randint(1, tile_px - 2)
color = palette[rng.randrange(len(palette))]
radius = rng.choice((1, 1, 1, max(1, round(scale))))
pygame.draw.circle(surf, (*color, rng.randint(75, 170)), (px, py), radius)
if i % 5 == 0 and radius > 1:
pygame.draw.circle(surf, (220, 232, 232, 120), (max(0, px - 1), max(0, py - 1)), 1)
for j in range(rng.randint(2, 4)):
cx = rng.randint(3, tile_px - 4)
cy = rng.randint(3, tile_px - 4)
delta = max(3, round(4 * scale))
pts = [(cx, cy), (cx + rng.randint(-delta, delta), cy + rng.randint(-delta, delta)), (cx + rng.randint(-delta * 2, delta * 2), cy + rng.randint(-delta * 2, delta * 2))]
pygame.draw.lines(surf, (*palette[j % len(palette)], 115), False, pts, 1)
if scale >= 1.4 and (not cave) and (not is_land_biome(biome)) and (rng.random() < 0.28):
px = rng.randint(tile_px // 4, tile_px * 3 // 4)
py = rng.randint(tile_px // 4, tile_px * 3 // 4)
radius = max(3, round(2.2 * scale))
color = (205, 136, 109, 180)
for i in range(5):
a = i * math.tau / 5
xx = round(px + math.cos(a) * radius)
yy = round(py + math.sin(a) * radius)
pygame.draw.line(surf, color, (px, py), (xx, yy), max(1, round(0.8 * scale)))
pygame.draw.circle(surf, (242, 189, 133, 210), (px, py), max(1, round(scale)))
self._hd_floor_cache[key] = surf
if len(self._hd_floor_cache) > 550:
self._hd_floor_cache.popitem(last=False)
return surf
def _draw_zoom_details(self, zoom, canvas_px, screen_offset):
"""Второй проход В ИСТИННОМ разрешении экрана, под ночной маской."""
if not ZOOM_DETAIL_ENABLED or zoom < ZOOM_DETAIL_START:
return
tile_px = CELL_SIZE * zoom
tile_int = max(4, round(tile_px))
layer = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE), pygame.SRCALPHA)
half = SCREEN_SIZE * 0.5
def position(x, y):
return (round(half + (x - self.player_x) * tile_px), round(half + (y - self.player_y) * tile_px))
def sprite(entity, center, angle=None, fit=None):
image = self._hd_image(entity, zoom, angle)
if image is None:
return
if fit is not None:
image = self._fit_surface(image, *fit)
layer.blit(image, image.get_rect(center=center))
margin = SCREEN_SIZE / (2 * tile_px) + 2
left = max(0, math.floor(self.player_x - margin))
right = min(MAP_SIZE - 1, math.ceil(self.player_x + margin))
top = max(0, math.floor(self.player_y - margin))
bottom = min(MAP_SIZE - 1, math.ceil(self.player_y + margin))
objects = self._get_current_object_grid()
plants = self._current_flora_grid()
dropped = self._current_dropped_items()
planter_types = self._planter_types()
for y in range(top, bottom + 1):
for x in range(left, right + 1):
at = position(x, y)
if not (-tile_px * 2 < at[0] < SCREEN_SIZE + tile_px * 2 and -tile_px * 2 < at[1] < SCREEN_SIZE + tile_px * 2):
continue
cave_tunnel = self.get_cave_tunnel_at(x, y) if self.in_cave else None
biome = None if self.in_cave else self.world.get_biome_at(x, y)
if self.in_cave:
can_detail = cave_tunnel is not None
else:
can_detail = biome is not None and (not self.is_aurora_biome(biome)) and (not (self.start_capsule_rect[0] <= x <= self.start_capsule_rect[2] and self.start_capsule_rect[1] <= y <= self.start_capsule_rect[3]))
if can_detail:
micro = self._hd_micro_tile(x, y, tile_int, self.in_cave, biome)
if micro is not None:
layer.blit(micro, micro.get_rect(center=at))
for y in range(top, bottom + 1):
for x in range(left, right + 1):
at = position(x, y)
if not (-130 < at[0] < SCREEN_SIZE + 130 and -130 < at[1] < SCREEN_SIZE + 130):
continue
flora = plants.get((x, y))
if flora is not None:
sprite(flora, at)
obj = objects.get((x, y))
if obj is not None and (not getattr(obj, 'is_buildable', False)):
sprite(obj, at)
contents = dropped.get((x, y))
if contents:
item = contents[-1]
sprite(item, at)
for animal in self.get_nearby_animals(margin + 2):
if animal.hp <= 0 or animal.type.image is None:
continue
at = position(animal.x, animal.y)
if -400 < at[0] < SCREEN_SIZE + 400 and -400 < at[1] < SCREEN_SIZE + 400:
if getattr(animal, 'fastener_anchored', False):
head = getattr(animal.type, 'fastener_anchor_hd_image', None)
if head is not None:
size = max(6, round(44 * zoom))
head = pygame.transform.smoothscale(head, (size, size))
layer.blit(head, head.get_rect(center=at))
else:
sprite(animal.type, at, getattr(animal, 'visual_angle', 0))
if not self.in_cave:
self._draw_mad_moss(0, 0, surface=layer, zoom=zoom)
for y in range(top, bottom + 1):
for x in range(left, right + 1):
obj = objects.get((x, y))
if obj is None or not getattr(obj, 'is_buildable', False):
continue
at = position(x, y)
sprite(obj, at)
if obj in planter_types:
planted = self.planted_flora.get(self._planter_key(x, y), ())
offsets = ((0, 1), (-8, -3), (8, -3), (-8, 7), (8, 7), (0, -8), (0, 9))
max_size = 20 if len(planted) <= 2 else 15
for i, plant in enumerate(planted):
ox, oy = offsets[i % len(offsets)]
sprite(plant, (round(at[0] + ox * zoom), round(at[1] + oy * zoom)), fit=(round(max_size * zoom), round(max_size * zoom)))
center = (round(half), round(half))
def blit_zoomed(image, at):
if image is None:
return
size = (max(1, round(image.get_width() * zoom)), max(1, round(image.get_height() * zoom)))
enlarged = pygame.transform.smoothscale(image, size)
layer.blit(enlarged, enlarged.get_rect(center=at))
if self.active_vehicle is not None:
blit_zoomed(self.get_vehicle_rotated(self.active_vehicle, self.player_angle), center)
else:
if self.equipped_weapon is not None:
radians = math.radians(self.player_angle)
weapon_pos = (round(half + math.cos(radians) * tile_px * 0.55), round(half + math.sin(radians) * tile_px * 0.55))
blit_zoomed(self.get_weapon_rotated(self.equipped_weapon, self.player_angle), weapon_pos)
blit_zoomed(self.get_diver_rotated(self.player_angle), center)
if self._night_scene_alpha() > 0:
mask = pygame.transform.smoothscale(self.lighting_overlay, (canvas_px, canvas_px))
layer.blit(mask, (screen_offset, screen_offset), special_flags=pygame.BLEND_RGBA_SUB)
self.screen.blit(layer, (0, 0))
if BIOLUMINESCENCE_ENABLED:
night = 1.0 if self.in_cave else max(0.0, 1.0 - self._solar_brightness() * 1.35)
alpha = round((BIOGLOW_DAY_ALPHA + (BIOGLOW_NIGHT_ALPHA - BIOGLOW_DAY_ALPHA) * night) * (0.88 + 0.12 * math.sin(self.frame_count * 0.045)))
for y in range(top, bottom + 1):
for x in range(left, right + 1):
at = position(x, y)
flora = plants.get((x, y))
if flora is None or flora.glow_image is None:
continue
image = self._hd_image(flora, zoom, glow=True)
if image is not None:
image.set_alpha(alpha)
self.screen.blit(image, image.get_rect(center=at))
image.set_alpha(None)
for animal in self.get_nearby_animals(margin + 2):
if animal.hp <= 0 or animal.type.glow_image is None:
continue
at = position(animal.x, animal.y)
image = self._hd_image(animal.type, zoom, getattr(animal, 'visual_angle', 0), glow=True)
if image is not None:
image.set_alpha(alpha)
self.screen.blit(image, image.get_rect(center=at))
image.set_alpha(None)
def _night_scene_alpha(self):
if not getattr(self, 'setting_water_darkening_enabled', True):
return 0
if self.in_cave:
return 250
brightness = self._solar_brightness()
if MIDNIGHT_BLACKOUT and min(self._visual_day_time(), 1.0 - self._visual_day_time()) <= MIDNIGHT_BLACKOUT_HALF_WINDOW:
return 255
twilight = max(0.0, min(1.0, (0.72 - brightness) / 0.72))
return round(255 * twilight ** 1.5)
def handle_events(self):
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
elif event.type == pygame.MOUSEMOTION:
mouse_x, mouse_y = event.pos
self.mouse_world_x, self.mouse_world_y = self.screen_to_world(mouse_x, mouse_y)
elif event.type == pygame.MOUSEWHEEL:
if self.state == 'map':
over_panel = self.selected_biome is not None and self._map_info_panel_area().collidepoint(pygame.mouse.get_pos())
if over_panel:
self.map_panel_scroll = max(0, self.map_panel_scroll - event.y * 57)
elif event.y > 0:
self.map_zoom = min(self.map_max_zoom, self.map_zoom * 1.15)
elif event.y < 0:
self.map_zoom = max(self.map_min_zoom, self.map_zoom / 1.15)
elif self.state == 'scanner':
self.scanner_scroll_offset = max(0, min(self.scanner_scroll_max, self.scanner_scroll_offset - event.y * 50))
elif self.state == 'inventory':
pages = max(1, (len(self.whale_quests) + 1) // 2)
self.whale_quest_panel_page = max(0, min(pages - 1, self.whale_quest_panel_page - event.y))
elif self.state == 'playing':
self._change_camera_zoom(event.y)
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_n:
if self.state == 'playing' or self.settings_open:
self.settings_open = not self.settings_open
elif event.key == pygame.K_ESCAPE:
if self.settings_open:
self.settings_open = False
elif self.state != 'playing':
self.state = 'playing'
self.notification = ''
self.selected_biome = None
else:
self.running = False
elif event.key == pygame.K_o and self.state in ('playing', 'crew_panel'):
# При одиночном прохождении панель бригады недоступна.
if self.state == 'crew_panel':
self.state = 'playing'
elif self.setting_workers_enabled and not self.settings_open:
self.state = 'crew_panel'
elif self.state=='crew_panel' and event.key in (pygame.K_1,pygame.K_2,pygame.K_3,pygame.K_4,pygame.K_5):
self.foreman.order={pygame.K_1:'авто',pygame.K_2:'добыча',
pygame.K_3:'строительство',pygame.K_4:'производство',
pygame.K_5:'охота'}[event.key]
for w in self.foreman.workers:w.rethink=1000
elif event.key == pygame.K_F8:
self.force_midnight_preview = not self.force_midnight_preview
self.notification = 'Предпросмотр полной ночи: ВКЛ (F8)' if self.force_midnight_preview else 'Предпросмотр полной ночи: ВЫКЛ (F8)'
self.notification_timer = 110
elif event.key == pygame.K_l and self.active_vehicle is not None and (self.state == 'playing'):
self.vehicle_headlights_on = not self.vehicle_headlights_on
self.notification = 'Фары подлодки включены' if self.vehicle_headlights_on else 'Фары подлодки выключены'
self.notification_timer = 75
elif event.key == pygame.K_r:
self.toggle_inventory()
elif event.key == pygame.K_i:
self.toggle_crafting()
elif event.key == pygame.K_m:
self.toggle_map()
elif event.key == pygame.K_t:
self.toggle_pet_list()
elif event.key == pygame.K_f and self.state in ('playing', 'inventory'):
self.handle_vehicle_action()
elif event.key == pygame.K_c and self.state == 'playing':
self.use_cave_entrance()
elif event.key == pygame.K_b and self.state == 'playing':
self.build()
elif event.key == pygame.K_x and self.state == 'playing':
self.destroy_build()
elif event.key == pygame.K_e and self.state == 'playing':
self.pick_up()
elif event.key == pygame.K_SPACE and self.state == 'playing':
self.attack()
elif event.key == pygame.K_u and self.state in ('playing', 'inventory'):
self.use_item()
elif event.key == pygame.K_q and self.state == 'playing':
self.equip_weapon()
elif event.key == pygame.K_p and self.state == 'inventory':
self.drop_selected_item()
elif event.key == pygame.K_h and self.state == 'inventory':
self.spawn_selected_companion()
if self.state == 'inventory':
pages = max(1, (len(self.whale_quests) + 1) // 2)
if event.key == pygame.K_PAGEDOWN:
self.whale_quest_panel_page = min(pages - 1, self.whale_quest_panel_page + 1)
elif event.key == pygame.K_PAGEUP:
self.whale_quest_panel_page = max(0, self.whale_quest_panel_page - 1)
if self.state == 'pets':
pets = self._get_all_companions()
max_page = max(0, (len(pets) - 1) // self.pets_per_page)
if event.key in (pygame.K_RIGHT, pygame.K_PAGEDOWN):
self.pet_list_page = min(max_page, self.pet_list_page + 1)
self.selected_pet = None
elif event.key in (pygame.K_LEFT, pygame.K_PAGEUP):
self.pet_list_page = max(0, self.pet_list_page - 1)
self.selected_pet = None
elif pygame.K_1 <= event.key <= pygame.K_9:
self.select_pet_from_list(event.key - pygame.K_0)
elif pygame.K_KP1 <= event.key <= pygame.K_KP9:
self.select_pet_from_list(event.key - pygame.K_KP1 + 1)
elif event.key == pygame.K_e:
self.return_selected_pet_to_inventory()
elif event.key == pygame.K_u:
self.heal_selected_pet()
if self.state == 'planting':
plants = self._researched_plant_list()
max_page = max(0, (len(plants) - 1) // self.plants_per_page)
if event.key in (pygame.K_RIGHT, pygame.K_PAGEDOWN):
self.planting_page = min(max_page, self.planting_page + 1)
elif event.key in (pygame.K_LEFT, pygame.K_PAGEUP):
self.planting_page = max(0, self.planting_page - 1)
elif pygame.K_1 <= event.key <= pygame.K_9:
self.plant_researched_flora(event.key - pygame.K_1)
if self.state == 'map':
if event.key in (pygame.K_EQUALS, pygame.K_PLUS, pygame.K_KP_PLUS, pygame.K_PAGEUP):
self.map_zoom = min(self.map_max_zoom, self.map_zoom * 1.2)
elif event.key in (pygame.K_MINUS, pygame.K_KP_MINUS, pygame.K_PAGEDOWN):
self.map_zoom = max(self.map_min_zoom, self.map_zoom / 1.2)
if self.state == 'crafting':
if event.key == pygame.K_TAB:
stations = self.get_available_crafting_stations()
if stations:
self.craft_station_index = (self.craft_station_index + 1) % len(stations)
self.craft_page = 0
else:
station_recipes = self.get_current_station_recipes()
max_station_page = max(0, (len(station_recipes) - 1) // self.recipes_per_page)
if event.key == pygame.K_RIGHT:
if self.craft_page < max_station_page:
self.craft_page += 1
elif event.key == pygame.K_LEFT:
if self.craft_page > 0:
self.craft_page -= 1
elif pygame.K_1 <= event.key <= pygame.K_9:
index = event.key - pygame.K_1
recipe_index = self.craft_page * self.recipes_per_page + index
if recipe_index < len(station_recipes):
if not self.can_craft_here():
self.state = 'playing'
self.notification = 'Здесь нет доступной станции крафта'
self.notification_timer = 120
continue
recipe = station_recipes[recipe_index]
if recipe.can_craft(self.inventory_counts):
free_slots = sum((1 for slot in self.inventory if slot is None))
possible_slots = free_slots + sum(recipe.ingredients.values())
if possible_slots < recipe.result_count:
self.notification = 'Нет места в инвентаре!'
self.notification_timer = 60
continue
result = recipe.craft(self.inventory, self.inventory_counts)
if result is not None:
crafted = 0
for _ in range(recipe.result_count):
if self.add_to_inventory(result):
crafted += 1
suffix = f' x{crafted}' if crafted > 1 else ''
self.notification = f'Создано: {result.name}{suffix}'
self.notification_timer = 60
else:
self.notification = 'Не хватает ресурсов'
self.notification_timer = 60
elif event.type == pygame.MOUSEBUTTONDOWN and self.settings_open:
if event.button == 1:
mouse_x, mouse_y = event.pos
option_rects = self._settings_option_rects()
if option_rects[0].collidepoint(mouse_x, mouse_y):
self.setting_plankton_enabled = not self.setting_plankton_enabled
if not self.setting_plankton_enabled:
self._night_sparks_visible = 0
self._night_sparks.clear()
elif option_rects[1].collidepoint(mouse_x, mouse_y):
self.setting_seabed_details_enabled = not self.setting_seabed_details_enabled
elif option_rects[2].collidepoint(mouse_x, mouse_y):
self.setting_water_darkening_enabled = not self.setting_water_darkening_enabled
elif option_rects[3].collidepoint(mouse_x, mouse_y):
self.setting_workers_enabled = not self.setting_workers_enabled
# Без пересоздания бригады: при обратном включении
# её инвентарь, здоровье, здания и запасы останутся прежними.
if not self.setting_workers_enabled and self.state == 'crew_panel':
self.state = 'playing'
self.notification = ('Одиночный режим: рабочие отключены'
if not self.setting_workers_enabled
else 'Рабочие снова включены')
self.notification_timer = 110
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if self.state == 'playing':
if self.active_vehicle is not None:
self.fire_vehicle_torpedo(self.mouse_world_x, self.mouse_world_y)
else:
self.shoot(self.mouse_world_x, self.mouse_world_y)
elif self.state == 'inventory':
mouse_x, mouse_y = event.pos
start_x, start_y, total_width, total_height = self._inventory_grid_layout()
if start_x <= mouse_x <= start_x + total_width and start_y <= mouse_y <= start_y + total_height:
col = (mouse_x - start_x) // (SLOT_SIZE + SLOT_MARGIN)
row = (mouse_y - start_y) // (SLOT_SIZE + SLOT_MARGIN)
if col < INVENTORY_COLS and row < INVENTORY_ROWS:
index = row * INVENTORY_COLS + col
if index < INVENTORY_SIZE:
self.active_slot_index = index
item = self.inventory[index]
if item:
self.notification = f'Выбран: {item.name}'
else:
self.notification = 'Слот пуст'
self.notification_timer = 60
elif self.state == 'map':
mouse_x, mouse_y = event.pos
if not (self.selected_biome is not None and self._map_info_panel_area().collidepoint(mouse_x, mouse_y)):
self.map_select_biome(mouse_x, mouse_y)
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 3:
if self.state == 'playing':
if self.active_vehicle is armored_carrier_type:
self._land_vehicle_shot(self.mouse_world_x, self.mouse_world_y, 'machine_gun')
elif self._is_scanner_selected():
self.scan_with_scanner()
else:
self.open_planting_menu_at_cursor()
elif self.state == 'inventory':
mouse_x, mouse_y = event.pos
start_x, start_y, total_width, total_height = self._inventory_grid_layout()
if start_x <= mouse_x <= start_x + total_width and start_y <= mouse_y <= start_y + total_height:
col = (mouse_x - start_x) // (SLOT_SIZE + SLOT_MARGIN)
row = (mouse_y - start_y) // (SLOT_SIZE + SLOT_MARGIN)
if col < INVENTORY_COLS and row < INVENTORY_ROWS:
index = row * INVENTORY_COLS + col
if index < INVENTORY_SIZE:
self.active_slot_index = index
item = self.inventory[index]
if item is None:
self.notification = 'Слот пуст'
self.notification_timer = 60
elif getattr(item, 'edible', False):
self.use_item()
else:
self.notification = f'{item.name}: этот предмет нельзя съесть'
self.notification_timer = 60
elif self.state == 'scanner':
self.state = 'playing'
self.scanner_target = None
def _visual_day_time(self):
return 0.0 if self.force_midnight_preview else self.day_time
def get_time_string(self):
t = self._visual_day_time()
hours = t * 24.0
h = int(hours) % 24
m = int((hours - int(hours)) * 60)
if 5 <= h < 11:
period = 'Утро'
elif 11 <= h < 17:
period = 'День'
elif 17 <= h < 21:
period = 'Вечер'
else:
period = 'Ночь'
suffix = ' [F8: тест ночи]' if self.force_midnight_preview else ''
return f'{period} {h:02d}:{m:02d}{suffix}'
def _solar_brightness(self):
return (1.0 - math.cos(self._visual_day_time() * math.tau)) * 0.5
def _is_dark_night(self):
"""Ночь или подземелье; F8 также влияет на поведение хищников."""
return self.in_cave or self._solar_brightness() < 0.25
def _night_plankton_strength(self):
"""0 днём; плавный рост от заката до полуночи и спад к рассвету.
F8 для проверки глубокой ночи автоматически влияет и на планктон.
Темнота пещер сама по себе не включает дневной планктон.
"""
if not NIGHT_PLANKTON_ENABLED or not getattr(self, 'setting_plankton_enabled', True):
return 0.0
light = self._solar_brightness()
if light >= NIGHT_VISIBLE_BRIGHTNESS - 1e-08:
return 0.0
progress = max(0.0, min(1.0, (NIGHT_VISIBLE_BRIGHTNESS - light) / NIGHT_VISIBLE_BRIGHTNESS))
return progress ** 1.18
def _night_spawn_spark(self, radius):
rng = self._night_rng
x = self.player_x + rng.uniform(-radius, radius)
y = self.player_y + rng.uniform(-radius, radius)
return NightWaterSpark(rng, max(0.0, min(MAP_SIZE - 0.01, x)), max(0.0, min(MAP_SIZE - 0.01, y)))
def _update_night_plankton(self):
"""Перемещаем только активные точки, обходясь без тысяч объектов мира."""
night = self._night_plankton_strength()
if night <= 0.001:
self._night_sparks_visible = 0
self._night_sparks.clear()
return
total = int(NIGHT_PLANKTON_MAX * night ** 1.35)
total = max(0, min(NIGHT_PLANKTON_MAX, total))
self._night_sparks_visible = total
radius = self._render_view_radius + 2.5
rng = self._night_rng
while len(self._night_sparks) < total:
self._night_sparks.append(self._night_spawn_spark(radius))
px, py = (self.player_x, self.player_y)
for spark in self._night_sparks[:total]:
spark.phase = (spark.phase + spark.blink_speed) % math.tau
drift_x = spark.vx + math.sin(spark.phase * 0.77) * spark.wander
drift_y = spark.vy + math.cos(spark.phase * 1.13) * spark.wander
if spark.flee_distance:
dx = spark.x - px
dy = spark.y - py
distance_sq = dx * dx + dy * dy
limit_sq = spark.flee_distance * spark.flee_distance
if distance_sq < limit_sq:
distance = max(0.001, math.sqrt(distance_sq))
impulse = spark.flee_speed * (0.45 + 0.55 * (1.0 - distance / spark.flee_distance))
if distance_sq < 1e-06:
dx, dy = (math.cos(spark.phase), math.sin(spark.phase))
distance = 1.0
drift_x += dx / distance * impulse
drift_y += dy / distance * impulse
spark.x += drift_x
spark.y += drift_y
offset_x, offset_y = (spark.x - px, spark.y - py)
if abs(offset_x) > radius or abs(offset_y) > radius:
if abs(offset_x) > radius * 2 or abs(offset_y) > radius * 2:
spark.x = px + rng.uniform(-radius, radius)
spark.y = py + rng.uniform(-radius, radius)
elif abs(offset_x) > radius:
spark.x = px + (-radius if offset_x > 0 else radius)
spark.y = py + rng.uniform(-radius, radius)
else:
spark.y = py + (-radius if offset_y > 0 else radius)
spark.x = px + rng.uniform(-radius, radius)
spark.x = max(0.0, min(MAP_SIZE - 0.01, spark.x))
spark.y = max(0.0, min(MAP_SIZE - 0.01, spark.y))
def draw_night_plankton(self):
"""Мерцающие точки поверх ночи, но ниже HUD и без перекрытия игрока.
Рисуем в реальном разрешении окна после LOD-прохода. Так светящиеся
мальки сохраняют чёткость и при приближении, и при отдалении.
"""
if self._night_sparks_visible <= 0:
return
strength = self._night_plankton_strength()
if strength <= 0.001:
return
size = self.screen.get_size()
if self._night_spark_surface is None or self._night_spark_surface.get_size() != size:
self._night_spark_surface = pygame.Surface(size, pygame.SRCALPHA).convert_alpha()
layer = self._night_spark_surface
layer.fill((0, 0, 0, 0))
zoom = self.camera_zoom
pixels_per_cell = CELL_SIZE * zoom
cx = size[0] * 0.5
cy = size[1] * 0.5
margin = 7
rngless_flicker = self.frame_count * 0.026
if self.active_vehicle is not None:
sprite = self.get_vehicle_rotated(self.active_vehicle, self.player_angle)
else:
sprite = self.get_diver_rotated(self.player_angle)
protected = pygame.Rect(0, 0, 1, 1) if sprite is None else sprite.get_rect()
if sprite is not None:
protected.size = (max(1, round(sprite.get_width() * zoom)), max(1, round(sprite.get_height() * zoom)))
protected.center = (round(cx), round(cy))
protected.inflate_ip(3, 3)
objects = self._get_current_object_grid()
for spark in self._night_sparks[:self._night_sparks_visible]:
sx = int(cx + (spark.x - self.player_x) * pixels_per_cell + math.sin(spark.phase * 7.2) * spark.jitter * zoom)
sy = int(cy + (spark.y - self.player_y) * pixels_per_cell + math.cos(spark.phase * 6.7) * spark.jitter * zoom)
if not (margin <= sx < size[0] - margin and margin <= sy < size[1] - margin):
continue
if protected.collidepoint(sx, sy):
continue
gx, gy = (int(spark.x), int(spark.y))
if self.in_cave:
if self.get_cave_tunnel_at(gx, gy) is None:
continue
elif self.is_inside_start_capsule(gx, gy) or not self.world.is_water_at(gx, gy):
continue
obj = objects.get((gx, gy))
if obj is not None and getattr(obj, 'is_buildable', False):
continue
flicker = 0.5 + 0.5 * math.sin(spark.phase * 2.9 + rngless_flicker)
flash = max(0.0, math.sin(spark.phase * 5.1 + rngless_flicker * 1.7)) ** 10
twinkle = 0.1 + 0.65 * flicker + 0.25 * flash
if twinkle < 0.18:
continue
alpha = int(232 * strength ** 0.74 * twinkle)
if alpha < 9:
continue
radius_px = max(1, min(8, round(spark.radius * zoom)))
color = spark.color
pygame.draw.circle(layer, (*color, max(3, alpha // 9)), (sx, sy), radius_px + 4)
pygame.draw.circle(layer, (*color, min(220, alpha // 3)), (sx, sy), radius_px + 2)
pygame.draw.circle(layer, (*color, alpha), (sx, sy), radius_px)
if spark.fry and radius_px >= 2:
speed = math.hypot(spark.vx, spark.vy) or 0.001
tail = max(2, round(radius_px * 1.7))
tx = round(sx - spark.vx / speed * tail)
ty = round(sy - spark.vy / speed * tail)
pygame.draw.line(layer, (*color, alpha // 3), (sx, sy), (tx, ty), max(1, radius_px // 2))
if flash > 0.55:
pygame.draw.circle(layer, (235, 250, 255, int(alpha * flash)), (sx, sy), max(1, radius_px // 2))
self.screen.blit(layer, (0, 0))
@staticmethod
def _make_light_source(x, y, radius, power=255, angle=None, width=40, color=(205, 230, 255), kind='lamp', reacts=True):
"""Координаты и размеры в КЛЕТКАХ, угол и ширина — в градусах."""
return {'x': float(x), 'y': float(y), 'radius': float(radius), 'power': max(0, min(255, int(power))), 'angle': angle, 'width': width, 'color': color, 'kind': kind, 'reacts': reacts}
def _collect_light_sources(self):
"""Общий источник данных для отрисовки света и реакции существ.
Сканирование ограничено видимыми клетками + несколькими вокруг;
не обходит гигантскую карту и не создаёт новых построек.
"""
if not LIGHTING_ENABLED:
return []
out = []
add = out.append
px, py = (self.player_x, self.player_y)
radius_view = VIEW_RADIUS + 9
if not self.in_cave:
x1, y1, x2, y2 = self.start_capsule_rect
if x1 - radius_view < px < x2 + radius_view and y1 - radius_view < py < y2 + radius_view:
cy = (y1 + y2) * 0.5
add(self._make_light_source(x1 + 2, cy, 4.3, 255, color=(220, 242, 255), kind='capsule', reacts=False))
add(self._make_light_source(x2 - 2, cy, 4.3, 255, color=(220, 242, 255), kind='capsule', reacts=False))
door_cx = sum((p[0] for p in self.start_capsule_door_cells)) / 2
add(self._make_light_source(door_cx, y2 + 0.35, 5.0, 235, angle=90, width=45, color=(255, 228, 167), kind='capsule_beacon'))
for bx in (x1 + 0.45, x2 + 0.55):
add(self._make_light_source(bx, y1 + 0.5, 1.8, 205, color=(160, 220, 255), kind='capsule_beacon'))
grid = self._get_current_object_grid()
cx = int(px)
cy = int(py)
xr = range(max(0, cx - radius_view), min(MAP_SIZE, cx + radius_view + 1))
yr = range(max(0, cy - radius_view), min(MAP_SIZE, cy + radius_view + 1))
interior_names = {'Многоцелевая комната', 'Большая комната', 'Комната сканирования', 'Обсерватория', 'Стыковочная шахта', 'Прямой отсек', 'Угловой отсек', 'T-образный отсек', 'Крестообразный отсек', 'Прямой стеклянный отсек', 'Угловой стеклянный отсек', 'Стеклянный купол многоцелевой комнаты', 'Стеклянный купол большой комнаты', 'Вертикальный соединитель', 'Люк', 'Окно', 'Фундамент', 'Пол'}
for iy in yr:
for ix in xr:
obj = grid.get((ix, iy))
if obj is None:
continue
x = ix
y = iy
if obj is spotlight_type:
angle = (ix * 73 + iy * 31) % 360
nearest_sq = 10.0 * 10.0
for animal in self.get_nearby_animals(VIEW_RADIUS + 3):
if animal.hp <= 0 or getattr(animal, 'is_companion', False):
continue
d2 = (animal.x - x) ** 2 + (animal.y - y) ** 2
if 0.01 < d2 < nearest_sq:
nearest_sq = d2
angle = math.degrees(math.atan2(animal.y - y, animal.x - x))
add(self._make_light_source(x, y, 8.5, 255, angle=angle, width=34, color=(255, 246, 193), kind='spotlight'))
add(self._make_light_source(x, y, 1.4, 230, color=(255, 242, 204), kind='lamp_near'))
elif obj is floodlight_type:
add(self._make_light_source(x, y, 6.3, 248, color=(255, 238, 185), kind='floodlight'))
elif obj in (seamoth_type, cyclops_type):
if self.vehicle_headlights_on:
add(self._make_light_source(x, y, 5.0, 220, angle=0, width=42, kind='vehicle_parked'))
add(self._make_light_source(x, y, 1.9, 185, color=(105, 210, 240), kind='engine', reacts=False))
elif getattr(obj, 'name', '') in interior_names:
size = 3.5 if obj.name not in ('Фундамент', 'Пол') else 1.8
add(self._make_light_source(x, y, size, 215, color=(219, 240, 255), kind='room', reacts=False))
elif getattr(obj, 'is_buildable', False) and obj.name in ('Изготовитель', 'Модификационная станция', 'Биореактор', 'Ядерный реактор', 'Кофейный торговый автомат', 'Радио', 'Водоочистная станция', 'Изготовитель аптечек', 'Консоль улучшения транспорта', 'Зарядное устройство батарей', 'Зарядное устройство энергоячеек'):
add(self._make_light_source(x, y, 1.7, 145, color=(135, 210, 235), kind='console', reacts=False))
if self.active_vehicle is not None:
if self.vehicle_headlights_on:
angle = self.player_angle
rad = math.radians(angle)
forward = 1.0 if self.active_vehicle is seamoth_type else 2.1
nose_x = px + math.cos(rad) * forward
nose_y = py + math.sin(rad) * forward
beam_r = 7.7 if self.active_vehicle is seamoth_type else 9.0
add(self._make_light_source(nose_x, nose_y, beam_r, 255, angle=angle, width=35, color=(205, 240, 255), kind='vehicle_headlight'))
add(self._make_light_source(nose_x, nose_y, 2.1, 225, color=(205, 243, 255), kind='vehicle_bulb'))
add(self._make_light_source(px, py, 2.2, 185, color=(80, 195, 230), kind='engine', reacts=False))
else:
if self.flashlight_on:
rad = math.radians(self.player_angle)
add(self._make_light_source(px + math.cos(rad) * 0.3, py + math.sin(rad) * 0.3, 5.1, 255, angle=self.player_angle, width=33, color=(230, 247, 255), kind='flashlight'))
add(self._make_light_source(px, py, 0.85, 230, color=(215, 235, 255), kind='flashlight_bulb'))
if self.seaglide_on:
add(self._make_light_source(px, py, 3.0, 216, color=(140, 210, 255), kind='seaglide'))
if self.active_flare_timer > 0:
flicker = 0.94 + 0.06 * math.sin(self.frame_count * 0.27)
add(self._make_light_source(px, py, 4.2, int(253 * flicker), color=(255, 112, 62), kind='flare'))
return out
def _radial_light_sprite(self, radius_px, power):
"""Кэшируемая круговая альфа-маска с плавным спадом яркости."""
radius_px = max(2, int(radius_px))
power = max(0, min(255, int(power)))
key = (radius_px, power)
surf = self._radial_light_cache.get(key)
if surf is not None:
return surf
surf = pygame.Surface((radius_px * 2 + 2, radius_px * 2 + 2), pygame.SRCALPHA)
c = radius_px + 1
steps = max(24, min(80, radius_px // 2))
for step in range(steps, -1, -1):
f = step / max(1, steps)
r = max(1, int(radius_px * f))
intensity = int(power * max(0, 1 - f ** 1.65))
pygame.draw.circle(surf, (0, 0, 0, intensity), (c, c), r)
self._radial_light_cache[key] = surf
return surf
def _beam_light_sprite(self, radius_px, power, angle, width):
"""Кэшированный конус: затухает к концу и боковым границам."""
radius_px = max(3, int(radius_px))
power = min(255, max(0, int(power)))
angle_bin = round(float(angle) / 15) * 15 % 360
width_bin = int(round(float(width) / 5) * 5)
key = (radius_px, power, angle_bin, width_bin)
cached = self._beam_light_cache.get(key)
if cached is not None:
return cached
R = radius_px
surface = pygame.Surface((2 * R + 2, 2 * R + 2), pygame.SRCALPHA)
c = R + 1
rings = 32
half = math.radians(width_bin)
for i in range(rings, -1, -1):
frac = max(0.015, i / rings)
rad = max(1, int(R * frac))
distance_fade = 1.0 - frac ** 1.7
for angular_fraction, angular_power in ((1.0, 0.4), (0.85, 0.69), (0.65, 1.0)):
points = [(c, c)]
for j in range(13):
a = -half * angular_fraction + 2 * half * angular_fraction * j / 12
points.append((int(c + rad * math.cos(a)), int(c + rad * math.sin(a))))
light = int(power * distance_fade * angular_power)
pygame.draw.polygon(surface, (0, 0, 0, light), points)
if angle_bin:
surface = pygame.transform.rotate(surface, -angle_bin)
if len(self._beam_light_cache) > 110:
self._beam_light_cache.clear()
self._beam_light_cache[key] = surface
return surface
def _light_hits_creature(self, source, ax, ay):
dx = ax - source['x']
dy = ay - source['y']
ds = dx * dx + dy * dy
if ds > source['radius'] ** 2:
return 0.0
if source['angle'] is not None and ds > 0.35:
aim = math.radians(source['angle'])
cos_angle = (dx * math.cos(aim) + dy * math.sin(aim)) / max(0.001, math.sqrt(ds))
if cos_angle < math.cos(math.radians(source['width'])):
return 0.0
return max(0.0, 1.0 - math.sqrt(ds) / max(0.01, source['radius'])) * (source['power'] / 255)
def _apply_light_behavior(self, animal, world, sources):
"""Притяжение и бегство без телепортации и прохода сквозь стены."""
if getattr(animal, 'is_companion', False) or animal.hp <= 0:
return False
name = animal.type.name
attraction = LIGHT_ATTRACTION.get(name, 0.0)
aversion = LIGHT_AVERSION.get(name, 0.0)
if not (attraction or aversion):
return False
nearest = None
score = 0.0
for source in sources:
if not source['reacts']:
continue
if source['kind'] == 'flare' and FLARES_DO_NOT_AFFECT_PREDATORS_AT_NIGHT and self._is_dark_night():
continue
effect = self._light_hits_creature(source, animal.x, animal.y)
if effect > score:
score, nearest = (effect, source)
if nearest is None or score < 0.035:
return False
dx = nearest['x'] - animal.x
dy = nearest['y'] - animal.y
dist = math.hypot(dx, dy)
if dist < 0.05:
return False
sign = -1 if aversion else 1
sensitivity = aversion or attraction
speed = animal.type.speed
factor = min(1.0, 0.55 + score * 0.9) * sensitivity
ux, uy = (dx / dist * sign, dy / dist * sign)
old_x, old_y = (animal.x, animal.y)
nx = old_x + ux * speed * (0.5 + factor)
ny = old_y + uy * speed * (0.5 + factor)
if not animal.home_biome.contains(nx, ny):
return False
tile = world.get_object_at(int(nx), int(ny))
if tile is not None and tile.is_wall:
return False
if not self.in_cave and self.is_start_capsule_wall(int(nx), int(ny)):
return False
animal.x, animal.y = (nx, ny)
animal.dx, animal.dy = (ux * speed, uy * speed)
animal.move_timer = min(animal.move_timer, 10)
animal._update_rotation()
if not self.in_cave:
self.world.update_animal_index(animal, old_x, old_y)
return bool(aversion)
@staticmethod
def _color_mix(color_a, color_b, t):
t = max(0.0, min(1.0, float(t)))
return tuple((max(0, min(255, int(round(a + (b - a) * t)))) for a, b in zip(color_a[:3], color_b[:3])))
@staticmethod
def _color_scale(color, factor):
factor = max(0.0, float(factor))
return tuple((max(0, min(255, int(round(channel * factor)))) for channel in color[:3]))
@staticmethod
def _stable_visual_hash(*parts):
value = 2166136261
for part in parts:
for ch in str(part):
value ^= ord(ch)
value = value * 16777619 & 4294967295
return value
@staticmethod
def _draw_starfish_shape(surface, x, y, radius, color, seed=0):
rng = random.Random(seed)
points = []
inner = max(2, int(radius * 0.46))
for i in range(10):
ang = -math.pi / 2 + i * math.pi / 5
rr = radius if i % 2 == 0 else inner
rr *= rng.uniform(0.9, 1.08)
points.append((int(x + math.cos(ang) * rr), int(y + math.sin(ang) * rr)))
pygame.draw.polygon(surface, color, points)
pygame.draw.polygon(surface, Game._color_scale(color, 0.65), points, 1)
pygame.draw.circle(surface, Game._color_mix(color, (255, 240, 220), 0.35), (int(x), int(y)), max(1, radius // 3))
def _surface_theme_for_biome(self, biome):
if biome is None:
return None
cache_key = (biome.name, tuple(biome.color))
cached = self._seabed_theme_cache.get(cache_key)
if cached is not None:
return cached
name = biome.name.lower()
base = tuple(biome.color)
theme = {'key': 'generic', 'base_mix': Game._color_mix(base, (165, 176, 184), 0.16), 'highlight': Game._color_mix(base, (230, 238, 245), 0.22), 'shadow': Game._color_scale(base, 0.72), 'sand_ripples': False, 'pebble_range': (2, 5), 'algae_chance': 0.1, 'coral_chance': 0.08, 'starfish_chance': 0.02, 'mottle_count': (3, 5), 'coral_palette': [(188, 122, 92), (210, 160, 110), (122, 170, 170)], 'algae_palette': [(78, 112, 78), (62, 93, 67)], 'pebble_palette': [Game._color_mix(base, (185, 185, 185), 0.28), (92, 98, 103)]}
if name in ('центральное море', 'глубоководье'):
theme.update({'key': 'asteroid_abyss', 'base_mix': Game._color_mix(base, (4, 24, 78), 0.42), 'highlight': Game._color_mix(base, (12, 70, 155), 0.2), 'shadow': Game._color_mix(base, (2, 10, 45), 0.55), 'sand_ripples': False, 'pebble_range': (2, 4), 'algae_chance': 0.07, 'coral_chance': 0.12, 'starfish_chance': 0.01, 'mottle_count': (3, 6)})
elif name in ('мелководье', 'озёра (внешние)'):
theme.update({'key': 'safe_shallows', 'base_mix': Game._color_mix(base, (222, 205, 158), 0.55), 'highlight': Game._color_mix(base, (243, 233, 193), 0.5), 'shadow': Game._color_mix(base, (148, 137, 102), 0.48), 'sand_ripples': True, 'pebble_range': (4, 8), 'algae_chance': 0.18, 'coral_chance': 0.22, 'starfish_chance': 0.14, 'mottle_count': (5, 7), 'coral_palette': [(242, 123, 118), (255, 177, 97), (160, 110, 205), (116, 206, 205)], 'algae_palette': [(92, 128, 83), (63, 96, 74)], 'pebble_palette': [(177, 165, 151), (130, 121, 112), (99, 102, 105)]})
elif name == 'леса':
theme.update({'key': 'forest_floor', 'base_mix': Game._color_mix(base, (62, 105, 51), 0.28), 'highlight': (100, 151, 74), 'shadow': (27, 71, 38), 'sand_ripples': False, 'pebble_range': (1, 3), 'algae_chance': 0.26, 'coral_chance': 0.0, 'starfish_chance': 0.0, 'mottle_count': (5, 9), 'algae_palette': [(77, 128, 56), (112, 151, 62)], 'pebble_palette': [(88, 96, 72), (66, 80, 62)]})
elif name == 'кельповый лес':
theme.update({'key': 'kelp', 'base_mix': Game._color_mix(base, (95, 110, 72), 0.36), 'highlight': Game._color_mix(base, (145, 171, 96), 0.3), 'shadow': Game._color_mix(base, (52, 69, 41), 0.46), 'sand_ripples': True, 'pebble_range': (3, 6), 'algae_chance': 0.34, 'coral_chance': 0.12, 'starfish_chance': 0.06, 'mottle_count': (5, 7), 'coral_palette': [(212, 161, 78), (182, 108, 92), (121, 162, 170)], 'algae_palette': [(74, 118, 54), (98, 140, 66), (55, 88, 48)], 'pebble_palette': [(126, 122, 102), (98, 102, 87), (75, 79, 68)]})
elif name == 'био-люминесцентная зона':
theme.update({'key': 'mushroom', 'base_mix': Game._color_mix(base, (77, 104, 150), 0.42), 'highlight': Game._color_mix(base, (126, 150, 198), 0.3), 'shadow': Game._color_mix(base, (35, 49, 77), 0.45), 'sand_ripples': False, 'pebble_range': (5, 8), 'algae_chance': 0.1, 'coral_chance': 0.14, 'starfish_chance': 0.03, 'mottle_count': (4, 6), 'coral_palette': [(126, 160, 225), (178, 123, 214), (101, 194, 186)], 'algae_palette': [(76, 108, 114), (60, 91, 100)], 'pebble_palette': [(112, 128, 148), (88, 96, 112), (67, 72, 81)]})
elif name == 'коралловый риф':
theme.update({'key': 'reef_plateau', 'base_mix': Game._color_mix(base, (116, 112, 124), 0.2), 'highlight': Game._color_mix(base, (183, 161, 175), 0.18), 'shadow': Game._color_mix(base, (74, 57, 69), 0.24), 'sand_ripples': False, 'pebble_range': (4, 7), 'algae_chance': 0.22, 'coral_chance': 0.24, 'starfish_chance': 0.08, 'mottle_count': (5, 7), 'coral_palette': [(234, 130, 151), (255, 181, 106), (129, 221, 186), (142, 173, 248)], 'algae_palette': [(76, 117, 88), (96, 145, 98), (63, 101, 85)], 'pebble_palette': [(150, 123, 128), (119, 93, 101), (86, 76, 83)]})
elif name in ('горы', 'острова'):
theme.update({'algae_chance': 0.0, 'coral_chance': 0.0, 'starfish_chance': 0.0})
theme.update({'key': 'rocky', 'base_mix': Game._color_mix(base, (92, 88, 82), 0.18), 'highlight': Game._color_mix(base, (132, 127, 119), 0.18), 'shadow': Game._color_mix(base, (38, 37, 39), 0.24), 'sand_ripples': False, 'pebble_range': (6, 10), 'algae_chance': 0.08, 'coral_chance': 0.1, 'starfish_chance': 0.02, 'mottle_count': (4, 7), 'coral_palette': [(176, 120, 102), (129, 132, 145), (110, 164, 169)], 'algae_palette': [(78, 82, 66), (66, 74, 58)], 'pebble_palette': [(142, 137, 132), (103, 100, 96), (72, 71, 74)]})
elif name in ('побережье', 'реки', 'притоки', 'водопады'):
if name == 'побережье':
theme.update({'algae_chance': 0.0, 'coral_chance': 0.0, 'starfish_chance': 0.0})
theme.update({'key': 'dunes', 'base_mix': Game._color_mix(base, (208, 188, 132), 0.34), 'highlight': Game._color_mix(base, (235, 218, 174), 0.3), 'shadow': Game._color_mix(base, (144, 128, 91), 0.28), 'sand_ripples': True, 'pebble_range': (3, 6), 'algae_chance': 0.05, 'coral_chance': 0.07, 'starfish_chance': 0.05, 'mottle_count': (5, 7), 'coral_palette': [(225, 150, 99), (204, 131, 115), (112, 176, 180)], 'algae_palette': [(90, 105, 70), (66, 84, 58)], 'pebble_palette': [(179, 166, 140), (138, 125, 103), (111, 103, 87)]})
if is_land_biome(biome):
theme['coral_chance'] = 0.0
theme['starfish_chance'] = 0.0
theme['algae_chance'] = 0.0
self._seabed_theme_cache[cache_key] = theme
return theme
def _build_seabed_tile_surface(self, biome, variant):
theme = self._surface_theme_for_biome(biome)
if theme is None:
return None
rng = random.Random(variant * 9173 + self._stable_visual_hash(biome.name, theme['key']))
surf = pygame.Surface((CELL_SIZE, CELL_SIZE), pygame.SRCALPHA)
surf.fill(theme['base_mix'])
for y in range(CELL_SIZE):
frac = y / max(1, CELL_SIZE - 1)
top_color = theme['highlight']
low_color = theme['shadow']
row_color = Game._color_mix(top_color, low_color, frac * 0.65)
alpha = 36
pygame.draw.line(surf, (*row_color, alpha), (0, y), (CELL_SIZE, y))
mottle_min, mottle_max = theme['mottle_count']
for _ in range(rng.randint(mottle_min, mottle_max)):
w = rng.randint(8, 18)
h = rng.randint(5, 14)
x = rng.randint(-3, CELL_SIZE - 4)
y = rng.randint(-3, CELL_SIZE - 4)
tint = theme['highlight'] if rng.random() < 0.45 else theme['shadow']
alpha = rng.randint(26, 52)
pygame.draw.ellipse(surf, (*tint, alpha), (x, y, w, h))
if theme['sand_ripples']:
ripple = Game._color_mix(theme['shadow'], theme['highlight'], 0.4)
for _ in range(rng.randint(2, 4)):
base_y = rng.randint(4, CELL_SIZE - 5)
phase = rng.uniform(0.0, math.tau)
amp = rng.uniform(0.8, 1.8)
pts = []
for xx in range(-2, CELL_SIZE + 3, 3):
yy = base_y + math.sin(xx * 0.33 + phase) * amp
pts.append((xx, int(round(yy))))
pygame.draw.lines(surf, (*ripple, 58), False, pts, 1)
pebble_count = rng.randint(*theme['pebble_range'])
for _ in range(pebble_count):
px = rng.randint(2, CELL_SIZE - 3)
py = rng.randint(2, CELL_SIZE - 3)
pw = rng.randint(2, 5)
ph = rng.randint(2, 4)
pcol = rng.choice(theme['pebble_palette'])
rect = pygame.Rect(px - pw // 2, py - ph // 2, pw, ph)
pygame.draw.ellipse(surf, pcol, rect)
pygame.draw.ellipse(surf, Game._color_mix(pcol, (240, 240, 240), 0.22), rect.inflate(-1, -1))
if rng.random() < theme['algae_chance']:
clump_x = rng.randint(5, CELL_SIZE - 6)
clump_y = rng.randint(6, CELL_SIZE - 5)
base_y = min(CELL_SIZE - 3, clump_y + rng.randint(2, 5))
for _ in range(rng.randint(4, 7)):
height = rng.randint(6, 12)
bend = rng.randint(-4, 4)
tip_x = clump_x + bend + rng.randint(-3, 3)
tip_y = max(2, base_y - height)
col = rng.choice(theme['algae_palette'])
mid_x = (clump_x + tip_x) // 2 + rng.randint(-2, 2)
mid_y = base_y - height // 2
pygame.draw.lines(surf, (*col, 200), False, [(clump_x, base_y), (mid_x, mid_y), (tip_x, tip_y)], 1)
pygame.draw.circle(surf, Game._color_mix(col, (220, 240, 210), 0.16), (tip_x, tip_y), 1)
if rng.random() < theme['coral_chance']:
coral_x = rng.randint(5, CELL_SIZE - 6)
coral_y = rng.randint(5, CELL_SIZE - 6)
coral_color = rng.choice(theme['coral_palette'])
style_roll = rng.random()
if style_roll < 0.34:
base_point = (coral_x, coral_y + 3)
for _ in range(rng.randint(3, 5)):
tip = (coral_x + rng.randint(-5, 5), coral_y - rng.randint(2, 6))
mid = ((base_point[0] + tip[0]) // 2 + rng.randint(-2, 2), (base_point[1] + tip[1]) // 2 - rng.randint(0, 2))
pygame.draw.lines(surf, coral_color, False, [base_point, mid, tip], 2)
pygame.draw.circle(surf, Game._color_mix(coral_color, (250, 240, 215), 0.28), tip, 1)
elif style_roll < 0.67:
for _ in range(rng.randint(2, 4)):
rr = rng.randint(2, 4)
ox = rng.randint(-3, 3)
oy = rng.randint(-3, 3)
pygame.draw.circle(surf, coral_color, (coral_x + ox, coral_y + oy), rr)
pygame.draw.circle(surf, Game._color_mix(coral_color, (255, 240, 230), 0.22), (coral_x + ox - 1, coral_y + oy - 1), max(1, rr - 1))
else:
rect = pygame.Rect(coral_x - 5, coral_y - 3, 11, 6)
pygame.draw.ellipse(surf, coral_color, rect)
pygame.draw.ellipse(surf, Game._color_scale(coral_color, 0.68), rect, 1)
pygame.draw.line(surf, Game._color_mix(coral_color, (250, 240, 225), 0.25), (rect.left + 2, coral_y), (rect.right - 2, coral_y), 1)
if rng.random() < theme['starfish_chance']:
sx = rng.randint(6, CELL_SIZE - 7)
sy = rng.randint(6, CELL_SIZE - 7)
star_color = rng.choice([(227, 102, 86), (250, 172, 77), (189, 114, 210)])
self._draw_starfish_shape(surf, sx, sy, rng.randint(4, 5), star_color, seed=variant * 13 + sx + sy)
grit = Game._color_mix(theme['highlight'], (230, 225, 210), 0.3)
for _ in range(rng.randint(6, 11)):
gx = rng.randint(0, CELL_SIZE - 1)
gy = rng.randint(0, CELL_SIZE - 1)
surf.set_at((gx, gy), (*grit, 105))
return surf
def _get_seabed_tile_surface(self, biome, x, y):
if biome is None:
return None
theme = self._surface_theme_for_biome(biome)
if theme is None:
return None
variant = self._stable_visual_hash(biome.name, x // 2, y // 2, x % 3, y % 3) % 24
key = (theme['key'], tuple(theme['base_mix']), tuple(theme['highlight']), tuple(theme['shadow']), variant)
tile = self._seabed_tile_cache.get(key)
if tile is None:
tile = self._build_seabed_tile_surface(biome, variant)
self._seabed_tile_cache[key] = tile
return tile
def _cave_theme_for_tunnel(self, tunnel):
if tunnel is None:
return {'key': 'solid_wall', 'base_mix': (18, 18, 23), 'highlight': (40, 40, 49), 'shadow': (8, 8, 11), 'mottle_count': (5, 8), 'pebble_range': (2, 5), 'crystal_chance': 0.1, 'fungus_chance': 0.02, 'fossil_chance': 0.03, 'mineral_palette': [(80, 102, 132), (72, 84, 98), (90, 78, 68)]}
parent = getattr(tunnel, 'parent_surface_biome', None)
parent_name = parent.name if parent is not None else 'interbiome'
cache_key = (tunnel.name, tuple(tunnel.color), parent_name, getattr(tunnel, 'cave_kind', ''))
cached = self._cave_theme_cache.get(cache_key)
if cached is not None:
return cached
base = tuple(tunnel.color)
name = parent_name.lower()
kind = getattr(tunnel, 'cave_kind', '')
theme = {'key': 'generic_cave', 'base_mix': Game._color_mix(base, (68, 77, 88), 0.24), 'highlight': Game._color_mix(base, (126, 140, 158), 0.16), 'shadow': Game._color_mix(base, (14, 18, 25), 0.44), 'mottle_count': (4, 6), 'pebble_range': (4, 8), 'crystal_chance': 0.14, 'fungus_chance': 0.08, 'fossil_chance': 0.06, 'mineral_palette': [(119, 146, 186), (106, 128, 149), (122, 109, 95)]}
if 'безопасные отмели' in name:
theme.update({'key': 'shallow_cave', 'base_mix': Game._color_mix(base, (101, 88, 76), 0.24), 'highlight': Game._color_mix(base, (158, 141, 120), 0.18), 'shadow': Game._color_mix(base, (28, 24, 24), 0.4), 'mottle_count': (5, 7), 'pebble_range': (5, 9), 'crystal_chance': 0.1, 'fungus_chance': 0.04, 'fossil_chance': 0.08, 'mineral_palette': [(145, 128, 103), (98, 94, 90), (122, 154, 171)]})
elif 'грибной лес' in name or 'большой риф' in name or 'редкий риф' in name:
theme.update({'key': 'fungal_cave', 'base_mix': Game._color_mix(base, (74, 83, 109), 0.26), 'highlight': Game._color_mix(base, (138, 155, 192), 0.18), 'shadow': Game._color_mix(base, (18, 20, 32), 0.42), 'crystal_chance': 0.19, 'fungus_chance': 0.18, 'fossil_chance': 0.05, 'mineral_palette': [(145, 176, 229), (168, 116, 206), (115, 188, 184)]})
elif 'бурые водоросли' in name or 'водоросли' == name or 'зона куша' in name:
theme.update({'key': 'kelp_cave', 'base_mix': Game._color_mix(base, (76, 84, 63), 0.24), 'highlight': Game._color_mix(base, (118, 138, 96), 0.16), 'shadow': Game._color_mix(base, (18, 24, 18), 0.44), 'crystal_chance': 0.1, 'fungus_chance': 0.1, 'fossil_chance': 0.05, 'mineral_palette': [(122, 136, 96), (84, 92, 72), (113, 154, 120)]})
elif 'горы' in name or 'скалистое поле' in name or 'зона крушения' in name or ('подводные острова' in name):
theme.update({'key': 'rock_cave', 'base_mix': Game._color_mix(base, (82, 78, 82), 0.18), 'highlight': Game._color_mix(base, (132, 126, 136), 0.14), 'shadow': Game._color_mix(base, (13, 14, 18), 0.48), 'mottle_count': (5, 8), 'pebble_range': (6, 10), 'crystal_chance': 0.09, 'fungus_chance': 0.04, 'fossil_chance': 0.1, 'mineral_palette': [(122, 124, 136), (92, 94, 104), (126, 110, 96)]})
elif 'дюны' in name or 'морской путь' in name:
theme.update({'key': 'dune_cave', 'base_mix': Game._color_mix(base, (118, 103, 76), 0.24), 'highlight': Game._color_mix(base, (176, 158, 114), 0.16), 'shadow': Game._color_mix(base, (30, 24, 18), 0.44), 'crystal_chance': 0.07, 'fungus_chance': 0.03, 'fossil_chance': 0.1, 'mineral_palette': [(150, 129, 91), (124, 111, 88), (162, 166, 154)]})
if kind in ('hub', 'branch_room'):
theme['highlight'] = Game._color_mix(theme['highlight'], (190, 206, 220), 0.14)
theme['crystal_chance'] = min(0.24, theme['crystal_chance'] + 0.04)
theme['fungus_chance'] = min(0.22, theme['fungus_chance'] + 0.03)
theme['mottle_count'] = (theme['mottle_count'][0] + 1, theme['mottle_count'][1] + 1)
elif kind in ('interbiome_corridor', 'local_corridor'):
theme['shadow'] = Game._color_mix(theme['shadow'], (7, 8, 10), 0.18)
theme['crystal_chance'] = max(0.05, theme['crystal_chance'] - 0.03)
theme['fungus_chance'] = max(0.02, theme['fungus_chance'] - 0.02)
elif kind == 'interbiome_junction':
theme['highlight'] = Game._color_mix(theme['highlight'], (166, 174, 188), 0.1)
theme['fossil_chance'] = min(0.12, theme['fossil_chance'] + 0.03)
self._cave_theme_cache[cache_key] = theme
return theme
def _build_cave_floor_tile_surface(self, tunnel, variant):
theme = self._cave_theme_for_tunnel(tunnel)
if theme is None:
return None
seed = self._stable_visual_hash(tunnel.name, theme['key'], variant)
rng = random.Random(seed)
surf = pygame.Surface((CELL_SIZE, CELL_SIZE), pygame.SRCALPHA)
surf.fill(theme['base_mix'])
for y in range(CELL_SIZE):
frac = y / max(1, CELL_SIZE - 1)
row_color = Game._color_mix(theme['highlight'], theme['shadow'], frac * 0.8)
alpha = 40 + int(18 * frac)
pygame.draw.line(surf, (*row_color, alpha), (0, y), (CELL_SIZE, y))
mottle_min, mottle_max = theme['mottle_count']
for _ in range(rng.randint(mottle_min, mottle_max)):
w = rng.randint(6, 18)
h = rng.randint(5, 14)
x = rng.randint(-4, CELL_SIZE - 4)
y = rng.randint(-3, CELL_SIZE - 4)
tint = theme['highlight'] if rng.random() < 0.42 else theme['shadow']
alpha = rng.randint(24, 54)
pygame.draw.ellipse(surf, (*tint, alpha), (x, y, w, h))
vein_color = Game._color_mix(theme['shadow'], theme['highlight'], 0.3)
for _ in range(rng.randint(2, 4)):
sx = rng.randint(-2, CELL_SIZE // 3)
sy = rng.randint(1, CELL_SIZE - 2)
pts = [(sx, sy)]
cx = sx
cy = sy
for _step in range(rng.randint(2, 4)):
cx += rng.randint(3, 7)
cy += rng.randint(-3, 3)
pts.append((max(-2, min(CELL_SIZE + 2, cx)), max(0, min(CELL_SIZE - 1, cy))))
pygame.draw.lines(surf, (*vein_color, 65), False, pts, 1)
for _ in range(rng.randint(*theme['pebble_range'])):
px = rng.randint(2, CELL_SIZE - 3)
py = rng.randint(2, CELL_SIZE - 3)
pw = rng.randint(2, 5)
ph = rng.randint(2, 4)
pcol = rng.choice(theme['mineral_palette'])
rect = pygame.Rect(px - pw // 2, py - ph // 2, pw, ph)
pygame.draw.ellipse(surf, pcol, rect)
inner = rect.inflate(-1, -1)
if inner.width > 0 and inner.height > 0:
pygame.draw.ellipse(surf, Game._color_mix(pcol, (222, 226, 232), 0.2), inner)
if rng.random() < 0.22:
base_x = rng.randint(5, CELL_SIZE - 6)
base_y = rng.randint(CELL_SIZE // 2, CELL_SIZE - 3)
count = rng.randint(2, 4)
ridge_col = Game._color_mix(theme['shadow'], theme['base_mix'], 0.45)
for idx in range(count):
height = rng.randint(5, 10)
width = rng.randint(3, 5)
cx = base_x + idx * rng.randint(2, 4) - count
pts = [(cx - width // 2, base_y), (cx, base_y - height), (cx + width // 2, base_y)]
pygame.draw.polygon(surf, ridge_col, pts)
pygame.draw.line(surf, Game._color_mix(ridge_col, (190, 195, 205), 0.18), pts[0], pts[1], 1)
if rng.random() < theme['crystal_chance']:
crystal_color = rng.choice(theme['mineral_palette'])
crystal_color = Game._color_mix(crystal_color, (185, 220, 255), 0.24)
origin_x = rng.randint(4, CELL_SIZE - 5)
origin_y = rng.randint(4, CELL_SIZE - 5)
for _ in range(rng.randint(2, 4)):
cx = origin_x + rng.randint(-4, 4)
cy = origin_y + rng.randint(-3, 3)
h = rng.randint(4, 8)
w = rng.randint(2, 4)
pts = [(cx, cy - h), (cx + w, cy), (cx, cy + 1), (cx - w, cy)]
pygame.draw.polygon(surf, crystal_color, pts)
pygame.draw.polygon(surf, Game._color_scale(crystal_color, 0.55), pts, 1)
pygame.draw.line(surf, Game._color_mix(crystal_color, (245, 250, 255), 0.45), (cx, cy - h + 1), (cx, cy), 1)
if rng.random() < theme['fungus_chance']:
stem = Game._color_mix(theme['highlight'], (208, 214, 224), 0.22)
cap = Game._color_mix(rng.choice(theme['mineral_palette']), (208, 170, 224), 0.3)
base_x = rng.randint(5, CELL_SIZE - 6)
base_y = rng.randint(7, CELL_SIZE - 5)
for _ in range(rng.randint(2, 3)):
cx = base_x + rng.randint(-4, 4)
h = rng.randint(4, 7)
pygame.draw.line(surf, stem, (cx, base_y), (cx, base_y - h), 1)
rect = pygame.Rect(cx - 3, base_y - h - 2, 7, 4)
pygame.draw.ellipse(surf, cap, rect)
pygame.draw.ellipse(surf, Game._color_scale(cap, 0.6), rect, 1)
if rng.random() < theme['fossil_chance']:
fossil = Game._color_mix(theme['shadow'], theme['highlight'], 0.55)
fx = rng.randint(6, CELL_SIZE - 7)
fy = rng.randint(6, CELL_SIZE - 7)
style = rng.random()
if style < 0.5:
pygame.draw.arc(surf, (*fossil, 130), (fx - 5, fy - 4, 10, 8), math.pi * 0.15, math.pi * 1.75, 1)
pygame.draw.arc(surf, (*fossil, 90), (fx - 3, fy - 2, 6, 4), math.pi * 0.15, math.pi * 1.75, 1)
else:
for off in range(-3, 4, 2):
pygame.draw.line(surf, (*fossil, 120), (fx - 4, fy + off), (fx + 4, fy + off // 2), 1)
grit = Game._color_mix(theme['highlight'], (220, 220, 225), 0.24)
for _ in range(rng.randint(6, 12)):
gx = rng.randint(0, CELL_SIZE - 1)
gy = rng.randint(0, CELL_SIZE - 1)
surf.set_at((gx, gy), (*grit, 95))
return surf
def _build_cave_wall_tile_surface(self, variant):
rng = random.Random(310000 + variant * 271)
surf = pygame.Surface((CELL_SIZE, CELL_SIZE), pygame.SRCALPHA)
base = (13, 13, 18)
high = (34, 34, 42)
deep = (5, 5, 8)
surf.fill(base)
for y in range(CELL_SIZE):
frac = y / max(1, CELL_SIZE - 1)
row = Game._color_mix(high, deep, frac * 0.78)
pygame.draw.line(surf, (*row, 42), (0, y), (CELL_SIZE, y))
for _ in range(rng.randint(6, 10)):
x = rng.randint(-4, CELL_SIZE - 4)
y = rng.randint(-3, CELL_SIZE - 4)
w = rng.randint(7, 16)
h = rng.randint(5, 14)
col = high if rng.random() < 0.42 else deep
pygame.draw.ellipse(surf, (*col, rng.randint(24, 52)), (x, y, w, h))
for _ in range(rng.randint(2, 4)):
sx = rng.randint(0, CELL_SIZE - 1)
sy = rng.randint(0, CELL_SIZE - 1)
pts = [(sx, sy)]
cx = sx
cy = sy
for _step in range(rng.randint(2, 5)):
cx += rng.randint(-4, 4)
cy += rng.randint(2, 6)
pts.append((max(0, min(CELL_SIZE - 1, cx)), max(0, min(CELL_SIZE - 1, cy))))
pygame.draw.lines(surf, (48, 48, 57, 90), False, pts, 1)
for _ in range(rng.randint(5, 9)):
cx = rng.randint(2, CELL_SIZE - 3)
cy = rng.randint(2, CELL_SIZE - 3)
rr = rng.randint(1, 3)
col = rng.choice([(62, 65, 76), (78, 84, 92), (56, 48, 44)])
pygame.draw.circle(surf, col, (cx, cy), rr)
if rr > 1:
pygame.draw.circle(surf, Game._color_mix(col, (170, 176, 184), 0.18), (cx - 1, cy - 1), rr - 1)
pygame.draw.rect(surf, (0, 0, 0, 34), (0, 0, CELL_SIZE, CELL_SIZE), 2)
return surf
def _get_cave_floor_tile_surface(self, tunnel, x, y):
if tunnel is None:
return None
theme = self._cave_theme_for_tunnel(tunnel)
variant = self._stable_visual_hash(tunnel.name, x // 2, y // 2, x % 3, y % 3) % 24
key = (theme['key'], tuple(theme['base_mix']), tuple(theme['highlight']), tuple(theme['shadow']), variant)
tile = self._cave_floor_tile_cache.get(key)
if tile is None:
tile = self._build_cave_floor_tile_surface(tunnel, variant)
self._cave_floor_tile_cache[key] = tile
return tile
def _get_cave_wall_tile_surface(self, x, y):
variant = self._stable_visual_hash('cave_wall', x // 2, y // 2, x % 5, y % 5) % 32
tile = self._cave_wall_tile_cache.get(variant)
if tile is None:
tile = self._build_cave_wall_tile_surface(variant)
self._cave_wall_tile_cache[variant] = tile
return tile
def draw_cave_cell(self, x, y, screen_x, screen_y, tunnel):
if tunnel is None:
wall = self._get_cave_wall_tile_surface(x, y)
self.screen.blit(wall, (screen_x, screen_y))
return
tile = self._get_cave_floor_tile_surface(tunnel, x, y) if getattr(self, 'setting_seabed_details_enabled', True) else None
if tile is not None:
self.screen.blit(tile, (screen_x, screen_y))
else:
pygame.draw.rect(self.screen, tunnel.color, (screen_x, screen_y, CELL_SIZE, CELL_SIZE))
edge_shadow = pygame.Surface((CELL_SIZE, CELL_SIZE), pygame.SRCALPHA)
neighbors = {'top': self.get_cave_tunnel_at(x, y - 1) is None, 'bottom': self.get_cave_tunnel_at(x, y + 1) is None, 'left': self.get_cave_tunnel_at(x - 1, y) is None, 'right': self.get_cave_tunnel_at(x + 1, y) is None}
if neighbors['top']:
pygame.draw.rect(edge_shadow, (0, 0, 0, 56), (0, 0, CELL_SIZE, 4))
if neighbors['bottom']:
pygame.draw.rect(edge_shadow, (0, 0, 0, 60), (0, CELL_SIZE - 4, CELL_SIZE, 4))
if neighbors['left']:
pygame.draw.rect(edge_shadow, (0, 0, 0, 52), (0, 0, 4, CELL_SIZE))
if neighbors['right']:
pygame.draw.rect(edge_shadow, (0, 0, 0, 52), (CELL_SIZE - 4, 0, 4, CELL_SIZE))
if neighbors['top'] and neighbors['left']:
pygame.draw.circle(edge_shadow, (0, 0, 0, 48), (2, 2), 4)
if neighbors['top'] and neighbors['right']:
pygame.draw.circle(edge_shadow, (0, 0, 0, 48), (CELL_SIZE - 3, 2), 4)
if neighbors['bottom'] and neighbors['left']:
pygame.draw.circle(edge_shadow, (0, 0, 0, 48), (2, CELL_SIZE - 3), 4)
if neighbors['bottom'] and neighbors['right']:
pygame.draw.circle(edge_shadow, (0, 0, 0, 48), (CELL_SIZE - 3, CELL_SIZE - 3), 4)
self.screen.blit(edge_shadow, (screen_x, screen_y))
border = Game._color_mix(tuple(tunnel.color), (165, 176, 190), 0.32)
pygame.draw.rect(self.screen, border, (screen_x, screen_y, CELL_SIZE, CELL_SIZE), 1)
def draw_land_tree(self, x, y, screen_x, screen_y):
if self.in_cave:
return
tree = self.world.tree_grid.get((x, y))
if tree is None:
return
biome = self.world.get_biome_at(x, y)
if biome is None or biome.name != 'Леса':
return
sprite = self.tree_sprite_cache.get(tree)
if sprite is None:
sprite = generate_tree_sprite(*tree)
self.tree_sprite_cache[tree] = sprite
center = (screen_x + CELL_SIZE // 2, screen_y + CELL_SIZE // 2)
self.screen.blit(sprite, sprite.get_rect(center=center))
def draw_surface_seabed_cell(self, x, y, screen_x, screen_y, biome):
if not getattr(self, 'setting_seabed_details_enabled', True):
return
tile = self._get_seabed_tile_surface(biome, x, y)
if tile is not None:
self.screen.blit(tile, (screen_x, screen_y))
def draw_day_night_overlay(self):
"""Маски света вырезают плавные пятна и лучи в чёрной завесе."""
if not getattr(self, 'setting_water_darkening_enabled', True):
return
if self.in_cave:
alpha = 250
else:
brightness = self._solar_brightness()
if MIDNIGHT_BLACKOUT and min(self._visual_day_time(), 1.0 - self._visual_day_time()) <= MIDNIGHT_BLACKOUT_HALF_WINDOW:
alpha = 255
else:
twilight = max(0.0, min(1.0, (0.72 - brightness) / 0.72))
alpha = round(255 * twilight ** 1.5)
if alpha <= 0:
return
overlay = self.lighting_overlay
overlay.fill((0, 0, 0, alpha))
camera_x, camera_y = self.get_render_camera_origin()
if LIGHTING_ENABLED:
for src in self._collect_light_sources():
sx = int((src['x'] - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = int((src['y'] - camera_y) * CELL_SIZE + CELL_SIZE // 2)
radius = max(3, int(src['radius'] * CELL_SIZE))
if sx < -radius or sy < -radius or sx > self._render_canvas_size + radius or (sy > self._render_canvas_size + radius):
continue
if src['angle'] is None:
light = self._radial_light_sprite(radius, src['power'])
else:
light = self._beam_light_sprite(radius, src['power'], src['angle'], src['width'])
overlay.blit(light, light.get_rect(center=(sx, sy)), special_flags=pygame.BLEND_RGBA_SUB)
self.screen.blit(overlay, (0, 0))
def draw_light_emitters(self, camera_x, camera_y):
"""Яркие сердцевины источников видны даже среди чёрной воды."""
if not LIGHTING_ENABLED:
return
for src in self._collect_light_sources():
if src['kind'] == 'room':
continue
sx = int((src['x'] - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = int((src['y'] - camera_y) * CELL_SIZE + CELL_SIZE // 2)
if not (-20 <= sx <= self._render_canvas_size + 20 and -20 <= sy <= self._render_canvas_size + 20):
continue
color = src['color']
glow = self._emitter_glow_cache.get(color)
if glow is None:
glow = pygame.Surface((50, 50), pygame.SRCALPHA)
pygame.draw.circle(glow, (*color, 16), (25, 25), 23)
pygame.draw.circle(glow, (*color, 27), (25, 25), 14)
pygame.draw.circle(glow, (*color, 75), (25, 25), 7)
self._emitter_glow_cache[color] = glow
self.screen.blit(glow, (sx - 25, sy - 25))
pygame.draw.circle(self.screen, tuple((min(255, channel + 28) for channel in color)), (sx, sy), 2)
def _draw_one_bioglow(self, image, center, alpha):
if image is None:
return
image.set_alpha(max(0, min(255, int(alpha))))
self.screen.blit(image, image.get_rect(center=center))
def draw_bioluminescence(self, camera_x, camera_y):
"""Динамическое свечение ВСЕХ видимых растений и животных.
Работает и в пещерах, и с питомцами, и с высаженной флорой.
Сканер, инвентарь и крафт не меняют свой исходный интерфейс.
"""
if not BIOLUMINESCENCE_ENABLED:
return
night_strength = 1.0 if self.in_cave else max(0.0, 1.0 - self._solar_brightness() * 1.35)
base_alpha = BIOGLOW_DAY_ALPHA + (BIOGLOW_NIGHT_ALPHA - BIOGLOW_DAY_ALPHA) * night_strength
pulse = 0.88 + 0.12 * math.sin(self.frame_count * 0.045)
alpha = round(base_alpha * pulse)
start_x = max(0, int(math.floor(camera_x)) - 2)
start_y = max(0, int(math.floor(camera_y)) - 2)
end_x = min(MAP_SIZE - 1, int(math.ceil(camera_x + self._render_view_radius * 2)) + 2)
end_y = min(MAP_SIZE - 1, int(math.ceil(camera_y + self._render_view_radius * 2)) + 2)
flora_grid = self._current_flora_grid()
object_grid = self._get_current_object_grid()
planter_types = self._planter_types()
for y in range(start_y, end_y + 1):
sy = int(round((y - camera_y) * CELL_SIZE + CELL_SIZE // 2))
for x in range(start_x, end_x + 1):
sx = int(round((x - camera_x) * CELL_SIZE + CELL_SIZE // 2))
flora = flora_grid.get((x, y))
if flora is not None:
self._draw_one_bioglow(flora.glow_image, (sx, sy), alpha)
obj = object_grid.get((x, y))
if obj is not None:
self._draw_one_bioglow(getattr(obj, 'glow_image', None), (sx, sy), alpha)
if obj in planter_types:
planted = self.planted_flora.get(self._planter_key(x, y), ())
offsets = ((0, 1), (-8, -3), (8, -3), (-8, 7), (8, 7), (0, -8), (0, 9))
max_size = 20 if len(planted) <= 2 else 15
for index, plant in enumerate(planted):
if plant.glow_image is None:
continue
key = (plant, max_size)
glow = self._bio_planted_scaled_cache.get(key)
if glow is None:
original = plant.image
scale = min(max_size / max(1, original.get_width()), max_size / max(1, original.get_height()))
target = (max(1, round(plant.glow_image.get_width() * scale)), max(1, round(plant.glow_image.get_height() * scale)))
glow = pygame.transform.smoothscale(plant.glow_image, target)
self._bio_planted_scaled_cache[key] = glow
ox, oy = offsets[index % len(offsets)]
self._draw_one_bioglow(glow, (sx + ox, sy + oy), alpha)
if not self.in_cave and night_strength > 0.6:
for moss in self.world.moss_colonies:
if abs(moss.x - self.player_x) > self._render_view_radius + 2 or abs(moss.y - self.player_y) > self._render_view_radius + 2:
continue
sx = round((moss.x - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = round((moss.y - camera_y) * CELL_SIZE + CELL_SIZE // 2)
pulse = 0.7 + 0.3 * math.sin(self.frame_count * 0.09 + moss.variant * 2)
if pulse > 0.7:
pygame.draw.circle(self.screen, (139, 243, 91), (sx, sy), 1)
if not self.in_cave:
for golem in self.world.golem_colonies:
if golem.phase not in ('active', 'breeding', 'rubble'):
continue
if abs(golem.x - self.player_x) > self._render_view_radius + 2 or abs(golem.y - self.player_y) > self._render_view_radius + 2:
continue
sx = int((golem.x - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = int((golem.y - camera_y) * CELL_SIZE + CELL_SIZE // 2)
gl = {'stone': (116, 240, 218), 'water': (165, 255, 255), 'wood': (139, 251, 111), 'dung': (250, 207, 72)}[golem.kind]
radii = ((2, 0), (2, 9), (2, -9), (8, -4))
if night_strength > 0.5:
for radius, dx in radii:
pygame.draw.circle(self.screen, gl, (sx + dx, sy), radius)
else:
pygame.draw.circle(self.screen, gl, (sx, sy), 1)
for animal in self.get_nearby_animals(self._render_view_radius + 2):
if animal.hp <= 0:
continue
sx = int((animal.x - camera_x) * CELL_SIZE + CELL_SIZE // 2)
sy = int((animal.y - camera_y) * CELL_SIZE + CELL_SIZE // 2)
phase = animal.x * 0.27 + animal.y * 0.39
creature_pulse = 0.93 + 0.07 * math.sin(self.frame_count * 0.052 + phase)
bioglow = animal.rotated_glow or animal.type.glow_image
self._draw_one_bioglow(bioglow, (sx, sy), round(alpha * creature_pulse))
def update(self):
if self.state == 'map':
self.update_map_pan()
self.update_notification()
return
if self.state != 'playing':
self.update_notification()
return
if self.settings_open:
self.update_notification()
return
self.day_time = (self.day_time + self.day_speed) % 1.0
self.elapsed_game_days += self.day_speed
while self.elapsed_game_days + 1e-09 >= self.next_ecosystem_refresh_day:
self.refresh_ecosystem()
self.next_ecosystem_refresh_day += self.ecosystem_refresh_interval_days
keys = pygame.key.get_pressed()
on_land = self._is_player_on_land()
self.player_mode = 'walking' if on_land else 'swimming'
if on_land and self.active_vehicle in (seamoth_type, cyclops_type):
self.active_vehicle = None
self.notification = 'Берег: подлодка убрана, переход на ходьбу'
self.notification_timer = 100
elif (not on_land) and self.active_vehicle in (light_jeep_type, armored_carrier_type):
self.active_vehicle = None
self.notification = 'Наземный транспорт оставлен перед водой или пещерой'
self.notification_timer = 100
if self.active_vehicle is seamoth_type:
move_speed = 0.78
elif self.active_vehicle is cyclops_type:
move_speed = 0.46
elif self.active_vehicle is light_jeep_type:
move_speed = 0.78 * 1.5
elif self.active_vehicle is armored_carrier_type:
move_speed = 0.46
else:
move_speed = WALK_SPEED if on_land else 0.5
if not on_land and self.inventory_counts.get(fins_type, 0) > 0:
move_speed *= 1.15
if not on_land and self.seaglide_on:
move_speed *= 1.55
if self.speed_boost_timer > 0:
move_speed *= 1.35
dx = 0
dy = 0
if keys[pygame.K_w] or keys[pygame.K_UP]:
dy = -move_speed
if keys[pygame.K_s] or keys[pygame.K_DOWN]:
dy = move_speed
if keys[pygame.K_a] or keys[pygame.K_LEFT]:
dx = -move_speed
if keys[pygame.K_d] or keys[pygame.K_RIGHT]:
dx = move_speed
old_player_x = self.player_x
old_player_y = self.player_y
new_x = self.player_x + dx
new_y = self.player_y + dy
# Проходим путь малыми шагами: быстрый джип не перескакивает
# через берег, стену или узкое препятствие между кадрами.
steps = max(1, math.ceil(max(abs(dx), abs(dy)) * 3))
for _step in range(steps):
new_x = self.player_x + dx / steps
new_y = self.player_y + dy / steps
if not (0 <= new_x < MAP_SIZE and 0 <= new_y < MAP_SIZE):
break
if self.in_cave:
if self.cave_is_passable(new_x, new_y):
self.player_x, self.player_y = new_x, new_y
else:
break
else:
target_cell_x, target_cell_y = int(new_x), int(new_y)
target_object = self.world.object_grid.get((target_cell_x, target_cell_y))
biome = self.world.get_biome_at(target_cell_x, target_cell_y)
object_passable = target_object is None or not target_object.is_wall
capsule_wall = self.is_start_capsule_wall(target_cell_x, target_cell_y)
if self.is_start_capsule_passage(target_cell_x, target_cell_y):
biome_passable = True
else:
biome_passable = biome is not None and biome.passable
vehicle_can_pass = (self.active_vehicle is None or
(is_land_biome(biome) if getattr(self.active_vehicle, 'land_vehicle', False)
else (biome is not None and not is_land_biome(biome))))
if object_passable and biome_passable and vehicle_can_pass and not capsule_wall:
self.player_x, self.player_y = new_x, new_y
else:
break
player_move_x = self.player_x - old_player_x
player_move_y = self.player_y - old_player_y
self.player_mode = 'walking' if self._is_player_on_land() else 'swimming'
self._update_whale_quest(old_player_x, old_player_y)
if self.player_mode == 'walking' and abs(player_move_x) + abs(player_move_y) > 0.001:
self.walk_animation += 1
else:
self.walk_animation = 0
mouse_x, mouse_y = pygame.mouse.get_pos()
for decoy in self.active_decoys:
if decoy['in_cave'] != self.in_cave:
continue
screen_x = int((decoy['x'] - camera_x) * CELL_SIZE + CELL_SIZE // 2)
screen_y = int((decoy['y'] - camera_y) * CELL_SIZE + CELL_SIZE // 2)
pulse = 8 + self.frame_count // 6 % 6
pygame.draw.circle(self.screen, (255, 170, 70), (screen_x, screen_y), pulse, 2)
pygame.draw.circle(self.screen, (255, 235, 120), (screen_x, screen_y), 3)
player_screen_x = VIEW_RADIUS * CELL_SIZE + CELL_SIZE // 2
player_screen_y = VIEW_RADIUS * CELL_SIZE + CELL_SIZE // 2
mouse_dx = mouse_x - player_screen_x
mouse_dy = mouse_y - player_screen_y
if mouse_dx != 0 or mouse_dy != 0:
self.player_angle = math.degrees(math.atan2(mouse_dy, mouse_dx))
self.mouse_world_x, self.mouse_world_y = self.screen_to_world(mouse_x, mouse_y)
self._update_vehicle_torpedoes()
self._update_mad_moss()
self._update_golems()
if self.frame_count % 180 == 0:
self._map_biome_species_cache = None
active_radius = VIEW_RADIUS + 2
active_radius_squared = active_radius ** 2
player_x = self.player_x
player_y = self.player_y
active_world = self.cave_world if self.in_cave else self.world
light_sources = self._collect_light_sources()
for animal in tuple(self.cooldown_animals):
if animal.hp <= 0:
self.cooldown_animals.discard(animal)
continue
if animal.attack_timer > 0:
animal.attack_timer -= 1
if animal.attack_timer <= 0:
self.cooldown_animals.discard(animal)
for animal in self.get_nearby_animals(active_radius):
if animal.hp <= 0:
continue
if getattr(animal, 'stasis_timer', 0) > 0:
animal.stasis_timer -= 1
animal.active = True
continue
animal_dx = animal.x - player_x
animal_dy = animal.y - player_y
distance_squared = animal_dx ** 2 + animal_dy ** 2
if distance_squared > active_radius_squared:
animal.active = False
continue
# Full AI near the diver; lower frequency for distant, non-combat fauna.
# Pets, pursuing enemies and large quest fauna never lose responsiveness.
is_pet = getattr(animal, 'is_companion', False)
far_passive = (distance_squared > 49.0 and not is_pet
and animal.type.behavior != 'predator'
and getattr(animal, 'pet_combat_target', None) is None)
if far_passive and (self.frame_count + getattr(animal, 'world_order', 0)) % 4 != 0:
continue
old_animal_x = animal.x
old_animal_y = animal.y
if is_pet:
self._update_companion(animal, player_move_x, player_move_y)
else:
pet_target = getattr(animal, 'pet_combat_target', None)
pet_target_valid = pet_target is not None and getattr(pet_target, 'is_companion', False) and (pet_target.hp > 0) and (pet_target in self.get_current_animals()) and (abs(pet_target.x - player_x) <= VIEW_RADIUS) and (abs(pet_target.y - player_y) <= VIEW_RADIUS) and (animal.type.behavior == 'predator')
if not pet_target_valid:
animal.pet_combat_target = None
pet_target = None
if pet_target is not None:
animal.update(pet_target.x, pet_target.y, active_world)
else:
animal.update(player_x, player_y, active_world)
light_frightened = False
if not getattr(animal, 'is_companion', False) and (getattr(animal, 'pet_combat_target', None) is None):
light_frightened = self._apply_light_behavior(animal, active_world, light_sources)
if not self.in_cave and (not getattr(animal, 'is_companion', False)) and self.is_start_capsule_wall(int(animal.x), int(animal.y)):
animal.x = old_animal_x
animal.y = old_animal_y
animal.dx = -animal.dx
animal.dy = -animal.dy
animal._update_rotation()
if not self.in_cave:
self.world.update_animal_index(animal, old_animal_x, old_animal_y)
if animal.type.behavior == 'predator' and (not getattr(animal, 'is_companion', False)):
same_layer_decoys = [d for d in self.active_decoys if d['in_cave'] == self.in_cave]
if same_layer_decoys:
nearest_decoy = min(same_layer_decoys, key=lambda d: (animal.x - d['x']) ** 2 + (animal.y - d['y']) ** 2)
ddx = nearest_decoy['x'] - animal.x
ddy = nearest_decoy['y'] - animal.y
dist = math.hypot(ddx, ddy)
if 0.1 < dist <= 6.0:
effect_old_x, effect_old_y = (animal.x, animal.y)
candidate_x = animal.x + ddx / dist * animal.type.speed * 1.6
candidate_y = animal.y + ddy / dist * animal.type.speed * 1.6
if animal.home_biome.contains(candidate_x, candidate_y):
animal.x = candidate_x
animal.y = candidate_y
animal.attack_timer = max(animal.attack_timer, 45)
animal._update_rotation()
if not self.in_cave:
self.world.update_animal_index(animal, effect_old_x, effect_old_y)
if self.active_flare_timer > 0 and (not self._is_dark_night()) and (animal.type.name not in LIGHT_ATTRACTION):
flare_dx = animal.x - player_x
flare_dy = animal.y - player_y
flare_dist_sq = flare_dx ** 2 + flare_dy ** 2
if 0.01 < flare_dist_sq <= 49.0:
flare_dist = math.sqrt(flare_dist_sq)
effect_old_x, effect_old_y = (animal.x, animal.y)
candidate_x = animal.x + flare_dx / flare_dist * animal.type.speed * 2.2
candidate_y = animal.y + flare_dy / flare_dist * animal.type.speed * 2.2
if animal.home_biome.contains(candidate_x, candidate_y):
animal.x = candidate_x
animal.y = candidate_y
animal.attack_timer = max(animal.attack_timer, 45)
animal._update_rotation()
if not self.in_cave:
self.world.update_animal_index(animal, effect_old_x, effect_old_y)
if getattr(animal, 'is_companion', False) or animal.type.behavior != 'predator' or (not animal.active) or light_frightened or (animal.type.name == WHALE_NAME):
continue
pet_target = getattr(animal, 'pet_combat_target', None)
if pet_target is not None and getattr(pet_target, 'is_companion', False) and (pet_target.hp > 0) and (pet_target in self.get_current_animals()):
attack_dx = animal.x - pet_target.x
attack_dy = animal.y - pet_target.y
attack_distance_squared = attack_dx ** 2 + attack_dy ** 2
if attack_distance_squared <= 2.25 and animal.attack_timer <= 0:
pet_target.hp -= max(1, int(animal.type.damage))
animal.attack_timer = animal.attack_cooldown
self.cooldown_animals.add(animal)
if pet_target.hp <= 0:
pet_target.hp = 0
self._clear_companion_target(pet_target)
self.remove_current_animal(pet_target)
animal.pet_combat_target = None
self.notification = f'{pet_target.type.name} погиб в бою с {animal.type.name}'
self.notification_timer = 90
continue
attack_dx = animal.x - player_x
attack_dy = animal.y - player_y
attack_distance_squared = attack_dx ** 2 + attack_dy ** 2
if attack_distance_squared <= 2.25 and animal.attack_timer <= 0:
if self.active_vehicle is not None:
self.vehicle_take_damage(animal.type.damage)
else:
self.player_take_damage(animal.type.damage, source=animal.type.name)
animal.attack_timer = animal.attack_cooldown
self.cooldown_animals.add(animal)
self._update_special_creature_attacks()
self.hunger_timer += 1
self.thirst_timer += 1
hunger_loss = 0.2
thirst_loss = 0.2
if self.inventory_counts.get(water_filtration_suit, 0) > 0:
thirst_loss *= 0.7
if self.inventory_counts.get(rebreather_type, 0) > 0:
hunger_loss *= 0.85
if self.hunger_timer >= 60:
self.hunger_timer = 0
self.player_hunger = max(0, self.player_hunger - hunger_loss)
if self.player_hunger <= 0:
self.player_take_damage(1)
if self.thirst_timer >= 60:
self.thirst_timer = 0
self.player_thirst = max(0, self.player_thirst - thirst_loss)
if self.player_thirst <= 0:
self.player_take_damage(1)
self._update_night_plankton()
self._update_base_effects()
self.update_notification()
def _damage_player_or_vehicle(self, damage):
if self.active_vehicle is not None:
self.vehicle_take_damage(damage)
else:
self.player_take_damage(damage)
def _add_special_explosion(self, x, y, kind, in_cave, radius=1.0, duration_ms=420):
now = pygame.time.get_ticks()
self.special_explosions.append({'x': float(x), 'y': float(y), 'kind': kind, 'in_cave': bool(in_cave), 'radius': float(radius), 'created_at': now, 'expires_at': now + int(duration_ms)})
def _spawn_hostile_projectile(self, x, y, target_x, target_y, kind, speed, damage, in_cave, spread=0.0, target_animal=None, companion_owner=None):
dx = target_x - x
dy = target_y - y
length = math.hypot(dx, dy)
if length < 0.001:
angle = random.uniform(0.0, math.tau)
else:
angle = math.atan2(dy, dx)
angle += random.uniform(-spread, spread) if spread else 0.0
vx = math.cos(angle) * speed
vy = math.sin(angle) * speed
self.hostile_projectiles.append({'x': float(x), 'y': float(y), 'vx': vx, 'vy': vy, 'damage': int(damage), 'ttl': 110 if kind == 'quest_wave' else 100 if kind == 'needle' else 90, 'in_cave': bool(in_cave), 'kind': kind, 'hit_radius': 0.7 if kind == 'quest_wave' else 0.3 if kind == 'needle' else 0.43, 'target_animal': target_animal, 'companion_owner': companion_owner})
def _update_special_creature_attacks(self):
"""Шипы Tiger Plant, очереди Морского дракона, газовые шары и взрывы."""
now = pygame.time.get_ticks()
player_x, player_y = (self.player_x, self.player_y)
in_cave = bool(self.in_cave)
flora_grid = self._current_flora_grid()
center_x, center_y = (int(player_x), int(player_y))
for y in range(center_y - 5, center_y + 6):
for x in range(center_x - 5, center_x + 6):
flora = flora_grid.get((x, y))
if flora is None or getattr(flora, 'kind', '') != 'tiger_plant':
continue
dx, dy = (player_x - x, player_y - y)
distance_sq = dx * dx + dy * dy
if distance_sq < 0.15 or distance_sq > 25.0:
continue
key = (in_cave, x, y)
if now < self.plant_attack_timers.get(key, 0):
continue
self._spawn_hostile_projectile(x, y, player_x, player_y, 'needle', speed=0.66, damage=4, in_cave=in_cave, spread=0.025)
self.plant_attack_timers[key] = now + 1150
for animal in self.get_nearby_animals(VIEW_RADIUS + 2):
if animal.hp <= 0:
continue
is_companion = bool(getattr(animal, 'is_companion', False))
target_animal = None
companion_owner = None
if animal.type.name in ('Морской дракон-левиафан', 'Морской дракон-левиафан'):
if is_companion:
target_animal = getattr(animal, 'companion_target', None)
current_animals = self.get_current_animals()
if target_animal is None or target_animal not in current_animals or target_animal.hp <= 0 or getattr(target_animal, 'is_companion', False) or (abs(target_animal.x - player_x) > VIEW_RADIUS) or (abs(target_animal.y - player_y) > VIEW_RADIUS):
continue
companion_owner = animal
target_x, target_y = (target_animal.x, target_animal.y)
distance_sq = (target_x - animal.x) ** 2 + (target_y - animal.y) ** 2
else:
target_x, target_y = (player_x, player_y)
dx, dy = (target_x - animal.x, target_y - animal.y)
distance_sq = dx * dx + dy * dy
if distance_sq > 8.0 * 8.0 or distance_sq < 1.1 * 1.1:
continue
if not hasattr(animal, 'fire_burst_remaining'):
animal.fire_burst_remaining = 0
animal.fire_burst_next_at = now + random.randint(450, 950)
animal.fire_next_attack_at = animal.fire_burst_next_at
if animal.fire_burst_remaining > 0:
if now < animal.fire_burst_next_at:
continue
self._spawn_hostile_projectile(animal.x, animal.y, target_x, target_y, 'fireball', speed=0.44, damage=8, in_cave=in_cave, spread=0.065, target_animal=target_animal, companion_owner=companion_owner)
animal.fire_burst_remaining -= 1
if animal.fire_burst_remaining > 0:
animal.fire_burst_next_at = now + 190
else:
animal.fire_next_attack_at = now + 2100
elif now >= animal.fire_next_attack_at:
animal.fire_burst_remaining = 3
self._spawn_hostile_projectile(animal.x, animal.y, target_x, target_y, 'fireball', speed=0.44, damage=8, in_cave=in_cave, spread=0.065, target_animal=target_animal, companion_owner=companion_owner)
animal.fire_burst_remaining = 2
animal.fire_burst_next_at = now + 190
elif animal.type.name == WHALE_NAME:
if is_companion:
continue
dist_sq = (animal.x - player_x) ** 2 + (animal.y - player_y) ** 2
if dist_sq > 9.0 ** 2 or dist_sq < 0.3 ** 2:
continue
if now < getattr(animal, 'whale_wave_next_at', 0):
continue
self._spawn_hostile_projectile(animal.x, animal.y, player_x, player_y, 'quest_wave', speed=0.28, damage=0, in_cave=in_cave, spread=0.025)
animal.whale_wave_next_at = now + 4500
projectile_survivors = []
for projectile in self.hostile_projectiles:
if projectile['in_cave'] != in_cave:
projectile_survivors.append(projectile)
continue
old_x, old_y = (projectile['x'], projectile['y'])
new_x = old_x + projectile['vx']
new_y = old_y + projectile['vy']
projectile['ttl'] -= 1
if projectile['ttl'] <= 0 or not (0 <= new_x < MAP_SIZE and 0 <= new_y < MAP_SIZE):
continue
if in_cave and (not self.cave_is_passable(new_x, new_y)):
continue
sx, sy = (projectile['vx'], projectile['vy'])
seg_len_sq = sx * sx + sy * sy
target_animal = projectile.get('target_animal')
if target_animal is not None:
if target_animal.hp <= 0 or target_animal not in self.get_current_animals() or getattr(target_animal, 'is_companion', False):
continue
target_x, target_y = (target_animal.x, target_animal.y)
if seg_len_sq > 1e-09:
t = max(0.0, min(1.0, ((target_x - old_x) * sx + (target_y - old_y) * sy) / seg_len_sq))
else:
t = 0.0
closest_x = old_x + sx * t
closest_y = old_y + sy * t
hit_dx = target_x - closest_x
hit_dy = target_y - closest_y
if hit_dx * hit_dx + hit_dy * hit_dy <= (projectile['hit_radius'] + 0.35) ** 2:
target_animal.hp -= projectile['damage']
owner = projectile.get('companion_owner')
if target_animal.hp <= 0:
target_animal.hp = 0
if owner is not None and owner.hp > 0:
self._finish_companion_kill(owner, target_animal)
else:
self.remove_current_animal(target_animal)
if projectile['kind'] == 'fireball':
self._add_special_explosion(new_x, new_y, 'fireball', in_cave, 0.65, 260)
continue
else:
if seg_len_sq > 1e-09:
t = max(0.0, min(1.0, ((player_x - old_x) * sx + (player_y - old_y) * sy) / seg_len_sq))
else:
t = 0.0
closest_x = old_x + sx * t
closest_y = old_y + sy * t
hit_dx = player_x - closest_x
hit_dy = player_y - closest_y
if hit_dx * hit_dx + hit_dy * hit_dy <= projectile['hit_radius'] ** 2:
if projectile['kind'] == 'quest_wave':
self._start_whale_quest()
else:
self._damage_player_or_vehicle(projectile['damage'])
if projectile['kind'] == 'fireball':
self._add_special_explosion(new_x, new_y, 'fireball', in_cave, 0.65, 260)
continue
projectile['x'], projectile['y'] = (new_x, new_y)
projectile_survivors.append(projectile)
self.hostile_projectiles = projectile_survivors
self.special_explosions = [effect for effect in self.special_explosions if now < effect['expires_at']]
def update_notification(self):
if self.notification_timer > 0:
self.notification_timer -= 1
if self.notification_timer == 0:
self.notification = ''
def draw_dropped_item_at(self, x, y, screen_x, screen_y):
dropped = self._current_dropped_items().get((x, y))
if not dropped:
return
item = dropped[-1]
center = (screen_x + CELL_SIZE // 2, screen_y + CELL_SIZE // 2)
if item.image:
image = self.get_inventory_image(item)
rect = image.get_rect(center=center)
self.screen.blit(image, rect)
elif item.shape == 'circle':
pygame.draw.circle(self.screen, item.color, center, min(item.size // 2, CELL_SIZE // 2 - 3))
else:
size = min(item.size, CELL_SIZE - 6)
rect = pygame.Rect(0, 0, size, size)
rect.center = center
pygame.draw.rect(self.screen, item.color, rect)
if len(dropped) > 1:
count_text = self.tiny_font.render(str(len(dropped)), True, (255, 255, 255))
self.screen.blit(count_text, (screen_x + CELL_SIZE - count_text.get_width() - 2, screen_y + CELL_SIZE - count_text.get_height() - 1))
def render(self):
if self.state == 'map':
self.draw_map()
self.draw_damage_vignette()
pygame.display.flip()
return
world_zoom = self.camera_zoom
self._render_view_radius = max(1, math.ceil((SCREEN_SIZE / (CELL_SIZE * world_zoom) - 1) / 2))
self._render_canvas_size = (self._render_view_radius * 2 + 1) * CELL_SIZE
display_surface = pygame.display.get_surface()
if abs(world_zoom - 1.0) < 1e-08:
self.screen = display_surface
else:
need_size = (self._render_canvas_size, self._render_canvas_size)
if self._world_render_surface is None or self._world_render_surface.get_size() != need_size:
self._world_render_surface = pygame.Surface(need_size).convert()
self.screen = self._world_render_surface
if self.lighting_overlay.get_size() != self.screen.get_size():
self.lighting_overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA).convert_alpha()
self.screen.fill((0, 0, 0))
camera_x, camera_y = self.get_render_camera_origin()
start_x = math.floor(camera_x)
start_y = math.floor(camera_y)
end_x = math.ceil(camera_x + self._render_view_radius * 2)
end_y = math.ceil(camera_y + self._render_view_radius * 2)
for y in range(start_y, end_y + 1):
if not 0 <= y < MAP_SIZE:
continue
screen_y = int(round((y - camera_y) * CELL_SIZE))
for x in range(start_x, end_x + 1):
if not 0 <= x < MAP_SIZE:
continue
screen_x = int(round((x - camera_x) * CELL_SIZE))
if screen_x >= self.screen.get_width() or screen_x + CELL_SIZE <= 0 or screen_y >= self.screen.get_height() or screen_y + CELL_SIZE <= 0:
continue
if self.in_cave:
tunnel = self.get_cave_tunnel_at(x, y)
self.draw_cave_cell(x, y, screen_x, screen_y, tunnel)
flora = self.cave_flora_grid.get((x, y))
if flora is not None and flora.image is not None:
flora_rect = flora.image.get_rect(center=(screen_x + CELL_SIZE // 2, screen_y + CELL_SIZE // 2))
self.screen.blit(flora.image, flora_rect)
obj = self.cave_object_grid.get((x, y))
if obj is not None and (not getattr(obj, 'is_buildable', False)):
center_x = screen_x + CELL_SIZE // 2
center_y = screen_y + CELL_SIZE // 2
if obj.image:
rect = obj.image.get_rect(center=(center_x, center_y))
self.screen.blit(obj.image, rect)
elif obj.shape == 'circle':
pygame.draw.circle(self.screen, obj.color, (center_x, center_y), obj.size // 2)
else:
rect = pygame.Rect(0, 0, obj.size, obj.size)
rect.center = (center_x, center_y)
pygame.draw.rect(self.screen, obj.color, rect)
exit_data = self.get_cave_exit_at(x, y)
if exit_data is not None:
pygame.draw.rect(self.screen, (16, 74, 100), (screen_x, screen_y, CELL_SIZE, CELL_SIZE))
pygame.draw.rect(self.screen, (175, 230, 255), (screen_x, screen_y, CELL_SIZE, CELL_SIZE), 2)
self.draw_dropped_item_at(x, y, screen_x, screen_y)
if (x, y) in self.scanner_highlight_cells:
pygame.draw.rect(self.screen, (70, 250, 170), (screen_x + 1, screen_y + 1, CELL_SIZE - 2, CELL_SIZE - 2), 2)
continue
biome = self.world.get_biome_at(x, y)
color = biome.color if biome is not None else (3, 7, 19)
if self.is_aurora_biome(biome):
self.draw_aurora_cell(x, y, screen_x, screen_y, biome)
else:
pygame.draw.rect(self.screen, color, (screen_x, screen_y, CELL_SIZE, CELL_SIZE))
if biome is None:
seed = self._stable_visual_hash('ring_star', x, y)
if seed % 6 == 0:
star_color = ((55, 100, 158), (105, 150, 211), (180, 204, 238))[seed % 3]
star_x = screen_x + 4 + seed // 17 % (CELL_SIZE - 8)
star_y = screen_y + 4 + seed // 71 % (CELL_SIZE - 8)
pygame.draw.circle(self.screen, star_color, (star_x, star_y), 1)
else:
self.draw_surface_seabed_cell(x, y, screen_x, screen_y, biome)
self.draw_start_capsule_cell(x, y, screen_x, screen_y)
self.draw_land_tree(x, y, screen_x, screen_y)
flora = self.world.flora_grid.get((x, y))
if flora is not None and flora.image is not None:
flora_rect = flora.image.get_rect(center=(screen_x + CELL_SIZE // 2, screen_y + CELL_SIZE // 2))
self.screen.blit(flora.image, flora_rect)
obj = self.world.object_grid.get((x, y))
if obj is not None and (not getattr(obj, 'is_buildable', False)):
center_x = screen_x + CELL_SIZE // 2
center_y = screen_y + CELL_SIZE // 2
if obj.image:
rect = obj.image.get_rect(center=(center_x, center_y))
self.screen.blit(obj.image, rect)
elif obj.shape == 'circle':
pygame.draw.circle(self.screen, obj.color, (center_x, center_y), obj.size // 2)
else:
rect = pygame.Rect(0, 0, obj.size, obj.size)
rect.center = (center_x, center_y)
pygame.draw.rect(self.screen, obj.color, rect)
entrance = self.get_surface_cave_entrance_at(x, y)
if entrance is not None:
pygame.draw.rect(self.screen, (8, 8, 12), (screen_x, screen_y, CELL_SIZE, CELL_SIZE))
pygame.draw.rect(self.screen, (40, 45, 55), (screen_x, screen_y, CELL_SIZE, CELL_SIZE), 1)
self.draw_dropped_item_at(x, y, screen_x, screen_y)
if (x, y) in self.scanner_highlight_cells:
pygame.draw.rect(self.screen, (70, 250, 170), (screen_x + 1, screen_y + 1, CELL_SIZE - 2, CELL_SIZE - 2), 2)
foreground_objects = self._get_current_object_grid()
foreground_planters = self._planter_types()
for y in range(start_y, end_y + 1):
if not 0 <= y < MAP_SIZE:
continue
for x in range(start_x, end_x + 1):
if not 0 <= x < MAP_SIZE:
continue
screen_x = int(round((x - camera_x) * CELL_SIZE))
screen_y = int(round((y - camera_y) * CELL_SIZE))
if not self.in_cave and self.is_inside_start_capsule(x, y):
self.draw_start_capsule_cell(x, y, screen_x, screen_y)
obj = foreground_objects.get((x, y))
if obj is None or not getattr(obj, 'is_buildable', False):
continue
center = (screen_x + CELL_SIZE // 2, screen_y + CELL_SIZE // 2)
if obj.image is not None:
self.screen.blit(obj.image, obj.image.get_rect(center=center))
elif obj.shape == 'circle':
pygame.draw.circle(self.screen, obj.color, center, max(1, obj.size // 2))
else:
pygame.draw.rect(self.screen, obj.color, pygame.Rect(0, 0, obj.size, obj.size).move(center[0] - obj.size // 2, center[1] - obj.size // 2))
if obj in foreground_planters:
self._draw_planted_flora_at(x, y, screen_x, screen_y)
player_x = self.player_x
player_y = self.player_y
if self.setting_workers_enabled:
self.foreman.draw(self.screen, camera_x, camera_y)
self._draw_mad_moss(camera_x, camera_y)
self._draw_golems(camera_x, camera_y)
self.draw_vehicle_wrecks(camera_x, camera_y)
self.draw_vehicle_torpedoes(camera_x, camera_y)
for animal in self.get_nearby_animals(self._render_view_radius):
if animal.hp <= 0:
continue
screen_x = int((animal.x - camera_x) * CELL_SIZE + CELL_SIZE // 2)
screen_y = int((animal.y - camera_y) * CELL_SIZE + CELL_SIZE // 2)
animal_type = animal.type
if animal_type.image:
image = animal.rotated_image or animal_type.image
rect = image.get_rect(center=(screen_x, screen_y))
self.screen.blit(image, rect)
elif animal_type.shape == 'circle':
pygame.draw.circle(self.screen, animal_type.color, (screen_x, screen_y), animal_type.size // 2)
else:
rect = pygame.Rect(0, 0, animal_type.size, animal_type.size)
rect.center = (screen_x, screen_y)
pygame.draw.rect(self.screen, animal_type.color, rect)
if getattr(animal, 'is_companion', False):
marker_radius = max(9, min(28, animal_type.size // 2 + 6))
pygame.draw.circle(self.screen, (80, 245, 225), (screen_x, screen_y), marker_radius, 2)
if 0 < animal.hp < animal.max_hp:
bar_width = 20
bar_height = 4
bar_x = screen_x - bar_width // 2
bar_y = screen_y - animal_type.size // 2 - 8
pygame.draw.rect(self.screen, (120, 0, 0), (bar_x, bar_y, bar_width, bar_height))
fill = int(bar_width * animal.hp / animal.max_hp)
pygame.draw.rect(self.screen, (0, 220, 0), (bar_x, bar_y, fill, bar_height))
self._draw_hostile_attacks(camera_x, camera_y)
player_screen_x = self._render_view_radius * CELL_SIZE + CELL_SIZE // 2
player_screen_y = self._render_view_radius * CELL_SIZE + CELL_SIZE // 2
if self.active_vehicle is not None:
vehicle_image = self.get_vehicle_rotated(self.active_vehicle, self.player_angle)
if vehicle_image is not None:
vehicle_rect = vehicle_image.get_rect(center=(player_screen_x, player_screen_y))
self.screen.blit(vehicle_image, vehicle_rect)
else:
if self.equipped_weapon is not None:
weapon_image = self.get_weapon_rotated(self.equipped_weapon, self.player_angle)
if weapon_image:
radians = math.radians(self.player_angle)
offset = CELL_SIZE * 0.55
weapon_x = player_screen_x + math.cos(radians) * offset
weapon_y = player_screen_y + math.sin(radians) * offset
rect = weapon_image.get_rect(center=(int(weapon_x), int(weapon_y)))
self.screen.blit(weapon_image, rect)
diver_image = self.get_diver_rotated(self.player_angle)
diver_rect = diver_image.get_rect(center=(player_screen_x, player_screen_y))
self.screen.blit(diver_image, diver_rect)
pygame.draw.circle(self.screen, (255, 255, 255), (player_screen_x, player_screen_y), CELL_SIZE // 2 + 2, 1)
self.draw_day_night_overlay()
self.draw_light_emitters(camera_x, camera_y)
self.draw_bioluminescence(camera_x, camera_y)
if self.screen is not display_surface:
target_px = max(1, round(self._render_canvas_size * world_zoom))
scaled_world = pygame.transform.smoothscale(self.screen, (target_px, target_px))
self.screen = display_surface
display_surface.fill((0, 0, 0))
screen_offset = (SCREEN_SIZE - target_px) // 2
self.screen.blit(scaled_world, (screen_offset, screen_offset))
if world_zoom > ZOOM_DETAIL_START and self.screen is display_surface:
self._draw_zoom_details(world_zoom, target_px, screen_offset)
self.draw_night_plankton()
self._draw_blue_edge_haze()
self._draw_night_edge_vignette()
self.draw_hud()
self._draw_whale_quest_hud()
if self.state == 'inventory':
self.draw_inventory()
elif self.state == 'crafting':
self.draw_crafting()
elif self.state == 'pets':
self.draw_pet_list()
elif self.state == 'scanner':
self.draw_scanner_view()
elif self.state == 'planting':
self.draw_planting_menu()
if self.settings_open:
self.draw_settings_panel()
if self.state == 'crew_panel' and self.setting_workers_enabled:
self.foreman.panel()
self._draw_whale_quest_popup()
if self.notification_timer > 0 and self.notification and (not self.settings_open):
text = self.large_font.render(self.notification, True, (255, 255, 0))
rect = text.get_rect(center=(SCREEN_SIZE // 2, SCREEN_SIZE // 2 + 100))
pygame.draw.rect(self.screen, (0, 0, 0), rect.inflate(20, 10))
self.screen.blit(text, rect)
if self.state == 'playing':
mouse_x, mouse_y = pygame.mouse.get_pos()
pygame.draw.line(self.screen, (255, 255, 255), (mouse_x - 8, mouse_y), (mouse_x + 8, mouse_y), 2)
pygame.draw.line(self.screen, (255, 255, 255), (mouse_x, mouse_y - 8), (mouse_x, mouse_y + 8), 2)
pygame.draw.circle(self.screen, (255, 255, 255), (mouse_x, mouse_y), 3, 1)
self.draw_damage_vignette()
pygame.display.flip()
def _create_damage_vignette_surface(self):
"""Prebuild a red edge gradient whose centre stays fully transparent."""
vignette = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE), pygame.SRCALPHA)
fade_width = max(90, int(SCREEN_SIZE * 0.34))
for inset in range(fade_width):
t = 1.0 - inset / max(1, fade_width - 1)
alpha = int(235 * t ** 2.15)
if alpha <= 0:
continue
w = SCREEN_SIZE - inset * 2
h = SCREEN_SIZE - inset * 2
if w <= 0 or h <= 0:
break
pygame.draw.rect(vignette, (235, 8, 8, alpha), pygame.Rect(inset, inset, w, h), 1)
return vignette.convert_alpha()
def draw_damage_vignette(self):
"""Tint screen edges red according to lost HP, with a brief boost on each hit."""
if self.max_player_hp <= 0:
return
missing_ratio = 1.0 - max(0.0, min(1.0, self.player_hp / self.max_player_hp))
if missing_ratio <= 0.0 and self.damage_vignette_flash <= 0:
return
if self.damage_vignette_surface is None:
self.damage_vignette_surface = self._create_damage_vignette_surface()
persistent_alpha = 0
if missing_ratio > 0.0:
persistent_alpha = int(38 + 182 * missing_ratio ** 0.78)
flash_alpha = 0
if self.damage_vignette_flash > 0:
flash_t = self.damage_vignette_flash / 18.0
flash_alpha = int(255 * flash_t ** 0.55)
self.damage_vignette_flash -= 1
alpha = max(persistent_alpha, flash_alpha)
self.damage_vignette_surface.set_alpha(max(0, min(255, alpha)))
self.screen.blit(self.damage_vignette_surface, (0, 0))
self.damage_vignette_surface.set_alpha(None)
def _settings_option_rects(self):
"""Единая геометрия для отображения настроек и обработки мыши."""
panel = pygame.Rect((SCREEN_SIZE - 520) // 2, (SCREEN_SIZE - 400) // 2, 520, 400)
return [pygame.Rect(panel.x + 24, panel.y + 84 + index * 58, 472, 48)
for index in range(4)]
def draw_settings_panel(self):
"""Модальная панель настроек, включая одиночное прохождение без NPC."""
overlay = pygame.Surface((SCREEN_SIZE, SCREEN_SIZE), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 175))
self.screen.blit(overlay, (0, 0))
panel = pygame.Rect((SCREEN_SIZE - 520) // 2, (SCREEN_SIZE - 400) // 2, 520, 400)
pygame.draw.rect(self.screen, (13, 32, 47), panel, border_radius=12)
pygame.draw.rect(self.screen, (74, 174, 205), panel, 2, border_radius=12)
title = self.large_font.render('Настройки', True, (225, 247, 255))
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 36)))
subtitle = self.small_font.render('N — закрыть панель', True, (155, 190, 205))
self.screen.blit(subtitle, subtitle.get_rect(center=(panel.centerx, panel.y + 62)))
options = [
('Отключить планктон', not self.setting_plankton_enabled),
('Отключить мелкие объекты на дне', not self.setting_seabed_details_enabled),
('Отключить затемнение воды', not self.setting_water_darkening_enabled),
('Отключить всех рабочих', not self.setting_workers_enabled),
]
for rect, (label, disabled) in zip(self._settings_option_rects(), options):
pygame.draw.rect(self.screen, (25, 55, 72), rect, border_radius=7)
pygame.draw.rect(self.screen, (69, 112, 132), rect, 1, border_radius=7)
check = pygame.Rect(rect.x + 14, rect.y + 12, 24, 24)
pygame.draw.rect(self.screen, (8, 20, 29), check, border_radius=4)
pygame.draw.rect(self.screen, (124, 205, 221), check, 2, border_radius=4)
if disabled:
pygame.draw.line(self.screen, (255, 190, 105), (check.x + 5, check.y + 12), (check.x + 10, check.y + 18), 3)
pygame.draw.line(self.screen, (255, 190, 105), (check.x + 10, check.y + 18), (check.x + 20, check.y + 5), 3)
text = self.font.render(label, True, (230, 241, 247))
self.screen.blit(text, text.get_rect(midleft=(rect.x + 52, rect.centery)))
hint = self.tiny_font.render('Отключение рабочих — одиночный режим. Прогресс бригады не теряется.',
True, (153, 183, 195))
self.screen.blit(hint, hint.get_rect(center=(panel.centerx, panel.bottom - 16)))
def draw_hud(self):
hp_text = self.font.render(f'HP: {self.player_hp}/{self.max_player_hp}', True, (255, 255, 255))
self.screen.blit(hp_text, (10, 10))
bar_width = 100
bar_height = 12
bar_x = 10
pygame.draw.rect(self.screen, (100, 0, 0), (bar_x, 35, bar_width, bar_height))
fill = int(bar_width * self.player_hp / self.max_player_hp)
pygame.draw.rect(self.screen, (0, 200, 0), (bar_x, 35, fill, bar_height))
hunger_text = self.font.render(f'Голод: {int(self.player_hunger)}', True, (255, 255, 255))
self.screen.blit(hunger_text, (10, 55))
pygame.draw.rect(self.screen, (50, 50, 50), (bar_x, 75, bar_width, bar_height))
fill = int(bar_width * self.player_hunger / self.max_hunger)
pygame.draw.rect(self.screen, (0, 255, 0), (bar_x, 75, fill, bar_height))
thirst_text = self.font.render(f'Жажда: {int(self.player_thirst)}', True, (255, 255, 255))
self.screen.blit(thirst_text, (10, 95))
pygame.draw.rect(self.screen, (50, 50, 50), (bar_x, 115, bar_width, bar_height))
fill = int(bar_width * self.player_thirst / self.max_thirst)
pygame.draw.rect(self.screen, (0, 100, 255), (bar_x, 115, fill, bar_height))
if self.active_vehicle is not None:
vehicle_hp = self.vehicle_hp.get(self.active_vehicle, 0)
vehicle_max = self.vehicle_max_hp[self.active_vehicle]
text = self.font.render(f"{self.active_vehicle.name} HP: {vehicle_hp}/{vehicle_max} | Фары L: {('ВКЛ' if self.vehicle_headlights_on else 'ВЫКЛ')}", True, (120, 225, 245))
self.screen.blit(text, (10, 140))
pygame.draw.rect(self.screen, (75, 30, 30), (10, 165, 130, 10))
vehicle_fill = int(130 * vehicle_hp / max(1, vehicle_max))
pygame.draw.rect(self.screen, (45, 190, 215), (10, 165, vehicle_fill, 10))
elif self.equipped_weapon is not None:
text = self.font.render(f'Оружие: {self.equipped_weapon.name}', True, (230, 220, 80))
self.screen.blit(text, (10, 140))
stats = self.small_font.render(f'Урон: {self.equipped_weapon.damage} | Дальность: {self.equipped_weapon.weapon_range}', True, (210, 210, 210))
self.screen.blit(stats, (10, 165))
layer_text = self.small_font.render('Слой: пещера' if self.in_cave else 'Слой: поверхность', True, (180, 220, 240))
self.screen.blit(layer_text, (10, 190))
locomotion_text = self.small_font.render('Передвижение: ХОДЬБА' if self._is_player_on_land() else 'Передвижение: ПЛАВАНИЕ', True, (231, 215, 157) if self._is_player_on_land() else (125, 226, 252))
self.screen.blit(locomotion_text, (10, 210))
zoom_text = self.tiny_font.render(f'Камера: {self.camera_zoom:.2f}x | Колесо мыши: +/-', True, (160, 220, 245))
self.screen.blit(zoom_text, (SCREEN_SIZE - zoom_text.get_width() - 10, 35))
time_text = self.small_font.render(self.get_time_string(), True, (235, 235, 250))
time_rect = time_text.get_rect()
time_rect.topright = (SCREEN_SIZE - 10, 10)
self.screen.blit(time_text, time_rect)
statuses = []
if self.flashlight_on:
statuses.append('Фонарик')
if self.seaglide_on:
statuses.append('Глайдер')
if self.active_flare_timer > 0:
statuses.append('Ракета')
if self.damage_resist_timer > 0:
statuses.append('Защита')
if self.speed_boost_timer > 0:
statuses.append('Рывок')
if statuses:
status_text = self.small_font.render('Активно: ' + ', '.join(statuses), True, (255, 220, 120))
self.screen.blit(status_text, (10, 215))
hint = self.small_font.render('C — вход/всплыть | M — карта | R — инвентарь | F — транспорт | T — питомцы | I — крафт | U — использовать | ЛКМ — торпеда в судне | ПКМ — сканер/посадка', True, (210, 210, 210))
self.screen.blit(hint, (10, SCREEN_SIZE - 25))
def draw_pet_list(self):
self.screen.blit(self.inventory_overlay, (0, 0))
pets = self._get_all_companions()
total = len(pets)
max_page = max(0, (total - 1) // self.pets_per_page)
self.pet_list_page = min(self.pet_list_page, max_page)
if self.selected_pet not in pets:
self.selected_pet = None
title = self.large_font.render(f'Питомцы: {total}', True, (120, 245, 225))
self.screen.blit(title, title.get_rect(center=(SCREEN_SIZE // 2, 47)))
for i, instruction in enumerate(('1–9 — выбрать | E — вернуть в инвентарь | U — лечить (+35 HP/аптечка)', 'T или ESC — закрыть | ←/→ — страницы')):
subtitle = self.small_font.render(instruction, True, (215, 225, 230))
self.screen.blit(subtitle, subtitle.get_rect(center=(SCREEN_SIZE // 2, 77 + i * 24)))
if not pets:
empty = self.small_font.render('Нет питомцев. Выберите существо в инвентаре и нажмите H.', True, (225, 225, 225))
self.screen.blit(empty, empty.get_rect(center=(SCREEN_SIZE // 2, SCREEN_SIZE // 2)))
return
start = self.pet_list_page * self.pets_per_page
page_pets = pets[start:start + self.pets_per_page]
y = 130
row_height = 47
for number, pet in enumerate(page_pets, start=1):
selected = pet is self.selected_pet
row_rect = pygame.Rect(14, y - 3, SCREEN_SIZE - 28, row_height - 3)
pygame.draw.rect(self.screen, (24, 85, 76) if selected else (14, 35, 43), row_rect, border_radius=6)
if selected:
pygame.draw.rect(self.screen, (95, 255, 199), row_rect, width=2, border_radius=6)
layer = 'пещера' if pet in self.cave_animals else 'поверхность'
visible_here = pet in self.get_current_animals() and abs(pet.x - self.player_x) <= VIEW_RADIUS and (abs(pet.y - self.player_y) <= VIEW_RADIUS)
state_text = getattr(pet, 'companion_state', 'следует за игроком')
if not visible_here and pet in self.get_current_animals():
state_text = 'ждёт вне видимости'
follow_distance = self._companion_follow_distance(pet)
hp_text = f'HP {max(0, int(pet.hp))}/{int(pet.max_hp)}'
text_color = (170, 255, 204) if selected else (235, 240, 245)
line_1 = self.small_font.render(f'{number}. {pet.type.name} | {hp_text}', True, text_color)
line_2 = self.tiny_font.render(f'{layer} | дистанция {follow_distance:.1f} | {state_text}', True, (177, 207, 216))
self.screen.blit(line_1, (24, y))
self.screen.blit(line_2, (42, y + 24))
y += row_height
page_text = self.small_font.render(f'Страница {self.pet_list_page + 1}/{max_page + 1}', True, (180, 210, 220))
self.screen.blit(page_text, page_text.get_rect(center=(SCREEN_SIZE // 2, SCREEN_SIZE - 33)))
def draw_inventory(self):
self.screen.blit(self.inventory_overlay, (0, 0))
start_x, start_y, total_width, total_height = self._inventory_grid_layout()
title = self.small_font.render('Инвентарь (R/ESC — закрыть)', True, (255, 255, 255))
title_rect = title.get_rect(center=(start_x + total_width // 2, start_y - 30))
self.screen.blit(title, title_rect)
used = sum((1 for slot in self.inventory if slot is not None))
counter = self.font.render(f'{used} / {INVENTORY_SIZE}', True, (200, 200, 200))
counter_rect = counter.get_rect(center=(start_x + total_width // 2, start_y + total_height + 20))
self.screen.blit(counter, counter_rect)
if self.active_slot_index != -1:
hint = self.small_font.render('U — использовать | P — выбросить', True, (210, 210, 210))
hint_rect = hint.get_rect(center=(start_x + total_width // 2, start_y + total_height + 45))
self.screen.blit(hint, hint_rect)
companion_hint = self.small_font.render('H — питомец | F — транспорт | Q — оружие', True, (120, 235, 225))
companion_hint_rect = companion_hint.get_rect(center=(start_x + total_width // 2, start_y + total_height + 68))
self.screen.blit(companion_hint, companion_hint_rect)
eat_hint = self.small_font.render('ПКМ — съесть рыбу | B — построить', True, (255, 215, 125))
eat_hint_rect = eat_hint.get_rect(center=(start_x + total_width // 2, start_y + total_height + 91))
self.screen.blit(eat_hint, eat_hint_rect)
for row in range(INVENTORY_ROWS):
for col in range(INVENTORY_COLS):
index = row * INVENTORY_COLS + col
x = start_x + col * (SLOT_SIZE + SLOT_MARGIN)
y = start_y + row * (SLOT_SIZE + SLOT_MARGIN)
pygame.draw.rect(self.screen, (55, 55, 65), (x, y, SLOT_SIZE, SLOT_SIZE))
border_color = (0, 255, 0) if index == self.active_slot_index else (110, 110, 120)
pygame.draw.rect(self.screen, border_color, (x, y, SLOT_SIZE, SLOT_SIZE), 3)
item = self.inventory[index]
if item is None:
continue
center = (x + SLOT_SIZE // 2, y + SLOT_SIZE // 2)
if item.image:
image = self.get_inventory_image(item)
rect = image.get_rect(center=center)
self.screen.blit(image, rect)
elif item.shape == 'circle':
pygame.draw.circle(self.screen, item.color, center, min(item.size // 2, SLOT_SIZE // 2 - 5))
else:
size = min(item.size, SLOT_SIZE - 8)
rect = pygame.Rect(0, 0, size, size)
rect.center = center
pygame.draw.rect(self.screen, item.color, rect)
if item.is_weapon:
weapon_mark = self.small_font.render('W', True, (255, 230, 0))
self.screen.blit(weapon_mark, (x + 3, y + 2))
self._draw_whale_quest_inventory_column()
def draw_crafting(self):
self.screen.blit(self.crafting_overlay, (0, 0))
title = self.large_font.render('Крафт', True, (255, 255, 255))
self.screen.blit(title, title.get_rect(center=(SCREEN_SIZE // 2, 30)))
location_name = self.get_crafting_location_name()
location_text = self.tiny_font.render(f"Зона крафта: {location_name or 'недоступна'}", True, (120, 230, 245))
self.screen.blit(location_text, location_text.get_rect(center=(SCREEN_SIZE // 2, 55)))
station_recipes = self.get_current_station_recipes()
self.max_craft_page = max(0, (len(station_recipes) - 1) // self.recipes_per_page)
self.craft_page = min(self.craft_page, self.max_craft_page)
start = self.craft_page * self.recipes_per_page
end = min(start + self.recipes_per_page, len(station_recipes))
current_recipes = station_recipes[start:end]
y = 82
for index, recipe in enumerate(current_recipes):
can_craft = recipe.can_craft(self.inventory_counts)
color = (80, 255, 100) if can_craft else (255, 100, 100)
output_suffix = f' x{recipe.result_count}' if recipe.result_count > 1 else ''
title_text = self.small_font.render(f'{index + 1}. {recipe.name}{output_suffix}', True, color)
self.screen.blit(title_text, (40, y))
ingredients = ', '.join((f'{item.name} x{count}' for item, count in recipe.ingredients.items()))
meta = f'[{recipe.station} / {recipe.category}] ' + ingredients
ingredient_text = self.tiny_font.render(meta, True, (210, 210, 210))
self.screen.blit(ingredient_text, (55, y + 21))
y += 52
page_text = self.small_font.render(f'Страница {self.craft_page + 1}/{self.max_craft_page + 1} | ← → | TAB: станция', True, (220, 220, 220))
self.screen.blit(page_text, page_text.get_rect(center=(SCREEN_SIZE // 2, SCREEN_SIZE - 30)))
def run(self):
while self.running:
self.handle_events()
self.update()
self.render()
self.clock.tick(60)
pygame.quit()
sys.exit()
# >>> FASTENER_FISH_MOD_V1 >>>
# -*- coding: utf-8 -*-
"""МегоСубнатика2D: семейство рыб-крепежей.
Этот файл предназначен для встраивания установщиком
install_fastener_fish.py в исходный однофайловый скрипт игры.
Для интеграции используется только публичная структура версии
МегоСубнатика2D_големогенные_бактерии (19.10.2026).
"""
def install_fastener_fish(game_ns):
"""Подключить виды, рецепты и механику к основному модулю игры."""
import math
import random
import pygame
required = ('Game', 'Animal', 'AnimalType', 'ObjectType', 'Recipe',
'init_assets', 'biomes')
# biomes появляется при выполнении init_assets(), поэтому проверяем
# только имена типов и методов, существующие во время импорта.
missing = [name for name in required if name != 'biomes' and name not in game_ns]
if missing:
raise RuntimeError('Мод рыб-крепежей: неподдерживаемая версия игры: '
+ ', '.join(missing))
if game_ns.get('_FASTENER_FISH_INSTALLED'):
return
game_ns['_FASTENER_FISH_INSTALLED'] = True
AnimalType = game_ns['AnimalType']
ObjectType = game_ns['ObjectType']
Recipe = game_ns['Recipe']
Animal = game_ns['Animal']
Game = game_ns['Game']
rand = random.Random(20261020)
KINDS = ('selftap', 'screw', 'nail', 'driver')
BIOMES = {
'Мелководье': (5, 2, 6, 1),
'Глубоководье': (3, 4, 1, 2),
'Центральное море': (4, 2, 3, 2),
'Коралловый риф': (10, 5, 3, 3),
'Кельповый лес': (10, 8, 4, 3),
'Био-люминесцентная зона': (3, 4, 3, 4),
'Озёра (внешние)': (3, 2, 4, 1),
'Реки': (2, 1, 3, 1),
'Притоки': (1, 0, 1, 0),
'Водопады': (0, 0, 1, 0),
}
FISH_INFO = {
'selftap': ('Рыба-саморез', (158, 187, 199), 12, .13),
'screw': ('Рыба-шуруп', (186, 149, 115), 16, .09),
'nail': ('Рыба-гвоздь', (196, 199, 218), 8, .19),
'driver': ('Рыба-отвёртка', (234, 150, 49), 22, .16),
}
GLOW = {
'selftap': (108, 222, 235),
'screw': (248, 184, 95),
'nail': (161, 188, 242),
'driver': (248, 222, 122),
}
fish_by_kind = {}
materials = {}
def notice(game, message, ticks=95):
game.notification = message
game.notification_timer = ticks
def distance_sq(ax, ay, bx, by):
return (ax-bx)**2 + (ay-by)**2
def polished_sprite(kind):
"""Детальная модель без внешних PNG: цветной металлический организм."""
s = pygame.Surface((176, 88), pygame.SRCALPHA)
draw = pygame.draw
black = (25, 35, 49)
spec = FISH_INFO[kind]
base = spec[1]
shine = tuple(min(255, c + 56) for c in base)
dim = tuple(max(0, c - 64) for c in base)
fish_blue = (85, 185, 211)
y = 44
if kind in ('selftap', 'screw'):
# Дорсальные/вентральные плавники, выполняющие роль ключа.
draw.polygon(s, fish_blue, [(61, 37), (80, 12), (92, 35), (118, 28), (110, 44)])
draw.polygon(s, (44, 119, 159), [(67, 52), (87, 76), (100, 52), (117, 57)])
draw.polygon(s, black, [(24, 28), (129, 31), (162, 44), (129, 57), (24, 60)])
draw.polygon(s, base, [(24, 31), (128, 34), (157, 44), (128, 54), (24, 57)])
draw.line(s, shine, (27, 36), (126, 36), 4)
draw.line(s, dim, (28, 55), (128, 51), 3)
# Реалистичная резьба на цилиндре.
for x in range(48, 129, 11):
draw.lines(s, black, False, [(x-5, 33), (x+5, 54)], 4)
draw.lines(s, shine, False, [(x-2, 33), (x+8, 53)], 2)
# Шляпка и надрез под отвёртку.
head_color = (125, 141, 157) if kind == 'selftap' else (169, 112, 71)
draw.rect(s, black, (12, 28, 22, 33), border_radius=5)
draw.rect(s, head_color, (15, 30, 17, 29), border_radius=4)
draw.line(s, (239, 226, 200), (22, 33), (22, 57), 2)
if kind == 'screw':
draw.line(s, (40, 50, 62), (16, 44), (30, 44), 3)
draw.line(s, (40, 50, 62), (22, 36), (22, 52), 3)
else:
draw.line(s, (40, 50, 62), (16, 44), (30, 44), 3)
eye_x = 135
elif kind == 'nail':
# Уплощённая широкая шляпка + длинное узкое заострённое тело.
draw.polygon(s, (89, 169, 219), [(80, 38), (94, 17), (111, 37)])
draw.polygon(s, (80, 126, 177), [(77, 49), (97, 69), (113, 50)])
draw.polygon(s, black, [(23, 39), (143, 39), (166, 44), (143, 49), (23, 49)])
draw.polygon(s, base, [(23, 40), (141, 40), (163, 44), (141, 48), (23, 48)])
draw.line(s, shine, (25, 41), (144, 41), 2)
draw.rect(s, black, (14, 33, 13, 22), border_radius=3)
draw.rect(s, (140, 148, 170), (16, 35, 9, 18), border_radius=2)
for x in (71, 87, 103):
draw.line(s, dim, (x, 42), (x+3, 47), 1)
eye_x = 142
else:
# Рыба-отвёртка: шестиугольная рукоятка-жаберный корпус.
draw.polygon(s, fish_blue, [(65, 33), (85, 10), (96, 35), (118, 33)])
draw.polygon(s, (54, 139, 194), [(67, 56), (84, 78), (106, 52)])
draw.polygon(s, black, [(14, 40), (18, 27), (81, 27), (96, 38),
(151, 40), (168, 44), (151, 49),
(96, 51), (81, 61), (18, 61)])
draw.polygon(s, (202, 104, 31), [(16, 39), (19, 31), (81, 31),
(93, 41), (93, 49), (81, 57),
(19, 57)])
draw.line(s, (255, 200, 100), (24, 34), (79, 34), 5)
for x in (29, 42, 55, 68):
draw.line(s, (90, 58, 51), (x, 34), (x+7, 54), 3)
draw.rect(s, (151, 165, 174), (94, 40, 58, 9))
draw.line(s, (235, 240, 243), (102, 42), (149, 42), 2)
draw.polygon(s, (110, 124, 146), [(152, 39), (169, 43), (169, 46), (152, 49)])
eye_x = 83
# Глаза остаются узнаваемо живыми, включая биолюминесценцию.
eye_y = 38 if kind in ('selftap', 'screw') else 39
draw.circle(s, black, (eye_x, eye_y), 6)
draw.circle(s, GLOW[kind], (eye_x, eye_y), 4)
draw.circle(s, (254, 255, 249), (eye_x-1, eye_y-1), 2)
# Мельчайшие металлические пятна-чешуйки.
for i in range(12):
x = 40 + i * 8
if (kind == 'nail' and i > 10) or (kind == 'driver' and i > 5):
break
draw.circle(s, (246, 240, 215, 140), (x, 44), 1)
return s.convert_alpha()
def anchor_head_sprite(kind, diameter=176):
"""Вкрученная рыба: круглая шляпка с настоящим крестовым шлицем."""
s = pygame.Surface((diameter, diameter), pygame.SRCALPHA)
d = pygame.draw
cx = cy = diameter // 2
r = diameter * 0.46
base = FISH_INFO[kind][1]
dark = tuple(max(0, c - 90) for c in base)
pale = tuple(min(255, c + 70) for c in base)
d.circle(s, (18, 29, 39, 245), (cx + 2, cy + 3), round(r + 2))
d.circle(s, dark, (cx, cy), round(r))
d.circle(s, base, (cx, cy), round(r * 0.91))
for j in range(18):
radius = r * (0.86 - j * .021)
shade = tuple(min(255, round(c*(.77+j*.016))) for c in base)
d.circle(s, shade, (cx, cy), round(radius), max(1,round(diameter / 160)))
d.circle(s, pale, (cx, cy), round(r*.83), max(1,round(diameter*.024)))
slot = max(2, round(diameter * .07))
arm = round(diameter * .26)
# Тёмная канавка-крест и металлические световые кромки.
d.line(s, (18, 28, 39), (cx-arm, cy), (cx+arm, cy), slot)
d.line(s, (18, 28, 39), (cx, cy-arm), (cx, cy+arm), slot)
off = max(1,round(diameter*.016))
d.line(s, pale, (cx-arm, cy-slot//2-off), (cx+arm, cy-slot//2-off), off)
d.line(s, pale, (cx-slot//2-off, cy-arm), (cx-slot//2-off, cy+arm), off)
d.circle(s, (24, 33, 44), (cx, cy), max(1, slot//3))
return s.convert_alpha()
def tool_sprite(kind, amount=1):
s = pygame.Surface((64, 64), pygame.SRCALPHA)
d = pygame.draw
if kind == 'driver':
d.line(s, (32, 45, 54), (12, 51), (52, 11), 8)
d.line(s, (187, 202, 212), (22, 41), (52, 11), 5)
d.line(s, (247, 170, 56), (10, 53), (25, 38), 10)
d.circle(s, (250, 209, 111), (10, 53), 5)
else:
palette = {'selftap': (162, 201, 226), 'screw': (219, 167, 112),
'nail': (190, 190, 209), 'broken': (151, 145, 160)}
color = palette[kind]
for i in range(min(6, amount)):
x = 13 + (i % 3) * 17
y = 12 + (i // 3) * 24
d.line(s, (43, 49, 64), (x+2, y+2), (x+6, y+19), 6)
d.line(s, color, (x+2, y+2), (x+6, y+19), 3)
d.line(s, (245, 229, 207), (x-2, y+2), (x+6, y+2), 3)
return s.convert_alpha()
def register():
for kind, (name, color, hp, speed) in FISH_INFO.items():
fish = AnimalType(name, color, 32, speed=speed, hp=hp,
behavior='neutral', fish_style=True,
fish_length=120, fish_width=34,
volume=1, edible=False,
zigzag_strength=.15 if kind == 'nail' else .08,
speed_variation=.23)
fish.fastener_hd_image = polished_sprite(kind)
fish.fastener_anchor_hd_image = anchor_head_sprite(kind)
fish.fastener_anchor_image = pygame.transform.smoothscale(
fish.fastener_anchor_hd_image, (44, 44)).convert_alpha()
# Мир имеет клетки ~32 px: рыба не должна быть 5 клеток длиной.
fish.image = pygame.transform.smoothscale(
fish.fastener_hd_image, (84, 42)).convert_alpha()
fish.rotated_cache.clear()
fish.glow_image = None # Не подсвечивать металл целиком ночью.
fish.glow_rotated_cache.clear()
fish.fastener_kind = kind
fish_by_kind[kind] = fish
game_ns['fastener_' + kind + '_type'] = fish
screwdriver = ObjectType('Отвёртка', (233, 164, 57), 26, 'rect',
is_weapon=True, damage=3,
weapon_range=1.5, weapon_type='ближний бой')
screwdriver.image = tool_sprite('driver')
materials['driver'] = screwdriver
game_ns['fastener_screwdriver_type'] = screwdriver
for kind, name, count in (('selftap', 'Набор саморезов', 4),
('screw', 'Набор шурупов', 4),
('nail', 'Набор гвоздей', 6)):
item = ObjectType(name, FISH_INFO[kind][1], 23, 'rect')
item.image = tool_sprite(kind, count)
materials[kind] = item
game_ns['fastener_pack_' + kind] = item
broken = ObjectType('Сломанный саморез', (114, 113, 125), 16, 'rect')
broken.image = tool_sprite('broken', 2)
materials['broken'] = broken
game_ns['fastener_broken_type'] = broken
# Входные материалы — сами рыбы. Наборы имеют несколько единиц крепежа,
# но занимают ОДНУ клетку инвентаря.
recipes = game_ns['recipes']
recipes.extend([
Recipe('Отвёртка', {fish_by_kind['driver']: 1,
game_ns['titan_type']: 1}, screwdriver,
1, 'Изготовитель', 'Инструменты'),
Recipe('Набор саморезов (4)', {fish_by_kind['selftap']: 2},
materials['selftap'], 1, 'Изготовитель', 'Крепёж'),
Recipe('Набор шурупов (4)', {fish_by_kind['screw']: 2},
materials['screw'], 1, 'Изготовитель', 'Крепёж'),
Recipe('Набор гвоздей (6)', {fish_by_kind['nail']: 3},
materials['nail'], 1, 'Изготовитель', 'Крепёж'),
Recipe('Титан из сломанного крепежа',
{broken: 3}, game_ns['titan_type'], 1,
'Изготовитель', 'Переработка'),
])
# Только сложные постройки, а не базовые стены/люки, требуют крепежа.
fastening = {
'Многоцелевая комната': {'screw': 1, 'selftap': 1},
'Большая комната': {'screw': 2, 'selftap': 1},
'Стыковочная шахта': {'screw': 2, 'selftap': 1},
'Комната сканирования': {'screw': 1, 'selftap': 1},
'Обсерватория': {'selftap': 1},
'Большой аквариум': {'selftap': 1},
'Биореактор': {'screw': 1},
'Ядерный реактор': {'screw': 2, 'selftap': 1},
'Водоочистная станция': {'screw': 1},
'Теплоэлектростанция': {'screw': 1},
'Солнечная панель': {'selftap': 1},
'Стационарный воздушный насос': {'selftap': 1},
'Консоль улучшения транспорта': {'screw': 1},
'Модификационная станция': {'screw': 1},
'Переносной сборщик транспорта': {'screw': 1},
'Шкаф': {'nail': 1},
'Стеллаж для растений': {'nail': 1},
'Внутренняя грядка': {'nail': 1},
'Наружная грядка': {'nail': 1},
}
for recipe in recipes:
needs = fastening.get(recipe.name)
if needs is None:
continue
for kind, quantity in needs.items():
ingredient = materials[kind]
recipe.ingredients[ingredient] = (
recipe.ingredients.get(ingredient, 0) + quantity)
recipe.requires_fastener_tool = True
recipe.result.requires_fastener_assembly = True
# Каждая особь попадает в реальные игровые биомы,
# а не только в интерфейс карты.
for biome in game_ns['biomes']:
if game_ns['is_land_biome'](biome) or getattr(biome, 'aurora_style', False):
continue
densities = BIOMES.get(biome.name)
if densities:
for kind, count in zip(KINDS, densities):
if count:
biome.animals.append((fish_by_kind[kind], count))
registry = game_ns.get('START_ITEM_REGISTRY')
if isinstance(registry, dict):
for key, val in list(fish_by_kind.items()) + list(materials.items()):
registry[val.name] = val
registry['fastener_' + key] = val
old_init_assets = game_ns['init_assets']
def enhanced_init_assets():
old_init_assets()
fish_by_kind.clear()
materials.clear()
register()
game_ns['init_assets'] = enhanced_init_assets
old_recipe_can_craft = Recipe.can_craft
def fastener_can_craft(self, counts):
if getattr(self, 'requires_fastener_tool', False):
tool = materials.get('driver')
if tool is None or counts.get(tool, 0) < 1:
return False
return old_recipe_can_craft(self, counts)
Recipe.can_craft = fastener_can_craft
# Защитить прикрученную рыбу от любого источника урона, даже торпед,
# урона питомцев и прочих модулей, пока её не выкрутили.
def protected_get_hp(self):
return self.__dict__.get('_fastener_protected_hp', self.__dict__.get('hp', 0))
def protected_set_hp(self, value):
previous = self.__dict__.get('_fastener_protected_hp', self.__dict__.get('hp', value))
if (getattr(self, 'fastener_anchored', False)
and getattr(getattr(self, 'type', None), 'fastener_kind', None) in KINDS
and value < previous):
return
self.__dict__['_fastener_protected_hp'] = value
Animal.hp = property(protected_get_hp, protected_set_hp)
old_rotation = Animal._update_rotation
def fastener_rotation(self):
if (getattr(self, 'fastener_anchored', False)
and getattr(self.type, 'fastener_anchor_image', None) is not None):
self.rotated_image = self.type.fastener_anchor_image
self.rotated_glow = None
self.visual_angle = 0
return
return old_rotation(self)
Animal._update_rotation = fastener_rotation
old_animal_update = Animal.update
def fastener_animal_update(self, player_x, player_y, world):
if getattr(self, 'fastener_anchored', False) and not getattr(
self, 'is_companion', False):
self.active = (abs(self.x-player_x) < game_ns['VIEW_RADIUS'] + 3
and abs(self.y-player_y) < game_ns['VIEW_RADIUS'] + 3)
self.dx = self.dy = 0
return
return old_animal_update(self, player_x, player_y, world)
Animal.update = fastener_animal_update
def _is_fastener(animal):
return getattr(animal.type, 'fastener_kind', None) in KINDS
def _unfasten(animal, frame):
animal.fastener_anchored = False
animal.fastener_anchor = None
animal.fastener_next_anchor = frame + random.randint(180, 450)
animal.dx = math.cos(animal.swim_heading) * animal.type.speed
animal.dy = math.sin(animal.swim_heading) * animal.type.speed
animal._update_rotation()
def _anchor_target(game, fish):
# Один «поиск отверстия» выполняется не чаще одного раза за 30 кадров.
# Вводится различие характеров: гвоздь любит обломки/постройки,
# саморез — кораллы и водоросли, шуруп — конструкции.
cx, cy = int(fish.x), int(fish.y)
flora = game._current_flora_grid()
structures = game._get_current_object_grid()
kind = fish.type.fastener_kind
candidates = []
for y in range(cy-1, cy+2):
for x in range(cx-1, cx+2):
dist = distance_sq(fish.x, fish.y, x+.5, y+.5)
if dist > 1.1:
continue
plant = flora.get((x, y))
if plant is not None and kind in ('selftap', 'screw'):
candidates.append((dist + (.25 if kind == 'screw' else 0), x, y,
'flora'))
obj = structures.get((x, y))
if obj is not None and (kind in ('nail', 'screw')
or getattr(obj, 'is_buildable', False)):
candidates.append((dist + (-.1 if kind == 'nail' else 0),
x, y, 'object'))
if not candidates:
return None
_, x, y, category = min(candidates)
return x, y, category
def _fastener_step(game):
frame = getattr(game, 'frame_count', 0)
if game.state != 'playing' or getattr(game, 'settings_open', False):
return
stuck = getattr(game, '_fastener_player_stuck', 0)
if stuck and frame % 140 == 0:
game._damage_player_or_vehicle(min(3, 1 + stuck // 2))
notice(game, f'В вас вкручено рыб-саморезов: {stuck}! '
'Выберите отвёртку и нажмите U', 80)
if frame % 12 != 0:
return
nearby = [a for a in game.get_nearby_animals(10)
if a.hp > 0 and _is_fastener(a)
and not getattr(a, 'is_companion', False)]
if not nearby:
return
active_world = game.cave_world if game.in_cave else game.world
drivers = [a for a in nearby if a.type.fastener_kind == 'driver']
targets = [a for a in nearby if a.type.fastener_kind != 'driver']
# Хищная отвертка определяет ближайшую цель (даже закрученную).
for driver in drivers:
victim = min(targets, key=lambda a: distance_sq(a.x, a.y, driver.x, driver.y),
default=None)
if victim is None:
continue
ds = distance_sq(victim.x, victim.y, driver.x, driver.y)
if ds > 6.5 ** 2:
continue
if ds < .8 ** 2:
if getattr(victim, 'fastener_anchored', False):
# Кооператив: три и более рыбы, зацепившиеся за одно
# сооружение, мешают природной отвёртке их выкрутить.
anchor = getattr(victim, 'fastener_anchor', None)
allies = sum(1 for other in targets
if getattr(other, 'fastener_anchored', False)
and getattr(other, 'fastener_anchor', None) == anchor)
if anchor is not None and anchor[2] == 'object' \
and allies >= 3 and rand.random() < .72:
driver.fastener_hunt_cooldown = frame + 80
continue
_unfasten(victim, frame)
if distance_sq(victim.x, victim.y, game.player_x,
game.player_y) < 5:
notice(game, 'Рыба-отвёртка выкрутила '
+ victim.type.name.lower() + '!', 80)
# Отвёртка разгоняет соседей, но не уничтожает весь вид.
victim.fastener_next_anchor = frame + 180
victim.swim_heading = math.atan2(victim.y-driver.y,
victim.x-driver.x)
victim.cruise_heading = victim.swim_heading
driver.fastener_hunt_cooldown = frame + 100
continue
if frame < getattr(driver, 'fastener_hunt_cooldown', 0):
continue
old_x, old_y = driver.x, driver.y
heading = math.atan2(victim.y-driver.y, victim.x-driver.x)
step = driver.type.speed * 1.45 * 12
nx = driver.x + math.cos(heading)*min(step, math.sqrt(ds))
ny = driver.y + math.sin(heading)*min(step, math.sqrt(ds))
# Делим ускорение на маленькие микрошаги: сквозь стену не плавать.
segments = max(1, math.ceil(math.hypot(nx-driver.x,ny-driver.y)/.15))
for i in range(segments):
px = old_x+(nx-old_x)*(i+1)/segments
py = old_y+(ny-old_y)*(i+1)/segments
if driver._can_swim_to(px, py, active_world):
driver.x, driver.y = px, py
else:
break
if not game.in_cave:
game.world.update_animal_index(driver, old_x, old_y)
driver.dx = math.cos(heading)*driver.type.speed
driver.dy = math.sin(heading)*driver.type.speed
driver.swim_heading = heading
driver._update_rotation()
# Рыбы-гвозди сбиваются в косяк и синхронизируют направление рывков.
nails = [a for a in targets if a.type.fastener_kind == 'nail'
and not getattr(a, 'fastener_anchored', False)]
for nail in nails:
mates = [b for b in nails if b is not nail and
distance_sq(b.x, b.y, nail.x, nail.y) < 2.2**2]
if len(mates) >= 2:
ax = math.cos(nail.swim_heading) + sum(
math.cos(b.swim_heading) for b in mates)
ay = math.sin(nail.swim_heading) + sum(
math.sin(b.swim_heading) for b in mates)
desired = math.atan2(ay, ax)
nail.cruise_heading = (nail.cruise_heading * .55
+ desired * .45)
# Экологическая механика самостоятельного закручивания.
for fish in targets:
if getattr(fish, 'fastener_anchored', False):
anchor = getattr(fish, 'fastener_anchor', None)
if anchor is not None:
ax, ay, category = anchor
substrate = (game._current_flora_grid() if category == 'flora'
else game._get_current_object_grid())
if substrate.get((ax, ay)) is None:
_unfasten(fish, frame)
continue
if frame < getattr(fish, 'fastener_next_anchor', 0):
continue
if rand.random() > .17:
continue
# Саморез способен вкрутиться прямо в игрока.
if (fish.type.fastener_kind == 'selftap'
and distance_sq(fish.x, fish.y, game.player_x,
game.player_y) < .75 ** 2
and rand.random() < .09):
if rand.random() < .075:
game.remove_current_animal(fish)
game._current_dropped_items().setdefault(
(int(fish.x), int(fish.y)), []).append(materials['broken'])
notice(game, 'Саморез сломался при завинчивании!', 95)
else:
game.remove_current_animal(fish)
game._fastener_player_stuck = min(6,
getattr(game, '_fastener_player_stuck', 0) + 1)
notice(game, 'Рыба-саморез вкрутилась в вас! '
'Отвёртка + U для извлечения.', 110)
continue
target = _anchor_target(game, fish)
if target is None:
continue
x, y, surface = target
if (fish.type.fastener_kind == 'selftap'
and rand.random() < .03):
game.remove_current_animal(fish)
game._current_dropped_items().setdefault(
(int(fish.x), int(fish.y)), []).append(materials['broken'])
continue
old_x, old_y = fish.x, fish.y
fish.x, fish.y = x+.5, y+.5
fish.fastener_anchored = True
fish.fastener_anchor = (x, y, surface)
fish.fastener_next_anchor = frame + 900
# При изменении чанка важно обновить пространственный индекс.
if not game.in_cave:
game.world.update_animal_index(fish, old_x, old_y)
fish.dx = fish.dy = 0
fish._update_rotation()
# Скреплённые «рыболюбы» придают постройке дополнительную прочность.
# Прочность носит декоративный/защитный характер (в оригинале у
# построек нет числовых HP), зато охотники могут освободить рыб.
old_game_init = Game.__init__
def enhanced_game_init(self, *args, **kwargs):
old_game_init(self, *args, **kwargs)
self._fastener_player_stuck = 0
# В пещерах обитают собственные популяции, независимо от поверхности.
count = 0
for tunnel in getattr(self, 'cave_tunnels', []):
if count >= 55:
break
if not hasattr(tunnel, 'random_point'):
continue
if random.random() > .23:
continue
for _ in range(10):
x, y = tunnel.random_point()
if self.get_cave_tunnel_at(x, y) is not tunnel:
continue
if self.cave_world.get_object_at(x, y) is not None:
continue
kind = random.choices(KINDS, weights=(4, 3, 3, 2), k=1)[0]
a = Animal(x, y, fish_by_kind[kind], tunnel)
a.fastener_next_anchor = self.frame_count + random.randint(90, 480)
self.cave_animals.append(a)
count += 1
break
# Игрок может прописать новых рыб или отвёртку в START_INVENTORY.
Game.__init__ = enhanced_game_init
old_game_update = Game.update
def enhanced_game_update(self, *args, **kwargs):
old_game_update(self, *args, **kwargs)
if getattr(self, 'running', True):
_fastener_step(self)
Game.update = enhanced_game_update
old_game_use = Game.use_item
def enhanced_game_use(self, *args, **kwargs):
ix = getattr(self, 'active_slot_index', -1)
if ix is None or ix < 0 or ix >= len(self.inventory):
return old_game_use(self, *args, **kwargs)
if self.inventory[ix] is not materials.get('driver'):
return old_game_use(self, *args, **kwargs)
if self.state != 'playing':
return
if getattr(self, '_fastener_player_stuck', 0) > 0:
self._fastener_player_stuck -= 1
notice(self, 'Саморез выкручен! Осталось: '
+ str(self._fastener_player_stuck))
return
close = [a for a in self.get_nearby_animals(2.2)
if a.hp > 0 and _is_fastener(a)
and getattr(a, 'fastener_anchored', False)
and not getattr(a, 'is_companion', False)
and distance_sq(a.x, a.y, self.player_x,
self.player_y) <= 2.2**2]
if close:
fish = min(close, key=lambda a: distance_sq(a.x,a.y,
self.player_x,self.player_y))
if self.add_to_inventory(fish.type, wild_catch=True):
self.remove_current_animal(fish)
notice(self, f'Отвёрткой выкручен и пойман: {fish.type.name}')
else:
notice(self, 'Инвентарь полон: рыбку выкрутить некуда')
return
notice(self, 'Нет вкрученной рыбы поблизости (радиус 2,2 клетки)')
Game.use_item = enhanced_game_use
# При закреплении иконка сразу становится круглой. При выкручивании
# обычная плавательная модель вновь появляется в исходных пропорциях.
old_game_pick_up = Game.pick_up
def enhanced_pick_up(self, *args, **kwargs):
# Прикрученных рыб нельзя подобрать без инструмента.
for fish in self.get_nearby_animals(1.15):
if (_is_fastener(fish) and fish.hp > 0
and getattr(fish, 'fastener_anchored', False)
and int(fish.x) == int(self.player_x)
and int(fish.y) == int(self.player_y)):
notice(self, 'Рыба прикручена! Возьмите отвёртку и нажмите U')
return
return old_game_pick_up(self, *args, **kwargs)
Game.pick_up = enhanced_pick_up
old_game_build = Game.build
def enhanced_build(self, *args, **kwargs):
ix = getattr(self, 'active_slot_index', -1)
if ix is not None and 0 <= ix < len(self.inventory):
item = self.inventory[ix]
if (item is not None
and getattr(item, 'requires_fastener_assembly', False)
and self.inventory_counts.get(materials.get('driver'), 0) == 0):
notice(self, 'Для сборки сложной конструкции нужна отвёртка!')
return
return old_game_build(self, *args, **kwargs)
Game.build = enhanced_build
old_game_attack = Game.attack
def enhanced_attack(self, *args, **kwargs):
if self.equipped_weapon is materials.get('driver'):
previous = self.player_attack_damage
self.player_attack_damage = 3 # слабее обычного ножа
try:
return old_game_attack(self, *args, **kwargs)
finally:
self.player_attack_damage = previous
return old_game_attack(self, *args, **kwargs)
Game.attack = enhanced_attack
# Информация при сканировании видов хранится в читаемом виде;
# действующие игровые справочники обновляются без удаления старых данных.
info = {
'Рыба-саморез': 'Спиральный организм. Завинчивается в водоросли, кораллы, постройки и иногда в дайверов; при заклинивании ломается.',
'Рыба-шуруп': 'Более медленный родственник самореза. Любит древесину и затонувшие конструкции. Служит сырьём для набора шурупов.',
'Рыба-гвоздь': 'Самая быстрая из крепёжных рыб. Специализируется на застревании в предметах и собирается в опасные косяки.',
'Рыба-отвёртка': 'Природный враг крепёжных рыб. Выкручивает тех из укрытий. Пойманная особь служит основой многоразового инструмента.',
}
game_ns['FASTENER_FISH_DESCRIPTIONS'] = info
game_ns['FASTENER_FISH_BIOMES'] = BIOMES
game_ns['FASTENER_FISH_TYPES'] = fish_by_kind
game_ns['FASTENER_FISH_MATERIALS'] = materials
# В самом конце установщик добавляет: install_fastener_fish(globals())
install_fastener_fish(globals())
# <<< FASTENER_FISH_MOD_V1 <<<
# >>> FUNGUS_NUCLI_MOD_V1: Ядрёный гриб >>>
FUNGUS_NUCLI_NAME = 'Ядрёный гриб'
FUNGUS_NUCLI_LATIN = 'Fungus nucli'
FUNGUS_NUCLI_HP = 10
FUNGUS_NUCLI_DAMAGE = 250
FUNGUS_NUCLI_METERS_PER_CELL = 10.0
FUNGUS_NUCLI_RADIUS_METERS = 50.0
FUNGUS_NUCLI_RADIUS_CELLS = FUNGUS_NUCLI_RADIUS_METERS / FUNGUS_NUCLI_METERS_PER_CELL
FUNGUS_NUCLI_FUSE_SECONDS = (10.0, 15.0)
FUNGUS_NUCLI_SELF_DESTRUCT_DISTANCE = 1.4
def generate_fungus_nucli_sprite(size=48):
"""Детализированный гриб сверху: радиальные пластинки и ядерное ядро."""
size = max(34, int(size))
surf = pygame.Surface((size, size), pygame.SRCALPHA)
cx = cy = size // 2
r = size * 0.38
draw = pygame.draw
draw.circle(surf, (23, 43, 25), (cx, cy), round(r + 2))
for i in range(16):
a = math.tau * i / 16
a2 = a + 0.16
outer = (round(cx + r * math.cos(a)), round(cy + r * math.sin(a)))
inner = (round(cx + r * 0.27 * math.cos(a2)), round(cy + r * 0.27 * math.sin(a2)))
draw.line(surf, (72, 116, 55) if i % 2 else (134, 160, 44), inner, outer, max(2, size // 20))
draw.circle(surf, (39, 74, 38), (cx, cy), round(r * 0.7))
draw.circle(surf, (128, 174, 53), (cx, cy), round(r * 0.54), 2)
for i in range(7):
a = math.tau * i / 7
x = round(cx + math.cos(a) * r * 0.48)
y = round(cy + math.sin(a) * r * 0.48)
draw.circle(surf, (211, 220, 65), (x, y), max(2, size // 17))
draw.circle(surf, (247, 250, 150), (x - 1, y - 1), 1)
draw.circle(surf, (9, 35, 19), (cx, cy), round(r * 0.29))
draw.circle(surf, (112, 255, 65), (cx, cy), round(r * 0.21))
draw.circle(surf, (225, 255, 152), (cx - 2, cy - 2), max(2, size // 13))
return surf
def install_fungus_nucli(game_ns):
original_assets = game_ns['init_assets']
def init_assets_with_fungus():
original_assets()
fungus = AnimalType(FUNGUS_NUCLI_NAME, (120, 205, 54), 42,
speed=0.0, behavior='neutral', hp=FUNGUS_NUCLI_HP,
damage=0, edible=False, volume=1)
fungus.image = generate_fungus_nucli_sprite(46)
fungus.glow_image = generate_bioluminescence_sprite(
fungus.image, 'acid_mushroom', is_plant=True)
game_ns['fungus_nucli_type'] = fungus
game_ns['FUNGUS_NUCLI_DESCRIPTIONS'] = {
FUNGUS_NUCLI_NAME: ('Fungus nucli. Неподвижное взрывное существо, 10 HP. '
'После гибели либо самоуничтожения детонирует через 10–15 секунд. '
'Радиус 50 м, урон 250 HP.')}
game_ns['init_assets'] = init_assets_with_fungus
old_generate_animals = World._generate_animals
def generate_animals_with_fungus(self):
old_generate_animals(self)
fungus = game_ns['fungus_nucli_type']
rng = random.Random(734175)
for biome in self.biomes:
if not biome.passable or is_land_biome(biome):
continue
if not any(token in biome.name.lower() for token in
('риф', 'биолюмин', 'гриб', 'глубин', 'вулкан')):
continue
quantity = min(16, max(2, int(biome.area() / 800)))
for _ in range(quantity):
for _attempt in range(60):
x, y = biome.random_point(rng)
if self.get_biome_at(int(x), int(y)) is biome:
self.animals.append(Animal(x, y, fungus, biome))
break
World._generate_animals = generate_animals_with_fungus
original_death = Game.handle_animal_death
def nuclear_death(self, animal):
if animal.type.name != FUNGUS_NUCLI_NAME:
return original_death(self, animal)
if getattr(animal, 'fungus_armed', False):
return
animal.fungus_armed = True
animal.hp = 0
animal.fungus_detonation_at = pygame.time.get_ticks() + random.randint(10000, 15000)
animal.fungus_in_cave = self.in_cave
self.nuclear_fuses.append(animal)
self.notification = 'Ядрёный гриб взведён! Покиньте радиус 50 м!'
self.notification_timer = 135
Game.handle_animal_death = nuclear_death
old_game_init = Game.__init__
def init_with_fungus(self):
old_game_init(self)
self.nuclear_fuses = []
self.nuclear_blasts = []
self.nuclear_craters = [] # (world_x, world_y, cave, expiry_ms)
self.nuclear_flashes = [] # время вспышки, координаты и уровень
Game.__init__ = init_with_fungus
old_game_update = Game.update
def update_with_fungus(self):
old_game_update(self)
if self.state != 'playing' or self.settings_open:
return
now = pygame.time.get_ticks()
if not self.in_cave:
for animal in self.world.get_animals_near(self.player_x, self.player_y, 2.5):
if (animal.type.name == FUNGUS_NUCLI_NAME and animal.hp > 0
and not getattr(animal, 'fungus_armed', False)
and math.hypot(animal.x - self.player_x,
animal.y - self.player_y) <= FUNGUS_NUCLI_SELF_DESTRUCT_DISTANCE):
self.handle_animal_death(animal)
for fungus in self.nuclear_fuses[:]:
if now < fungus.fungus_detonation_at:
continue
self.nuclear_fuses.remove(fungus)
self.nuclear_blasts.append((fungus.x, fungus.y, now + 850, fungus.fungus_in_cave))
self.nuclear_craters.append((fungus.x, fungus.y, fungus.fungus_in_cave, now + 10000))
self.nuclear_flashes.append((now, fungus.x, fungus.y, fungus.fungus_in_cave))
# Урон по игроку и его транспорту — только на той же карте/уровне.
if self.in_cave == fungus.fungus_in_cave and math.hypot(
self.player_x - fungus.x, self.player_y - fungus.y) <= FUNGUS_NUCLI_RADIUS_CELLS:
self._damage_player_or_vehicle(FUNGUS_NUCLI_DAMAGE)
population = self.cave_animals if fungus.fungus_in_cave else self.world.animals
for other in tuple(population):
if other is fungus or other.hp <= 0:
continue
if math.hypot(other.x - fungus.x, other.y - fungus.y) <= FUNGUS_NUCLI_RADIUS_CELLS:
other.hp -= FUNGUS_NUCLI_DAMAGE
if other.hp <= 0:
# Взрыв грибов вызывает цепное взведение с новой задержкой.
if other.type.name == FUNGUS_NUCLI_NAME:
self.handle_animal_death(other)
else:
# Трупы не выдаются игроку автоматически.
self.remove_current_animal(other) if (self.in_cave == fungus.fungus_in_cave) else (
population.remove(other) if other in population else None)
if fungus in population:
population.remove(fungus)
if not fungus.fungus_in_cave:
self.world.rebuild_animal_index()
self.notification = 'Ядрёный гриб: ВЗРЫВ!'
self.notification_timer = 75
self.nuclear_craters = [c for c in self.nuclear_craters if c[3] > now]
self.nuclear_blasts = [b for b in self.nuclear_blasts if b[2] > now]
self.nuclear_flashes = [f for f in self.nuclear_flashes if now - f[0] < 4000]
Game.update = update_with_fungus
original_render = Game.render
def render_with_fungus(self):
original_render(self)
if self.state != 'playing':
return
now = pygame.time.get_ticks()
camera_x = self.player_x - getattr(self, '_render_view_radius', VIEW_RADIUS)
camera_y = self.player_y - getattr(self, '_render_view_radius', VIEW_RADIUS)
for fungus in self.nuclear_fuses:
if fungus.fungus_in_cave != self.in_cave:
continue
sx = int((fungus.x - camera_x) * CELL_SIZE + CELL_SIZE / 2)
sy = int((fungus.y - camera_y) * CELL_SIZE + CELL_SIZE / 2)
if not (-90 <= sx <= SCREEN_SIZE + 90 and -90 <= sy <= SCREEN_SIZE + 90):
continue
seconds = max(0, (fungus.fungus_detonation_at - now) / 1000)
flashing = int(now / max(75, 430 * seconds / 15)) % 2 == 0
col = (255, 55, 32) if flashing else (255, 215, 65)
pygame.draw.circle(self.screen, col, (sx, sy), 13, 3)
label = self.small_font.render(f'{seconds:.1f} с', True, col)
self.screen.blit(label, label.get_rect(center=(sx, sy - 28)))
for x, y, expiry, cave in self.nuclear_blasts:
if cave != self.in_cave:
continue
progress = 1 - max(0, expiry - now) / 850
sx = int((x - camera_x) * CELL_SIZE + CELL_SIZE / 2)
sy = int((y - camera_y) * CELL_SIZE + CELL_SIZE / 2)
radius = max(1, int(FUNGUS_NUCLI_RADIUS_CELLS * CELL_SIZE * progress))
pygame.draw.circle(self.screen, (255, 205, 65), (sx, sy), radius, 4)
pygame.draw.circle(self.screen, (255, 105, 25), (sx, sy), max(2, radius // 2), 3)
# Эти эффекты рисуются на итоговой поверхности, чтобы зум и интерфейс
# не мешали общей вспышке. Воронка исчезает через 10 секунд.
display = pygame.display.get_surface()
zoom = getattr(self, 'camera_zoom', 1.0)
for x, y, cave, expiry in self.nuclear_craters:
if cave != self.in_cave:
continue
sx = int(display.get_width() / 2 + (x - self.player_x) * CELL_SIZE * zoom)
sy = int(display.get_height() / 2 + (y - self.player_y) * CELL_SIZE * zoom)
radius = max(4, int(1.35 * CELL_SIZE * zoom))
if sx < -radius or sy < -radius or sx > display.get_width()+radius or sy > display.get_height()+radius:
continue
pygame.draw.circle(display, (32, 24, 19), (sx, sy), radius)
pygame.draw.circle(display, (94, 65, 43), (sx, sy), radius, max(2, int(5*zoom)))
pygame.draw.circle(display, (12, 16, 19), (sx, sy), max(2, int(radius*.67)))
for a in range(12):
angle = math.tau*a/12
x1 = sx + math.cos(angle)*radius*.84
y1 = sy + math.sin(angle)*radius*.84
x2 = sx + math.cos(angle)*radius*1.22
y2 = sy + math.sin(angle)*radius*1.22
pygame.draw.line(display, (89, 62, 45), (x1,y1), (x2,y2), max(1,int(2*zoom)))
# 4 секунды: начальная ослепительная вспышка и нелинейное затухание.
# Взрывы на другом уровне не ослепляют игрока.
strengths = [(1.0 - (now - t)/4000.0)**2 for t,x,y,cave in self.nuclear_flashes
if cave == self.in_cave and now-t < 4000
and math.hypot(x-self.player_x, y-self.player_y) <= 16]
if strengths:
alpha = min(245, int(245 * max(strengths)))
flash = pygame.Surface(display.get_size(), pygame.SRCALPHA)
flash.fill((255, 247, 213, alpha))
display.blit(flash, (0, 0))
if self.nuclear_fuses or self.nuclear_blasts or self.nuclear_flashes or self.nuclear_craters:
pygame.display.flip()
Game.render = render_with_fungus
install_fungus_nucli(globals())
# <<< FUNGUS_NUCLI_MOD_V1 <<<
# >>> AVIS_PACHOMICA_MOD_V1: Птица Говносрун и горох >>>
# 1 г гороха -> 1 т токсичного помёта; 100 HP за тонну.
DUNGBIRD_NAME = 'Птица Говносрун'
DUNGBIRD_LATIN = 'Avis pachomica'
DUNGBIRD_SPEED = 4.0 / 60.0 # 4 игровые клетки в секунду при 60 FPS
DUNGBIRD_MAX_TONNES = 10.0
DUNGBIRD_DAMAGE_PER_TONNE = 100
DUNGBIRD_BURST_MIN = 10
DUNGBIRD_BURST_MAX = 30
DUNGBIRD_SHOT_INTERVAL_MS = 500 # Полсекунды между соседними шарами.
DUNGBIRD_SHOT_SPEED = 0.32
DUNGBIRD_SPREAD_RADIANS = math.radians(14)
DUNGBIRD_FRAGMENT_MIN = 3
DUNGBIRD_FRAGMENT_MAX = 7
DUNGBIRD_MAX_ACTIVE_PROJECTILES = 850
PEA_REGROW_MS = 25000
def generate_pea_plant_sprite(size=38, ripe=True):
"""Небольшой куст гороха с усиками, листьями и созревающими стручками."""
s = max(28, int(size))
surf = pygame.Surface((s, s), pygame.SRCALPHA)
d = pygame.draw
k = s / 40.0
def pt(x, y):
return (round(x * k), round(y * k))
d.line(surf, (47, 112, 47), pt(20, 37), pt(19, 8), max(2, round(3*k)))
for x, y, endx, endy in ((19, 28, 8, 19), (19, 23, 32, 14),
(19, 16, 10, 7), (20, 13, 30, 7)):
d.line(surf, (60, 139, 57), pt(x, y), pt(endx, endy), max(1, round(2*k)))
cx, cy = pt(endx, endy)
rx = max(3, round(7*k))
ry = max(2, round(4*k))
d.ellipse(surf, (53, 160, 68), (cx-rx, cy-ry, 2*rx, 2*ry))
d.ellipse(surf, (108, 199, 80), (cx-rx+1, cy-ry, rx+2, ry))
for coords in (((19, 9), (21, 3), (25, 3)), ((32, 14), (36, 17), (34, 20))):
d.lines(surf, (127, 196, 91), False, [pt(*p) for p in coords], max(1, round(k)))
if ripe:
for x, y, tilt in ((7, 21, -1), (28, 23, 1), (15, 8, -1)):
stem_end = pt(x + 3 * tilt, y - 5)
d.line(surf, (43, 114, 47), stem_end, pt(x, y), max(1, round(k)))
px, py = pt(x, y)
w, h = max(5, round(9*k)), max(8, round(13*k))
d.ellipse(surf, (39, 116, 39), (px-w//2, py-h//2, w, h))
d.ellipse(surf, (120, 215, 68), (px-w//2+1, py-h//2, max(3,w-2), h-2))
for off in (-3, 0, 3):
gx, gy = pt(x, y+off)
d.circle(surf, (168, 233, 90), (gx, gy), max(1, round(1.4*k)))
return surf
def generate_dungbird_sprite(size=34):
"""Вид сверху: коричневая птица с крыльями, хвостом и жёлтым клювом."""
s = max(30, int(size))
surf = pygame.Surface((s, s), pygame.SRCALPHA)
d = pygame.draw
k = s / 40.0
def pt(x, y):
return (round(x*k), round(y*k))
def polygon(color, coords):
d.polygon(surf, color, [pt(x, y) for x, y in coords])
polygon((84, 72, 60), [(12, 19), (3, 12), (1, 8), (11, 14), (13, 11), (17, 21)])
polygon((94, 81, 68), [(12, 22), (2, 30), (2, 35), (12, 29), (16, 26)])
polygon((106, 93, 81), [(14, 19), (7, 3), (12, 6), (22, 17), (23, 22)])
polygon((111, 95, 80), [(14, 23), (9, 38), (14, 35), (23, 24)])
polygon((172, 155, 132), [(15, 13), (24, 13), (31, 17), (31, 23),
(23, 29), (15, 26), (12, 20)])
polygon((222, 202, 166), [(26, 16), (32, 17), (33, 22), (25, 22)])
polygon((229, 181, 64), [(31, 18), (39, 20), (31, 23)])
d.circle(surf, (22, 23, 18), pt(29, 16), max(1, round(1.6*k)))
d.circle(surf, (249, 242, 212), pt(29, 16), 1)
return surf
def install_dungbird_and_peas(game_ns):
if game_ns.get('_DUNGBIRD_PEA_INSTALLED'):
return
game_ns['_DUNGBIRD_PEA_INSTALLED'] = True
old_init_assets = game_ns['init_assets']
def init_assets_with_peas():
old_init_assets()
# Используем штатные типы: горох попадает в сканер и меню посадки.
pea = FloraType('Горох', 'acid_mushroom', 38, 31415)
pea.kind = 'pea'
pea.image = generate_pea_plant_sprite(38, True)
pea.glow_image = None
bare = FloraType('Горох', 'acid_mushroom', 38, 31416)
bare.kind = 'pea'
bare.image = generate_pea_plant_sprite(38, False)
bare.glow_image = None
flora_types['pea'] = pea
pod = ObjectType('Стручок гороха', (98, 198, 66), 16,
edible=True, hunger_value=6, thirst_value=2)
pod.image = pygame.transform.smoothscale(generate_pea_plant_sprite(38, True), (24, 24))
bird = AnimalType(DUNGBIRD_NAME, (163, 139, 113), 25,
speed=DUNGBIRD_SPEED, behavior='neutral', hp=25,
damage=0, edible=False, volume=1)
bird.is_dungbird = True
bird.fish_style = True # спрайт ориентирован клювом вправо
bird.image = generate_dungbird_sprite(34)
bird.glow_image = None
bird.inflated_cache = {}
game_ns['pea_flora_type'] = pea
game_ns['pea_bare_type'] = bare
game_ns['pea_pod_type'] = pod
game_ns['dungbird_type'] = bird
game_ns['init_assets'] = init_assets_with_peas
old_generate_flora = World._generate_flora
def generate_flora_with_peas(self):
old_generate_flora(self)
self.pea_patches = {}
self.pea_ready_at = {}
rng = random.Random(20261025)
densities = {'Леса': 8.0, 'Острова': 5.0, 'Побережье': 4.0, 'Горы': 2.0}
pea = game_ns['pea_flora_type']
bird_type = game_ns['dungbird_type']
for biome in self.biomes:
if not biome.passable or biome.name not in densities:
continue
count = min(230, max(3, round(biome.area()/400.0*densities[biome.name])))
positions = []
for _ in range(count * 14):
if len(positions) >= count:
break
rx, ry = biome.random_point(rng)
key = (int(rx), int(ry))
if (self.get_biome_at(*key) is not biome or key in self.object_grid
or key in self.flora_grid or key in self.tree_grid):
continue
self.flora_grid[key] = pea
positions.append(key)
if not positions:
continue
self.pea_patches[biome] = positions
# Стаи рядом с кормовыми растениями; обычная подводная фауна не меняется.
for _ in range(min(11, max(2, len(positions)//24))):
px, py = rng.choice(positions)
bird = Animal(px+0.5, py+0.5, bird_type, biome)
bird.pea_target = (px, py)
bird.poop_stock_tonnes = 0.0
bird.poop_charge_started_at = 0
bird.poop_next_attack_at = 0
bird.poop_burst_remaining = 0
bird.poop_burst_next_at = 0
bird.poop_burst_mass_each = 0.0
bird.poop_burst_total = 0
bird.pea_next_feed_at = 0
self.animals.append(bird)
World._generate_flora = generate_flora_with_peas
original_rotation = Animal._update_rotation
def bird_rotation(self):
if not getattr(self.type, 'is_dungbird', False):
return original_rotation(self)
angle = -math.degrees(math.atan2(self.dy, self.dx)) if self.dx or self.dy else 0
# Размер меняется только в момент зарядки (0.25 м -> несколько метров).
started = getattr(self, 'poop_charge_started_at', 0)
bursting = getattr(self, 'poop_burst_remaining', 0) > 0
elapsed = (1800 if bursting else
max(0, pygame.time.get_ticks()-started) if started else 0)
stage = min(8, int(8 * elapsed / 1800)) if (started or bursting) else 0
rotation = round(angle / 15) * 15
cache_key = (stage, rotation)
image = self.type.inflated_cache.get(cache_key)
if image is None:
scale = 1.0 + 1.8 * stage/8
base = self.type.image
size = (round(base.get_width()*scale), round(base.get_height()*scale))
image = pygame.transform.rotate(pygame.transform.smoothscale(base, size), rotation)
self.type.inflated_cache[cache_key] = image
self.visual_angle = angle
self.rotated_image = image
self.rotated_glow = None
Animal._update_rotation = bird_rotation
old_animal_update = Animal.update
def bird_update(self, player_x, player_y, world):
if (not getattr(self.type, 'is_dungbird', False)
or getattr(self, 'is_companion', False)
or not hasattr(world, 'pea_patches')):
return old_animal_update(self, player_x, player_y, world)
self.active = abs(self.x-player_x) <= VIEW_RADIUS+3 and abs(self.y-player_y) <= VIEW_RADIUS+3
if not self.active or self.hp <= 0:
return
patches = world.pea_patches.get(self.home_biome, ())
target = getattr(self, 'pea_target', None)
now = pygame.time.get_ticks()
if (not target or target not in world.flora_grid
or (world.pea_ready_at.get(target, 0) > now
and now >= getattr(self, 'next_pea_search_at', 0))):
choices = random.sample(patches, min(18, len(patches))) if patches else ()
ripe = [p for p in choices if world.pea_ready_at.get(p, 0) <= now]
self.pea_target = min(ripe, key=lambda p: (p[0]+.5-self.x)**2
+ (p[1]+.5-self.y)**2, default=None)
self.next_pea_search_at = now + 1200
target = self.pea_target
if target:
dx, dy = target[0]+0.5-self.x, target[1]+0.5-self.y
desired = math.atan2(dy, dx) if abs(dx)+abs(dy) > 0.02 else self.swim_heading
else:
desired = self.swim_heading + math.sin(now*.00065 + self.swim_phase)*.08
if (getattr(self, 'poop_charge_started_at', 0) or
getattr(self, 'poop_burst_remaining', 0)):
desired = math.atan2(player_y-self.y, player_x-self.x)
turn = Animal._angle_difference(desired, self.swim_heading)
self.swim_heading += max(-.16, min(.16, turn))
speed = self.type.speed * (.28 if (getattr(self, 'poop_charge_started_at', 0) or
getattr(self, 'poop_burst_remaining', 0)) else 1.0)
nx = self.x + math.cos(self.swim_heading)*speed
ny = self.y + math.sin(self.swim_heading)*speed
if self._can_swim_to(nx, ny, world):
self.x, self.y = nx, ny
else:
self.swim_heading += random.choice((-.8, .8))
self.dx = math.cos(self.swim_heading)*speed
self.dy = math.sin(self.swim_heading)*speed
self._update_rotation()
Animal.update = bird_update
original_game_init = Game.__init__
def init_with_dungbirds(self):
original_game_init(self)
self.dung_projectiles = []
self.dung_puddles = []
self.next_dung_poison_at = 0
Game.__init__ = init_with_dungbirds
original_game_update = Game.update
def update_with_dungbirds(self):
original_game_update(self)
if self.state != 'playing' or self.settings_open:
return
now = pygame.time.get_ticks()
world = self.world
if not self.in_cave:
for key, ready in list(world.pea_ready_at.items()):
if now >= ready:
if world.flora_grid.get(key) is game_ns['pea_bare_type']:
world.flora_grid[key] = game_ns['pea_flora_type']
del world.pea_ready_at[key]
for bird in self.get_nearby_animals(VIEW_RADIUS + 3):
if (bird.hp <= 0 or not getattr(bird.type, 'is_dungbird', False)
or getattr(bird, 'is_companion', False)):
if getattr(bird.type, 'is_dungbird', False):
bird.poop_burst_remaining = 0
bird.poop_charge_started_at = 0
continue
key = getattr(bird, 'pea_target', None)
if (key is not None and bird.poop_stock_tonnes < DUNGBIRD_MAX_TONNES
and now >= getattr(bird, 'pea_next_feed_at', 0)
and world.flora_grid.get(key) is game_ns['pea_flora_type']
and math.hypot(bird.x - key[0] - .5,
bird.y - key[1] - .5) < .62):
# Масса очереди ограничена реально накопленным помётом.
bird.poop_stock_tonnes = min(DUNGBIRD_MAX_TONNES,
bird.poop_stock_tonnes + 1.0)
world.flora_grid[key] = game_ns['pea_bare_type']
world.pea_ready_at[key] = now + PEA_REGROW_MS
bird.pea_next_feed_at = now + 950
bird.pea_target = None
dist = math.hypot(self.player_x - bird.x,
self.player_y - bird.y)
remaining = getattr(bird, 'poop_burst_remaining', 0)
if remaining == 0:
if (dist <= 6.5 and bird.poop_stock_tonnes >= .01
and now >= getattr(bird, 'poop_next_attack_at', 0)
and getattr(bird, 'stasis_timer', 0) <= 0):
if not getattr(bird, 'poop_charge_started_at', 0):
bird.poop_charge_started_at = now or 1
elif now - bird.poop_charge_started_at >= 1800:
count = random.randint(DUNGBIRD_BURST_MIN,
DUNGBIRD_BURST_MAX)
total_mass = min(bird.poop_stock_tonnes,
random.uniform(.01, DUNGBIRD_MAX_TONNES))
total_mass = max(.01, round(total_mass, 3))
bird.poop_stock_tonnes = max(
0.0, bird.poop_stock_tonnes - total_mass)
bird.poop_burst_remaining = count
bird.poop_burst_total = count
bird.poop_burst_mass_each = total_mass / count
bird.poop_burst_next_at = now # Первый шар — сразу.
bird.poop_charge_started_at = 0
if dist < VIEW_RADIUS:
self.notification = (
f'ГОВНОАТАКА! Очередь {count} шаров, '
f'{total_mass:.2f} т помёта!')
self.notification_timer = 105
else:
bird.poop_charge_started_at = 0
# Реальное время (миллисекунды), а не счётчик кадров.
# После начала очередь заканчивается и при отходе игрока.
if getattr(bird, 'poop_burst_remaining', 0) > 0:
if getattr(bird, 'stasis_timer', 0) > 0:
bird.poop_burst_next_at = max(
bird.poop_burst_next_at, now + DUNGBIRD_SHOT_INTERVAL_MS)
continue
if (bird.poop_burst_remaining > 0
and now >= bird.poop_burst_next_at):
direction = math.atan2(self.player_y - bird.y,
self.player_x - bird.x)
direction += random.uniform(-DUNGBIRD_SPREAD_RADIANS,
DUNGBIRD_SPREAD_RADIANS)
mass = bird.poop_burst_mass_each
# Начальная точка чуть впереди птицы. Запоминаем
# исходную клетку: растущий здесь горох не должен
# разбить снаряд в момент вылета.
start_x = bird.x + math.cos(direction) * .44
start_y = bird.y + math.sin(direction) * .44
self.dung_projectiles.append({
'kind': 'ball', 'x': start_x, 'y': start_y,
'vx': math.cos(direction) * DUNGBIRD_SHOT_SPEED,
'vy': math.sin(direction) * DUNGBIRD_SHOT_SPEED,
'mass': mass,
'damage': max(1, round(mass * DUNGBIRD_DAMAGE_PER_TONNE)),
'ttl': 60, 'trail': [],
'ignore_origin': (int(bird.x), int(bird.y)),
})
bird.poop_burst_remaining -= 1
bird.poop_burst_next_at = now + DUNGBIRD_SHOT_INTERVAL_MS
if bird.poop_burst_remaining == 0:
bird.poop_next_attack_at = now + 3200
bird._update_rotation()
break
def obstacle_at(x, y, projectile=None):
ix, iy = int(x), int(y)
if not (0 <= x < MAP_SIZE and 0 <= y < MAP_SIZE):
return True
# Не учитываем исходную клетку шара: птица может стоять
# непосредственно внутри кустика гороха.
if (projectile is not None
and projectile.get('ignore_origin') == (ix, iy)):
return False
obj = world.get_object_at(ix, iy)
biome = world.get_biome_at(ix, iy)
return bool(
(obj is not None and getattr(obj, 'is_wall', False))
or (ix, iy) in world.flora_grid
or (ix, iy) in world.tree_grid
or (biome is not None and not biome.passable)
or self.is_start_capsule_wall(ix, iy))
new_projectiles = []
fragments = []
for shot in self.dung_projectiles:
# Не даём тысячам осколков замедлить игру при одновременной атаке стаи.
if len(new_projectiles) + len(fragments) >= DUNGBIRD_MAX_ACTIVE_PROJECTILES:
continue
shot['trail'].append((shot['x'], shot['y']))
shot['trail'] = shot['trail'][-5 if shot.get('kind') == 'fragment' else -7:]
old_x, old_y = shot['x'], shot['y']
new_x = old_x + shot['vx']
new_y = old_y + shot['vy']
shot['ttl'] -= 1
small = shot.get('kind') == 'fragment'
radius = (max(.055, min(.16, .045 + .09 * shot['mass']**(1/3)))
if small else min(.75, .24 + .18 * shot['mass']**(1/3)))
# Проверяем всю траекторию, чтобы быстрые шары не пролетали
# сквозь тонкие преграды между кадрами.
collided = False
if not self.in_cave:
for factor in (.33, .66, 1.0):
test_x = old_x + shot['vx'] * factor
test_y = old_y + shot['vy'] * factor
if obstacle_at(test_x, test_y, shot):
collided = True
break
out_of_world = not (0 <= new_x < MAP_SIZE and 0 <= new_y < MAP_SIZE)
if collided:
if small:
if shot.get('bounces_left', 0) > 0:
# Отражение скорости от клетки + угловой разброс.
flip_x = obstacle_at(new_x, old_y, shot)
flip_y = obstacle_at(old_x, new_y, shot)
if flip_x or (not flip_x and not flip_y):
shot['vx'] = -shot['vx']
if flip_y or (not flip_x and not flip_y):
shot['vy'] = -shot['vy']
perturb = random.uniform(-.32, .32)
cs, sn = math.cos(perturb), math.sin(perturb)
vx, vy = shot['vx'], shot['vy']
shot['vx'] = (vx * cs - vy * sn) * .88
shot['vy'] = (vx * sn + vy * cs) * .88
shot['bounces_left'] -= 1
shot['x'], shot['y'] = old_x, old_y
if shot['ttl'] > 0:
new_projectiles.append(shot)
continue
# Один большой шар разбивается на 3–7 маленьких частиц.
count = random.randint(DUNGBIRD_FRAGMENT_MIN,
DUNGBIRD_FRAGMENT_MAX)
reflection = math.atan2(-shot['vy'], -shot['vx'])
for index in range(count):
if len(new_projectiles) + len(fragments) >= DUNGBIRD_MAX_ACTIVE_PROJECTILES:
break
scatter = ((index - (count - 1) / 2) /
max(1, (count - 1) / 2)) * 1.33
angle = reflection + scatter + random.uniform(-.30, .30)
speed = random.uniform(.11, .24)
fragments.append({
'kind': 'fragment', 'x': old_x, 'y': old_y,
'vx': math.cos(angle) * speed,
'vy': math.sin(angle) * speed,
'mass': shot['mass'] / count,
'damage': max(1, round(shot['damage'] * .2 / count)),
'ttl': random.randint(14, 34), 'trail': [],
'bounces_left': random.randint(1, 2),
'ignore_origin': (int(old_x), int(old_y)),
})
self.dung_puddles.append({
'x': old_x, 'y': old_y, 'radius': radius * .55,
'until': now + 7000})
continue
if out_of_world:
continue
if not self.in_cave and math.hypot(new_x - self.player_x,
new_y - self.player_y) < radius + .34:
self._damage_player_or_vehicle(shot['damage'])
if not small:
self.dung_puddles.append({
'x': new_x, 'y': new_y, 'radius': radius + .42,
'until': now + 10000})
self.notification = f'Попадание помёта: -{shot["damage"]} HP!'
self.notification_timer = 90
continue
if shot['ttl'] <= 0:
if not small:
self.dung_puddles.append({
'x': new_x, 'y': new_y, 'radius': radius + .42,
'until': now + 10000})
continue
shot['x'], shot['y'] = new_x, new_y
new_projectiles.append(shot)
self.dung_projectiles = (new_projectiles + fragments)[
-DUNGBIRD_MAX_ACTIVE_PROJECTILES:]
self.dung_puddles = [p for p in self.dung_puddles
if p['until'] > now][-250:]
if not self.in_cave and now >= self.next_dung_poison_at:
for puddle in self.dung_puddles:
if math.hypot(self.player_x - puddle['x'],
self.player_y - puddle['y']) < puddle['radius']:
self._damage_player_or_vehicle(3)
self.next_dung_poison_at = now + 1100
break
Game.update = update_with_dungbirds
original_pick_up = Game.pick_up
def pick_up_with_peas(self):
if not self.in_cave:
key = (int(self.player_x), int(self.player_y))
if (self.world.flora_grid.get(key) is game_ns['pea_flora_type']
and not self.world.get_object_at(*key)
and not self._current_dropped_items().get(key)
and not self._moss_near(self.player_x, self.player_y, .72)
and not any(int(a.x) == key[0] and int(a.y) == key[1]
for a in self.get_nearby_animals(1.5))):
if self.add_to_inventory(game_ns['pea_pod_type']):
self.world.flora_grid[key] = game_ns['pea_bare_type']
self.world.pea_ready_at[key] = pygame.time.get_ticks()+PEA_REGROW_MS
self.notification = 'Собран стручок гороха! (E, затем R/U — съесть)'
else:
self.notification = 'Инвентарь полон, горох не собран'
self.notification_timer = 95
return
return original_pick_up(self)
Game.pick_up = pick_up_with_peas
original_scanner_details = Game._scanner_details
def scanner_details_with_dungbirds(self, target):
entity = target['entity']
if entity is game_ns.get('dungbird_type'):
return [
'Вид: Avis pachomica (Птица Говносрун).',
'Обычная длина: 25 см; перед атакой раздувается до нескольких метров.',
'Здоровье: 25 HP. Скорость: 4 клетки/сек.',
'Питание: горох и другие плоды семейства Бобовые.',
'1 г съеденного гороха превращается в 1 тонну токсичного помёта.',
'Очередь: 10–30 шаров, интервал 0,5 секунды, разброс при стрельбе.',
'Масса всей очереди: от 10 кг до 10 т; 100 HP за тонну.',
'При попадании в стену или растение шар разбивается на 3–7',
'осколков, которые отскакивают в разные стороны.',
'После попадания остаётся токсичная лужа.',
'Распространение: леса, побережья, острова и горы.',
'Совет КПК: при раздувании птицы немедленно уходите с линии огня.'
]
if getattr(entity, 'kind', None) == 'pea':
return [
'Вид: горох, семейство Бобовые.',
'Среда обитания: наземные биомы.',
'На кусте растут зелёные стручки; E — собрать стоя на растении.',
'Стручки съедобны: +6 еды, +2 воды.',
'После сбора новый урожай вырастает за 25 секунд.',
'Любимый корм Avis pachomica: 1 горошина (1 г) = 1 т помёта.',
'Исследованный горох можно посадить в грядке или горшке.'
]
return original_scanner_details(self, target)
Game._scanner_details = scanner_details_with_dungbirds
original_render = Game.render
def render_with_dungbirds(self):
original_render(self)
if self.state != 'playing' or self.in_cave:
return
screen = pygame.display.get_surface()
zoom = self.camera_zoom
def pos(x, y):
return (round(SCREEN_SIZE*.5+(x-self.player_x)*CELL_SIZE*zoom),
round(SCREEN_SIZE*.5+(y-self.player_y)*CELL_SIZE*zoom))
now = pygame.time.get_ticks()
overlay = pygame.Surface(screen.get_size(), pygame.SRCALPHA)
for puddle in self.dung_puddles:
sx, sy = pos(puddle['x'], puddle['y'])
radius = round(puddle['radius']*CELL_SIZE*zoom)
# В режиме паузы/меню время продолжает идти, а очистка луж
# может не выполняться. Просроченную лужу нельзя рисовать:
# отрицательная прозрачность даёт ValueError: invalid color argument.
remaining_ms = puddle['until'] - now
if remaining_ms <= 0:
continue
if -radius < sx < SCREEN_SIZE+radius and -radius < sy < SCREEN_SIZE+radius:
alpha = max(0, min(170, round(170 * remaining_ms / 10000)))
pygame.draw.circle(overlay, (79, 61, 26, alpha), (sx, sy), max(3, radius))
pygame.draw.circle(overlay, (143, 170, 49, min(210, alpha + 30)),
(sx, sy), max(2, round(radius*.68)), max(2, round(3*zoom)))
for shot in self.dung_projectiles:
sx, sy = pos(shot['x'], shot['y'])
if not (-100 < sx < SCREEN_SIZE + 100 and
-100 < sy < SCREEN_SIZE + 100):
continue
small = shot.get('kind') == 'fragment'
radius = (max(2, round((.05 + .045 * shot['mass']**(1/3)) *
CELL_SIZE * zoom)) if small else
max(4, round((.16 + .17 * shot['mass']**(1/3)) *
CELL_SIZE * zoom)))
for i, (px, py) in enumerate(shot['trail']):
trail_pos = pos(px, py)
pygame.draw.circle(overlay,
(174, 137, 61, min(255, 30 + i * 22)) if small
else (141, 97, 47, min(255, 35 + i * 22)),
trail_pos, max(1, round(radius * (i+1) / 10)))
pygame.draw.circle(overlay, (60, 45, 22, 245),
(sx, sy), radius + (1 if small else 2))
pygame.draw.circle(overlay,
(176, 140, 71, 255) if small
else (139, 100, 52, 255),
(sx, sy), radius)
if not small:
pygame.draw.circle(overlay, (196, 165, 83, 255),
(sx - radius//3, sy - radius//3),
max(2, radius//3))
screen.blit(overlay, (0, 0))
for bird in self.get_nearby_animals(VIEW_RADIUS+2):
if bird.hp <= 0 or not getattr(bird.type, 'is_dungbird', False):
continue
started = getattr(bird, 'poop_charge_started_at', 0)
bursting = getattr(bird, 'poop_burst_remaining', 0)
if started or bursting:
sx, sy = pos(bird.x, bird.y)
progress = (1.0 if bursting else min(1.0, (now-started)/1800))
ring_radius = round((.65+1.25*progress)*CELL_SIZE*zoom)
pygame.draw.circle(screen, (241, 184, 41), (sx, sy), max(3,ring_radius), 2)
caption = (f'ЗАЛП: {bursting} шаров' if bursting else
f'ПОМЁТ: {bird.poop_stock_tonnes:.1f} т')
text_image = self.tiny_font.render(caption,
True, (252, 226, 97))
screen.blit(text_image, text_image.get_rect(center=(sx, sy-ring_radius-12)))
pygame.display.flip()
Game.render = render_with_dungbirds
install_dungbird_and_peas(globals())
# <<< AVIS_PACHOMICA_MOD_V1 <<<
# >>> AKLASUR_MOD_V1: Акласур — рыба, которая вышла на сушу >>>
AKLASUR_NAME = 'АКЛАСУР'
AKLASUR_HEIGHT_METERS = 1.5
AKLASUR_HP = 100
AKLASUR_SPEED = 1.0 / 60.0 # 1 игровая клетка/сек при 60 FPS
AKLASUR_BITE_DAMAGE = 5
AKLASUR_KICK_DAMAGE = 20
def generate_aklasur_sprite(stride=0, size=76):
"""Повернутый вправо рыбочеловек наоборот: рыбья голова и тело, человеческие ноги."""
surf = pygame.Surface((size, size), pygame.SRCALPHA)
scale = size / 80.0
def p(x, y):
return (round(x * scale), round(y * scale))
def shape(points, color):
pygame.draw.polygon(surf, color, [p(*xy) for xy in points])
def oval(x, y, w, h, color):
pygame.draw.ellipse(surf, color, pygame.Rect(round(x*scale), round(y*scale),
max(1, round(w*scale)), max(1, round(h*scale))))
# Две человеческие ноги с коленями, стопами и пальцами — не рыбьи плавники.
offset = 5 if stride else -5
for upper, foot in [((25, 30), (4, 12+offset)), ((27, 50), (4, 65-offset))]:
knee = (14, upper[1] + (3 if upper[1] < 40 else -3))
pygame.draw.line(surf, (93, 58, 44), p(*upper), p(*knee), max(8, round(12*scale)))
pygame.draw.line(surf, (211, 151, 112), p(*upper), p(*knee), max(5, round(9*scale)))
pygame.draw.line(surf, (93, 58, 44), p(*knee), p(*foot), max(7, round(10*scale)))
pygame.draw.line(surf, (226, 169, 129), p(*knee), p(*foot), max(5, round(7*scale)))
fx, fy = foot
oval(fx-2, fy-4, 14, 9, (219, 164, 127))
for j in range(3):
pygame.draw.circle(surf, (239, 187, 147), p(fx+9+j, fy-2+j*2), max(1, round(1.7*scale)))
# Рыбье тело занимает верхнюю часть силуэта; ноги начинаются за брюшком.
shape([(19, 40), (29, 23), (43, 19), (58, 22), (69, 30),
(72, 42), (65, 51), (52, 58), (38, 59), (25, 52)], (30, 86, 116))
shape([(25, 39), (39, 30), (53, 28), (67, 35), (70, 44),
(57, 52), (39, 55), (27, 49)], (72, 158, 171))
shape([(30, 26), (39, 13), (49, 17), (55, 27)], (26, 84, 107))
shape([(27, 51), (40, 66), (52, 56), (46, 52)], (24, 102, 131))
# Чешуя, жаберные крышки и крупная рыбья голова с раскрытым ртом.
for x, y in ((33, 34), (41, 41), (36, 49), (49, 34), (49, 48)):
pygame.draw.arc(surf, (145, 208, 202), pygame.Rect(*p(x-4, y-4),
max(3, round(8*scale)), max(3, round(8*scale))),
-0.9, 1.2, max(1, round(scale)))
oval(56, 29, 15, 23, (106, 185, 186))
pygame.draw.arc(surf, (26, 75, 92), pygame.Rect(*p(54, 31),
max(2, round(12*scale)), max(2, round(20*scale))),
1.6, 4.2, max(1, round(2*scale)))
oval(61, 32, 7, 7, (247, 235, 181))
pygame.draw.circle(surf, (16, 25, 26), p(65, 35), max(1, round(2.5*scale)))
shape([(69, 42), (79, 45), (71, 50), (67, 47)], (32, 66, 80))
pygame.draw.line(surf, (234, 168, 147), p(71, 48), p(77, 46), max(1, round(2*scale)))
return surf
def install_aklasur(game_ns):
if game_ns.get('_AKLASUR_INSTALLED'):
return
game_ns['_AKLASUR_INSTALLED'] = True
old_init_assets = game_ns['init_assets']
def init_assets_with_aklasur():
old_init_assets()
aklasur = AnimalType(AKLASUR_NAME, (64, 151, 168), 42,
speed=AKLASUR_SPEED, behavior='neutral',
hp=AKLASUR_HP, damage=0, edible=False,
volume=3)
aklasur.is_aklasur = True
aklasur.fish_style = True
aklasur.image = generate_aklasur_sprite(0)
aklasur.glow_image = None
aklasur.walk_frames = (aklasur.image, generate_aklasur_sprite(1))
aklasur.walk_rotated_cache = {}
upper = ObjectType('Верхняя половина Акласура', (88, 159, 167), 30,
edible=True, hunger_value=48, thirst_value=5, volume=2)
upper.image = pygame.transform.smoothscale(aklasur.image, (36, 36))
legs = ObjectType('Ноги Акласура (несъедобные)', (219, 161, 121), 27,
edible=False, volume=1)
legs.image = pygame.Surface((32, 32), pygame.SRCALPHA)
for y, delta in ((11, -4), (23, 4)):
pygame.draw.line(legs.image, (219, 161, 121), (29, y), (9, y+delta), 7)
pygame.draw.ellipse(legs.image, (230, 178, 138), (1, y+delta-4, 12, 9))
game_ns['aklasur_type'] = aklasur
game_ns['aklasur_upper_type'] = upper
game_ns['aklasur_legs_type'] = legs
game_ns['init_assets'] = init_assets_with_aklasur
old_generate_flora = World._generate_flora
def generate_flora_and_aklasurs(self):
old_generate_flora(self)
rng = random.Random(2026102507)
aklasur_type = game_ns['aklasur_type']
for biome in self.biomes:
if not biome.passable or biome.name not in ('Побережье', 'Острова'):
continue
quantity = min(5, max(1, round(biome.area()/10000)))
for _ in range(quantity):
chosen = None
fallback = None
for _attempt in range(120):
rx, ry = biome.random_point(rng)
x, y = int(rx), int(ry)
if self.get_biome_at(x, y) is not biome:
continue
obj = self.get_object_at(x, y)
if obj is not None and obj.is_wall:
continue
fallback = (rx, ry)
if any(self.is_water_at(x+dx, y+dy)
for dx, dy in ((6, 0), (-6, 0), (0, 6), (0, -6),
(12, 0), (-12, 0), (0, 12), (0, -12))):
chosen = (rx, ry)
break
if chosen is None:
chosen = fallback
if chosen is not None:
animal = Animal(*chosen, aklasur_type, biome)
animal.aklasur_attack_is_kick = False
animal.aklasur_attack_ready_at = 0
animal.aklasur_attack_flash_until = 0
self.animals.append(animal)
World._generate_flora = generate_flora_and_aklasurs
old_can_swim_to = Animal._can_swim_to
def aklasur_can_move_to(self, x, y, world):
if not getattr(self.type, 'is_aklasur', False):
return old_can_swim_to(self, x, y, world)
if not (0 <= x < MAP_SIZE and 0 <= y < MAP_SIZE):
return False
if hasattr(world, 'cave_lookup'):
return False
biome = world.get_biome_at(x, y)
if biome is None or not biome.passable:
return False
if biome.name not in LAND_BIOMES and biome.name not in ('Мелководье', 'Реки', 'Притоки'):
return False
obj = world.get_object_at(int(x), int(y))
if obj is not None and obj.is_wall:
return False
blocked = getattr(world, 'creature_blocked', None)
return blocked is None or not blocked(int(x), int(y))
Animal._can_swim_to = aklasur_can_move_to
old_rotation = Animal._update_rotation
def aklasur_rotation(self):
if not getattr(self.type, 'is_aklasur', False):
return old_rotation(self)
angle = -math.degrees(math.atan2(self.dy, self.dx)) if self.dx or self.dy else 0
moving = abs(self.dx)+abs(self.dy) > .003
stride = int(pygame.time.get_ticks()/220) % 2 if moving else 0
degrees = round(angle/15)*15
key = (stride, degrees)
cached = self.type.walk_rotated_cache.get(key)
if cached is None:
cached = pygame.transform.rotate(self.type.walk_frames[stride], degrees)
self.type.walk_rotated_cache[key] = cached
self.visual_angle = angle
self.rotated_image = cached
self.rotated_glow = None
Animal._update_rotation = aklasur_rotation
old_animal_update = Animal.update
def aklasur_update(self, player_x, player_y, world):
if (not getattr(self.type, 'is_aklasur', False)
or getattr(self, 'is_companion', False)):
return old_animal_update(self, player_x, player_y, world)
self.active = abs(self.x-player_x) <= VIEW_RADIUS+2 and abs(self.y-player_y) <= VIEW_RADIUS+2
if not self.active or self.hp <= 0:
return
dist = math.hypot(player_x-self.x, player_y-self.y)
if .4 < dist < 6.0:
target_angle = math.atan2(player_y-self.y, player_x-self.x)
else:
self.swim_phase += .035
target_angle = self.cruise_heading + .4*math.sin(self.swim_phase)
if random.random() < .007:
self.cruise_heading += random.uniform(-.8, .8)
turn = Animal._angle_difference(target_angle, self.swim_heading)
self.swim_heading += max(-.13, min(.13, turn))
velocity = self.type.speed
nx = self.x+math.cos(self.swim_heading)*velocity
ny = self.y+math.sin(self.swim_heading)*velocity
if self._can_swim_to(nx, ny, world):
self.x, self.y = nx, ny
else:
self.swim_heading += random.choice((-.65, .65))
self.cruise_heading = self.swim_heading
self.dx = math.cos(self.swim_heading)*velocity
self.dy = math.sin(self.swim_heading)*velocity
self._update_rotation()
Animal.update = aklasur_update
old_game_update = Game.update
def update_with_aklasurs(self):
old_game_update(self)
if self.state != 'playing' or self.settings_open or self.in_cave:
return
now = pygame.time.get_ticks()
for animal in self.get_nearby_animals(2.2):
if (animal.hp <= 0 or not getattr(animal.type, 'is_aklasur', False)
or getattr(animal, 'is_companion', False)
or getattr(animal, 'stasis_timer', 0) > 0
or now < getattr(animal, 'aklasur_attack_ready_at', 0)):
continue
distance = math.hypot(animal.x-self.player_x, animal.y-self.player_y)
if distance > 1.5:
continue
kick = getattr(animal, 'aklasur_attack_is_kick', False)
# Чередование приёмов: кусает за 5 HP, затем бьёт ногой за 20 HP.
damage = AKLASUR_KICK_DAMAGE if kick else AKLASUR_BITE_DAMAGE
animal.aklasur_attack_is_kick = not kick
animal.aklasur_attack_ready_at = now + (1700 if kick else 1100)
animal.aklasur_attack_flash_until = now + 380
animal.aklasur_last_attack = 'УДАР НОГОЙ' if kick else 'УКУС'
self._damage_player_or_vehicle(damage)
if self.running:
self.notification = f'{AKLASUR_NAME}: {animal.aklasur_last_attack}! −{damage} HP'
self.notification_timer = 90
Game.update = update_with_aklasurs
old_pick_up = Game.pick_up
def pickup_without_aklasur_legs(self):
if not self.in_cave:
px, py = int(self.player_x), int(self.player_y)
for animal in self.get_nearby_animals(1.5):
if (getattr(animal.type, 'is_aklasur', False)
and int(animal.x) == px and int(animal.y) == py):
if animal.hp <= 0:
self.handle_animal_death(animal)
else:
self.notification = 'Живой Акласур не даёт себя подобрать!'
self.notification_timer = 75
return
return old_pick_up(self)
Game.pick_up = pickup_without_aklasur_legs
old_death = Game.handle_animal_death
def aklasur_death(self, animal):
if not getattr(animal.type, 'is_aklasur', False):
return old_death(self, animal)
# Ноги намеренно несъедобны; в инвентарь как пища попадает только рыба.
drops = self._current_dropped_items()
pos = (int(animal.x), int(animal.y))
upper = game_ns['aklasur_upper_type']
legs = game_ns['aklasur_legs_type']
collected = self.add_to_inventory(upper)
if not collected:
drops.setdefault(pos, []).append(upper)
drops.setdefault(pos, []).append(legs)
self.remove_current_animal(animal)
self.notification = ('Акласур разделан: съедобная верхняя половина получена, '
'ноги остались на земле.' if collected else
'Акласур погиб: верхняя половина и несъедобные ноги остались на земле.')
self.notification_timer = 130
Game.handle_animal_death = aklasur_death
old_scanner_details = Game._scanner_details
def scanner_details_with_aklasur(self, target):
entity = target['entity']
if entity is game_ns.get('aklasur_type'):
return [
f'Вид: {AKLASUR_NAME}. Рыбья голова, рыбье туловище и человеческие ноги.',
'Первое животное Абсероида, вышедшее из древнего океана на сушу.',
'Рост: 1,5 м. Здоровье: 100 HP. Скорость: 1 клетка/сек.',
'Атаки: укус — 5 HP; удар ногой — 20 HP.',
'Обитает на побережьях и островах, может входить в мелководье.',
'Съедобна только верхняя, рыбья половина тела.',
'При гибели разделяется на съедобную верхнюю часть и несъедобные ноги.',
'Совет КПК: особенно опасайтесь удара ногой.'
]
return old_scanner_details(self, target)
Game._scanner_details = scanner_details_with_aklasur
old_render = Game.render
def render_with_aklasurs(self):
old_render(self)
if self.state != 'playing' or self.in_cave:
return
display = pygame.display.get_surface()
now = pygame.time.get_ticks()
zoom = self.camera_zoom
for animal in self.get_nearby_animals(VIEW_RADIUS+2):
if not getattr(animal.type, 'is_aklasur', False) or animal.hp <= 0:
continue
until = getattr(animal, 'aklasur_attack_flash_until', 0)
if until <= now:
continue
sx = round(SCREEN_SIZE*.5+(animal.x-self.player_x)*CELL_SIZE*zoom)
sy = round(SCREEN_SIZE*.5+(animal.y-self.player_y)*CELL_SIZE*zoom)
radius = round(34*zoom)
pygame.draw.circle(display, (250, 100, 70), (sx, sy), radius, 3)
label = self.small_font.render(getattr(animal, 'aklasur_last_attack', 'АТАКА'),
True, (255, 229, 151))
display.blit(label, label.get_rect(center=(sx, sy-radius-12)))
pygame.display.flip()
Game.render = render_with_aklasurs
install_aklasur(globals())
# <<< AKLASUR_MOD_V1 <<<
# >>> LEECH_HYDRA_MOD_V1: Пиявка-гидра >>>
HYDRA_NAME = 'Пиявка-гидра'
HYDRA_BODY_HP = 70
HYDRA_TENTACLE_HP = 9
HYDRA_BODY_METERS = 1.2
HYDRA_REACH_METERS = 4.0
HYDRA_DAMAGE_PER_TENTACLE_SECOND = 1.0
HYDRA_REGEN_MS = 26000
def generate_leech_hydra_body_sprite(size=38):
"""На поверхности виден лишь скрытый в грунте массивный тёмный хоботок."""
surf = pygame.Surface((size, size), pygame.SRCALPHA)
d = pygame.draw
cx = cy = size//2
d.ellipse(surf, (28, 57, 61, 145), (2, 8, size-4, size-14))
d.ellipse(surf, (20, 41, 45, 235), (6, 11, size-12, size-19))
d.ellipse(surf, (49, 76, 65, 255), (10, 13, size-20, size-24))
d.ellipse(surf, (12, 21, 22, 255), (cx-7, cy-5, 14, 10))
d.circle(surf, (137, 162, 110), (cx-4, cy-1), 2)
d.circle(surf, (137, 162, 110), (cx+4, cy-1), 2)
for angle in range(0, 360, 45):
a = math.radians(angle)
p1 = (round(cx+12*math.cos(a)), round(cy+9*math.sin(a)))
p2 = (round(cx+18*math.cos(a)), round(cy+13*math.sin(a)))
d.line(surf, (28, 45, 41, 185), p1, p2, 2)
return surf
def _hydra_initialize_tentacles(animal):
"""Каждое щупальце имеет собственные 9 HP, состояние и время регенерации."""
rng = random.Random(740003+int(animal.x*131)+int(animal.y*317))
count = rng.randint(10, 15)
animal.hydra_tentacles = []
for index in range(count):
animal.hydra_tentacles.append({
'angle': math.tau*index/count+rng.uniform(-.11, .11),
'length': rng.uniform(2.5, HYDRA_REACH_METERS),
'phase': rng.uniform(0, math.tau),
'hp': HYDRA_TENTACLE_HP,
'regrow_at': 0,
'attached': False,
'target': None, # 'player' или конкретная рыба
'blood_carry': 0.0, # дробная часть HP, высосанная этим щупальцем
})
animal.hydra_next_attach_at = 0
animal.hydra_damage_carry = 0.0
animal.hydra_last_tick = pygame.time.get_ticks()
animal.hydra_last_damage_at = 0
animal.hydra_blood_drunk = 0
def _hydra_tentacle_tip(animal, tentacle, now, player_xy=None):
if tentacle['attached']:
target = tentacle.get('target')
if target == 'player' and player_xy is not None:
return (float(player_xy[0]), float(player_xy[1]))
if isinstance(target, Animal) and target.hp > 0:
return (float(target.x), float(target.y))
angle = tentacle['angle']+.12*math.sin(now*.0014+tentacle['phase'])
radius = tentacle['length']*(.92+.08*math.sin(now*.002+tentacle['phase']))
return (animal.x+math.cos(angle)*radius,
animal.y+math.sin(angle)*radius)
def _hydra_is_fish(animal):
"""Все плавниковые рыбы, включая добавленные модами; без птиц и наземных видов."""
if animal.hp <= 0:
return False
kind = animal.type
if (getattr(kind, 'is_leech_hydra', False)
or getattr(kind, 'is_dungbird', False)
or getattr(kind, 'is_aklasur', False)
or kind.name == WHALE_NAME):
return False
return (bool(getattr(kind, 'fish_style', False) or getattr(kind, 'schooling', False))
and not is_land_biome(animal.home_biome))
def _hydra_segment_distance_sq(x, y, ax, ay, bx, by):
vx, vy = bx-ax, by-ay
length_sq = vx*vx+vy*vy
if length_sq <= 1e-10:
return (x-ax)**2+(y-ay)**2
t = max(0.0, min(1.0, ((x-ax)*vx+(y-ay)*vy)/length_sq))
cx, cy = ax+t*vx, ay+t*vy
return (x-cx)**2+(y-cy)**2
def install_leech_hydra(game_ns):
if game_ns.get('_LEECH_HYDRA_INSTALLED'):
return
game_ns['_LEECH_HYDRA_INSTALLED'] = True
old_init_assets = game_ns['init_assets']
def init_assets_with_hydra():
old_init_assets()
hydra = AnimalType(HYDRA_NAME, (45, 75, 64), 38,
speed=0.0, behavior='neutral', hp=HYDRA_BODY_HP,
damage=0, edible=True, hunger_value=32,
thirst_value=0, volume=3)
hydra.is_leech_hydra = True
hydra.image = generate_leech_hydra_body_sprite(38)
hydra.glow_image = None
game_ns['leech_hydra_type'] = hydra
game_ns['init_assets'] = init_assets_with_hydra
old_generate_animals = World._generate_animals
def generate_animals_with_hydras(self):
old_generate_animals(self)
rng = random.Random(2026102591)
hydra_type = game_ns['leech_hydra_type']
for biome in self.biomes:
if biome.name != 'Мелководье' or not biome.passable:
continue
# Несколько засад на целый биом, а не плотная популяция.
quantity = min(3, max(1, round(biome.area()/26000)))
for _ in range(quantity):
for _attempt in range(100):
x, y = biome.random_point(rng)
if self.get_biome_at(x, y) is not biome:
continue
obj = self.get_object_at(int(x), int(y))
if obj is not None and obj.is_wall:
continue
animal = Animal(x, y, hydra_type, biome)
_hydra_initialize_tentacles(animal)
self.animals.append(animal)
break
World._generate_animals = generate_animals_with_hydras
old_animal_update = Animal.update
def hydra_animal_update(self, player_x, player_y, world):
if not getattr(self.type, 'is_leech_hydra', False):
return old_animal_update(self, player_x, player_y, world)
self.active = abs(self.x-player_x) <= VIEW_RADIUS+4 and abs(self.y-player_y) <= VIEW_RADIUS+4
# Тело закопано: без движения и без стандартного контактного урона.
self.dx = self.dy = 0.0
return
Animal.update = hydra_animal_update
old_spawn_animal = Game._spawn_animal_from_inventory_item
def spawn_hydra_with_tentacles(self, animal_type, companion=False):
animal = old_spawn_animal(self, animal_type, companion=companion)
if animal is not None and getattr(animal.type, 'is_leech_hydra', False):
_hydra_initialize_tentacles(animal)
animal.companion_state = ('засела и ловит рыбу' if companion
else 'подстерегает добычу')
return animal
Game._spawn_animal_from_inventory_item = spawn_hydra_with_tentacles
old_update_companion = Game._update_companion
def update_hydra_companion(self, animal, player_move_x, player_move_y):
if not getattr(animal.type, 'is_leech_hydra', False):
return old_update_companion(self, animal, player_move_x, player_move_y)
# Ручная гидра — стационарная ловушка, не плавает вокруг хозяина.
animal.active = (abs(animal.x-self.player_x) <= VIEW_RADIUS+4
and abs(animal.y-self.player_y) <= VIEW_RADIUS+4)
animal.dx = animal.dy = 0.0
catches = any(arm['attached'] and isinstance(arm.get('target'), Animal)
for arm in getattr(animal, 'hydra_tentacles', ()))
animal.companion_state = ('пьёт кровь пойманной рыбы' if catches
else 'ловит рыбу щупальцами')
Game._update_companion = update_hydra_companion
old_game_update = Game.update
def update_with_hydras(self):
old_game_update(self)
if self.state != 'playing' or self.settings_open or self.in_cave:
return
now = pygame.time.get_ticks()
for hydra in self.get_nearby_animals(6.0):
if hydra.hp <= 0 or not getattr(hydra.type, 'is_leech_hydra', False):
continue
if not hasattr(hydra, 'hydra_tentacles'):
_hydra_initialize_tentacles(hydra)
elapsed_ms = max(0, now-hydra.hydra_last_tick)
hydra.hydra_last_tick = now
# Долгая пауза или отсутствие игрока не вызывает накопленный урон.
if elapsed_ms > 1100:
for arm in hydra.hydra_tentacles:
arm['attached'] = False
arm['target'] = None
arm['blood_carry'] = 0.0
hydra.hydra_damage_carry = 0.0
elapsed_ms = 0
for arm in hydra.hydra_tentacles:
if arm['hp'] <= 0 and now >= arm['regrow_at']:
arm['hp'] = HYDRA_TENTACLE_HP
arm['regrow_at'] = 0
arm['attached'] = False
arm['target'] = None
arm['blood_carry'] = 0.0
# Все рыбьи виды, включая добавленные модами: даже те, что
# не являются обычными съедобными стайными рыбами.
fishes = [fish for fish in self.world.get_animals_near(
hydra.x, hydra.y, HYDRA_REACH_METERS+1)
if fish is not hydra and _hydra_is_fish(fish)
and (not hydra.is_companion or not fish.is_companion)
and math.hypot(fish.x-hydra.x, fish.y-hydra.y) <= HYDRA_REACH_METERS]
live_fish = set(fishes)
player_distance = math.hypot(hydra.x-self.player_x,
hydra.y-self.player_y)
# Живая добыча может уплыть, а игрок — уйти из радиуса сети.
# Хозяин никогда не входит в список целей ручной гидры.
for arm in hydra.hydra_tentacles:
target = arm.get('target')
if target == 'player':
valid = (not hydra.is_companion
and player_distance <= arm['length'])
elif isinstance(target, Animal):
valid = (target in live_fish and
math.hypot(target.x-hydra.x,
target.y-hydra.y) <= arm['length'])
else:
valid = False
if arm['hp'] <= 0 or not valid or getattr(hydra, 'stasis_timer', 0) > 0:
arm['attached'] = False
arm['target'] = None
arm['blood_carry'] = 0.0
if getattr(hydra, 'stasis_timer', 0) > 0:
continue
if now >= hydra.hydra_next_attach_at:
free_arms = [arm for arm in hydra.hydra_tentacles
if arm['hp'] > 0 and not arm['attached']]
targets = list(fishes)
if not hydra.is_companion and player_distance <= HYDRA_REACH_METERS:
targets.append('player')
for arm in free_arms:
reachable = []
for target in targets:
d = (player_distance if target == 'player' else
math.hypot(target.x-hydra.x, target.y-hydra.y))
if d <= arm['length']:
attached_count = sum(a['attached'] and a.get('target') == target
for a in hydra.hydra_tentacles)
# Распределяем щупальца между рыбами; при прочих
# равных сначала выбираем рыбу, затем игрока.
reachable.append((attached_count,
1 if target == 'player' else 0,
d, target))
if reachable:
_, _, _, target = min(reachable, key=lambda item: item[:3])
arm['attached'] = True
arm['target'] = target
arm['blood_carry'] = 0.0
hydra.hydra_next_attach_at = now+550
if target == 'player':
attached = sum(a['attached'] and a.get('target') == 'player'
for a in hydra.hydra_tentacles)
self.notification = f'Пиявка-гидра: присосалось щупалец {attached}!'
self.notification_timer = 48
break
player_damage = 0
fish_damage = {}
for arm in hydra.hydra_tentacles:
if not arm['attached'] or arm['hp'] <= 0:
continue
# Каждая присоска отдельно копит 1 HP/сек на своей жертве.
arm['blood_carry'] += elapsed_ms/1000.0 * HYDRA_DAMAGE_PER_TENTACLE_SECOND
damage = int(arm['blood_carry'])
if damage <= 0:
continue
arm['blood_carry'] -= damage
target = arm['target']
if target == 'player' and not hydra.is_companion:
player_damage += damage
elif isinstance(target, Animal) and target in live_fish:
fish_damage[target] = fish_damage.get(target, 0) + damage
if player_damage:
self._damage_player_or_vehicle(player_damage)
hydra.hydra_last_damage_at = now
for fish, damage in fish_damage.items():
if fish.hp <= 0:
continue
old_hp = fish.hp
# Даже прикрученных рыб-крепежей присоска достаёт изнутри:
# временно снимаем защиту от обычных ударов только на момент
# кровососания, не откручивая рыбу от её опоры.
anchored_fastener = (getattr(fish, 'fastener_anchored', False)
and getattr(fish.type, 'fastener_kind', None))
if anchored_fastener:
fish.fastener_anchored = False
try:
fish.hp = max(0, fish.hp-damage)
finally:
if anchored_fastener:
fish.fastener_anchored = True
taken = max(0, old_hp-fish.hp)
hydra.hydra_blood_drunk += taken
if fish.hp <= 0:
if hydra.is_companion:
# Пойманная ручной гидрой рыба остаётся добычей у её
# тела: игрок может забрать улов клавишей E.
key = (int(hydra.x), int(hydra.y))
self.surface_dropped_items.setdefault(key, []).append(fish.type)
self.notification = f'Ручная пиявка-гидра поймала {fish.type.name}!'
self.notification_timer = 90
hydra.companion_state = f'поймала: {fish.type.name}'
self.remove_current_animal(fish)
for arm in hydra.hydra_tentacles:
if arm.get('target') is fish:
arm['attached'] = False
arm['target'] = None
arm['blood_carry'] = 0.0
# Высосанная кровь понемногу восстанавливает тело хищника.
if hydra.hydra_blood_drunk >= 5:
healing = int(hydra.hydra_blood_drunk // 5)
hydra.hydra_blood_drunk -= healing*5
hydra.hp = min(hydra.max_hp, hydra.hp+healing)
Game.update = update_with_hydras
def find_targeted_arm(game, x, y, max_distance, from_player=False):
if game.in_cave:
return None
now = pygame.time.get_ticks()
best = None
max_dist_sq = max_distance**2
for hydra in game.get_nearby_animals(VIEW_RADIUS+5):
if hydra.hp <= 0 or not getattr(hydra.type, 'is_leech_hydra', False):
continue
if not hasattr(hydra, 'hydra_tentacles'):
continue
if not from_player and math.hypot(x-hydra.x, y-hydra.y) < .62:
continue # прицел точно на тело — можно ранить тело (70 HP)
for index, arm in enumerate(hydra.hydra_tentacles):
if arm['hp'] <= 0:
continue
tip_x, tip_y = _hydra_tentacle_tip(hydra, arm, now,
(game.player_x,game.player_y))
dist_sq = _hydra_segment_distance_sq(x, y, hydra.x, hydra.y,
tip_x, tip_y)
if dist_sq <= max_dist_sq and (best is None or dist_sq < best[0]):
best = (dist_sq, hydra, index)
return best
def hurt_tentacle(game, hydra, index, damage):
arm = hydra.hydra_tentacles[index]
arm['hp'] = max(0, arm['hp']-max(1, int(damage)))
if arm['hp'] == 0:
arm['attached'] = False
arm['target'] = None
arm['blood_carry'] = 0.0
arm['regrow_at'] = pygame.time.get_ticks()+HYDRA_REGEN_MS
game.notification = 'Щупальце пиявки-гидры оторвано! Отрастёт через 26 секунд.'
else:
game.notification = f'Щупальце пиявки-гидры: {arm["hp"]}/9 HP'
game.notification_timer = 80
# Тестировать, повреждать и отрывать щупальца можно независимо от тела.
Game._hurt_hydra_tentacle = hurt_tentacle
old_attack = Game.attack
def attack_hydra_arm(self):
if self.state == 'playing' and not self.in_cave:
hit = find_targeted_arm(self, self.player_x, self.player_y,
self.player_attack_range, True)
if hit is not None:
_, hydra, index = hit
hurt_tentacle(self, hydra, index, self.player_attack_damage)
return
return old_attack(self)
Game.attack = attack_hydra_arm
old_shoot = Game.shoot
def shoot_hydra_arm(self, target_x, target_y):
if (self.state == 'playing' and not self.in_cave
and self.equipped_weapon is not None):
weapon = self.equipped_weapon
if math.hypot(target_x-self.player_x,
target_y-self.player_y) <= weapon.weapon_range:
hit = find_targeted_arm(self, target_x, target_y, .34)
if hit is not None:
_, hydra, index = hit
hurt_tentacle(self, hydra, index, weapon.damage)
return
return old_shoot(self, target_x, target_y)
Game.shoot = shoot_hydra_arm
old_pick_up = Game.pick_up
def prevent_live_hydra_pickup(self):
if not self.in_cave:
px, py = int(self.player_x), int(self.player_y)
# Улов ручной гидры лежит рядом с ней: сначала забираем рыбу.
if self.surface_dropped_items.get((px, py)):
return old_pick_up(self)
for hydra in self.get_nearby_animals(1.6):
if (getattr(hydra.type, 'is_leech_hydra', False)
and int(hydra.x) == px and int(hydra.y) == py
and hydra.hp > 0):
if hydra.is_companion or hydra.hp <= 20:
if self.add_to_inventory(hydra.type,
wild_catch=not hydra.is_companion):
self.remove_current_animal(hydra)
self.notification = ('Ручная гидра возвращена в инвентарь.'
if hydra.is_companion else
'Ослабленная гидра поймана! R — инвентарь, H — приручить.')
else:
self.notification = 'В инвентаре нет места для гидры!'
else:
self.notification = 'Гидра сопротивляется! Ослабьте тело до 20 HP.'
self.notification_timer = 70
return
return old_pick_up(self)
Game.pick_up = prevent_live_hydra_pickup
old_scan_target = Game._scan_target_at_cursor
def scan_hydra_net(self):
if not self.in_cave:
hit = find_targeted_arm(self, self.mouse_world_x,
self.mouse_world_y, .42)
if hit is not None:
_, hydra, index = hit
return {'kind':'animal', 'entity': hydra.type,
'animal': hydra, 'x': hydra.x, 'y': hydra.y,
'hydra_arm_index': index}
return old_scan_target(self)
Game._scan_target_at_cursor = scan_hydra_net
old_scanner_details = Game._scanner_details
def scanner_details_with_hydra(self, target):
if target['entity'] is game_ns.get('leech_hydra_type'):
hydra = target.get('animal')
count = len(getattr(hydra, 'hydra_tentacles', ()))
intact = sum(arm['hp'] > 0 for arm in getattr(hydra, 'hydra_tentacles', ()))
return [
'Пиявка-гидра — редкий засадный хищник мелководья.',
'Большая часть тела скрыта в грунте; длина тела до 1,2 м.',
f'Тело: 70 HP. Щупалец-присосок: {count or "10–15"}; целых сейчас: {intact}.',
'Каждое щупальце длиной до 4 м имеет 9 HP.',
'Повреждённые щупальца можно отсечь ножом или оружием.',
'Щупальца самостоятельно отрастают через 26 секунд.',
'Урон: 1 HP × секунды × количество присосавшихся щупалец.',
'Высасывает кровь из любых рыб, восстанавливая здоровье.',
'Ручная гидра не кусает хозяина: щупальцами ловит рыбу и оставляет улов.',
'Для приручения ослабьте тело до 20 HP, E — поймать, затем H в инвентаре.',
'Уйдите из ловчей сети, чтобы присоски дикой гидры отцепились.',
'Съедобна, хотя по вкусу напоминает резину.',
]
return old_scanner_details(self, target)
Game._scanner_details = scanner_details_with_hydra
old_render = Game.render
def render_with_hydras(self):
old_render(self)
if self.state != 'playing' or self.in_cave:
return
display = pygame.display.get_surface()
zoom = self.camera_zoom
now = pygame.time.get_ticks()
def on_screen(x, y):
return (round(SCREEN_SIZE*.5+(x-self.player_x)*CELL_SIZE*zoom),
round(SCREEN_SIZE*.5+(y-self.player_y)*CELL_SIZE*zoom))
for hydra in self.get_nearby_animals(VIEW_RADIUS+HYDRA_REACH_METERS+1):
if hydra.hp <= 0 or not getattr(hydra.type, 'is_leech_hydra', False):
continue
if not hasattr(hydra, 'hydra_tentacles'):
continue
origin = on_screen(hydra.x, hydra.y)
if not (-170 < origin[0] < SCREEN_SIZE+170
and -170 < origin[1] < SCREEN_SIZE+170):
continue
for index, arm in enumerate(hydra.hydra_tentacles):
if arm['hp'] <= 0:
continue
tip_x, tip_y = _hydra_tentacle_tip(hydra, arm, now,
(self.player_x,self.player_y))
p0, p3 = origin, on_screen(tip_x, tip_y)
sway = math.sin(now*.002+arm['phase'])*.18*CELL_SIZE*zoom
dx, dy = p3[0]-p0[0], p3[1]-p0[1]
length = max(1.0, math.hypot(dx, dy))
nx, ny = -dy/length, dx/length
control = [
(round(p0[0]+dx*t+nx*sway*math.sin(math.pi*t)),
round(p0[1]+dy*t+ny*sway*math.sin(math.pi*t)))
for t in (0.0, .2, .4, .6, .8, 1.0)]
bright = arm['attached']
pygame.draw.lines(display, (13, 35, 28), False,
control, max(3, round(6*zoom)))
pygame.draw.lines(display, (136, 185, 91) if bright else (78, 122, 92),
False, control, max(1, round(3*zoom)))
cup_radius = max(3, round((7 if bright else 5)*zoom))
pygame.draw.circle(display, (25, 36, 32), p3, cup_radius+2)
pygame.draw.circle(display, (176, 88, 97) if bright else (132, 162, 111),
p3, cup_radius, max(2, round(2*zoom)))
if bright:
pygame.draw.circle(display, (247, 121, 105), p3, cup_radius+3, 1)
# Само тело почти не видно: оно остаётся закамуфлированным под грунтом.
pygame.draw.circle(display, (44, 61, 44), origin,
max(5, round(9*zoom)), max(1, round(2*zoom)))
pygame.display.flip()
Game.render = render_with_hydras
install_leech_hydra(globals())
# <<< LEECH_HYDRA_MOD_V1 <<<
# >>> LAND_VEHICLES_AND_RESOURCE_COMPLETENESS_V1 >>>
# Дополнение к МегоСубнатике2D: наземная техника + ресурсы.
def generate_land_vehicle_sprite(kind):
"""Детализированный вид сверху, нос справа; только фигуры pygame."""
big = kind == 'apc'
w, h = (192, 112) if big else (136, 86)
s = pygame.Surface((w, h), pygame.SRCALPHA)
cx, cy = w // 2, h // 2
d = pygame.draw
dark = (35, 39, 34) if big else (39, 45, 45)
main = (107, 119, 87) if big else (189, 137, 63)
light = (156, 166, 119) if big else (231, 183, 97)
# Колёса или шесть бронекатков вдоль двух бортов.
wheel_xs = (-49, -24, 1, 26, 51, 67) if big else (-36, 32)
for side in (-1, 1):
for x in wheel_xs:
half = 10 if big else 14
y = cy + side * (34 if big else 26)
d.rect(s, (16, 19, 21), (cx + x - half // 2, y - 9, half, 18), border_radius=4)
d.line(s, (71, 78, 76), (cx + x - half // 3, y - 5), (cx + x + half // 3, y - 5), 2)
body = pygame.Rect(16, 26 if big else 19, w - 32, h - (52 if big else 38))
d.rect(s, dark, body.inflate(4, 4), border_radius=13 if big else 10)
d.rect(s, main, body, border_radius=10 if big else 8)
d.rect(s, light, body.inflate(-10, -12), 2, border_radius=8)
if big:
for x in (39, 68, 129):
d.line(s, (64, 79, 63), (x, 28), (x, 83), 3)
# Командирская башня, ствол пушки и короткий ствол пулемёта.
d.circle(s, (45, 55, 45), (102, cy), 29)
d.circle(s, (122, 141, 105), (102, cy), 25)
d.polygon(s, (87, 103, 77), [(96, 38), (117, 44), (122, 55), (117, 67), (96, 72), (84, 56)])
d.rect(s, (32, 39, 38), (111, cy - 6, 75, 11), border_radius=3)
d.rect(s, (145, 161, 145), (131, cy - 4, 45, 6), border_radius=2)
d.rect(s, (29, 35, 36), (179, cy - 7, 11, 13), border_radius=2)
d.rect(s, (38, 39, 42), (103, cy - 23, 63, 5), border_radius=2)
d.circle(s, (24, 32, 36), (90, cy), 9)
d.circle(s, (132, 172, 177), (90, cy), 5)
for yy in (-20, 20):
d.rect(s, (194, 205, 162), (36, cy + yy - 3, 18, 6), border_radius=2)
else:
# Капот, лобовое стекло, багажник и запасное колесо.
d.polygon(s, (106, 81, 47), [(77, 25), (115, 30), (122, 43), (115, 55), (77, 60)])
d.line(s, (249, 211, 119), (87, 32), (108, 35), 2)
d.rect(s, (28, 72, 82), (54, 24, 25, 38), border_radius=5)
d.rect(s, (93, 163, 164), (59, 28, 15, 29), border_radius=3)
d.line(s, (46, 62, 61), (65, 28), (65, 56), 2)
d.circle(s, (22, 25, 28), (24, cy), 11)
d.circle(s, (115, 118, 112), (24, cy), 7, 2)
for yy in (29, 55):
d.circle(s, (250, 238, 173), (118, yy), 4)
# Джип вдвое меньше по каждой оси (68x43 вместо 136x86).
if not big:
return pygame.transform.smoothscale(s, (w // 2, h // 2)).convert_alpha()
return s.convert_alpha()
def install_land_vehicles_and_resources(game_ns):
if game_ns.get('_LAND_VEHICLES_RESOURCES_INSTALLED'):
return
game_ns['_LAND_VEHICLES_RESOURCES_INSTALLED'] = True
old_init_assets = game_ns['init_assets']
def with_land_resources_and_vehicles():
old_init_assets()
# Часть исходных объектов определена локально в init_assets().
cave_sulfur_type = START_ITEM_REGISTRY['Пещерная сера']
metal_salvage_type = START_ITEM_REGISTRY['Металлолом']
wiring_kit = START_ITEM_REGISTRY['Комплект проводов']
advanced_wiring_kit = START_ITEM_REGISTRY['Расширенный комплект проводов']
global light_jeep_type, armored_carrier_type
# Новый транспорт становится обычными игровыми предметами.
light_jeep_type = ObjectType('Лёгкий джип', (204, 151, 75), 20, 'rect')
light_jeep_type.image = generate_land_vehicle_sprite('jeep')
light_jeep_type.land_vehicle = True
armored_carrier_type = ObjectType('Бронетранспортёр', (117, 130, 93), 55, 'rect')
armored_carrier_type.image = generate_land_vehicle_sprite('apc')
armored_carrier_type.land_vehicle = True
game_ns['light_jeep_type'] = light_jeep_type
game_ns['armored_carrier_type'] = armored_carrier_type
# Стандартный глобальный «Медный провод» был другим объектом, чем
# медный провод из рецептов. Объединяем записи в один объект.
canonical_wire = next((r.result for r in recipes if r.result.name == 'Медный провод'), None)
if canonical_wire is not None:
global provod_type
duplicate_wire = provod_type
for recipe in recipes:
recipe.ingredients = {canonical_wire if obj is duplicate_wire else obj: amount
for obj, amount in recipe.ingredients.items()}
if recipe.result is duplicate_wire:
recipe.result = canonical_wire
provod_type = canonical_wire
game_ns['provod_type'] = canonical_wire
# Добавляем недостающие природные ресурсы без возвращения удалённых рыб.
fossil_tooth = ObjectType('Зуб сталкера', (229, 218, 176), 17, 'rect')
fossil_tooth.image = generate_crystal_sprite(22, (229, 218, 176), seed=760)
gas_bubble = ObjectType('Газовый пузырь', (157, 210, 119), 18, 'circle')
gas_bubble.image = generate_lumpy_sprite(23, gas_bubble.color, seed=761, shine=True)
game_ns['fossil_tooth_type'] = fossil_tooth
game_ns['gas_bubble_type'] = gas_bubble
additions = {
'Горы': [(coal_type, 1.9), (sulfur_type, 1.0), (uraninite_type, 0.7), (oil_type, 0.4)],
'Леса': [(coal_type, 1.1), (clay_type, 0.9)],
'Острова': [(coal_type, 0.75), (uraninite_type, 0.35)],
'Побережье': [(sulfur_type, 1.1), (oil_type, 1.3), (coal_type, 0.65)],
'Мелководье': [(cave_sulfur_type, 0.5), (metal_salvage_type, 0.9), (fossil_tooth, 0.28)],
'Глубоководье': [(uraninite_type, 0.8), (oil_type, 1.1), (gas_bubble, 0.65)],
'Центральное море': [(oil_type, 1.0), (gas_bubble, 0.85)],
'Био-люминесцентная зона': [(uraninite_type, 0.35), (cave_sulfur_type, 0.6)],
'Кельповый лес': [(fossil_tooth, 0.4), (gas_bubble, 0.25)],
'Коралловый риф': [(cave_sulfur_type, 0.25)],
}
# Исключаем отсеки Авроры и непроходимые вспомогательные биомы.
for b in biomes:
if not b.passable or getattr(b, 'aurora_style', False):
continue
present = {obj.name for obj, density in b.objects}
for obj, density in additions.get(b.name, ()):
if obj.name not in present:
b.objects.append((obj, density))
present.add(obj.name)
# Все объявленные ранее материалы теперь можно изготовить из сырья.
# Дополнительные рецепты транспорта используют уже существующие детали.
new_recipes = [
Recipe('Сталь', {iron_type: 3, coal_type: 2}, steel_type, 1,
'Изготовитель', 'Основные материалы'),
Recipe('Порох x2', {sulfur_type: 2, coal_type: 1}, gunpowder_type, 2,
'Изготовитель', 'Химия'),
Recipe('Топливо x2', {oil_type: 2, sulfur_type: 1}, fuel_type, 2,
'Изготовитель', 'Химия'),
Recipe('Бетон x2', {limestone_type: 2, sand_type: 2, clay_type: 1}, concrete_type, 2,
'Изготовитель', 'Основные материалы'),
Recipe('Лёгкий джип', {steel_type: 2, titan_type: 4, copper_type: 2,
glass_type: 1, fuel_type: 1, wiring_kit: 1},
light_jeep_type, 1, 'Переносной сборщик транспорта', 'Наземная техника'),
Recipe('Бронетранспортёр', {steel_type: 7, titan_type: 8, lead_type: 4,
diamond_type: 2, advanced_wiring_kit: 2,
fuel_type: 3, gunpowder_type: 3},
armored_carrier_type, 1, 'Переносной сборщик транспорта', 'Наземная техника'),
]
for recipe in new_recipes:
if not any(existing.name == recipe.name for existing in recipes):
recipes.append(recipe)
# Регистры начальных предметов создаются после исходных рецептов,
# поэтому новым объектам нужна собственная регистрация.
START_ITEM_REGISTRY.update({
light_jeep_type.name: light_jeep_type,
armored_carrier_type.name: armored_carrier_type,
'light_jeep_type': light_jeep_type,
'armored_carrier_type': armored_carrier_type,
fossil_tooth.name: fossil_tooth,
gas_bubble.name: gas_bubble,
canonical_wire.name: canonical_wire,
} if canonical_wire is not None else {
light_jeep_type.name: light_jeep_type,
armored_carrier_type.name: armored_carrier_type,
fossil_tooth.name: fossil_tooth,
gas_bubble.name: gas_bubble,
})
game_ns['init_assets'] = with_land_resources_and_vehicles
# Гарантируем, что новые ресурсы действительно появятся даже в маленьких
# или перекрытых полигонах; не размещаем в стенах, строениях и на воде для
# наземного сырья, если конкретный биом задан как суша.
old_world_gen = World._generate_objects
def generate_objects_with_missing_resources(self):
old_world_gen(self)
by_name = {name: [] for name in ('Уголь', 'Сера', 'Пещерная сера',
'Нефть', 'Уранинит', 'Зуб сталкера', 'Газовый пузырь')}
for b in self.biomes:
if getattr(b, 'aurora_style', False) or not b.passable:
continue
for resource, density in b.objects:
if resource.name in by_name:
by_name[resource.name].append((b, resource))
actual = {name: 0 for name in by_name}
for obj in self.object_grid.values():
if obj.name in actual:
actual[obj.name] += 1
rng = random.Random(202610301)
for name, destinations in by_name.items():
if not destinations:
continue
missing = max(0, 10 - actual[name])
attempts = 0
while missing and attempts < 2800:
attempts += 1
biome, item = destinations[attempts % len(destinations)]
x, y = biome.random_point(rng)
key = (int(x), int(y))
if (self.get_biome_at(*key) is biome and key not in self.object_grid):
self.object_grid[key] = item
missing -= 1
World._generate_objects = generate_objects_with_missing_resources
# Особо пещерный материал дополнительно находится именно в пещерах.
old_caves = Game._init_caves
def init_caves_with_sulfur(self):
old_caves(self)
cave_sulfur_type = START_ITEM_REGISTRY['Пещерная сера']
rng = random.Random(202610302)
for tunnel in self.cave_tunnels:
if getattr(tunnel, 'cave_kind', '') != 'hub':
continue
parent = getattr(tunnel, 'parent_surface_biome', None)
if parent is None or parent.name not in ('Мелководье', 'Горы', 'Био-люминесцентная зона'):
continue
for i in range(3):
for attempt in range(150):
x, y = tunnel.random_point(rng)
key = (int(x), int(y))
if key not in self.cave_object_grid and self.get_cave_tunnel_at(*key) is not None:
self.cave_object_grid[key] = cave_sulfur_type
# Численность фиксируется позже _capture_ecosystem_targets().
break
Game._init_caves = init_caves_with_sulfur
old_game_init = Game.__init__
def init_with_land_vehicles(self):
old_game_init(self)
self.vehicle_max_hp.update({light_jeep_type: 15, armored_carrier_type: 200})
self.vehicle_hp.update({light_jeep_type: 15, armored_carrier_type: 200})
self.armored_mg_cooldown = 0
self.armored_heavy_cooldown = 0
Game.__init__ = init_with_land_vehicles
def handle_vehicle_action_with_land(self):
if self.state not in ('playing', 'inventory'):
return
if self.active_vehicle is not None:
vehicle = self.active_vehicle
self.active_vehicle = None
self.state = 'playing'
self.notification = f'Вы покинули: {vehicle.name}'
self.notification_timer = 80
return
on_land = self._is_player_on_land()
# Ищем ближайшие только совместимые с типом местности обломки.
near_wrecks = [w for w in self.vehicle_wrecks if w['in_cave'] == self.in_cave
and bool(getattr(w['type'], 'land_vehicle', False)) == on_land
and (w['x'] - self.player_x) ** 2 + (w['y'] - self.player_y) ** 2 <= 1.7 ** 2]
if near_wrecks:
wreck = min(near_wrecks, key=lambda w: (w['x'] - self.player_x) ** 2 + (w['y'] - self.player_y) ** 2)
kind = wreck['type']
kits = 2 if kind in (cyclops_type, armored_carrier_type) else 1
if self.inventory_counts.get(repair_kit, 0) < kits:
self.notification = f'Для ремонта {kind.name} требуется {kits} ремкомплект(а)'
self.notification_timer = 95
return
if not any(item is None for item in self.inventory):
self.notification = 'Нужно место в инвентаре для восстановленного транспорта'
self.notification_timer = 95
return
if not self._remove_inventory_items_of_type(repair_kit, kits):
return
if not self.add_to_inventory(kind):
for _ in range(kits):
self.add_to_inventory(repair_kit)
return
self.vehicle_hp[kind] = self.vehicle_max_hp[kind]
self.vehicle_wrecks.remove(wreck)
self.active_vehicle = kind
self.state = 'playing'
self.notification = f'{kind.name} восстановлен и готов к работе'
self.notification_timer = 95
return
selected = None
if 0 <= self.active_slot_index < len(self.inventory):
item = self.inventory[self.active_slot_index]
if item in self.vehicle_max_hp:
selected = item
# При явном выборе несовместимого транспорта не подменяем его другим.
if selected is not None and bool(getattr(selected, 'land_vehicle', False)) != on_land:
self.notification = ('Наземную технику можно использовать только на суше' if not on_land
else 'Подлодки можно использовать только в воде')
self.notification_timer = 100
return
if selected is None:
selected = next((item for item in self.inventory
if item in self.vehicle_max_hp
and bool(getattr(item, 'land_vehicle', False)) == on_land), None)
if selected is None:
self.notification = ('В инвентаре нет наземного транспорта' if on_land
else 'В инвентаре нет подлодки')
self.notification_timer = 80
return
if self.vehicle_hp.get(selected, 0) <= 0:
self.notification = f'{selected.name} уничтожен: вернитесь к месту крушения'
self.notification_timer = 80
return
self.active_vehicle = selected
self.state = 'playing'
if selected is armored_carrier_type:
self.notification = 'БТР: WASD — движение, ЛКМ — тяжёлая пушка (3 с), ПКМ — пулемёт (0,2 с), F — выйти'
elif selected is light_jeep_type:
self.notification = 'Джип: WASD — движение, ЛКМ — экипированное оружие, F — выйти'
else:
self.notification = f'{selected.name}: WASD — движение, ЛКМ — торпеда, F — выйти'
self.notification_timer = 180
Game.handle_vehicle_action = handle_vehicle_action_with_land
def _land_vehicle_shot(self, target_x, target_y, kind):
if self.active_vehicle is not armored_carrier_type or self.state != 'playing' or self.in_cave:
return
if kind == 'heavy':
if self.armored_heavy_cooldown > 0:
return
damage, speed, cooldown, ttl = (30, 0.86, 180, 40)
self.armored_heavy_cooldown = cooldown
else:
if self.armored_mg_cooldown > 0:
return
damage, speed, cooldown, ttl = (1, 1.10, 12, 27)
self.armored_mg_cooldown = cooldown
dx, dy = (target_x - self.player_x, target_y - self.player_y)
length = math.hypot(dx, dy)
if length < 0.001:
angle = math.radians(self.player_angle)
dx, dy = math.cos(angle), math.sin(angle)
else:
dx, dy = dx / length, dy / length
self.vehicle_torpedoes.append({'x': self.player_x + dx * 0.8,
'y': self.player_y + dy * 0.8, 'vx': dx * speed,
'vy': dy * speed, 'damage': damage, 'ttl': ttl,
'in_cave': False, 'weapon_kind': kind})
Game._land_vehicle_shot = _land_vehicle_shot
old_torpedo = Game.fire_vehicle_torpedo
def fire_with_land_vehicles(self, x, y):
if self.active_vehicle is light_jeep_type:
if self.equipped_weapon is not None and self.equipped_weapon.is_weapon:
self.shoot(x, y)
else:
self.notification = 'Для стрельбы из джипа экипируйте оружие клавишей Q'
self.notification_timer = 85
return
if self.active_vehicle is armored_carrier_type:
self._land_vehicle_shot(x, y, 'heavy')
return
return old_torpedo(self, x, y)
Game.fire_vehicle_torpedo = fire_with_land_vehicles
old_update = Game.update
def update_with_land_weapons(self):
if self.state == 'playing' and not self.settings_open:
if self.armored_mg_cooldown > 0:
self.armored_mg_cooldown -= 1
if self.armored_heavy_cooldown > 0:
self.armored_heavy_cooldown -= 1
if self.active_vehicle is armored_carrier_type and pygame.mouse.get_pressed(num_buttons=3)[2]:
mouse_pos = pygame.mouse.get_pos()
x, y = self.screen_to_world(*mouse_pos)
self._land_vehicle_shot(x, y, 'machine_gun')
return old_update(self)
Game.update = update_with_land_weapons
old_draw_bullets = Game.draw_vehicle_torpedoes
def draw_land_vehicle_bullets(self, camera_x, camera_y):
# Оригинальные торпеды рисуются через прежний метод; новые рисуем поверх.
old_draw_bullets(self, camera_x, camera_y)
for shot in self.vehicle_torpedoes:
if shot.get('weapon_kind') not in ('heavy', 'machine_gun') or shot['in_cave'] != self.in_cave:
continue
sx = round((shot['x'] - camera_x) * CELL_SIZE + CELL_SIZE / 2)
sy = round((shot['y'] - camera_y) * CELL_SIZE + CELL_SIZE / 2)
if shot['weapon_kind'] == 'heavy':
pygame.draw.circle(self.screen, (244, 145, 57), (sx, sy), 6)
pygame.draw.circle(self.screen, (255, 222, 130), (sx - 1, sy - 1), 3)
else:
pygame.draw.circle(self.screen, (255, 240, 152), (sx, sy), 3)
Game.draw_vehicle_torpedoes = draw_land_vehicle_bullets
install_land_vehicles_and_resources(globals())
# <<< LAND_VEHICLES_AND_RESOURCE_COMPLETENESS_V1 <<<
# >>> EQUIPPED_WEAPON_PROJECTILES_V1 >>>
# Боеприпасы для всего экипируемого оружия и реальные летящие пули.
# Одна коробка/аккумулятор/канистра занимает одну ячейку и даёт 20 выстрелов.
WEAPON_AMMO_CAPACITY = 20
def install_equipped_weapon_projectiles(game_ns):
if game_ns.get('_EQUIPPED_WEAPON_PROJECTILES_INSTALLED'):
return
game_ns['_EQUIPPED_WEAPON_PROJECTILES_INSTALLED'] = True
old_init_assets = game_ns['init_assets']
def init_armed_assets():
old_init_assets()
global regular_ammo_type, energy_ammo_type, flame_ammo_type
regular_ammo_type = ObjectType('Коробка патронов (20)', (198, 174, 95), 23, 'rect')
energy_ammo_type = ObjectType('Энергозаряды (20)', (65, 196, 249), 23, 'rect')
flame_ammo_type = ObjectType('Огнесмесь (20)', (235, 126, 47), 23, 'rect')
for item in (regular_ammo_type, energy_ammo_type, flame_ammo_type):
item.image = generate_tech_item_sprite(item.name, item.size, item.color)
item.ammo_capacity = WEAPON_AMMO_CAPACITY
START_ITEM_REGISTRY[item.name] = item
game_ns[item.name] = item
game_ns['regular_ammo_type'] = regular_ammo_type
game_ns['energy_ammo_type'] = energy_ammo_type
game_ns['flame_ammo_type'] = flame_ammo_type
# Материалы берём из актуального регистра после всех предыдущих модов.
def resource(name):
return START_ITEM_REGISTRY[name]
recipes_new = [
Recipe('Коробка патронов (20)',
{iron_type: 1, copper_type: 1, gunpowder_type: 1}, regular_ammo_type,
1, 'Изготовитель', 'Боеприпасы'),
Recipe('Энергозаряды (20)',
{resource('Батарея'): 1, quartz_type: 1, resource('Медный провод'): 1},
energy_ammo_type, 1, 'Изготовитель', 'Боеприпасы'),
Recipe('Огнесмесь (20)',
{fuel_type: 1, sulfur_type: 1}, flame_ammo_type,
1, 'Изготовитель', 'Боеприпасы'),
Recipe('Лазерный пистолет',
{titan_type: 2, resource('Батарея'): 1,
resource('Медный провод'): 1, glass_type: 1},
laser_pistol, 1, 'Изготовитель', 'Оружие'),
Recipe('Подводное ружьё',
{steel_type: 2, resource('Медный провод'): 1,
glass_type: 1, resource('Силиконовая резина'): 1},
underwater_rifle, 1, 'Изготовитель', 'Оружие'),
Recipe('Плазменная пушка',
{magnetite_type: 2, resource('Батарея'): 2,
resource('Микросхема'): 1, resource('Полианилин'): 1},
plasma_cannon, 1, 'Изготовитель', 'Оружие'),
Recipe('Дальняя пушка',
{steel_type: 3, glass_type: 2,
resource('Расширенный комплект проводов'): 1},
lang_cannon, 1, 'Изготовитель', 'Оружие'),
Recipe('Тяжёлая пушка',
{steel_type: 5, lead_type: 3, diamond_type: 1,
resource('Расширенный комплект проводов'): 1},
schweer_cannon, 1, 'Изготовитель', 'Оружие'),
Recipe('Алмазный резак',
{diamond_type: 3, steel_type: 2, resource('Батарея'): 1},
diamond_cutter, 1, 'Изготовитель', 'Оружие'),
Recipe('Огнемёт',
{steel_type: 2, fuel_type: 2, resource('Медный провод'): 1},
flamethrower, 1, 'Изготовитель', 'Оружие'),
Recipe('Золотой пистолет',
{gold_type: 3, steel_type: 1, resource('Батарея'): 1,
resource('Микросхема'): 1},
golden_pistol, 1, 'Изготовитель', 'Оружие'),
]
names = {recipe.name for recipe in recipes}
for recipe in recipes_new:
if recipe.name not in names:
recipes.append(recipe)
names.add(recipe.name)
for item in (laser_pistol, underwater_rifle, plasma_cannon,
lang_cannon, schweer_cannon, diamond_cutter,
flamethrower, golden_pistol):
START_ITEM_REGISTRY[item.name] = item
game_ns['init_assets'] = init_armed_assets
old_game_init = Game.__init__
def init_armed_game(self):
old_game_init(self)
self.player_bullets = []
self.weapon_shot_cooldown = 0
# Заряженные патроны остаются в магазине, даже когда коробка уже потрачена.
self.loaded_weapon_ammo = {}
Game.__init__ = init_armed_game
def _weapon_ammo_type(self, weapon):
if weapon is None or not getattr(weapon, 'is_weapon', False):
return None
t = getattr(weapon, 'weapon_type', '')
if t == 'огонь':
return flame_ammo_type
if t in ('лазер', 'плазма'):
return energy_ammo_type
return regular_ammo_type
Game._weapon_ammo_type = _weapon_ammo_type
def _ammo_remaining(self, ammo_type):
if ammo_type is None:
return 0
return (self.loaded_weapon_ammo.get(ammo_type, 0) +
self.inventory_counts.get(ammo_type, 0) * WEAPON_AMMO_CAPACITY)
Game._ammo_remaining = _ammo_remaining
def fire_equipped_weapon_bullet(self, target_x, target_y):
if self.state != 'playing' or self.settings_open:
return
if self.active_vehicle is not None and self.active_vehicle is not light_jeep_type:
return
weapon = self.equipped_weapon
if weapon is None or not getattr(weapon, 'is_weapon', False):
self.notification = 'Сначала экипируйте оружие: выберите его в R, затем Q.'
self.notification_timer = 75
return
if self.weapon_shot_cooldown > 0 or len(self.player_bullets) >= 160:
return
dx = target_x - self.player_x
dy = target_y - self.player_y
dist = math.hypot(dx, dy)
if dist < 0.001:
a = math.radians(self.player_angle)
dx, dy = math.cos(a), math.sin(a)
else:
dx, dy = dx / dist, dy / dist
ammo_type = self._weapon_ammo_type(weapon)
available = self.loaded_weapon_ammo.get(ammo_type, 0)
if available == 0:
if self.inventory_counts.get(ammo_type, 0) <= 0:
self.notification = f'Нет боеприпасов: {ammo_type.name}. Изготовьте их в крафте (I).'
self.notification_timer = 85
return
if not self._remove_inventory_items_of_type(ammo_type, 1):
return
available = WEAPON_AMMO_CAPACITY
self.loaded_weapon_ammo[ammo_type] = available - 1
category = getattr(weapon, 'weapon_type', '')
params = {
'ружьё': (0.85, (250, 217, 128), 2.7, 10),
'лазер': (1.1, (95, 238, 252), 3.5, 10),
'плазма': (0.53, (232, 86, 250), 5.0, 22),
'огонь': (0.43, (255, 124, 36), 4.5, 5),
}
speed, color, radius, cooldown = params.get(category, params['ружьё'])
if weapon is schweer_cannon:
cooldown = 30
elif weapon is lang_cannon:
cooldown = 7
self.weapon_shot_cooldown = cooldown
self.player_bullets.append({
'x': self.player_x + dx * 0.38,
'y': self.player_y + dy * 0.38,
'vx': dx * speed, 'vy': dy * speed,
'distance': max(0.4, float(weapon.weapon_range)),
'damage': int(weapon.damage), 'color': color,
'radius': radius, 'kind': category,
'in_cave': bool(self.in_cave), 'weapon_name': weapon.name,
})
Game.shoot = fire_equipped_weapon_bullet
# FAST_BULLET_COLLISION_V2: единое столкновение траектории с целями
# вместо ~6-25 полных переборов фауны, гидр и растений за кадр.
def _bullet_circle_t(sx, sy, dx, dy, cx, cy, radius):
"""Первое попадание отрезка P(t)=P0+t*(dx,dy) в окружность, t∈[0,1]."""
rx = cx - sx
ry = cy - sy
if abs(rx) > abs(dx) + radius or abs(ry) > abs(dy) + radius:
return None
r2 = radius * radius
dist0 = rx * rx + ry * ry
if dist0 <= r2:
return 0.0
length2 = dx * dx + dy * dy
if length2 < 1e-12:
return None
t_center = (rx * dx + ry * dy) / length2
if t_center < 0.0 or t_center > 1.0 + radius / math.sqrt(length2):
return None
perp2 = dist0 - t_center * t_center * length2
if perp2 > r2:
return None
t = t_center - math.sqrt(max(0.0, r2 - perp2) / length2)
return max(0.0, t) if t <= 1.0 else None
def _bullet_capsule_t(sx, sy, dx, dy, ax, ay, bx, by, radius):
"""Отрезок пули против щупальца (отрезок с радиусом)."""
ux, uy = bx - ax, by - ay
length = math.hypot(ux, uy)
if length < 1e-9:
return _bullet_circle_t(sx, sy, dx, dy, ax, ay, radius)
ux /= length
uy /= length
# Пересечение двух линейных интервалов: расстояние от оси
# щупальца <= radius и проекция на его длину от 0 до length.
rx, ry = sx - ax, sy - ay
side0 = rx * uy - ry * ux
side_speed = dx * uy - dy * ux
along0 = rx * ux + ry * uy
along_speed = dx * ux + dy * uy
low, high = 0.0, 1.0
for value, speed, minimum, maximum in (
(side0, side_speed, -radius, radius),
(along0, along_speed, 0.0, length),
):
if abs(speed) < 1e-12:
if value < minimum or value > maximum:
low = 2.0
break
continue
t0, t1 = (minimum - value) / speed, (maximum - value) / speed
if t0 > t1:
t0, t1 = t1, t0
low = max(low, t0)
high = min(high, t1)
if low > high:
break
result = low if low <= high else None
for px, py in ((ax, ay), (bx, by)):
t = _bullet_circle_t(sx, sy, dx, dy, px, py, radius)
if t is not None and (result is None or t < result):
result = t
return result
def _bullet_wall_t(game, sx, sy, dx, dy):
"""Пройти клетки траектории один раз (DDA), не пропуская стены."""
x, y = math.floor(sx), math.floor(sy)
if not (0 <= x < MAP_SIZE and 0 <= y < MAP_SIZE):
return 0.0
endx, endy = sx + dx, sy + dy
if game.in_cave:
# Пещеры имеют не только клетки, но и наклонные границы.
# Проверяем только геометрию стен, без перебора животных.
steps = max(1, math.ceil(math.hypot(dx, dy) / 0.16))
for i in range(1, steps + 1):
t = i / steps
xx, yy = sx + t * dx, sy + t * dy
if (not (0 <= xx < MAP_SIZE and 0 <= yy < MAP_SIZE)
or not game.cave_is_passable(xx, yy)):
return t
return None
walls = game.world.object_grid
def blocked(ix, iy):
if not (0 <= ix < MAP_SIZE and 0 <= iy < MAP_SIZE):
return True
obj = walls.get((ix, iy))
if (obj is not None and obj.is_wall) or game.is_start_capsule_wall(ix, iy):
return True
biome = game.world.get_biome_at(ix, iy)
return biome is not None and not biome.passable
if blocked(x, y):
return 0.0
if abs(dx) < 1e-12 and abs(dy) < 1e-12:
return None
step_x = 1 if dx > 0 else -1
step_y = 1 if dy > 0 else -1
tx = (((x + 1 if dx > 0 else x) - sx) / dx) if dx else float('inf')
ty = (((y + 1 if dy > 0 else y) - sy) / dy) if dy else float('inf')
dtx = abs(1.0 / dx) if dx else float('inf')
dty = abs(1.0 / dy) if dy else float('inf')
while min(tx, ty) <= 1.0 + 1e-12:
t = min(tx, ty)
if abs(tx - ty) < 1e-10:
# Угол стены: не протискиваться между диагональными блоками.
if blocked(x + step_x, y) or blocked(x, y + step_y):
return max(0.0, t)
x += step_x
y += step_y
tx += dtx
ty += dty
elif tx < ty:
x += step_x
tx += dtx
else:
y += step_y
ty += dty
if blocked(x, y):
return max(0.0, t)
return None
def _bullet_frame_buckets(game, size=6):
"""Единый индекс малочисленной наземной фауны и мха на кадр."""
golems, moss = {}, {}
for g in game.world.golem_colonies:
if g.phase == 'active':
golems.setdefault((math.floor(g.x / size), math.floor(g.y / size)), []).append(g)
for m in game.world.moss_colonies:
moss.setdefault((math.floor(m.x / size), math.floor(m.y / size)), []).append(m)
return golems, moss
def _bullet_objects_in_path(buckets, sx, sy, ex, ey, radius, size=6):
x0 = math.floor((min(sx, ex) - radius) / size)
x1 = math.floor((max(sx, ex) + radius) / size)
y0 = math.floor((min(sy, ey) - radius) / size)
y1 = math.floor((max(sy, ey) + radius) / size)
for cy in range(y0, y1 + 1):
for cx in range(x0, x1 + 1):
yield from buckets.get((cx, cy), ())
def _update_equipped_weapon_bullets(self):
if not self.player_bullets:
return
survivors = []
# Построить индексы лишь один раз на кадр и только если есть пули.
if self.in_cave:
ground_golems = ground_moss = None
else:
ground_golems, ground_moss = _bullet_frame_buckets(self)
hydra_shapes = {}
current_time = None
for shot in self.player_bullets:
if shot['in_cave'] != self.in_cave:
survivors.append(shot)
continue
move = min(shot['distance'], math.hypot(shot['vx'], shot['vy']))
if move <= 1e-9:
continue
sx, sy = shot['x'], shot['y']
velocity = math.hypot(shot['vx'], shot['vy'])
dx, dy = shot['vx'] * move / velocity, shot['vy'] * move / velocity
ex, ey = sx + dx, sy + dy
best_t = _bullet_wall_t(self, sx, sy, dx, dy)
best_kind = 'wall' if best_t is not None else None
best_obj = None
best_index = None
# Surface: индекс существ по чанкам; cave: фильтрация локальной
# области (пещерная фауна не входит в индекс поверхности).
if self.in_cave:
nearby = (a for a in self.cave_animals
if (min(sx, ex)-1.45 <= a.x <= max(sx, ex)+1.45
and min(sy, ey)-1.45 <= a.y <= max(sy, ey)+1.45))
else:
mx, my = (sx + ex) * 0.5, (sy + ey) * 0.5
radius = move * .5 + HYDRA_REACH_METERS + 1.5
nearby = self.world.get_animals_near(mx, my, radius)
for animal in nearby:
if animal.hp <= 0 or getattr(animal, 'is_companion', False):
continue
# Если луч проходит над дальним существом, всё остальное
# для него можно пропустить без квадратного корня.
if (animal.x < min(sx, ex)-5.0 or animal.x > max(sx, ex)+5.0
or animal.y < min(sy, ey)-5.0 or animal.y > max(sy, ey)+5.0):
continue
if not self.in_cave and getattr(animal.type, 'is_leech_hydra', False) and getattr(animal, 'hydra_tentacles', None):
if animal not in hydra_shapes:
if current_time is None:
current_time = pygame.time.get_ticks()
hydra_shapes[animal] = [
(i, *_hydra_tentacle_tip(animal, arm, current_time,
(self.player_x, self.player_y)))
for i, arm in enumerate(animal.hydra_tentacles) if arm['hp'] > 0
]
for arm_index, tx, ty in hydra_shapes[animal]:
t = _bullet_capsule_t(sx, sy, dx, dy, animal.x, animal.y, tx, ty, .18)
if t is None or (best_t is not None and t >= best_t):
continue
# Внутри центра гидры стреляем по телу, не по щупальцу.
bx, by = sx + t * dx - animal.x, sy + t * dy - animal.y
if bx * bx + by * by < .65 * .65:
continue
best_t, best_kind, best_obj, best_index = t, 'arm', animal, arm_index
hit_radius = max(.24, min(1.45, animal.type.size / CELL_SIZE * .28))
t = _bullet_circle_t(sx, sy, dx, dy, animal.x, animal.y, hit_radius)
if t is not None and (best_t is None or t < best_t):
best_t, best_kind, best_obj, best_index = t, 'animal', animal, None
if not self.in_cave:
for g in _bullet_objects_in_path(ground_golems, sx, sy, ex, ey, .3):
t = _bullet_circle_t(sx, sy, dx, dy, g.x, g.y, .3)
if t is not None and (best_t is None or t < best_t):
best_t, best_kind, best_obj, best_index = t, 'golem', g, None
for m in _bullet_objects_in_path(ground_moss, sx, sy, ex, ey, .28):
t = _bullet_circle_t(sx, sy, dx, dy, m.x, m.y, .28)
if t is not None and (best_t is None or t < best_t):
best_t, best_kind, best_obj, best_index = t, 'moss', m, None
if best_t is not None:
# Пуля остаётся на точке контакта и сразу удаляется.
shot['x'] = sx + dx * best_t
shot['y'] = sy + dy * best_t
if best_kind == 'arm':
self._hurt_hydra_tentacle(best_obj, best_index, shot['damage'])
elif best_kind == 'animal':
animal = best_obj
animal.hp -= shot['damage']
self.notification = (f'{shot["weapon_name"]}: {animal.type.name}, '
f'HP {max(0, int(animal.hp))}')
self.notification_timer = 40
if animal.hp <= 0:
self.handle_animal_death(animal)
elif best_kind == 'golem':
self._damage_golem(best_obj, shot['damage'])
elif best_kind == 'moss':
self._hit_mad_moss(best_obj, shot['damage'])
ground_golems, ground_moss = _bullet_frame_buckets(self)
elif best_kind == 'wall':
_damage_wall_at_shot_contact(self, shot, sx, sy, dx, dy, best_t)
continue
shot['x'], shot['y'] = ex, ey
shot['distance'] -= move
if shot['distance'] > 1e-6:
survivors.append(shot)
self.player_bullets = survivors
Game._update_equipped_weapon_bullets = _update_equipped_weapon_bullets
def _update_fast_vehicle_torpedoes(self):
"""БТР/подлодки: столкновения с локальными целями вместо обхода всей фауны."""
if self.vehicle_torpedo_cooldown > 0:
self.vehicle_torpedo_cooldown -= 1
if not self.vehicle_torpedoes:
return
survivors = []
if self.in_cave:
ground_golems = ground_moss = None
else:
ground_golems, ground_moss = _bullet_frame_buckets(self)
for shot in self.vehicle_torpedoes:
if shot['in_cave'] != self.in_cave:
continue
shot['ttl'] -= 1
if shot['ttl'] <= 0:
continue
sx, sy = shot['x'], shot['y']
dx, dy = shot['vx'], shot['vy']
ex, ey = sx + dx, sy + dy
best_t = _bullet_wall_t(self, sx, sy, dx, dy)
best_kind = 'wall' if best_t is not None else None
best_obj = None
if self.in_cave:
nearby = (a for a in self.cave_animals
if min(sx, ex) - 8 <= a.x <= max(sx, ex) + 8
and min(sy, ey) - 8 <= a.y <= max(sy, ey) + 8)
else:
mx, my = (sx + ex) / 2, (sy + ey) / 2
nearby = self.world.get_animals_near(mx, my, math.hypot(dx, dy) / 2 + 8)
for animal in nearby:
if animal.hp <= 0 or getattr(animal, 'is_companion', False):
continue
visual_radius = max(.35, animal.type.size / CELL_SIZE * .32)
t = _bullet_circle_t(sx, sy, dx, dy, animal.x, animal.y, visual_radius)
if t is not None and (best_t is None or t < best_t):
best_t, best_kind, best_obj = t, 'animal', animal
if not self.in_cave:
for g in _bullet_objects_in_path(ground_golems, sx, sy, ex, ey, .9):
t = _bullet_circle_t(sx, sy, dx, dy, g.x, g.y, .9)
if t is not None and (best_t is None or t < best_t):
best_t, best_kind, best_obj = t, 'golem', g
for m in _bullet_objects_in_path(ground_moss, sx, sy, ex, ey, .55):
t = _bullet_circle_t(sx, sy, dx, dy, m.x, m.y, .55)
if t is not None and (best_t is None or t < best_t):
best_t, best_kind, best_obj = t, 'moss', m
if best_t is not None:
if best_kind == 'animal':
a = best_obj
a.hp -= shot['damage']
self.notification = f'Торпеда поразила {a.type.name}: HP {max(0, int(a.hp))}'
self.notification_timer = 45
if a.hp <= 0:
self.handle_animal_death(a)
elif best_kind == 'golem':
self._damage_golem(best_obj, shot['damage'])
elif best_kind == 'moss':
self._hit_mad_moss(best_obj, shot['damage'])
# При делении мха индекс должен отразить новые колонии.
ground_golems, ground_moss = _bullet_frame_buckets(self)
elif best_kind == 'wall':
_damage_wall_at_shot_contact(self, shot, sx, sy, dx, dy, best_t)
continue
shot['x'], shot['y'] = ex, ey
survivors.append(shot)
self.vehicle_torpedoes = survivors
Game._update_vehicle_torpedoes = _update_fast_vehicle_torpedoes
old_update = Game.update
def update_with_player_bullets(self):
if self.state == 'playing' and not self.settings_open:
if self.weapon_shot_cooldown > 0:
self.weapon_shot_cooldown -= 1
result = old_update(self)
if self.state == 'playing' and not self.settings_open:
self._update_equipped_weapon_bullets()
return result
Game.update = update_with_player_bullets
old_draw_torpedoes = Game.draw_vehicle_torpedoes
def draw_equipped_weapon_bullets(self, camera_x, camera_y):
old_draw_torpedoes(self, camera_x, camera_y)
for shot in self.player_bullets:
if shot['in_cave'] != self.in_cave:
continue
x = round((shot['x'] - camera_x) * CELL_SIZE + CELL_SIZE / 2)
y = round((shot['y'] - camera_y) * CELL_SIZE + CELL_SIZE / 2)
view_size = getattr(self, '_render_canvas_size', SCREEN_SIZE)
if x < -24 or y < -24 or x > view_size + 24 or y > view_size + 24:
continue
length = max(1e-8, math.hypot(shot['vx'], shot['vy']))
dx, dy = shot['vx']/length, shot['vy']/length
px = round(x - dx * (shot['radius'] * 3))
py = round(y - dy * (shot['radius'] * 3))
pygame.draw.line(self.screen, shot['color'], (px, py), (x, y),
max(2, round(shot['radius'])))
pygame.draw.circle(self.screen, (255, 252, 229), (x, y),
max(2, round(shot['radius'] * .65)))
Game.draw_vehicle_torpedoes = draw_equipped_weapon_bullets
old_draw_hud = Game.draw_hud
def draw_ammo_hud(self):
old_draw_hud(self)
if (self.equipped_weapon is None or self.state != 'playing' or
(self.active_vehicle is not None and self.active_vehicle is not light_jeep_type)):
return
ammo_type = self._weapon_ammo_type(self.equipped_weapon)
if ammo_type is None:
return
count = self._ammo_remaining(ammo_type)
label = 'Джип' if self.active_vehicle is light_jeep_type else 'Оружие'
line = self.small_font.render(f'{label} | {ammo_type.name}: {count} выстр.',
True, (245, 213, 127) if count else (255, 96, 88))
self.screen.blit(line, (10, 252))
Game.draw_hud = draw_ammo_hud
install_equipped_weapon_projectiles(globals())
# <<< EQUIPPED_WEAPON_PROJECTILES_V1 <<<
# >>> AURORA_SPECIALIZED_STOCK_V2 >>>
# Складские отделения: каждый отсек имеет собственную номенклатуру.
# В оружейном хранилище разрешено смешение оружия с коробками патронов.
# Точные названия предметов должны совпадать с START_ITEM_REGISTRY.
AURORA_ROOM_STOCK = {
'Входной коридор Авроры / Airlock': ('Батарея',),
'Коридор администрации Авроры': ('Медный провод',),
'Кабинет администрации Авроры': ('Микросхема',),
'Обрушенный коридор Авроры к грузовому отсеку': ('Стекло',),
'Коридор грузового отсека Авроры': ('Медная руда',),
'Главный грузовой отсек Авроры': ('Титан',),
'Грузовой подъёмник Авроры': ('Титановый слиток',),
'Нижний грузовой отсек Авроры': ('Сталь',),
'Четырёхсторонний коридор Авроры': ('Металлолом',),
'Отсек «Мотылёк» Авроры': (
'Модуль глубины «Мотылька» – Мод. 1',
'Солнечная зарядка «Мотылька»',
'Гидролокатор «Мотылька»'),
'Коридор двигательного отсека Авроры': ('Комплект проводов',),
'Двигательный отсек / реактор Авроры': ('Топливный стержень реактора',),
'Раздевалка Авроры': ('Обычный кислородный баллон',),
# Оружейная зона: большую часть составляют коробки патронов.
'Отсек костюмов КРАБ / PRAWN Bay Авроры': (
'Коробка патронов (20)', 'Коробка патронов (20)',
'Коробка патронов (20)', 'Коробка патронов (20)',
'Коробка патронов (20)', 'Коробка патронов (20)',
'Коробка патронов (20)', 'Коробка патронов (20)',
'Энергозаряды (20)', 'Энергозаряды (20)',
'Подводное ружьё', 'Плазменная пушка',
'Дальняя пушка', 'Тяжёлая пушка'),
'Пролом корпуса PRAWN Bay Авроры': ('Порох',),
'Жилой коридор Авроры': ('Аптечка',),
'Столовая Авроры': ('Сухой паёк Авроры', 'Бутылка воды Авроры'),
'Каюта №1 Авроры': ('Кварц',),
'Каюта капитана Авроры': ('Ионная батарея',),
'Каюта №3 Авроры': ('Золотая руда',),
'Каюта №4 Авроры': ('Литий',),
'Каюта №6 Авроры': ('Ремкомплект',),
'Каюта №7 Авроры': ('Фонарик',),
'Подводный технический проход Авроры': ('Энергоячейка',),
'Верхняя комната чёрного ящика Авроры': ('Рубин',),
'Нижняя комната чёрного ящика Авроры': ('Магнетит',),
'Большая катушка данных Авроры': ('Ионный куб',),
'Лаборатория Авроры': ('Сканер',),
'Обрушенный коридор лаборатории Авроры': ('Алмаз',),
'Выходной коридор Авроры': ('Свинец',),
}
def aurora_loot_for_room(name):
"""Без случайной смеси: ассортимент строго закреплён за отсеком."""
return AURORA_ROOM_STOCK.get(name, ('Металлолом',))
def populate_aurora_room_loot(world):
"""Плотные одинарные ряды у стен, в просторных помещениях — в центре.
Готовые места запоминаются, чтобы периодический респавн не разбрасывал
предметы по комнате. Все клетки проверяются по реальному приоритету биомов.
"""
registry = START_ITEM_REGISTRY
grid = world.object_grid
world.aurora_loot_slots = {}
rooms = [b for b in world.biomes if getattr(b, 'aurora_loot_room', False)]
for room_index, room in enumerate(rooms):
x1, y1, x2, y2 = room.rect
width, height = x2 - x1 + 1, y2 - y1 + 1
mid_x, mid_y = (x1 + x2) // 2, (y1 + y2) // 2
names = aurora_loot_for_room(room.name)
pool = [registry[n] for n in names
if n in registry and isinstance(registry[n], ObjectType)
and not registry[n].is_buildable]
if not pool:
continue
rng = random.Random(31102026 + room_index * 1091 + _bio_stable_seed(room.name))
rng.shuffle(pool)
def usable(x, y):
if not (x1 < x < x2 and y1 < y < y2):
return False
if (x, y) in grid or world.get_biome_at(x, y) is not room:
return False
# Пустое пространство около дверей и осевые проходы.
if (y in (y1 + 1, y2 - 1) and abs(x - mid_x) <= 3):
return False
if (x in (x1 + 1, x2 - 1) and abs(y - mid_y) <= 3):
return False
# Зона спавна героя в каюте №6 остаётся незаставленной.
if 790 <= x <= 796 and 632 <= y <= 638:
return False
return True
candidates = []
# Ровно один ряд возле каждого края, интервал 1 пустая клетка.
for x in range(x1 + 2, x2 - 1, 2):
candidates.extend(((x, y1 + 1), (x, y2 - 1)))
for y in range(y1 + 2, y2 - 1, 2):
candidates.extend(((x1 + 1, y), (x2 - 1, y)))
if width >= 23 and height >= 18:
row_offset = max(4, min(10, height // 5))
# Два внутренних ряда; у особо крупных — ещё третий.
lanes = [mid_y - row_offset, mid_y + row_offset]
if width >= 46 and height >= 32:
lanes.insert(1, mid_y)
for y in lanes:
for x in range(x1 + 4, x2 - 3, 4):
if abs(x - mid_x) <= 2 or abs(y - mid_y) <= 2:
continue
candidates.append((x, y))
placed = 0
seen = set()
for x, y in candidates:
if (x, y) in seen:
continue
seen.add((x, y))
if not usable(x, y):
continue
item = pool[placed % len(pool)]
grid[x, y] = item
world.aurora_loot_slots.setdefault((room, item), []).append((x, y))
placed += 1
_original_world_generate_with_resources = World._generate_objects
def generate_objects_with_aurora_loot(self):
_original_world_generate_with_resources(self)
populate_aurora_room_loot(self)
World._generate_objects = generate_objects_with_aurora_loot
# Столовая получает настоящие съедобные припасы, а не строительную химию.
_original_init_assets_before_aurora_food = init_assets
def init_assets_with_aurora_food():
_original_init_assets_before_aurora_food()
food = ObjectType('Сухой паёк Авроры', (198, 158, 91), 23, 'rect',
edible=True, hunger_value=45, thirst_value=2)
water = ObjectType('Бутылка воды Авроры', (95, 181, 218), 22, 'rect',
edible=True, hunger_value=0, thirst_value=35)
for item in (food, water):
item.image = generate_tech_item_sprite(item.name, item.size, item.color)
START_ITEM_REGISTRY[item.name] = item
init_assets = init_assets_with_aurora_food
# Повторное появление ресурсов — только на исходных местах ряда.
_original_respawn_aurora_resource = Game._spawn_missing_surface_resource
def respawn_aurora_stock_at_designated_slot(self, biome, object_type):
if getattr(biome, 'aurora_loot_room', False):
slots = getattr(self.world, 'aurora_loot_slots', {}).get((biome, object_type), ())
for x, y in slots:
cell = (x, y)
if (cell not in self.world.object_grid
and not self.surface_dropped_items.get(cell)
and self.world.get_biome_at(x, y) is biome):
self.world.object_grid[cell] = object_type
return True
return False
return _original_respawn_aurora_resource(self, biome, object_type)
Game._spawn_missing_surface_resource = respawn_aurora_stock_at_designated_slot
# <<< AURORA_SPECIALIZED_STOCK_V2 <<<
# >>> DESTRUCTIBLE_WALLS_V1 >>>
# Разрушаемые металлические стены Авроры, стены построек и капсулы.
# Прочность хранится по клеткам, а не в объекте типа стены:
# выстрел по одному сегменту не повреждает соседние сегменты.
AURORA_PARTITION_HP = 90
AURORA_HULL_HP = 180
PLAYER_WALL_HP = 65
CAPSULE_WALL_HP = 110
# Фоновая металлическая панель для клеток, где больше нет корпуса.
AURORA_BREACH_BIOME = Biome(
'Пролом корпуса Авроры', (69, 73, 76), 0, 0, 0, 0,
[], [], passable=True, priority=100)
AURORA_BREACH_BIOME.aurora_style = True
AURORA_BREACH_BIOME.location_kind = 'aurora_wreck'
_original_biome_lookup_before_damage = World.get_biome_at
def biome_lookup_with_aurora_breaches(self, x, y):
key = (int(x), int(y))
breach = getattr(self, 'aurora_breaches', None)
if breach is not None and key in breach:
return breach[key]
return _original_biome_lookup_before_damage(self, x, y)
World.get_biome_at = biome_lookup_with_aurora_breaches
_original_capsule_wall_check = Game.is_start_capsule_wall
def capsule_wall_can_be_breached(self, x, y):
if (int(x), int(y)) in getattr(self, 'capsule_breaches', ()):
return False
return _original_capsule_wall_check(self, x, y)
Game.is_start_capsule_wall = capsule_wall_can_be_breached
def _wall_hit_cell(game, sx, sy, dx, dy, t):
"""Find the blocked tile *after* a DDA entry, including corner hits."""
pt = min(1., max(0., t + 0.00001))
x, y = math.floor(sx + dx * pt), math.floor(sy + dy * pt)
candidates = ((x, y), (x + (1 if dx > 0 else -1), y),
(x, y + (1 if dy > 0 else -1)))
for cx, cy in candidates:
if not (0 <= cx < MAP_SIZE and 0 <= cy < MAP_SIZE):
continue
ob = game.world.object_grid.get((cx, cy))
if ob is not None and ob.is_wall:
return cx, cy, 'building', ob
if game.is_start_capsule_wall(cx, cy):
return cx, cy, 'capsule', None
b = game.world.get_biome_at(cx, cy)
if b is not None and not b.passable and getattr(b, 'aurora_style', False):
return cx, cy, 'aurora', b
return None
def _damage_wall_at_shot_contact(game, shot, sx, sy, dx, dy, t):
"""Damage the impacted wall; never destroy arbitrary terrain outside walls."""
# Каменные/полигональные границы пещер остаются нетронутыми,
# но построенную внутри пещеры стену можно прострелить.
weapon = str(shot.get('weapon_name', ''))
vehicle_kind = shot.get('weapon_kind')
is_cannon = ('пушка' in weapon.lower() or vehicle_kind == 'heavy'
or ('weapon_kind' not in shot and 'weapon_name' not in shot))
if not is_cannon:
return
if game.in_cave:
pt = min(1., max(0., t + 0.00001))
x = math.floor(sx + dx * pt)
y = math.floor(sy + dy * pt)
thing = game.cave_object_grid.get((x, y))
if thing is None or not thing.is_wall:
return
material = 'cave_building'
collided = (x, y, material, thing)
else:
collided = _wall_hit_cell(game, sx, sy, dx, dy, t)
if collided is None:
return
x, y, material, thing = collided
key = (x, y)
# Тяжёлые снаряды наиболее эффективны, пулемёт не пробивает корпус.
multiplier = 1.7 if vehicle_kind == 'heavy' else 1.25
damage = max(1, round(float(shot.get('damage', 0)) * multiplier))
if material == 'aurora':
hp_map = getattr(game.world, 'aurora_wall_hp', None)
if hp_map is None:
hp_map = game.world.aurora_wall_hp = {}
if 'Корпус' in thing.name or 'Нос' in thing.name:
max_hp = AURORA_HULL_HP
else:
max_hp = AURORA_PARTITION_HP
elif material in ('building', 'cave_building'):
owner = game if material == 'cave_building' else game.world
hp_map = getattr(owner, 'constructed_wall_hp', None)
if hp_map is None:
hp_map = owner.constructed_wall_hp = {}
max_hp = PLAYER_WALL_HP
else:
hp_map = getattr(game, 'capsule_wall_hp', None)
if hp_map is None:
hp_map = game.capsule_wall_hp = {}
max_hp = CAPSULE_WALL_HP
remaining = max(0, hp_map.get(key, max_hp) - damage)
hp_map[key] = remaining
if remaining > 0:
game.notification = f'Стена: {remaining}/{max_hp} HP'
game.notification_timer = 32
return
if material in ('building', 'cave_building'):
# Only remove if the block really is still a wall.
grid = (game.cave_object_grid if material == 'cave_building'
else game.world.object_grid)
if grid.get(key) is thing:
del grid[key]
hp_map.pop(key, None)
elif material == 'capsule':
game.capsule_breaches = getattr(game, 'capsule_breaches', set())
game.capsule_breaches.add(key)
hp_map.pop(key, None)
else:
# Seek the topmost passable metal surface below the broken wall.
candidates = game.world.biome_chunks.get(
(x // WORLD_CHUNK_SIZE, y // WORLD_CHUNK_SIZE), ())
floor = next((b for b in candidates
if b.passable and getattr(b, 'aurora_style', False)
and b.contains(x, y)), AURORA_BREACH_BIOME)
game.world.aurora_breaches = getattr(game.world, 'aurora_breaches', {})
game.world.aurora_breaches[key] = floor
hp_map.pop(key, None)
game.notification = 'Стена уничтожена — проход открыт!'
game.notification_timer = 75
_original_draw_aurora_cell = Game.draw_aurora_cell
def draw_aurora_with_wall_damage(self, x, y, sx, sy, biome):
_original_draw_aurora_cell(self, x, y, sx, sy, biome)
key = (x, y)
if key in getattr(self.world, 'aurora_breaches', ()):
# Прожжённый край пробоины, без создания отдельных изображений.
pygame.draw.rect(self.screen, (48, 42, 42), (sx + 2, sy + 2, CELL_SIZE - 4, CELL_SIZE - 4), 2)
pygame.draw.line(self.screen, (204, 118, 61), (sx + 5, sy + 5), (sx + 13, sy + 10), 2)
pygame.draw.line(self.screen, (45, 39, 36), (sx + 9, sy + 22), (sx + 21, sy + 13), 2)
elif key in getattr(self.world, 'aurora_wall_hp', ()):
# Трещины появляются только после повреждения.
pygame.draw.line(self.screen, (22, 28, 31), (sx + 4, sy + 5), (sx + 16, sy + 17), 2)
pygame.draw.line(self.screen, (25, 29, 33), (sx + 16, sy + 17), (sx + 26, sy + 12), 2)
pygame.draw.line(self.screen, (215, 142, 81), (sx + 8, sy + 21), (sx + 17, sy + 17), 1)
Game.draw_aurora_cell = draw_aurora_with_wall_damage
_original_draw_capsule_cell = Game.draw_start_capsule_cell
def draw_capsule_with_wall_damage(self, x, y, sx, sy):
if (x, y) in getattr(self, 'capsule_breaches', ()):
# Открытый проход в капсуле: рисуется тот же настил, что в помещении.
pygame.draw.rect(self.screen, (41, 67, 78), (sx, sy, CELL_SIZE, CELL_SIZE))
pygame.draw.rect(self.screen, (89, 125, 133), (sx + 4, sy + 4, CELL_SIZE - 8, CELL_SIZE - 8), 1)
return True
result = _original_draw_capsule_cell(self, x, y, sx, sy)
if (x, y) in getattr(self, 'capsule_wall_hp', ()):
pygame.draw.line(self.screen, (25, 32, 35), (sx + 4, sy + 5), (sx + 23, sy + 20), 2)
return result
Game.draw_start_capsule_cell = draw_capsule_with_wall_damage
# <<< DESTRUCTIBLE_WALLS_V1 <<<
# >>> FAST_BIOME_MAP_AND_HUMOR_V1 >>>
# Индексированная статистика: не делаем тысячи get_biome_at на каждый щелчок.
# При каждой операции изменения словаря корректируется ровно один счётчик.
class BiomeCountGrid(dict):
"""Обычный словарь координат с актуальным счётчиком предметов по биомам."""
def __init__(self, initial, biome_lookup, name_of):
super().__init__(initial)
self.biome_lookup = biome_lookup
self.name_of = name_of
self.biome_counts = {}
self._build_counts()
def _build_counts(self):
from collections import Counter
self.biome_counts.clear()
for key, value in self.items():
biome = self.biome_lookup(*key)
name = self.name_of(value)
if biome is not None and name:
self.biome_counts.setdefault(biome, Counter())[name] += 1
def _adjust(self, key, value, delta):
from collections import Counter
biome = self.biome_lookup(*key)
name = self.name_of(value)
if biome is None or not name:
return
counts = self.biome_counts.setdefault(biome, Counter())
counts[name] += delta
if counts[name] <= 0:
del counts[name]
if not counts:
self.biome_counts.pop(biome, None)
def __setitem__(self, key, value):
if key in self:
self._adjust(key, super().__getitem__(key), -1)
super().__setitem__(key, value)
self._adjust(key, value, 1)
def __delitem__(self, key):
value = super().__getitem__(key)
super().__delitem__(key)
self._adjust(key, value, -1)
def pop(self, key, *default):
if key in self:
value = super().pop(key)
self._adjust(key, value, -1)
return value
if default:
return default[0]
raise KeyError(key)
def popitem(self):
key, value = super().popitem()
self._adjust(key, value, -1)
return key, value
def setdefault(self, key, default=None):
if key not in self:
self[key] = default
return self[key]
def update(self, other=(), **kwargs):
for key, value in dict(other, **kwargs).items():
self[key] = value
def __ior__(self, other):
self.update(other)
return self
def clear(self):
super().clear()
self.biome_counts.clear()
def _map_grid_item_name(obj):
return getattr(obj, 'name', None)
def _map_grid_tree_name(tree):
try:
return TREE_SPECIES[tree[0]]
except (TypeError, ValueError, IndexError, KeyError):
return None
def _install_biome_map_index(self):
"""Один раз после генерации; автоматический учёт всех новых находок."""
surface_lookup = self.world.get_biome_at
cave_lookup = self.get_cave_tunnel_at
self.world.object_grid = BiomeCountGrid(
self.world.object_grid, surface_lookup, _map_grid_item_name)
self.world.flora_grid = BiomeCountGrid(
self.world.flora_grid, surface_lookup, _map_grid_item_name)
self.world.tree_grid = BiomeCountGrid(
self.world.tree_grid, surface_lookup, _map_grid_tree_name)
self.cave_object_grid = BiomeCountGrid(
self.cave_object_grid, cave_lookup, _map_grid_item_name)
self.cave_flora_grid = BiomeCountGrid(
self.cave_flora_grid, cave_lookup, _map_grid_item_name)
# CaveWorldProxy должен использовать тот же индексируемый словарь.
self.cave_world.object_grid = self.cave_object_grid
self._map_info_render_cache = None
Game._install_biome_map_index = _install_biome_map_index
BIOME_MAP_LORE = {
'Мелководье': (
'Тут красиво, пока кто-нибудь не объявит водоросль начальником экспедиции.',
'Обычно спокойно, но проверяйте неожиданных гостей и освещение.'),
'Глубоководье': (
'Каждый метр глубины бесплатный. Возвращение на поверхность — уже нет.',
'Тьма и потеря ориентиров; флору лучше сначала изучить.'),
'Центральное море': (
'Море занимает центр мироздания и уверено, что вы пришли выполнять поручения.',
'ЧБОС-кит выдаёт опасные задания; отказы не принимаются.'),
'Побережье': (
'Песок, ветер и местные жители, уверенные, что это суша навсегда.',
'Акласуры могут ударить ногой; следите за сухопутными големами.'),
'Острова': (
'Кусочки камня, официально объявившие себя независимыми государствами.',
'Големы и акласуры; опаснее всего считать их декорациями.'),
'Коралловый риф': (
'Кораллы старательно изображают курорт, а рыбы требуют визу для въезда.',
'Живность и тесные участки рифа; ночью ориентироваться сложнее.'),
'Леса': (
'Деревья растут быстрее, чем исследователь придумывает их названия.',
'Бешеный мох, големогенные колонии и наземные сюрпризы.'),
'Кельповый лес': (
'Морская лапша длиной в много километров. Соус не предусмотрен.',
'Заросли ограничивают обзор; внимательно осматривайте флору.'),
'Био-люминесцентная зона': (
'Здесь даже темнота подсвечена, чтобы было видно, насколько вам страшно.',
'Ночной мрак и необычная светящаяся флора.'),
'Горы': (
'Бесплатный альпинизм по камням с непредсказуемыми санитарными нормами.',
'Каменные големы; при встрече с живым валуном не спорьте о геологии.'),
'Озёра (внешние)': (
'Маленькое море, которое не прошло собеседование на большой океан.',
'Скрытые под водой угрозы и водяные големы.'),
'Реки': (
'Течение знает короткий путь и никому его не рассказывает.',
'Узкие проходы, водные существа и неудачные повороты.'),
'Притоки': (
'Служебный вход реки; туристы тут обычно только по ошибке.',
'Тесная вода; не забывайте про выход.'),
'Водопады': (
'Самая громкая водопроводная авария на астероиде.',
'Сложно ориентироваться возле перепадов и узких русел.'),
}
def _map_biome_description(biome, cave):
name = biome.name
if cave:
if 'развилка' in name.lower():
return ('Подземная развилка: право, лево и ещё один неправильный поворот.',
'Пещерная фауна, возможные узкие проходы и плохая видимость.')
if 'выходной тоннель' in name or 'Межбиомный тоннель' in name:
return ('Тоннель создан, чтобы до конца пути вы начали ценить открытое пространство.',
'Узкие участки и пещерные обитатели; держите путь к выходу.')
return ('Пещерный зал: потолок имеется, гарантия от падения камней не прилагается.',
'Тёмные закоулки и встреча с подводными соседями.')
if getattr(biome, 'aurora_style', False):
if getattr(biome, 'aurora_loot_room', False):
stocked = AURORA_ROOM_STOCK.get(name, ())
kinds = ', '.join(dict.fromkeys(stocked[:3])) if stocked else 'металлолом'
return (f'Отдел снабжения сообщает: складируем {kinds}. Всё остальное испарилось.',
'Металлические перегородки можно пробить пушками; смотрите под ноги.')
if not biome.passable:
return ('Железная стена: молчаливая и очень сопротивляется перестройке.',
'Проход закрыт, пока корпус не пробит подходящим орудием.')
return ('Аврора: построена с размахом, разобрана с энтузиазмом.',
'Повреждённые проходы и обломки; пушки меняют архитектуру.')
return BIOME_MAP_LORE.get(name, (
'Местность не описана в путеводителе. Вероятно, путеводитель не вернулся.',
'Проверьте местную фауну и выходы до наступления темноты.'))
def _map_danger_report(biome, fauna, cave):
"""Оценка основана на реально зарегистрированной в панели фауне."""
dangers = []
score = 1 if biome.passable else 4
for name, count in fauna.items():
if count <= 0:
continue
if name == WHALE_NAME:
dangers.append(f'{name} ({count}): выдаёт смертельные квесты')
score = max(score, 10)
elif name == 'Морской дракон-левиафан':
dangers.append(f'Морской дракон ({count}): огненные шары')
score = max(score, 10)
elif name == 'Пиявка-гидра':
dangers.append(f'Пиявка-гидра ({count}): пьёт кровь щупальцами')
score = max(score, 8)
elif name == 'Птица Говносрун':
dangers.append(f'Птица Говносрун ({count}): артобстрел')
score = max(score, 9)
elif name == 'АКЛАСУР':
dangers.append(f'Акласур ({count}): укусы и удары ногами')
score = max(score, 7)
elif 'голем' in name.lower() and 'бактерии' not in name.lower():
dangers.append(f'{name} ({count}): бьётся и может собраться заново')
score = max(score, 6)
elif 'бактерии' in name.lower():
dangers.append(f'Колонии големогенных бактерий ({count})')
score = max(score, 4)
if cave:
score = max(score, 3)
if getattr(biome, 'aurora_style', False) and not biome.passable:
score = max(score, 5)
return (min(10, score), dangers)
def _build_biome_panel_lines(self, biome, counts, width):
resources, flora, fauna = counts
cave = self.in_cave and biome in self.cave_tunnels
lines = []
default_color = (221, 231, 227)
def add(text='', color=default_color, bold=False, wrap=True):
font = self.small_font if bold else self.tiny_font
if not text:
lines.append(('', color, bold))
return
if not wrap or font.size(text)[0] <= width:
lines.append((text, color, bold))
return
for paragraph in text.split('\n'):
row = ''
for word in paragraph.split():
test = word if not row else row + ' ' + word
if font.size(test)[0] <= width:
row = test
else:
if row:
lines.append((row, color, bold))
row = word
# Переносим необычно длинные составные слова по символам.
while font.size(row)[0] > width and len(row) > 1:
cut = len(row) - 1
while cut > 1 and font.size(row[:cut])[0] > width:
cut -= 1
lines.append((row[:cut], color, bold))
row = row[cut:]
if row:
lines.append((row, color, bold))
add('Найдены на этом участке:', (194, 230, 226), True)
add('Реальные объекты и находки', (169, 187, 182))
add()
for heading, data, color in (
('РАСТЕНИЯ', flora, (143, 240, 151)),
('СУЩЕСТВА', fauna, (255, 187, 150)),
('РЕСУРСЫ', resources, (175, 212, 244))):
add(f'{heading} ({sum(data.values())})', color, True)
if data:
for species, count in sorted(data.items(), key=lambda t: (-t[1], t[0])):
add(f'{species}: {count}')
else:
add('Не обнаружено', (142, 153, 150))
add()
# Именно после перечня ресурсов располагается описание.
add('О БИОМЕ — ОТЧЁТ ЭКСПЕДИЦИИ', (245, 211, 104), True)
flavor, warning = _map_biome_description(biome, cave)
add(flavor, (229, 215, 175))
add()
add('ПАРАМЕТРЫ', (169, 225, 240), True)
if cave:
environment = 'Пещера / тоннель'
depth = 'Подземный уровень'
elif getattr(biome, 'aurora_style', False):
environment = 'Отсек корабля' if biome.passable else 'Стена корабля'
depth = 'Внутри Авроры'
elif is_land_biome(biome):
environment, depth = 'Суша', 'Не под водой'
else:
environment, depth = 'Вода', (getattr(biome, 'depth_label', None) or 'Не задана')
add('Среда: ' + environment)
add('Глубина: ' + depth)
add(f'Площадь контура: ~{int(biome.area()):,} кл.'.replace(',', ' '))
add('Проходимость: ' + ('да' if biome.passable else 'нет'))
add(f'Ресурсных предметов: {sum(resources.values())}')
add(f'Видов флоры / фауны: {len(flora)} / {len(fauna)}')
risk, dangers = _map_danger_report(biome, fauna, cave)
add(f'Опасность (условно): {risk}/10',
(255, 129, 113) if risk >= 7 else (250, 211, 137) if risk >= 4 else (155, 229, 167), True)
add()
add('ОПАСНОСТИ', (247, 154, 133), True)
add(warning, (239, 205, 191))
if dangers:
for threat in dangers[:6]:
add('• ' + threat, (255, 183, 163))
if len(dangers) > 6:
add(f'И ещё {len(dangers)-6} угрожающих разновидностей.')
else:
add('Опасных существ сейчас не обнаружено. Это не лицензия на беспечность.',
(163, 218, 174))
add()
add('Колесо: прокрутка | ЛКМ: выбор', (151, 184, 180))
return lines
Game._build_biome_panel_lines = _build_biome_panel_lines
# <<< FAST_BIOME_MAP_AND_HUMOR_V1 <<<
# >>> PROраб_САМОСТОЯТЕЛЬНАЯ_БРИГАДА_V2 >>>
# Все рабочие живут постоянно: расстояние до игрока НИКОГДА не влияет
# на скорость их перемещения, добычи, голод или здоровье.
from collections import Counter, deque
import heapq
CREW_SIZE = 100
CREW_WALK_SPEED = 0.115
CREW_PROFESSIONS = ('добытчик', 'строитель', 'инженер', 'технолог', 'исследователь', 'охотник', 'снабженец')
CREW_PRODUCTS = (
('Аптечка', 22), ('Сканер', 16), ('Ремонтный инструмент', 16),
('Строитель', 22), ('Подводное ружьё', 25), ('Лазерный пистолет', 20),
('Термоклинок', 18), ('Грави-ловушка', 16), ('Фонарик', 15),
('Морской глайдер', 12), ('Коробка патронов (20)', 15),
('Ремкомплект', 12), ('Лёгкий джип', 5), ('Бронетранспортёр', 3),
('Мотылёк', 7), ('Костюм КРАБ', 5)
)
class CrewBuilding:
def __init__(self, name, kind, pos, built=False, needs=None):
self.name = name
self.kind = kind
self.x, self.y = pos
self.built = built
self.needs = Counter(needs or {})
self.progress = 100.0 if built else 0.0
self.stock = Counter()
self.work = 0
self.reserved = False
def ready(self):
return all(self.stock[obj] >= n for obj,n in self.needs.items())
class CrewWorker:
def __init__(self, number, x, y):
self.number = number
self.x, self.y = float(x)+0.5, float(y)+0.5
self.role = CREW_PROFESSIONS[(number * 5 + number // 9) % len(CREW_PROFESSIONS)]
self.hp = 25.0
self.food = 100.0
self.water = 100.0
self.alive = True
self.weapon = START_ITEM_REGISTRY['Нож']
self.tools = set()
self.vehicle = None
self.vehicle_hp = 0
self.ammo = 0
self.pack = []
self.action = 'патрулирует'
self.goal = (x,y)
self.target = None
self.route = deque()
self.stuck = 0
self.rethink = 0
self.activity = 0.0
self.combat_timer = 0
self.damage_timer = 0
self.direction = 1
self.movement = 0.0
self.scans = 0
class ForemanCrew:
"""100 постоянно обновляемых NPC. Предметы и рецепты — настоящие объекты игры."""
def __init__(self, game):
self.game = game
self.workers = []
self.buildings = []
self.stats = Counter()
self.order = 'авто'
self.frame = 0
self.recipes = {recipe.result.name: recipe for recipe in recipes}
self.food_chunks = {}
for (x,y),item in self.game.world.object_grid.items():
if isinstance(item,ObjectType) and item.edible:
self.food_chunks.setdefault((x//16,y//16),set()).add((x,y))
self._spawn()
self._create_buildings()
def _spawn(self):
rooms = [r for r in self.game.world.biomes if getattr(r,'aurora_loot_room',False)
and (r.name == 'Раздевалка Авроры' or r.name.startswith('Каюта '))]
locker = next(r for r in rooms if r.name=='Раздевалка Авроры')
cabins = sorted((r for r in rooms if r is not locker), key=lambda r:r.name)
rng = random.Random(20261031)
for room, amount in [(locker, 30)]+[(r,70//len(cabins)+int(i<70%len(cabins)))
for i,r in enumerate(cabins)]:
x1,y1,x2,y2=room.rect
candidates=[(x,y) for y in range(y1+1,y2) for x in range(x1+1,x2)
if (self.game.world.get_biome_at(x,y) or room).passable
and (x,y)!=(793,635) and
not getattr(self.game.world.get_object_at(x,y),'is_wall',False)]
rng.shuffle(candidates)
if len(candidates)<amount:
raise RuntimeError('Недостаточно мест для рабочих: '+room.name)
for x,y in candidates[:amount]:
self.workers.append(CrewWorker(len(self.workers)+1,x,y))
assert len(self.workers)==CREW_SIZE
def _create_buildings(self):
obj = START_ITEM_REGISTRY
for name,kind,position,built,recipe in (
('Центральный склад Авроры','склад',(781,472),True,{}),
('Заводской цех Авроры','завод',(794,586),True,{}),
('Лаборатория Авроры','лаборатория',(838,620),True,{}),
('Новый склад','склад',(780,479),False,{'Титан':8,'Металлолом':3}),
('Сборочный цех','завод',(791,595),False,{'Титан':8,'Медная руда':3}),
('Исследовательский корпус','лаборатория',(835,625),False,{'Титан':6,'Кварц':3}),
):
self.buildings.append(CrewBuilding(name,kind,position,built,
{obj[k]:v for k,v in recipe.items() if k in obj}))
self.store, self.factory, self.lab = self.buildings[:3]
# Внутрикорабельная сеть складских конвейеров: груз сдаётся в ближайшей
# каюте/раздевалке, а изготовитель получает его из общего учтённого запаса.
self.factory.stock = self.store.stock
self.drop_points = [(int((b.rect[0]+b.rect[2])/2),int((b.rect[1]+b.rect[3])/2))
for b in self.game.world.biomes
if getattr(b,'aurora_loot_room',False)
and (b.name.startswith('Каюта ') or b.name=='Раздевалка Авроры')]
self.drop_points.append((self.factory.x,self.factory.y))
def _depot(self,w):
return min(self.drop_points,key=lambda p:(p[0]-w.x)**2+(p[1]-w.y)**2)
def _passable(self,x,y):
if not (1<=x<MAP_SIZE-1 and 1<=y<MAP_SIZE-1):return False
b=self.game.world.get_biome_at(x,y)
if b is None or not b.passable:return False
if self.game.is_start_capsule_wall(x,y):return False
o=self.game.world.get_object_at(x,y)
return not getattr(o,'is_wall',False)
def _path(self,w,dest):
# A* с бюджетом; при большой дистанции ищем промежуточную точку,
# поэтому никогда не просчитываем сразу всю карту для 100 работников.
start=(int(w.x),int(w.y)); end=(int(dest[0]),int(dest[1]))
d=max(abs(end[0]-start[0]),abs(end[1]-start[1]))
if d>19:
factor=18/d
end=(int(start[0]+(end[0]-start[0])*factor),
int(start[1]+(end[1]-start[1])*factor))
if not self._passable(*end):
neighbours=[(end[0]+dx,end[1]+dy) for dx in range(-3,4) for dy in range(-3,4)]
neighbours=[p for p in neighbours if self._passable(*p)]
if not neighbours:return deque()
end=min(neighbours,key=lambda p:(p[0]-end[0])**2+(p[1]-end[1])**2)
if start==end:return deque()
fringe=[(abs(end[0]-start[0])+abs(end[1]-start[1]),0,start)]
parents={}; distance={start:0}; count=0
while fringe and count<700:
_,g,p=heapq.heappop(fringe)
if g!=distance[p]:continue
count+=1
if p==end:
path=[]
while p!=start:
path.append(p);p=parents[p]
return deque(reversed(path))
x,y=p
for q in ((x+1,y),(x-1,y),(x,y+1),(x,y-1)):
if not self._passable(*q):continue
ng=g+1
if ng<distance.get(q,999999):
distance[q]=ng;parents[q]=p
h=abs(q[0]-end[0])+abs(q[1]-end[1])
heapq.heappush(fringe,(ng+h,ng,q))
return deque()
def _goal(self,w,p,action=None,target=None):
p=(int(p[0]),int(p[1]))
if w.goal!=p or (action is not None and action!=w.action):
w.route.clear();w.stuck=0
w.goal=p
if action is not None:w.action=action
if target is not None:w.target=target
def _move(self,w):
# Один и тот же темп ВСЕГДА, при любых расстояниях и у любой профессии.
speed=CREW_WALK_SPEED # строго постоянная скорость у всех рабочих
if not w.route and math.hypot(w.x-(w.goal[0]+0.5),w.y-(w.goal[1]+0.5)) < 0.9:
# Даже у станка рабочий постоянно ходит небольшими кругами.
angle=self.frame*speed/0.30 + w.number*0.7
nx=w.goal[0]+0.5+0.30*math.cos(angle)
ny=w.goal[1]+0.5+0.30*math.sin(angle)
if self._passable(int(nx),int(ny)):
w.x,w.y=nx,ny
w.movement+=speed
return
if not w.route and (w.rethink%16==0 or w.stuck==0):
w.route=self._path(w,w.goal)
if not w.route:
w.stuck+=1
# Периодический обход препятствий даже при ожидании материалов.
if w.stuck>25:
self._wander(w,5);w.stuck=0
return
px,py=w.route[0];tx,ty=px+0.5,py+0.5
dx,dy=tx-w.x,ty-w.y;dist=math.hypot(dx,dy)
if dist<=speed:
w.x,w.y=tx,ty;w.route.popleft()
else:
nx=w.x+dx/dist*speed;ny=w.y+dy/dist*speed
if self._passable(int(nx),int(ny)):
w.x,w.y=nx,ny
else:
w.route.clear()
w.direction=1 if dx>=0 else -1
w.movement+=speed
def _wander(self,w,radius=5):
rng=random
for _ in range(18):
x=int(w.x)+rng.randint(-radius,radius)
y=int(w.y)+rng.randint(-radius,radius)
if self._passable(x,y):
self._goal(w,(x,y),'патрулирует');return
def _near_resource(self,w,food=False):
g=self.game.world.object_grid
x,y=int(w.x),int(w.y)
best=None;score=1e9
for xx in range(x-11,x+12):
for yy in range(y-11,y+12):
obj=g.get((xx,yy))
if not isinstance(obj,ObjectType) or obj.is_wall or obj.is_buildable:continue
if food and not obj.edible:continue
d=(xx-w.x)**2+(yy-w.y)**2
if d<score:
best=(xx,yy,obj);score=d
if food and best is None:
# Индекс съедобных ресурсов позволяет найти провиант за пределами
# текущего экрана без сканирования всей карты на каждом кадре.
cx,cy=x//16,y//16
for radius in range(1,13):
found=[]
for bx in range(cx-radius,cx+radius+1):
for by in (cy-radius,cy+radius):
found.extend(self.food_chunks.get((bx,by),()))
for by in range(cy-radius+1,cy+radius):
for bx in (cx-radius,cx+radius):
found.extend(self.food_chunks.get((bx,by),()))
for xx,yy in found:
obj=g.get((xx,yy))
if obj is not None and obj.edible:
d=(xx-w.x)**2+(yy-w.y)**2
if d<score:best=(xx,yy,obj);score=d
if best is not None:break
return best
def _near_animal(self,w,predator=False,edible=False,radius=6):
animals=self.game.world.get_animals_near(w.x,w.y,radius)
choices=[a for a in animals if a.hp>0 and not getattr(a,'is_companion',False)
and (not predator or a.type.behavior=='predator')
and (not edible or (a.type.edible and a.type.hp<=35))]
if not choices:return None
return min(choices,key=lambda a:(a.x-w.x)**2+(a.y-w.y)**2)
def _use_food(self,w):
# Пища и питьё — только из реально поднятых съедобных предметов/рыбы.
for item in list(w.pack):
if item.edible and (w.food<85 or w.water<85):
w.food=min(100,w.food+item.hunger_value)
w.water=min(100,w.water+item.thirst_value)
w.pack.remove(item)
w.action='ест '+item.name
self.stats['съедено']+=1
return True
return False
def _get_medkit(self,w):
med=START_ITEM_REGISTRY.get('Аптечка')
if med in w.pack:
w.pack.remove(med);w.hp=min(25,w.hp+35)
w.action='лечится аптечкой';self.stats['вылечено']+=1
return True
return False
def _gear(self,w,item):
"""Распределение оружия и приборов, не исчезающих после использования."""
name=item.name
if name=='Коробка патронов (20)':
w.ammo+=20;w.action='пополнил боезапас';return True
if item.is_weapon:
if w.weapon is None or item.damage>w.weapon.damage:
old=w.weapon;w.weapon=item
if old is not None and old.name!='Нож':w.pack.append(old)
w.action='вооружился: '+name
return True
if name in ('Сканер','Строитель','Ремонтный инструмент','Фонарик',
'Грави-ловушка','Морской глайдер','Пропульсионная пушка',
'Стазис-винтовка','Лазерный резак'):
if name not in w.tools:
w.tools.add(name);w.action='оснастился: '+name
return True
if name in ('Лёгкий джип','Бронетранспортёр','Мотылёк','Костюм КРАБ','Циклоп'):
if w.vehicle is None:
w.vehicle=item
w.vehicle_hp={'Лёгкий джип':15,'Бронетранспортёр':200,
'Мотылёк':50,'Костюм КРАБ':150,'Циклоп':400}.get(name,50)
w.action='получил транспорт: '+name
return True
return False
def _combat(self,w):
enemy=self._near_animal(w,predator=True,radius=3.6)
if enemy is None:return False
d=math.hypot(enemy.x-w.x,enemy.y-w.y)
w.action='защищается от '+enemy.type.name
# Враги могут атаковать рабочих: это не декоративная симуляция.
if d<1.5 and w.damage_timer<=0:
damage=max(1,enemy.type.damage*0.42)
if w.vehicle is not None and w.vehicle_hp>0:
w.vehicle_hp=max(0,w.vehicle_hp-damage)
if w.vehicle_hp==0:
w.vehicle=None
else:
w.hp=max(0,w.hp-damage)
w.damage_timer=85
self.stats['нападений хищников']+=1
range_ = max(1.5,w.weapon.weapon_range)
is_ranged=range_>2.0
if w.combat_timer==0 and d<=(range_ if (not is_ranged or w.ammo>0) else 1.5):
damage=max(2,w.weapon.damage if (not is_ranged or w.ammo>0) else 10)
if is_ranged and w.ammo>0:w.ammo-=1
enemy.hp-=damage
w.combat_timer=30 if w.weapon.weapon_range<=2 else 48
self.stats['отражено атак']+=1
if enemy.hp<=0:
self.game.world.remove_animal(enemy)
if enemy.type.edible and len(w.pack)<6:w.pack.append(enemy.type)
self.stats['убито хищников']+=1
if w.hp<12 and d<2.8:
x=int(w.x+(w.x-enemy.x)*3/max(d,0.1))
y=int(w.y+(w.y-enemy.y)*3/max(d,0.1))
if self._passable(x,y):self._goal(w,(x,y),'отступает')
return True
def _stocks(self):
return self.factory.stock,self.store.stock
def _recipe_chain(self,product,available,depth=0,seen=None):
# Вернуть ближайший шаг настоящей производственной цепочки.
if seen is None:seen=set()
recipe=self.recipes.get(product)
if recipe is None or depth>5 or product in seen:return None
seen=seen|{product}
shortages=[(item,n-available.get(item,0)) for item,n in recipe.ingredients.items()
if available.get(item,0)<n]
if not shortages:return recipe
for item,_ in shortages:
next_step=self._recipe_chain(item.name,available,depth+1,seen)
if next_step:return next_step
return None
def _craft_one(self,w):
available,warehouse=self._stocks()
# Фабрика/изготовитель, модификационная станция и сборщик транспорта.
for name,quota in CREW_PRODUCTS:
if self.stats['создано '+name]>=quota:continue
if name=='Аптечка' and self.factory.stock[START_ITEM_REGISTRY.get('Аптечка')]>=12:continue
recipe=self._recipe_chain(name,available)
if recipe is None:continue
station=recipe.station
if station not in ('Изготовитель','Модификационная станция','Переносной сборщик транспорта'):
continue
if not recipe.can_craft(available):continue
for item,count in recipe.ingredients.items():
available[item]-=count
if available[item]<=0:available.pop(item,None)
available[recipe.result]+=recipe.result_count
self.stats['создано '+recipe.result.name]+=recipe.result_count
self.stats['всего изготовлено']+=recipe.result_count
w.action='изготовил на '+station+': '+recipe.result.name
return True
return False
def _needed_material(self):
fab,warehouse=self._stocks()
# Требуются также компоненты многоступенчатых рецептов.
demand=Counter()
for name,limit in CREW_PRODUCTS:
if self.stats['создано '+name]>=limit:continue
recipe=self.recipes.get(name)
if recipe:
for item,n in recipe.ingredients.items():
demand[item]=max(demand[item],n*2)
nested=self.recipes.get(item.name)
if nested:
for inner,qty in nested.ingredients.items():
demand[inner]=max(demand[inner],qty*2)
# Для строительства нужны отдельные стройматериалы.
for building in self.buildings:
if not building.built:
for obj,n in building.needs.items():
demand[obj]=max(demand[obj],n)
return demand
def _collect(self,w):
desired=self._near_resource(w,food=(w.food<65 or w.water<65))
if desired is None:desired=self._near_resource(w)
if desired is None:return False
self._goal(w,desired[:2],'собирает',desired)
return True
def _assign(self,w):
if not w.alive:return
w.target=None
if w.hp<20 and self._get_medkit(w):return
if w.food<78 or w.water<78:
if self._use_food(w):return
food=self._near_resource(w,food=True)
if food:
self._goal(w,food[:2],'ищет еду',food);return
animal=self._near_animal(w,edible=True,radius=9)
if animal is not None:
self._goal(w,(animal.x,animal.y),'ловит еду',animal);return
# Ремонт собственного транспорта — ремонтным прибором, без расхода HP.
if (w.vehicle is not None and 'Ремонтный инструмент' in w.tools
and w.vehicle_hp<{'Лёгкий джип':15,'Бронетранспортёр':200,
'Мотылёк':50,'Костюм КРАБ':150,'Циклоп':400}.get(w.vehicle.name,50)):
if w.rethink%40==0:
w.vehicle_hp+=1;self.stats['ремонтов']+=1
w.action='чинит транспорт ремонтным инструментом'
# Медпункт: на базе аптечки из настоящих запасов.
if w.hp<21 and self.factory.stock.get(START_ITEM_REGISTRY.get('Аптечка'),0)>0:
self._goal(w,self._depot(w),'к аптечке');return
if any(isinstance(i,AnimalType) for i in w.pack) and w.food>=75:
self._goal(w,(self.lab.x,self.lab.y),'сдаёт образцы');return
if self.order=='добыча':
if self._collect(w):return
# Снабжение рабочих: экипировка и приборы не выбрасываются в хранилище.
# Рабочие забирают изготовленные орудия и устройства с цеха.
preferred={'строитель':'Строитель','инженер':'Ремонтный инструмент',
'исследователь':'Сканер','охотник':'Грави-ловушка',
'добытчик':'Морской глайдер','технолог':'Сканер',
'снабженец':'Фонарик'}.get(w.role)
available=self.factory.stock
tool=START_ITEM_REGISTRY.get(preferred)
if preferred and preferred not in w.tools and tool and available.get(tool,0)>0:
self._goal(w,self._depot(w),'получает прибор');return
# Для настоящего строительства материалы переносятся со склада на площадку.
if not w.pack and (w.role in ('строитель','снабженец','инженер') or self.order=='строительство'):
for building in self.buildings:
if not building.built:
for obj,amount in building.needs.items():
if building.stock[obj]<amount and self.store.stock[obj]>0:
self._goal(w,self._depot(w),'забирает стройматериалы',(building,obj));return
if len(w.pack)>=3:
self._goal(w,self._depot(w),'сдаёт материалы');return
if w.pack and (w.rethink%150<80):
self._goal(w,self._depot(w),'сдаёт материалы');return
# Цеху нужны ресурсы, которые рабочий переносит со склада.
if w.role in ('снабженец','технолог','инженер') or self.order=='производство':
wanted=self._needed_material()
need=[o for o,n in wanted.items() if self.factory.stock[o]<n and self.store.stock[o]>0]
if need:
o=max(need,key=lambda obj:self.store.stock[obj]);
self._goal(w,self._depot(w),'везёт в цех',o);return
if w.role in ('технолог','инженер') or self.order=='производство':
self._goal(w,(self.factory.x,self.factory.y),'работает на изготовителе');return
if w.role in ('строитель','инженер') or self.order=='строительство':
choices=[b for b in self.buildings if not b.built and (b.ready() or b.reserved)]
if choices and 'Строитель' in w.tools:
b=min(choices,key=lambda b:(w.x-b.x)**2+(w.y-b.y)**2)
self._goal(w,(b.x,b.y),'строит помещение',b);return
if w.role=='исследователь' and 'Сканер' in w.tools:
a=self._near_animal(w,radius=7)
if a:
self._goal(w,(a.x,a.y),'сканирует',a);return
if (w.role=='охотник' or self.order=='охота'):
a=self._near_animal(w,edible=True,radius=9)
if a:
self._goal(w,(a.x,a.y),'ловит добычу',a);return
if self._collect(w):return
self._wander(w,8)
def _at_task(self,w):
act=w.action
if act in ('собирает','ищет еду'):
target=w.target
if not isinstance(target,tuple):return
x,y,item=target
if self.game.world.object_grid.get((x,y)) is item:
self.game.world.remove_object_at(x,y)
if item.edible:
self.food_chunks.get((x//16,y//16),set()).discard((x,y))
# Найденные оружие, приборы и транспорт рабочий оставляет себе.
if not self._gear(w,item):w.pack.append(item)
self.stats['добыто']+=1
w.action='подобрал '+item.name
elif act=='сдаёт образцы':
for item in w.pack:self.lab.stock[item]+=1
self.stats['образцов в лаборатории']+=len(w.pack)
w.pack.clear()
elif act=='получает прибор':
self._supply(w)
elif act=='забирает стройматериалы':
building,obj=w.target
qty=min(4,self.store.stock[obj],max(0,building.needs[obj]-building.stock[obj]))
if qty:
self.store.stock[obj]-=qty
if self.store.stock[obj]<=0:self.store.stock.pop(obj,None)
w.pack.extend([obj]*qty)
self._goal(w,(building.x,building.y),'доставляет на стройку',building)
return
elif act=='доставляет на стройку':
building=w.target
for obj in w.pack:
building.stock[obj]+=1
self.stats['сырьё на стройке']+=1
w.pack.clear()
elif act=='сдаёт материалы':
for item in w.pack:
self.store.stock[item]+=1
self.stats['в склад']+=1
w.pack.clear()
elif act=='везёт в цех':
item=w.target
if self.store.stock.get(item,0)>0:
qty=min(5,self.store.stock[item])
self.store.stock[item]-=qty
if self.store.stock[item]<=0:self.store.stock.pop(item,None)
w.pack.extend([item]*qty)
self._goal(w,(self.factory.x,self.factory.y),'поставка на завод',item)
return
elif act=='поставка на завод':
for item in w.pack:self.factory.stock[item]+=1
self.stats['в производство']+=len(w.pack)
w.pack.clear()
elif act=='к аптечке':
kit=START_ITEM_REGISTRY.get('Аптечка')
if kit is not None and self.factory.stock[kit]>0:
self.factory.stock[kit]-=1
w.hp=min(25,w.hp+35)
self.stats['вылечено']+=1
elif act=='работает на изготовителе':
self._craft_one(w)
elif act=='строит помещение':
b=w.target
if b and not b.built and (b.ready() or b.reserved) and 'Строитель' in w.tools:
if not b.reserved:
for obj,n in b.needs.items():b.stock[obj]-=n
b.reserved=True
b.progress=min(100,b.progress+3)
if b.progress>=100:
b.built=True
self.stats['построено']+=1
self.game.notification='Рабочие построили: '+b.name
self.game.notification_timer=100
elif act in ('ловит еду','ловит добычу'):
a=w.target
if a is not None and a.hp>0 and a in self.game.world.animals:
if math.hypot(w.x-a.x,w.y-a.y)<2.3:
# Ловушка помогает ловить добычу; без неё используется нож.
a.hp-=max(5,w.weapon.damage*0.8)
if a.hp<=0:
self.game.world.remove_animal(a)
if a.type.edible:w.pack.append(a.type)
self.stats['поймано']+=1
elif act=='сканирует':
a=w.target
if a is not None and a.hp>0:
w.scans+=1;self.stats['исследований']+=1
self._wander(w,4)
def _supply(self,w):
# Готовые предметы цеха выдаются участникам бригады без бесплатного появления.
stock=self.factory.stock
if math.hypot(w.x-self._depot(w)[0],w.y-self._depot(w)[1])>4:return
if w.hp<21 and stock.get(START_ITEM_REGISTRY.get('Аптечка'),0)>0:
item=START_ITEM_REGISTRY['Аптечка'];stock[item]-=1
w.hp=min(25,w.hp+35);self.stats['вылечено']+=1
main={'строитель':'Строитель','инженер':'Ремонтный инструмент',
'исследователь':'Сканер','охотник':'Грави-ловушка',
'добытчик':'Морской глайдер','технолог':'Сканер',
'снабженец':'Фонарик'}.get(w.role)
equipment=('Подводное ружьё','Лазерный пистолет','Термоклинок',
'Коробка патронов (20)','Лёгкий джип','Мотылёк')
for name in ((main,) if main else ()) + equipment:
obj=START_ITEM_REGISTRY.get(name)
if name=='Коробка патронов (20)' and w.ammo>=40:continue
if obj is not None and stock.get(obj,0)>0 and self._gear(w,obj):
stock[obj]-=1;self.stats['выдано снаряжения']+=1
break
def _build_supply(self,w):
# Реальные материалы перебрасываются в строительную площадку.
if not w.pack:return
eligible=[b for b in self.buildings if not b.built and any(b.stock[o]<n for o,n in b.needs.items())]
if not eligible:return
b=min(eligible,key=lambda b:(b.x-w.x)**2+(b.y-w.y)**2)
for obj in list(w.pack):
if b.stock[obj]<b.needs[obj]:
w.pack.remove(obj);b.stock[obj]+=1
self.stats['сырьё на стройке']+=1
def update(self):
self.frame+=1
for w in self.workers:
if not w.alive:continue
# Еда, жажда и здоровье обновляются постоянно, даже далеко от игрока.
w.food=max(0,w.food-0.0021)
w.water=max(0,w.water-0.0028)
if w.food==0 or w.water==0:
w.hp=max(0,w.hp-0.008)
if w.hp<=0:
w.alive=False;w.action='погиб';self.stats['потери']+=1
continue
w.rethink+=1
if w.combat_timer>0:w.combat_timer-=1
if w.damage_timer>0:w.damage_timer-=1
if self.frame%13==w.number%13:
if self._combat(w):
# Даже во время перестрелки рабочий не замирает.
if not w.route:self._wander(w,3)
if self.frame%27==w.number%27:
self._use_food(w)
if self.frame%43==w.number%43:
self._get_medkit(w)
self._supply(w)
if self.frame%100==w.number%100 or w.rethink>160:
# Доставку нельзя отменять посреди маршрута.
if w.action not in ('поставка на завод','доставляет на стройку'):
w.rethink=0
self._assign(w)
self._move(w)
d=math.hypot((w.goal[0]+0.5)-w.x,(w.goal[1]+0.5)-w.y)
if d<=1.1:
w.activity+=1
if w.activity>=38:
w.activity=0
self._at_task(w)
else:w.activity=0
if len(w.pack)>9:
self._goal(w,self._depot(w),'сдаёт материалы')
def draw(self,screen,camera_x,camera_y):
if self.game.in_cave:return
sw,sh=screen.get_size()
for building in self.buildings:
x=int((building.x-camera_x)*CELL_SIZE)
y=int((building.y-camera_y)*CELL_SIZE)
if not (-80<x<sw+80 and -80<y<sh+80):continue
color={'склад':(179,138,69),'завод':(66,143,178),'лаборатория':(128,89,179)}[building.kind]
rect=pygame.Rect(x-24,y-24,48,48)
pygame.draw.rect(screen,(31,37,42),rect,border_radius=3)
pygame.draw.rect(screen,color,rect.inflate(-8,-8),border_radius=3)
if not building.built:
pygame.draw.rect(screen,(70,54,31),(x-22,y+16,44,5))
pygame.draw.rect(screen,(251,194,80),(x-22,y+16,int(44*building.progress/100),5))
img=self.game.tiny_font.render(building.name,True,(245,240,205))
screen.blit(img,(x-img.get_width()//2,y-37))
for w in self.workers:
if not w.alive:continue
sx=int((w.x-camera_x)*CELL_SIZE)
sy=int((w.y-camera_y)*CELL_SIZE)
if not (-20<sx<sw+20 and -20<sy<sh+20):continue
# Разные приборы заметны как инструменты на поясе.
pygame.draw.circle(screen,(243,194,49),(sx,sy-8),6)
pygame.draw.rect(screen,(237,129,29),(sx-6,sy-3,12,11),border_radius=2)
pygame.draw.line(screen,(50,55,60),(sx-3,sy+8),(sx-5,sy+13),3)
pygame.draw.line(screen,(50,55,60),(sx+3,sy+8),(sx+5,sy+13),3)
device=(124,223,235) if 'Сканер' in w.tools else (95,240,156) if 'Строитель' in w.tools else (235,241,234)
pygame.draw.line(screen,device,(sx+6,sy+2),(sx+13,sy-5),3)
if w.vehicle is not None:
pygame.draw.ellipse(screen,(40,50,60),(sx-15,sy-10,30,20),2)
if w.hp<25:
pygame.draw.rect(screen,(91,27,27),(sx-10,sy-21,20,4))
pygame.draw.rect(screen,(75,227,104),(sx-10,sy-21,int(w.hp/25*20),4))
name=self.game.tiny_font.render(str(w.number),True,(254,252,231))
screen.blit(name,(sx-name.get_width()//2,sy-36))
def panel(self):
game=self.game;screen=game.screen
pane=pygame.Surface((SCREEN_SIZE,SCREEN_SIZE),pygame.SRCALPHA)
pane.fill((5,13,20,237));screen.blit(pane,(0,0))
def txt(s,x,y,col=(227,235,231),big=False):
font=game.font if big else game.small_font
screen.blit(font.render(s,True,col),(x,y))
txt('ПРОРАБ: 100 РАБОЧИХ — АВТОНОМНЫЙ РЕЖИМ',18,15,(255,208,94),True)
alive=sum(w.alive for w in self.workers)
txt(f'Живы: {alive} / 100 | Ходьба: {CREW_WALK_SPEED:.3f} клетки/кадр | Приказ: {self.order}',18,53)
txt('Рабочие постоянно ходят, едят, лечатся, собирают и производят предметы.',18,78)
txt('O / Esc закрыть | 1 авто 2 добыча 3 стройка 4 производство 5 охота',18,103,(173,220,242))
pygame.draw.line(screen,(89,121,134),(18,136),(SCREEN_SIZE-18,136),1)
txt('ПОСТРОЙКИ',20,153,(255,213,104),True)
y=184
for b in self.buildings:
txt(b.name[:31]+(' [готово]' if b.built else f' [{b.progress:.0f}%]'),22,y)
y+=22
y+=13
txt('ЗАПАСЫ',20,y,(255,213,104),True);y+=27
for title,building in [('Склад и конвейер',self.store)]:
qty=sum(building.stock.values())
txt(f'{title}: {qty} ед.',25,y);y+=20
right=SCREEN_SIZE//2+10
txt('ПРОИЗВОДСТВО',right,153,(255,213,104),True)
y=187
for name in ('всего изготовлено','выдано снаряжения','добыто','поймано',
'в производство','сырьё на стройке','построено',
'вылечено','нападений хищников','убито хищников','потери','исследований'):
txt(f'{name.capitalize()}: {self.stats[name]}',right,y)
y+=25
txt('Нажми O, чтобы вернуться в игру',right,SCREEN_SIZE-40,(156,219,231))
_original_foreman_init=Game.__init__
def _foreman_init(self):
_original_foreman_init(self)
self.foreman=ForemanCrew(self)
pygame.display.set_caption('МегоСубнатика2D — прораб и автономные рабочие')
Game.__init__=_foreman_init
_original_foreman_update=Game.update
def _foreman_update(self):
_original_foreman_update(self)
if self.state == 'playing' and not self.settings_open and self.setting_workers_enabled:
self.foreman.update()
Game.update=_foreman_update
_original_foreman_hud=Game.draw_hud
def _foreman_hud(self):
_original_foreman_hud(self)
if not self.setting_workers_enabled:
return # Ни счётчика NPC, ни подсказки O при одиночной игре.
alive=sum(w.alive for w in self.foreman.workers)
text=self.small_font.render(f'ПРОРАБ | Рабочие: {alive}/100 | O — бригада | Сделано: {self.foreman.stats["всего изготовлено"]}',True,(251,208,108))
bg=pygame.Rect(7,232,text.get_width()+10,22)
pygame.draw.rect(self.screen,(22,36,44),bg,border_radius=3)
self.screen.blit(text,(11,234))
Game.draw_hud=_foreman_hud
# <<< PROраб_САМОСТОЯТЕЛЬНАЯ_БРИГАДА_V2 <<<
# -*- coding: utf-8 -*-
# Appendable built-in module: vegetable relatives and hexagonal symbionts.
# No external graphics or asset files are required.
# >>> VEGETABLE_AND_NUT_FISH_V1 >>>
VEGETABLE_NUT_BONUS_HP = 10 # HP per attached fish-nut; maximum 2 per bolt.
VEGETABLE_NUTS_PER_BOLT = 2
VEGETABLE_NUT_DETECTION = 3.4 # Threat radius (in map cells).
VEGETABLE_NUT_SEARCH = 6.0 # Bolt-seeking radius (in map cells).
def _draw_asteroid_new_fish(kind, length, width):
"""Draw a high-detail top-down sprite, rescale only at the end.
Coordinates remain fixed, avoiding allocations inside the frame loop.
Images can be regenerated at higher resolution by the original zoom code.
"""
s = pygame.Surface((360, 240), pygame.SRCALPHA)
d = pygame.draw
pi = math.pi
if kind == 'cucumber_fish':
# Tail, paired glassy fins, elongated emerald body and fine prickles.
d.polygon(s, (28, 114, 70), [(68, 120), (10, 61), (37, 111),
(11, 182), (75, 130)])
d.polygon(s, (93, 210, 109), [(60, 119), (18, 82), (39, 118),
(18, 156), (64, 128)])
for sign in (-1, 1):
fin = [(153, 120 + sign*19), (182, 120 + sign*76),
(221, 120 + sign*69), (219, 120 + sign*16)]
d.polygon(s, (25, 115, 80), fin)
d.polygon(s, (78, 184, 94), [(159,120+sign*24),
(181,120+sign*66),
(210,120+sign*55),
(213,120+sign*16)])
d.line(s, (158, 232, 148), (182,120+sign*52), (211,120+sign*22), 3)
outline = [(53,120),(72,102),(105,91),(157,89),(209,91),
(271,99),(307,111),(323,120),(305,129),
(270,140),(209,149),(155,151),(105,149),(72,138)]
d.polygon(s,(10,75,53),outline)
d.polygon(s,(51,174,80),[(64,120),(95,105),(157,99),(211,102),
(268,110),(315,120),(271,132),(205,140),
(153,142),(92,135)])
d.polygon(s,(112,217,102),[(89,110),(151,104),(213,108),
(269,115),(288,119),(212,120),(139,119)])
d.lines(s,(183,248,154),False,[(85,108),(143,103),(206,107),(270,115)],4)
# Rows of short spines; all are part of the animal, not projectiles.
for i in range(15):
x = 93+i*13
h = max(5, int(9*math.sin(pi*(i+1)/16)))
ytop = 91 + int(13*abs((x-180)/170))
ybottom = 149 - int(11*abs((x-180)/170))
for sign in (-1,1):
y = ytop if sign < 0 else ybottom
d.polygon(s,(25,125,50),[(x-4,y+sign*-1),
(x,y+sign*h),(x+4,y+sign*-1)])
d.line(s,(204,236,136),(x-1,y),(x,y+sign*h),1)
if i % 2 == 0:
d.circle(s,(29,117,58),(x+2,124),3)
d.circle(s,(187,235,130),(x+1,123),1)
d.circle(s,(9,45,48),(293,116),10)
d.circle(s,(233,247,199),(293,116),7)
d.circle(s,(25,30,35),(296,116),4)
d.circle(s,(255,255,240),(295,113),2)
d.arc(s,(19,101,71),(297,121,22,12),0.3,2.8,2)
elif kind == 'patisson_fish':
# Flattened, scalloped squash disk with a rigid toothed fin margin.
cx, cy, outer = 180, 120, 96
outer_poly = []
for i in range(60):
a = i*2*pi/60
# Alternating crenellations; not a smooth circular puffer fish.
r = outer*(1.0 if i % 3 != 0 else 1.105)
outer_poly.append((cx+math.cos(a)*r,cy+math.sin(a)*r))
d.polygon(s,(30,105,58),outer_poly)
d.polygon(s,(91,155,57),[(cx+math.cos(i*2*pi/90)*87,
cy+math.sin(i*2*pi/90)*87) for i in range(90)])
d.circle(s,(161,198,75),(cx,cy),77)
d.circle(s,(202,219,118),(cx-6,cy-8),64)
# Corrugation from center to each hard fin tooth.
for i in range(20):
a = i*2*pi/20
x1 = cx+math.cos(a)*28
y1 = cy+math.sin(a)*28
x2 = cx+math.cos(a)*92
y2 = cy+math.sin(a)*92
d.line(s,(62,126,60),(x1,y1),(x2,y2),5)
d.line(s,(229,241,143),(x1,y1),(x2,y2),2)
for j in range(18):
a = j*2*pi/18
x = cx+math.cos(a)*50
y = cy+math.sin(a)*50
d.circle(s,(112,165,75),(round(x),round(y)),5)
d.circle(s,(75,118,48),(cx,cy),29)
d.circle(s,(196,223,113),(cx-4,cy-5),25)
d.circle(s,(238,241,164),(cx-9,cy-13),10)
# Face, on the direction-of-travel side.
for y in (102,139):
d.circle(s,(26,65,48),(226,y),10)
d.circle(s,(244,246,221),(228,y-1),7)
d.circle(s,(17,34,24),(231,y),4)
d.circle(s,(255,255,255),(229,y-3),2)
d.arc(s,(43,90,49),(233,115,18,13),0.3,2.8,3)
d.aalines(s,(25,83,45),True,outer_poly)
elif kind == 'nut_fish':
# A living copper-nickel nut, with a genuinely transparent aperture.
cx, cy = 180, 120
outer = [(cx+105*math.cos(i*pi/3),cy+105*math.sin(i*pi/3)) for i in range(6)]
inner = [(cx+91*math.cos(i*pi/3),cy+91*math.sin(i*pi/3)) for i in range(6)]
d.polygon(s,(40,63,72),outer)
d.polygon(s,(132,166,174),inner)
d.circle(s,(229,219,160),(cx-8,cy-12),77)
d.circle(s,(125,170,170),(cx,cy),75,8)
# The living body has an opening that is visually hollow.
d.circle(s,(0,0,0,0),(cx,cy),58)
d.circle(s,(26,53,58),(cx,cy),60,10)
d.circle(s,(244,230,179),(cx,cy),66,4)
for i in range(6):
a = i*pi/3
x1,y1 = (cx+math.cos(a)*78,cy+math.sin(a)*78)
x2,y2 = (cx+math.cos(a)*98,cy+math.sin(a)*98)
d.line(s,(248,237,182),(x1,y1),(x2,y2),5)
# Thin swimming fins and eyes, separate from the metal-looking ring.
for sign in (-1,1):
y = cy+sign*77
d.polygon(s,(45,140,166),[(115,y),(84,cy+sign*105),
(152,cy+sign*90)])
d.circle(s,(24,39,49),(260,106),11)
d.circle(s,(148,245,250),(263,105),7)
d.circle(s,(12,31,39),(265,106),4)
d.circle(s,(250,250,250),(264,103),2)
d.aalines(s,(40,55,63),True,outer)
elif kind == 'bolt_fish':
# Distinct fish-bolt is needed as a host: no bolt existed in the file.
d.polygon(s,(41,104,145),[(93,104),(134,44),(170,83),(188,109)])
d.polygon(s,(35,91,132),[(92,135),(134,196),(171,158),(188,133)])
d.polygon(s,(32,51,69),[(17,120),(42,85),(69,71),(88,77),
(91,100),(294,100),(333,120),(294,140),
(91,140),(88,163),(69,169),(42,155)])
d.polygon(s,(158,185,185),[(18,120),(43,91),(72,86),(86,104),
(289,104),(322,120),(289,136),
(86,136),(72,154),(43,149)])
d.polygon(s,(212,228,222),[(92,107),(285,107),(307,116),
(278,116),(98,116)])
# Hexagonal head at the tail, threaded shaft, rounded pointed nose.
hexagon = [(65+37*math.cos(i*pi/3),120+37*math.sin(i*pi/3)) for i in range(6)]
d.polygon(s,(45,62,71),hexagon)
d.polygon(s,(171,184,190),[(65+31*math.cos(i*pi/3),
120+31*math.sin(i*pi/3)) for i in range(6)])
d.circle(s,(64,100,117),(65,120),12)
for x in range(112,283,13):
d.line(s,(50,88,105),(x,103),(x+16,137),5)
d.line(s,(247,248,230),(x+4,103),(x+20,136),2)
d.circle(s,(10,35,43),(290,114),10)
d.circle(s,(145,243,246),(291,113),6)
d.circle(s,(22,35,44),(294,113),4)
d.circle(s,(255,255,255),(292,110),2)
else:
raise ValueError('Unknown new fish sprite: '+str(kind))
if kind in ('nut_fish','patisson_fish'):
diameter = max(10,int(round(max(length,width)*1.25)))
out_size = (diameter,diameter)
else:
out_size = (max(16,int(length*1.24)),max(13,int(width*1.85)))
# No convert_alpha() here: it must also work while rendering preview surfaces.
return pygame.transform.smoothscale(s,out_size)
_previous_creature_sprite_fn = generate_subnautica_creature_sprite
def generate_subnautica_creature_sprite(kind, length, width):
if kind in ('cucumber_fish','patisson_fish','nut_fish','bolt_fish'):
return _draw_asteroid_new_fish(kind,length,width)
return _previous_creature_sprite_fn(kind,length,width)
_previous_init_assets_for_fish = init_assets
def _init_assets_with_vegetable_nuts():
_previous_init_assets_for_fish()
global cucumber_fish_type, patisson_fish_type, nut_fish_type, bolt_fish_type
cucumber_fish_type = AnimalType(
'Рыба-огурец', (65,181,78), 10, speed=.165, behavior='prey',
hp=6, edible=True, hunger_value=17, thirst_value=14,
fish_length=24, fish_width=7, sprite_kind='cucumber_fish',
schooling=True, fear_distance=3.5, zigzag_strength=.27,
speed_variation=.24, flee_speed_boost=2.0)
patisson_fish_type = AnimalType(
'Рыба-патиссон', (165,201,87), 14, speed=.022, behavior='prey',
hp=zucchini_fish_type.hp * 3, edible=True, hunger_value=26,
thirst_value=5, fish_length=15, fish_width=15,
sprite_kind='patisson_fish', schooling=False,
fear_distance=1.25, zigzag_strength=.025,
speed_variation=.05, flee_speed_boost=1.15)
nut_fish_type = AnimalType(
'Рыба-гайка', (165,191,197), 9, speed=.11, behavior='prey',
hp=5, edible=False, fish_length=9, fish_width=9,
sprite_kind='nut_fish', fear_distance=3.3,
zigzag_strength=.14, speed_variation=.12)
bolt_fish_type = AnimalType(
'Рыба-болт', (160,190,201), 15, speed=.085, behavior='neutral',
hp=14, edible=False, fish_length=42, fish_width=10,
sprite_kind='bolt_fish', fear_distance=1.5,
zigzag_strength=.09, speed_variation=.14)
# Physically correct ratio with the current game's existing zucchini HP.
assert patisson_fish_type.hp == zucchini_fish_type.hp * 3
densities = {
'Реки': ((cucumber_fish_type,5),(patisson_fish_type,2),
(bolt_fish_type,2),(nut_fish_type,2)),
'Притоки': ((cucumber_fish_type,3),(bolt_fish_type,1)),
'Мелководье': ((cucumber_fish_type,4),(patisson_fish_type,3),
(bolt_fish_type,3),(nut_fish_type,2)),
'Озёра (внешние)': ((cucumber_fish_type,5),(patisson_fish_type,3),
(bolt_fish_type,3),(nut_fish_type,2)),
'Коралловый риф': ((patisson_fish_type,3),(bolt_fish_type,2),
(nut_fish_type,3)),
'Кельповый лес': ((patisson_fish_type,2),(bolt_fish_type,2),
(nut_fish_type,2)),
'Глубоководье': ((bolt_fish_type,1),(nut_fish_type,1)),
}
for biome in biomes:
if is_land_biome(biome) or getattr(biome,'aurora_style',False):
continue
biome.animals.extend(densities.get(biome.name,()))
registry = globals().get('START_ITEM_REGISTRY')
if isinstance(registry,dict):
for creature in (cucumber_fish_type,patisson_fish_type,nut_fish_type,bolt_fish_type):
registry[creature.name] = creature
# This is also available to mods, scanner and custom tunnel population.
globals()['VEGETABLE_NUT_FISH_TYPES'] = {
'cucumber_fish':cucumber_fish_type,
'patisson_fish':patisson_fish_type,
'nut_fish':nut_fish_type,
'bolt_fish':bolt_fish_type,
}
init_assets = _init_assets_with_vegetable_nuts
# Fastener fish already wrap the HP property. Preserve their protections.
_old_asteroid_hp_prop = Animal.hp
def _vegetable_get_hp(self):
return _old_asteroid_hp_prop.fget(self)
def _vegetable_set_hp(self, value):
if (getattr(getattr(self,'type',None),'sprite_kind',None) == 'nut_fish'
and getattr(self,'nut_bolt_host',None) is not None
and value < _old_asteroid_hp_prop.fget(self)):
return # Attached nuts cannot be killed or captured separately.
_old_asteroid_hp_prop.fset(self,value)
Animal.hp = property(_vegetable_get_hp,_vegetable_set_hp)
_old_nut_animal_update = Animal.update
def _nut_animal_update(self, player_x, player_y, world):
if getattr(self,'nut_bolt_host',None) is not None:
self.active = abs(self.x-player_x) <= VIEW_RADIUS+3 and abs(self.y-player_y) <= VIEW_RADIUS+3
self.dx = self.dy = 0.0
return
return _old_nut_animal_update(self,player_x,player_y,world)
Animal.update = _nut_animal_update
def _nut_release(nut, give_bonus_back=True):
bolt = getattr(nut,'nut_bolt_host',None)
nut.nut_bolt_host = None
if bolt is None:
return
passengers = getattr(bolt,'bolt_nuts',None)
if passengers is not None and nut in passengers:
passengers.remove(nut)
if give_bonus_back:
bolt.max_hp = max(bolt.type.hp, bolt.max_hp - VEGETABLE_NUT_BONUS_HP)
# Preserve damage already taken under the temporary HP bonus.
if bolt.hp > 0:
bolt.hp = min(bolt.hp,bolt.max_hp)
nut.nut_next_attach = 0
nut._update_rotation()
def _nut_attach(game,nut,bolt):
if getattr(nut,'nut_bolt_host',None) is not None:
return False
if getattr(bolt,'is_companion',False) or bolt.hp <= 0:
return False
passengers = getattr(bolt,'bolt_nuts',None)
if passengers is None:
passengers = []
bolt.bolt_nuts = passengers
if len(passengers) >= VEGETABLE_NUTS_PER_BOLT:
return False
nut.nut_bolt_host = bolt
passengers.append(nut)
bolt.max_hp += VEGETABLE_NUT_BONUS_HP
bolt.hp += VEGETABLE_NUT_BONUS_HP
game._nut_attached.add(nut)
game._nut_seeking.pop(nut,None)
nut.nut_next_attach = 0
return True
def _nut_move_towards_bolt(game,nut,bolt):
dx,dy = bolt.x-nut.x,bolt.y-nut.y
dist = math.hypot(dx,dy)
if dist < .75:
_nut_attach(game,nut,bolt)
return
if dist <= 0.01:
return
step = min(.22,dist-.65)
nx,ny = nut.x+dx/dist*step,nut.y+dy/dist*step
world = game.cave_world if game.in_cave else game.world
if not nut._can_swim_to(nx,ny,world):
return
oldx,oldy = nut.x,nut.y
nut.x,nut.y = nx,ny
nut.swim_heading = math.atan2(dy,dx)
nut.cruise_heading = nut.swim_heading
nut.dx,nut.dy = dx/dist*step,dy/dist*step
nut._update_rotation()
if not game.in_cave:
game.world.update_animal_index(nut,oldx,oldy)
def _nut_tick(game):
if getattr(game,'state',None) != 'playing' or getattr(game,'settings_open',False):
return
# A small per-game set, not a scan through every fish in a giant world.
for nut in tuple(game._nut_attached):
bolt = getattr(nut,'nut_bolt_host',None)
# Bonds persist when the player changes between caves and the surface.
current_layer = game.cave_animals if game.in_cave else game.world.animals
other_layer = game.world.animals if game.in_cave else game.cave_animals
if nut in other_layer and bolt in other_layer:
continue # Frozen but still attached off-screen.
if (bolt is None or bolt.hp <= 0
or getattr(bolt,'is_companion',False)
or nut not in current_layer or bolt not in current_layer):
_nut_release(nut)
game._nut_attached.discard(nut)
continue
passenger_number = (getattr(bolt,'bolt_nuts',[]) or [nut]).index(nut)
# Geometry: a ring clamped onto the threaded part of the fish-bolt.
dx,dy = (math.cos(bolt.swim_heading),math.sin(bolt.swim_heading))
lateralx,lateraly = -dy,dx
nx = bolt.x - dx*(.13+.17*passenger_number) + lateralx*.10
ny = bolt.y - dy*(.13+.17*passenger_number) + lateraly*.10
oldx,oldy = nut.x,nut.y
nut.x,nut.y = nx,ny
nut.swim_heading = bolt.swim_heading
nut.visual_angle = getattr(bolt,'visual_angle',0.0)
nut.rotated_image = nut.type.get_rotated(nut.visual_angle)
nut.rotated_glow = nut.type.get_rotated_glow(nut.visual_angle)
if not game.in_cave:
game.world.update_animal_index(nut,oldx,oldy)
# Already alerted nuts smoothly swim toward their selected hosts every
# frame; expensive target selection is only repeated every 10 frames.
for nut,bolt in tuple(game._nut_seeking.items()):
if (nut.hp <= 0 or bolt.hp <= 0
or getattr(nut,'nut_bolt_host',None) is not None
or getattr(bolt,'is_companion',False)
or (game.in_cave and (nut not in game.cave_animals or bolt not in game.cave_animals))
or (not game.in_cave and (nut not in game.world.animals or bolt not in game.world.animals))
or (nut.x-game.player_x)**2+(nut.y-game.player_y)**2 > (VIEW_RADIUS+3)**2):
game._nut_seeking.pop(nut,None)
continue
_nut_move_towards_bolt(game,nut,bolt)
if game.frame_count % 10:
return
nearby = [a for a in game.get_nearby_animals(VIEW_RADIUS+2)
if a.hp > 0 and not getattr(a,'is_companion',False)]
bolts = [a for a in nearby if a.type is bolt_fish_type
and len(getattr(a,'bolt_nuts',[])) < VEGETABLE_NUTS_PER_BOLT]
if not bolts:
return
predators = [a for a in nearby if a.type.behavior == 'predator']
for nut in nearby:
if nut.type is not nut_fish_type or getattr(nut,'nut_bolt_host',None) is not None:
continue
if game.frame_count < getattr(nut,'nut_next_attach',0):
continue
threatened = ((nut.x-game.player_x)**2 + (nut.y-game.player_y)**2
<= VEGETABLE_NUT_DETECTION**2)
if not threatened:
threatened = any((p.x-nut.x)**2+(p.y-nut.y)**2<9
for p in predators if p is not nut)
if not threatened:
continue
candidates = [bolt for bolt in bolts
if bolt.home_biome is nut.home_biome
and (bolt.x-nut.x)**2+(bolt.y-nut.y)**2 <= VEGETABLE_NUT_SEARCH**2]
if not candidates:
continue
host = min(candidates,key=lambda a:(a.x-nut.x)**2+(a.y-nut.y)**2)
game._nut_seeking[nut] = host
_nut_move_towards_bolt(game,nut,host)
_old_nut_game_init = Game.__init__
def _vegetable_nut_game_init(self,*args,**kwargs):
_old_nut_game_init(self,*args,**kwargs)
self._nut_attached = set()
self._nut_seeking = {}
# Nuts grow in the immediate vicinity of their symbiotic bolt fish.
# Seed some near natural hosts so players actually see this behaviour.
rng = random.Random(20261031)
bolts = [a for a in self.world.animals if a.type is bolt_fish_type]
rng.shuffle(bolts)
added = 0
for bolt in bolts:
if added >= 48:
break
if rng.random() > .36:
continue
a = rng.uniform(0,math.tau)
x,y = bolt.x + math.cos(a)*.85,bolt.y + math.sin(a)*.85
if (self.world.get_biome_at(x,y) is not bolt.home_biome
or not self.world.is_water_at(x,y)
or self.world.get_object_at(x,y) is not None):
continue
nut = Animal(x,y,nut_fish_type,bolt.home_biome)
self.world.animals.append(nut)
added += 1
if added:
self.world.rebuild_animal_index()
Game.__init__ = _vegetable_nut_game_init
_old_nut_game_update = Game.update
def _vegetable_nut_game_update(self,*args,**kwargs):
_old_nut_game_update(self,*args,**kwargs)
if hasattr(self,'_nut_attached'):
_nut_tick(self)
Game.update = _vegetable_nut_game_update
_old_nut_game_remove = Game.remove_current_animal
def _vegetable_nut_remove(self,animal):
passengers = list(getattr(animal,'bolt_nuts',()))
self._nut_seeking.pop(animal,None)
for prospect,host in tuple(self._nut_seeking.items()):
if host is animal:
self._nut_seeking.pop(prospect,None)
if getattr(animal,'nut_bolt_host',None) is not None:
_nut_release(animal)
self._nut_attached.discard(animal)
# Catching/removing a carrier frees its nuts. If it was caught, its
# attached nuts enter the player's inventory, subject to free slots.
for nut in passengers:
_nut_release(nut)
self._nut_attached.discard(nut)
if nut in self.get_current_animals():
if self.add_to_inventory(nut.type,wild_catch=True):
_old_nut_game_remove(self,nut)
else:
nut.nut_next_attach = self.frame_count + 60
return _old_nut_game_remove(self,animal)
Game.remove_current_animal = _vegetable_nut_remove
_old_nut_pick_up = Game.pick_up
def _vegetable_nut_pick_up(self,*args,**kwargs):
if getattr(self,'state',None) != 'playing':
return _old_nut_pick_up(self,*args,**kwargs)
px,py = int(self.player_x),int(self.player_y)
near = [a for a in self.get_nearby_animals(1.2) if a.hp > 0 and
int(a.x)==px and int(a.y)==py and not getattr(a,'is_companion',False)]
carriers = [a for a in near if a.type is bolt_fish_type and getattr(a,'bolt_nuts',())]
if carriers:
bolt = carriers[0]
need = 1 + len(getattr(bolt,'bolt_nuts',()))
free = sum(1 for item in self.inventory if item is None)
if free < need:
self.notification = f'Для болта с гайками требуется {need} свободных слотов'
self.notification_timer = 90
return
if self.add_to_inventory(bolt.type,wild_catch=True):
self.remove_current_animal(bolt)
self.notification = f'Пойман болт со всеми гайками ({need} существ)'
self.notification_timer = 95
return
nuts = [a for a in near if a.type is nut_fish_type and
getattr(a,'nut_bolt_host',None) is not None]
if nuts:
self.notification = 'Рыба-гайка крепко держится за болт: ловите болт целиком!'
self.notification_timer = 95
return
return _old_nut_pick_up(self,*args,**kwargs)
Game.pick_up = _vegetable_nut_pick_up
_old_nut_scan = Game._scanner_details
def _vegetable_nut_scan(self,target):
result = _old_nut_scan(self,target)
kind = getattr(target.get('entity'),'sprite_kind',None)
extra = {
'cucumber_fish':[
'Рыба-огурец: близкий родственник рыбы-кабачка.',
'Длина: 0,8 м. Вытянутое зелёное тело покрыто мелкими шипиками.',
'Очень быстро плавает в реках, на мелководье и в озёрах.'],
'patisson_fish':[
'Рыба-патиссон: родственница рыб-кабачков и огурцов.',
'Диаметр: 0,5 м. Плоское тело с зубчатым краем.',
'Плавает у самого дна и защищается твёрдыми плавниками.',
f'Тройная прочность: {patisson_fish_type.hp} HP вместо {zucchini_fish_type.hp}.'],
'nut_fish':[
'Рыба-гайка: живая гайка диаметром 0,3 м.',
'При опасности прикрепляется к рыбе-болту.',
'Поймать прикреплённую гайку отдельно нельзя: ловите болт.',
f'Увеличивает запас HP носителя на {VEGETABLE_NUT_BONUS_HP}.'],
'bolt_fish':[
'Рыба-болт: носитель рыбы-гайки, дополнение к местной фауне.',
f'Несёт до {VEGETABLE_NUTS_PER_BOLT} рыб-гаек.',
'Каждая присосавшаяся гайка повышает текущий и максимум HP.',
'При ловле болта все его гайки попадают в инвентарь.'],
}
if kind in extra:
return list(result)+extra[kind]
return result
Game._scanner_details = _vegetable_nut_scan
# <<< VEGETABLE_AND_NUT_FISH_V1 <<<
# >>> HYDROFISH_AND_LAND_PET_FIX_V1 >>>
HYDROFISH_NAME = 'Гидрорыба'
HYDROFISH_ATTACH_DURATION_MS = 2200
HYDROFISH_SMALL_PREY_HP = 18
HYDROFISH_BIG_PREY_HP = 24
HYDROFISH_LUNGE_RANGE = 1.15
AKLASUR_CAPTURE_HP = 35
def _entity_uses_land(entity_or_type):
kind = getattr(entity_or_type, 'type', entity_or_type)
return bool(getattr(kind, 'is_dungbird', False) or getattr(kind, 'is_aklasur', False))
def _entity_surface_passable(game, x, y, entity_or_type):
if not (0 <= x < MAP_SIZE and 0 <= y < MAP_SIZE):
return False
if game.in_cave:
return False
cell_x = int(x)
cell_y = int(y)
target_object = game.world.get_object_at(cell_x, cell_y)
if target_object is not None and target_object.is_wall:
return False
if game.is_start_capsule_wall(cell_x, cell_y):
return False
biome = game.world.get_biome_at(cell_x, cell_y)
if biome is None or not biome.passable:
return False
kind = getattr(entity_or_type, 'type', entity_or_type)
if getattr(kind, 'is_dungbird', False):
# Птица может садиться и идти/лететь по любой проходимой поверхности.
return True
if getattr(kind, 'is_aklasur', False):
return biome.name in LAND_BIOMES or biome.name in ('Мелководье', 'Реки', 'Притоки')
if game.is_start_capsule_passage(cell_x, cell_y):
return True
return not is_land_biome(biome)
def _find_nearby_passable_position_for_entity(game, entity_or_type, radius=1.25):
candidates = [(game.player_x, game.player_y)]
for ring in (radius, radius * 1.6, radius * 2.1):
for index in range(16):
angle = index / 16.0 * math.tau
candidates.append((game.player_x + math.cos(angle) * ring,
game.player_y + math.sin(angle) * ring))
for x, y in candidates:
if game.in_cave:
if _entity_uses_land(entity_or_type):
continue
if game.cave_is_passable(x, y):
return (x, y)
else:
if _entity_surface_passable(game, x, y, entity_or_type):
return (x, y)
return None
_old_spawn_from_inventory = Game._spawn_animal_from_inventory_item
def _spawn_animal_from_inventory_item_fixed(self, animal_type, companion=False):
spawn = _find_nearby_passable_position_for_entity(self, animal_type, 1.25)
if spawn is None:
return None
if self.in_cave:
if _entity_uses_land(animal_type):
return None
home_biome = self.get_cave_tunnel_at(spawn[0], spawn[1])
else:
home_biome = self.world.get_biome_at(spawn[0], spawn[1])
if home_biome is None:
return None
if not _entity_surface_passable(self, spawn[0], spawn[1], animal_type):
return None
if home_biome is None and (not companion):
return None
if home_biome is None:
home_biome = (self.get_cave_tunnel_at(self.player_x, self.player_y)
if self.in_cave else self.world.get_biome_at(self.player_x, self.player_y))
if home_biome is None:
return None
animal = Animal(spawn[0], spawn[1], animal_type, home_biome)
if companion:
animal.is_companion = True
animal.hp = animal.max_hp
animal.companion_orbit_angle = random.uniform(0.0, math.tau)
animal.companion_orbit_radius = self._companion_orbit_distance(animal)
animal.companion_target = None
animal.companion_attack_timer = 0
animal.companion_state = 'следует за игроком'
self._add_animal_to_current_layer(animal)
if getattr(animal.type, 'is_leech_hydra', False) and not hasattr(animal, 'hydra_tentacles'):
_hydra_initialize_tentacles(animal)
if getattr(animal.type, 'is_hydrofish', False):
animal.hydrofish_attach_target = None
animal.hydrofish_attach_until = 0
animal.hydrofish_next_bite_at = 0
return animal
Game._spawn_animal_from_inventory_item = _spawn_animal_from_inventory_item_fixed
def _position_is_passable_for_companion_fixed(self, x, y):
# Обратная совместимость: для старых водных питомцев поведение не меняется.
return _entity_surface_passable(self, x, y, getattr(self, '_companion_passability_probe', None) or getattr(self, 'selected_pet', None) or zucchini_fish_type)
Game._position_is_passable_for_companion = _position_is_passable_for_companion_fixed
def _move_companion_towards_fixed(self, animal, target_x, target_y, move_speed):
to_target_x = target_x - animal.x
to_target_y = target_y - animal.y
distance = math.hypot(to_target_x, to_target_y)
if distance <= 0.001:
animal.dx = 0.0
animal.dy = 0.0
return False
step = min(move_speed, distance)
velocity_x = to_target_x / distance * step
velocity_y = to_target_y / distance * step
new_x = animal.x + velocity_x
new_y = animal.y + velocity_y
moved = False
def passable(px, py):
if self.in_cave:
return (not _entity_uses_land(animal)) and self.cave_is_passable(px, py)
return _entity_surface_passable(self, px, py, animal)
if passable(new_x, new_y):
animal.x = new_x
animal.y = new_y
moved = True
elif passable(new_x, animal.y):
animal.x = new_x
velocity_y = 0.0
moved = True
elif passable(animal.x, new_y):
animal.y = new_y
velocity_x = 0.0
moved = True
else:
animal.companion_orbit_angle += 0.35
if moved:
animal.dx = velocity_x
animal.dy = velocity_y
animal._update_rotation()
else:
animal.dx = 0.0
animal.dy = 0.0
return moved
Game._move_companion_towards = _move_companion_towards_fixed
def _transfer_visible_companions_fixed(self, companions, target_in_cave):
if not companions:
return
player_on_land = (not target_in_cave) and self._is_player_on_land()
changed_surface = False
for index, animal in enumerate(companions):
if target_in_cave and _entity_uses_land(animal):
animal.companion_state = 'ждёт у входа в пещеру'
continue
if player_on_land and not _entity_uses_land(animal):
animal.companion_state = 'ждёт у воды'
continue
self._clear_companion_target(animal)
if target_in_cave:
self.world.remove_animal(animal)
if animal not in self.cave_animals:
self.cave_animals.append(animal)
animal.home_biome = self.get_cave_tunnel_at(self.player_x, self.player_y) or animal.home_biome
else:
if animal in self.cave_animals:
self.cave_animals.remove(animal)
if animal not in self.world.animals:
self.world.animals.append(animal)
changed_surface = True
animal.home_biome = self.world.get_biome_at(self.player_x, self.player_y) or animal.home_biome
angle = animal.companion_orbit_angle + index * 0.8
radius = min(self._companion_follow_distance(animal), VIEW_RADIUS * 0.6)
target_x = self.player_x + math.cos(angle) * radius
target_y = self.player_y + math.sin(angle) * radius
if ((target_in_cave and self.cave_is_passable(target_x, target_y)) or
((not target_in_cave) and _entity_surface_passable(self, target_x, target_y, animal))):
animal.x = target_x
animal.y = target_y
else:
animal.x = self.player_x
animal.y = self.player_y
animal.dx = 0.0
animal.dy = 0.0
animal.active = True
animal.companion_state = 'следует за игроком'
if not target_in_cave and changed_surface:
self.world.rebuild_animal_index()
Game._transfer_visible_companions = _transfer_visible_companions_fixed
_old_pick_up_after_nuts = Game.pick_up
def _pick_up_with_live_aklasur(self, *args, **kwargs):
if getattr(self, 'state', None) == 'playing' and not self.in_cave:
px, py = int(self.player_x), int(self.player_y)
for animal in self.get_nearby_animals(1.6):
if (getattr(getattr(animal, 'type', None), 'is_aklasur', False)
and int(animal.x) == px and int(animal.y) == py and animal.hp > 0):
if animal.is_companion or animal.hp <= AKLASUR_CAPTURE_HP:
if self.add_to_inventory(animal.type, wild_catch=not animal.is_companion):
self.remove_current_animal(animal)
self.notification = ('Акласур возвращён в инвентарь.'
if animal.is_companion else
'Ослабленный Акласур пойман живьём! H — сделать питомцем.')
else:
self.notification = 'Инвентарь заполнен — Акласура некуда поместить'
self.notification_timer = 90
return
self.notification = f'Акласур слишком бодр. Ослабьте его хотя бы до {AKLASUR_CAPTURE_HP} HP.'
self.notification_timer = 90
return
return _old_pick_up_after_nuts(self, *args, **kwargs)
Game.pick_up = _pick_up_with_live_aklasur
def generate_hydrofish_sprite(size=54):
surf = pygame.Surface((size, size), pygame.SRCALPHA)
k = size / 90.0
def p(x, y):
return (round(x * k), round(y * k))
def ellipse(color, rect):
x, y, w, h = rect
pygame.draw.ellipse(surf, color, pygame.Rect(round(x*k), round(y*k), max(1, round(w*k)), max(1, round(h*k))))
def poly(color, points):
pygame.draw.polygon(surf, color, [p(x, y) for x, y in points])
poly((49, 108, 122), [(12,45),(23,31),(44,25),(60,26),(75,35),(80,45),(75,55),(60,64),(42,65),(24,59)])
poly((96, 176, 179), [(17,45),(28,34),(46,30),(60,31),(72,37),(75,45),(71,53),(58,59),(43,60),(27,55)])
poly((175, 227, 214), [(22,42),(39,35),(58,36),(66,40),(68,45),(59,51),(42,54),(28,50)])
poly((62, 94, 118), [(7,45),(0,35),(0,55)])
poly((84, 130, 151), [(6,45),(1,38),(1,52)])
poly((50, 130, 142), [(33,29),(41,15),(52,23),(46,33)])
poly((36, 104, 118), [(32,60),(43,72),(55,64),(47,57)])
ellipse((234, 241, 218), (56, 37, 14, 16))
pygame.draw.circle(surf, (20, 29, 34), p(66, 44), max(1, round(3.2*k)))
pygame.draw.circle(surf, (255, 255, 255), p(67, 43), max(1, round(1.2*k)))
# Небольшие щупальца возле рта.
for idx, base_y in enumerate((39, 42, 46, 49)):
points = [p(69, base_y), p(78 + idx, base_y - 4 + idx % 2), p(86, base_y - 8 + idx)]
pygame.draw.lines(surf, (205, 224, 214), False, points, max(1, round(2.3*k)))
pygame.draw.circle(surf, (236, 244, 228), points[-1], max(1, round(1.5*k)))
for x, y in ((29,39),(38,45),(30,52),(47,39),(53,47)):
pygame.draw.arc(surf, (186, 223, 217), pygame.Rect(round((x-4)*k), round((y-4)*k), max(2, round(9*k)), max(2, round(9*k))), -0.9, 1.25, max(1, round(1.1*k)))
return surf
def generate_big_patisson_sprite(size=84):
surf = pygame.Surface((size, size), pygame.SRCALPHA)
c = size // 2
r = max(20, int(size * 0.33))
points = []
teeth = 14
for i in range(teeth * 2):
ang = i * math.pi / teeth
radius = r * (1.18 if i % 2 == 0 else 0.90)
points.append((c + math.cos(ang) * radius, c + math.sin(ang) * radius))
pygame.draw.polygon(surf, (138, 184, 86), points)
inner = []
for x, y in points:
inner.append((c + (x - c) * 0.86, c + (y - c) * 0.86))
pygame.draw.polygon(surf, (184, 222, 105), inner)
pygame.draw.circle(surf, (214, 236, 152), (c, c), int(r * 0.64))
pygame.draw.circle(surf, (97, 132, 66), (c, c), int(r * 0.72), max(1, size // 28))
for ang in [i * math.tau / 8 for i in range(8)]:
x1 = c + math.cos(ang) * r * 0.18
y1 = c + math.sin(ang) * r * 0.18
x2 = c + math.cos(ang) * r * 0.78
y2 = c + math.sin(ang) * r * 0.78
pygame.draw.line(surf, (118, 169, 81), (round(x1), round(y1)), (round(x2), round(y2)), max(1, size // 34))
# Жёсткие плавники-лепестки
for ang in [i * math.tau / 4 + 0.35 for i in range(4)]:
base1 = (c + math.cos(ang - 0.22) * r * 0.86, c + math.sin(ang - 0.22) * r * 0.86)
tip = (c + math.cos(ang) * r * 1.46, c + math.sin(ang) * r * 1.46)
base2 = (c + math.cos(ang + 0.22) * r * 0.86, c + math.sin(ang + 0.22) * r * 0.86)
pygame.draw.polygon(surf, (84, 119, 72), [base1, tip, base2])
pygame.draw.polygon(surf, (166, 205, 103), [((base1[0]+tip[0])*0.5, (base1[1]+tip[1])*0.5), tip, ((base2[0]+tip[0])*0.5, (base2[1]+tip[1])*0.5)])
# Глазки и рот
for ex in (-int(r*0.24), int(r*0.24)):
pygame.draw.circle(surf, (235, 243, 220), (c + ex, c - int(r * 0.13)), max(2, size // 18))
pygame.draw.circle(surf, (28, 36, 24), (c + ex, c - int(r * 0.13)), max(1, size // 34))
mouth_rect = pygame.Rect(c - int(r * 0.22), c + int(r * 0.02), int(r * 0.44), int(r * 0.18))
pygame.draw.arc(surf, (69, 96, 44), mouth_rect, 0.2, math.pi - 0.2, max(1, size // 28))
return surf
_old_sprite_fn_hydro = generate_subnautica_creature_sprite
def generate_subnautica_creature_sprite(kind, length, width):
if kind == 'hydrofish':
return generate_hydrofish_sprite(max(44, int(max(length, width) * 1.2)))
if kind == 'patisson_fish':
return generate_big_patisson_sprite(max(54, int(max(length, width) * 1.24)))
return _old_sprite_fn_hydro(kind, length, width)
_old_init_assets_hydro = init_assets
def init_assets_with_hydrofish_and_patisson_fix():
_old_init_assets_hydro()
global hydrofish_type, patisson_fish_type
if 'patisson_fish_type' in globals():
patisson_fish_type.fish_length = 45
patisson_fish_type.fish_width = 45
patisson_fish_type.image = generate_subnautica_creature_sprite('patisson_fish', 45, 45)
patisson_fish_type.glow_image = None
if hasattr(patisson_fish_type, 'rotated_cache'):
patisson_fish_type.rotated_cache = {}
if hasattr(patisson_fish_type, 'rotated_glow_cache'):
patisson_fish_type.rotated_glow_cache = {}
hydrofish_type = AnimalType(
HYDROFISH_NAME, (86, 163, 167), 20, speed=.052,
behavior='neutral', hp=18, damage=2, edible=True,
hunger_value=25, thirst_value=7, fish_length=28, fish_width=15,
sprite_kind='hydrofish', fear_distance=0.8,
zigzag_strength=.08, speed_variation=.05)
hydrofish_type.is_hydrofish = True
hydrofish_type.fish_style = True
hydrofish_type.image = generate_subnautica_creature_sprite('hydrofish', 28, 15)
hydrofish_type.glow_image = None
if 'dungbird_type' in globals():
dungbird_type.can_live_on_land = True
if 'aklasur_type' in globals():
aklasur_type.can_live_on_land = True
densities = {
'Мелководье': 2,
'Реки': 3,
'Притоки': 2,
'Коралловый риф': 2,
'Кельповый лес': 1,
'Озёра (внешние)': 2,
'Биолюминесцентные зоны': 1,
}
for biome in biomes:
if is_land_biome(biome) or getattr(biome, 'aurora_style', False):
continue
extra = densities.get(biome.name, 0)
if extra:
biome.animals.append((hydrofish_type, extra))
registry = globals().get('START_ITEM_REGISTRY')
if isinstance(registry, dict):
registry[hydrofish_type.name] = hydrofish_type
init_assets = init_assets_with_hydrofish_and_patisson_fix
_old_hydro_game_update = Game.update
def _hydrofish_current_layer(game):
return game.cave_animals if game.in_cave else game.world.animals
def _hydrofish_is_prey_candidate(subject, target):
if target is subject or target.hp <= 0:
return False
if getattr(target, 'is_companion', False):
return False
if getattr(getattr(target, 'type', None), 'is_hydrofish', False):
return False
if not _hydra_is_fish(target):
return False
if getattr(getattr(target, 'type', None), 'is_leech_hydra', False):
return False
return True
def update_with_hydrofish(self, *args, **kwargs):
_old_hydro_game_update(self, *args, **kwargs)
if getattr(self, 'state', None) != 'playing' or getattr(self, 'settings_open', False):
return
now = pygame.time.get_ticks()
current = _hydrofish_current_layer(self)
for fish in self.get_nearby_animals(VIEW_RADIUS + 3):
if fish.hp <= 0 or not getattr(getattr(fish, 'type', None), 'is_hydrofish', False):
continue
if not hasattr(fish, 'hydrofish_attach_target'):
fish.hydrofish_attach_target = None
fish.hydrofish_attach_until = 0
fish.hydrofish_next_bite_at = 0
host = fish.hydrofish_attach_target
if host is not None:
valid = (host in current and host.hp > 0 and now < fish.hydrofish_attach_until
and not getattr(host, 'is_companion', False))
if not valid:
fish.hydrofish_attach_target = None
else:
heading = getattr(host, 'swim_heading', 0.0)
oldx, oldy = fish.x, fish.y
fish.x = host.x - math.cos(heading) * 0.22 + math.sin(heading) * 0.10
fish.y = host.y - math.sin(heading) * 0.22 - math.cos(heading) * 0.10
fish.dx = host.dx
fish.dy = host.dy
fish.swim_heading = heading
fish._update_rotation()
if not self.in_cave:
self.world.update_animal_index(fish, oldx, oldy)
if now >= fish.hydrofish_next_bite_at:
host.hp = max(0, host.hp - 1)
fish.hydrofish_next_bite_at = now + 420
if host.hp <= 0 and host in self.get_current_animals():
self.remove_current_animal(host)
fish.hydrofish_attach_target = None
continue
nearby_targets = []
for target in current:
if not _hydrofish_is_prey_candidate(fish, target):
continue
dist = math.hypot(target.x - fish.x, target.y - fish.y)
if dist <= 2.2:
nearby_targets.append((dist, target))
if not nearby_targets:
continue
nearby_targets.sort(key=lambda item: item[0])
dist, target = nearby_targets[0]
target_size = max(getattr(target.type, 'fish_length', 0), getattr(target.type, 'volume', 0) * 10)
if target.hp <= HYDROFISH_SMALL_PREY_HP and dist <= HYDROFISH_LUNGE_RANGE:
target.hp = 0
if target in self.get_current_animals():
self.remove_current_animal(target)
fish.dx = math.cos(fish.swim_heading) * fish.type.speed
fish.dy = math.sin(fish.swim_heading) * fish.type.speed
continue
if target.hp >= HYDROFISH_BIG_PREY_HP and target_size >= 18 and dist <= 1.0:
fish.hydrofish_attach_target = target
fish.hydrofish_attach_until = now + HYDROFISH_ATTACH_DURATION_MS
fish.hydrofish_next_bite_at = now + 280
continue
# Медленное подплывание к интересной добыче.
if dist <= 1.8 and not getattr(fish, 'is_companion', False):
dx = target.x - fish.x
dy = target.y - fish.y
inv = 1.0 / max(0.001, math.hypot(dx, dy))
step = min(fish.type.speed * 1.55, dist)
nx = fish.x + dx * inv * step
ny = fish.y + dy * inv * step
if fish._can_swim_to(nx, ny, self.cave_world if self.in_cave else self.world):
oldx, oldy = fish.x, fish.y
fish.x, fish.y = nx, ny
fish.dx = dx * inv * step
fish.dy = dy * inv * step
fish.swim_heading = math.atan2(dy, dx)
fish._update_rotation()
if not self.in_cave:
self.world.update_animal_index(fish, oldx, oldy)
Game.update = update_with_hydrofish
_old_scanner_details_hydro = Game._scanner_details
def scanner_details_with_hydrofish_and_big_patisson(self, target):
entity = target.get('entity')
if getattr(entity, 'sprite_kind', None) == 'patisson_fish':
return [
'Рыба-патиссон: увеличенная тяжёлая родственница рыбы-кабачка и рыбы-огурца.',
'Новый размер: около 1,5 м в диаметре — втрое больше прежнего.',
'Имеет плоское дискообразное тело с зубчатым краем и жёсткими плавниками.',
'Плавает медленно над дном, но отличается очень высокой живучестью.',
f'Прочность: {patisson_fish_type.hp} HP, то есть втрое больше рыбы-кабачка.'
]
if entity is globals().get('hydrofish_type'):
return [
'Гидрорыба — переходная форма между пиявкой-гидрой и примитивными рыбами.',
'Внешность: рыба с короткими ротовыми щупальцами.',
'Поведение: плавает медленно, хватает щупальцами мелкую рыбу.',
'Может ненадолго присасываться к крупной добыче и ослаблять её.',
'Обитает главным образом на мелководье, в реках, притоках и у рифов.'
]
return _old_scanner_details_hydro(self, target)
Game._scanner_details = scanner_details_with_hydrofish_and_big_patisson
# <<< HYDROFISH_AND_LAND_PET_FIX_V1 <<<
if __name__ == '__main__':
Game().run()