From d2e11c54563bc48a087be899799f827de4bbaba8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 21:17:30 +0000 Subject: [PATCH 1/4] feat: implement snake game core mechanics Add modular game core with species levels, food types, senses, world generation by seed, combat/eating mechanics, shedding, wall breaking, parallel universes, abilities, and level goals. Includes 18 unit tests covering entities, mechanics, senses, world generation, and engine. Co-authored-by: Timofey P. Samodurov --- .gitignore | 8 ++ README.md | 65 +++++++++++- game/__init__.py | 14 +++ game/abilities.py | 86 +++++++++++++++ game/constants.py | 64 ++++++++++++ game/engine.py | 230 +++++++++++++++++++++++++++++++++++++++++ game/entities.py | 203 ++++++++++++++++++++++++++++++++++++ game/food_types.py | 112 ++++++++++++++++++++ game/levels.py | 150 +++++++++++++++++++++++++++ game/mechanics.py | 253 +++++++++++++++++++++++++++++++++++++++++++++ game/senses.py | 119 +++++++++++++++++++++ game/species.py | 50 +++++++++ game/world.py | 224 +++++++++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/test_core.py | 191 ++++++++++++++++++++++++++++++++++ 15 files changed, 1767 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 game/__init__.py create mode 100644 game/abilities.py create mode 100644 game/constants.py create mode 100644 game/engine.py create mode 100644 game/entities.py create mode 100644 game/food_types.py create mode 100644 game/levels.py create mode 100644 game/mechanics.py create mode 100644 game/senses.py create mode 100644 game/species.py create mode 100644 game/world.py create mode 100644 tests/__init__.py create mode 100644 tests/test_core.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1210db5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +dist/ +build/ +.venv/ +venv/ diff --git a/README.md b/README.md index 2a085a7..48ca724 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,63 @@ -# cli_snake -Simple game "Classic Snake" in the command line interface +# Змейка — ядро игры + +Расширенная змейка с бесконечным ростом, видами змей, чувствами, процедурным миром и параллельными вселенными. + +## Структура + +``` +game/ +├── constants.py # Константы и перечисления +├── species.py # Уровни/виды змеи +├── food_types.py # Типы еды и характеристики +├── entities.py # Snake, Food +├── senses.py # Обоняние, слух, слабое зрение +├── abilities.py # Прокачка способностей +├── world.py # Генерация мира по seed +├── levels.py # Уровни и цели прохождения +├── mechanics.py # Поедание, урон, линька, смерть +└── engine.py # Игровой движок +``` + +## Ключевые механики + +| Механика | Описание | +|----------|----------| +| Рост | Бесконечный через поедание; линька сбрасывает длину, уровень сохраняется | +| Виды | Червеобразная → Травяная → … → Космическая | +| Яд | Баланс ядовитой и неядовитой пищи определяет ядовитость | +| Поедание | Голова ≥ размер жертвы — можно съесть | +| Урон | Жертва больше головы — потеря сегментов при прохождении | +| Смерть | Потеря >50% длины, удар о препятствие, раздавливание | +| Труп | Тело змеи становится едой на том же уровне | +| Раздавливание | Хвост <50% длины — смерть; иначе только потеря длины | +| Чувства | Еда видна по запаху/звуку; движущаяся — при шаге/писке | +| Стены | Пробитие при достаточной длине и уровне (−50% длины) | +| Вселенные | После внешних стен — параллельный мир с другими правилами | +| Мир | Процедурная генерация по `seed` | + +## Запуск тестов + +```bash +python -m unittest discover -s tests -v +``` + +## CLI-прототип + +Классическая CLI-версия: `cli_snake.py` (не интегрирована с новым ядром). + +## Пример использования ядра + +```python +from game import GameEngine +from game.constants import Direction + +engine = GameEngine(seed=42) +engine.new_game(level_index=0) + +for _ in range(100): + result = engine.tick(Direction.RIGHT) + if not result.snake_alive: + break + +print(engine.status_text()) +``` diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 0000000..a09124d --- /dev/null +++ b/game/__init__.py @@ -0,0 +1,14 @@ +"""Ядро игры «Змейка» — растущая змея с уровнями видов, чувствами и процедурным миром.""" + +from game.engine import GameEngine, GameState +from game.entities import Food, Snake, Vec2 +from game.world import World + +__all__ = [ + "Food", + "GameEngine", + "GameState", + "Snake", + "Vec2", + "World", +] diff --git a/game/abilities.py b/game/abilities.py new file mode 100644 index 0000000..d9a3a38 --- /dev/null +++ b/game/abilities.py @@ -0,0 +1,86 @@ +"""Прокачка способностей змеи.""" + +from dataclasses import dataclass, field +from enum import Enum + +from game.senses import SenseProfile + + +class AbilityId(Enum): + SMELL = "smell" + HEARING = "hearing" + VISION = "vision" + TOUGHNESS = "toughness" + DIGESTION = "digestion" + VENOM = "venom" + + +@dataclass +class AbilityDef: + id: AbilityId + name: str + description: str + max_level: int + cost_per_level: int + + +ABILITY_DEFS: dict[AbilityId, AbilityDef] = { + AbilityId.SMELL: AbilityDef( + AbilityId.SMELL, "Обоняние", "Увеличивает дальность запаха", 5, 3, + ), + AbilityId.HEARING: AbilityDef( + AbilityId.HEARING, "Слух", "Усиливает восприятие звуков добычи", 5, 3, + ), + AbilityId.VISION: AbilityDef( + AbilityId.VISION, "Зрение", "Немного улучшает ближнее зрение", 3, 5, + ), + AbilityId.TOUGHNESS: AbilityDef( + AbilityId.TOUGHNESS, "Прочность", "Снижает урон при прохождении через крупную добычу", 5, 4, + ), + AbilityId.DIGESTION: AbilityDef( + AbilityId.DIGESTION, "Пищеварение", "Бонус к росту от еды", 5, 4, + ), + AbilityId.VENOM: AbilityDef( + AbilityId.VENOM, "Яд", "Усиливает эффект ядовитой пищи", 5, 4, + ), +} + + +@dataclass +class AbilityState: + levels: dict[AbilityId, int] = field(default_factory=dict) + skill_points: int = 0 + + def level_of(self, ability: AbilityId) -> int: + return self.levels.get(ability, 0) + + def can_upgrade(self, ability: AbilityId) -> bool: + current = self.level_of(ability) + definition = ABILITY_DEFS[ability] + return current < definition.max_level and self.skill_points >= definition.cost_per_level + + def upgrade(self, ability: AbilityId) -> bool: + if not self.can_upgrade(ability): + return False + cost = ABILITY_DEFS[ability].cost_per_level + self.skill_points -= cost + self.levels[ability] = self.level_of(ability) + 1 + return True + + def to_sense_profile(self) -> SenseProfile: + return SenseProfile( + smell_range=6 + self.level_of(AbilityId.SMELL) * 2, + hearing_range=8 + self.level_of(AbilityId.HEARING) * 2, + vision_range=2 + self.level_of(AbilityId.VISION), + smell_sensitivity=1.0 + self.level_of(AbilityId.SMELL) * 0.15, + hearing_sensitivity=1.0 + self.level_of(AbilityId.HEARING) * 0.15, + ) + + def damage_reduction(self) -> float: + return self.level_of(AbilityId.TOUGHNESS) * 0.1 + + def growth_bonus(self) -> int: + return self.level_of(AbilityId.DIGESTION) + + def poison_bonus(self) -> int: + return self.level_of(AbilityId.VENOM) diff --git a/game/constants.py b/game/constants.py new file mode 100644 index 0000000..ddec502 --- /dev/null +++ b/game/constants.py @@ -0,0 +1,64 @@ +"""Общие константы ядра игры.""" + +from enum import Enum, IntEnum + + +class Direction(IntEnum): + LEFT = 10 + RIGHT = 11 + UP = 12 + DOWN = 13 + + +class GamePhase(Enum): + NEW = "new" + PLAYING = "playing" + PAUSED = "paused" + ENDED = "ended" + UNIVERSE_TRANSITION = "universe_transition" + + +class TileType(Enum): + EMPTY = "empty" + WALL = "wall" + INNER_WALL = "inner_wall" + OUTER_WALL = "outer_wall" + BREAKABLE_WALL = "breakable_wall" + WATER = "water" + + +class Mobility(Enum): + STATIC = "static" + MOBILE = "mobile" + + +class FoodCategory(Enum): + INSECT = "insect" + RODENT = "rodent" + BIRD = "bird" + FRUIT = "fruit" + BERRY = "berry" + VEGETABLE = "vegetable" + CARCASS = "carcass" + HERBIVORE = "herbivore" + PREDATOR = "predator" + SNAKE_CARCASS = "snake_carcass" + + +# Порог потери длины за одно событие — смерть +DEATH_LENGTH_LOSS_RATIO = 0.5 + +# Доля хвоста от общей длины — выживание при раздавливании +TAIL_SURVIVAL_RATIO = 0.5 + +# Порог яда/антияда для определения ядовитости +POISON_THRESHOLD = 3 + +# Базовый размер головы новорождённой змеи +BASE_HEAD_SIZE = 1 + +# Начальная длина змеи +INITIAL_SNAKE_LENGTH = 3 + +# Доля длины, теряемая при пробитии стены / переходе вселенной +WALL_BREAK_LENGTH_LOSS_RATIO = 0.5 diff --git a/game/engine.py b/game/engine.py new file mode 100644 index 0000000..a41a920 --- /dev/null +++ b/game/engine.py @@ -0,0 +1,230 @@ +"""Игровой движок — связывает мир, змею, чувства и механики.""" + +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from typing import Optional + +from game.abilities import AbilityState +from game.constants import Direction, GamePhase +from game.entities import Snake, Vec2 +from game.levels import LevelProgress, can_unlock_next_level, evaluate_goals, get_level +from game.mechanics import EventType, TickResult, process_snake_move +from game.senses import PerceptionState, SenseSystem +from game.species import SPECIES_LEVELS +from game.world import World, generate_world, move_mobile_entities + + +@dataclass +class UniverseRules: + """Правила параллельной вселенной — меняются после пробития внешних стен.""" + + index: int + name: str + food_spawn_multiplier: float + mobile_speed_bonus: int + poison_drift: int + description: str + + +UNIVERSE_RULES: tuple[UniverseRules, ...] = ( + UniverseRules(0, "Первичная", 1.0, 0, 0, "Базовый мир"), + UniverseRules(1, "Зеркальная", 1.2, 1, 1, "Больше движущейся добычи, слабый ядовитый дрейф"), + UniverseRules(2, "Тёмная", 0.9, 0, -1, "Меньше еды, антияд в пище сильнее"), + UniverseRules(3, "Хаотическая", 1.5, 2, 2, "Много агрессивной добычи"), +) + + +@dataclass +class GameState: + phase: GamePhase = GamePhase.NEW + snake: Optional[Snake] = None + world: Optional[World] = None + level_index: int = 0 + seed: int = 42 + direction: Direction = Direction.RIGHT + abilities: AbilityState = field(default_factory=AbilityState) + level_progress: LevelProgress = field(default_factory=LevelProgress) + perception: PerceptionState = field(default_factory=PerceptionState) + last_tick_result: Optional[TickResult] = None + message: str = "" + score: int = 0 + rng: random.Random = field(default_factory=random.Random) + + @property + def universe_rules(self) -> UniverseRules: + idx = self.snake.universe_index if self.snake else 0 + if idx >= len(UNIVERSE_RULES): + return UNIVERSE_RULES[-1] + return UNIVERSE_RULES[idx] + + +class GameEngine: + """Главный контроллер игрового цикла.""" + + def __init__(self, seed: int = 42) -> None: + self.state = GameState(seed=seed, rng=random.Random(seed)) + self.sense_system = SenseSystem() + + def new_game(self, level_index: int = 0, seed: Optional[int] = None) -> GameState: + if seed is not None: + self.state.seed = seed + self.state.rng = random.Random(seed) + + level = get_level(level_index) + world = generate_world(level, self.state.seed, universe_index=0) + start_pos: Vec2 = (world.width // 4, world.height // 2) + snake = Snake.create(start_pos) + + self.state = GameState( + phase=GamePhase.PLAYING, + snake=snake, + world=world, + level_index=level_index, + seed=self.state.seed, + direction=Direction.RIGHT, + abilities=AbilityState(skill_points=3), + level_progress=LevelProgress(), + rng=self.state.rng, + message=f"Уровень: {level.name}", + ) + self.sense_system = SenseSystem(self.state.abilities.to_sense_profile()) + return self.state + + def tick(self, direction: Optional[Direction] = None) -> TickResult: + state = self.state + if state.phase != GamePhase.PLAYING or state.snake is None or state.world is None: + return TickResult() + + world = state.world + snake = state.snake + + world.advance_tick() + move_mobile_entities(world, state.rng) + + if direction is not None: + state.direction = direction + + self.sense_system.profile = state.abilities.to_sense_profile() + state.perception = self.sense_system.perceive(snake, world.foods, world.tick) + + tick_result = process_snake_move( + snake, world, state.direction, state.abilities, + ) + state.last_tick_result = tick_result + + for event in tick_result.events: + if event.event_type == EventType.ATE: + state.level_progress.eaten_count += 1 + state.score += event.food.definition.score if event.food else 10 + state.abilities.skill_points += 1 + elif event.event_type == EventType.WALL_BROKEN: + state.level_progress.walls_broken += 1 + elif event.event_type == EventType.DIED: + state.phase = GamePhase.ENDED + state.message = event.message + + state.level_progress.ticks_survived += 1 + + if not tick_result.snake_alive: + return tick_result + + if tick_result.universe_transition: + self._enter_parallel_universe() + return tick_result + + level = get_level(state.level_index) + all_done, _ = evaluate_goals(level, snake, state.level_progress) + if all_done: + tick_result.level_completed = True + state.message = f"Уровень «{level.name}» пройден!" + if can_unlock_next_level(snake, level): + state.level_index += 1 + self._load_level(state.level_index, preserve_snake=True) + + return tick_result + + def _enter_parallel_universe(self) -> None: + state = self.state + if state.snake is None or state.world is None: + return + + snake = state.snake + snake.universe_index += 1 + rules = self._universe_rules_for(snake.universe_index) + + level = get_level(state.level_index) + world = generate_world(level, state.seed, universe_index=snake.universe_index) + + snake.segments = snake.segments[: max(3, len(snake.segments))] + state.world = world + state.phase = GamePhase.PLAYING + state.message = ( + f"Параллельная вселенная: {rules.name}. " + f"Змея начинает заново с сохранением уровня {snake.species.name}." + ) + + def _universe_rules_for(self, index: int) -> UniverseRules: + if index >= len(UNIVERSE_RULES): + return UNIVERSE_RULES[-1] + return UNIVERSE_RULES[index] + + def _load_level(self, level_index: int, preserve_snake: bool = False) -> None: + state = self.state + level = get_level(level_index) + universe_index = state.snake.universe_index if state.snake else 0 + world = generate_world(level, state.seed, universe_index=universe_index) + + if preserve_snake and state.snake is not None: + snake = state.snake + start: Vec2 = (world.width // 4, world.height // 2) + snake.segments = [(start[0] - i, start[1]) for i in range(min(snake.length, 20))] + else: + start = (world.width // 4, world.height // 2) + snake = Snake.create(start) + + state.snake = snake + state.world = world + state.level_index = level_index + state.level_progress = LevelProgress() + state.message = f"Уровень: {level.name}" + + def get_visible_foods(self): + if self.state.world is None: + return [] + return self.sense_system.filter_foods_for_display( + self.state.world.foods, + self.state.perception, + ) + + def status_text(self) -> str: + state = self.state + if state.snake is None: + return state.message + + snake = state.snake + poison_label = "ядовитая" if snake.is_poisonous else "неядовитая" + perceived = len(state.perception.perceived) + lines = [ + f"Вид: {snake.species.name} (ур. {snake.level_index})", + f"Длина: {snake.length} | Голова: {snake.head_size} | {poison_label}", + f"Очки: {state.score} | SP: {state.abilities.skill_points}", + f"Чувствует: {perceived} объектов", + state.message, + ] + return " | ".join(lines) + + def is_max_level_reached(self) -> bool: + if self.state.snake is None: + return False + return self.state.snake.level_index >= len(SPECIES_LEVELS) - 1 + + def can_break_outer_world(self) -> bool: + if self.state.snake is None: + return False + snake = self.state.snake + return ( + self.is_max_level_reached() + and snake.length >= snake.species.wall_break_min_length + ) diff --git a/game/entities.py b/game/entities.py new file mode 100644 index 0000000..9dcc8d9 --- /dev/null +++ b/game/entities.py @@ -0,0 +1,203 @@ +"""Игровые сущности: змея, еда, позиции.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from typing import Optional + +from game.constants import ( + BASE_HEAD_SIZE, + DEATH_LENGTH_LOSS_RATIO, + Direction, + INITIAL_SNAKE_LENGTH, + POISON_THRESHOLD, + WALL_BREAK_LENGTH_LOSS_RATIO, +) +from game.food_types import FOOD_TYPES, FoodTypeDef, food_type_for_size +from game.species import SPECIES_LEVELS, SpeciesLevel, get_species + + +Vec2 = tuple[int, int] + + +@dataclass +class Food: + """Сущность еды или препятствия на поле.""" + + food_type_id: str + position: Vec2 + id: str = field(default_factory=lambda: str(uuid.uuid4())) + size: Optional[int] = None + is_alive: bool = True + direction: Optional[Direction] = None + moved_this_tick: bool = False + squeaked_this_tick: bool = False + is_immovable_obstacle: bool = False + + @property + def definition(self) -> FoodTypeDef: + return FOOD_TYPES[self.food_type_id] + + @property + def effective_size(self) -> int: + return self.size if self.size is not None else self.definition.size + + @property + def mobility(self): + return self.definition.mobility + + @property + def crush_size(self) -> int: + return self.definition.crush_size + + @property + def symbol(self) -> str: + return self.definition.symbol + + def can_be_eaten_by(self, head_size: int) -> bool: + if not self.is_alive or self.is_immovable_obstacle: + return False + return head_size >= self.effective_size + + def damages_snake_on_pass(self, head_size: int) -> bool: + if self.is_immovable_obstacle: + return False + return self.effective_size > head_size + + +@dataclass +class Snake: + """Главный герой — бесконечно растущая змея.""" + + segments: list[Vec2] + direction: Direction + level_index: int = 0 + poison_meter: int = 0 + length_at_last_shed: int = INITIAL_SNAKE_LENGTH + shed_count: int = 0 + universe_index: int = 0 + + @classmethod + def create(cls, start: Vec2, direction: Direction = Direction.RIGHT) -> Snake: + x, y = start + segments = [(x - i, y) for i in range(INITIAL_SNAKE_LENGTH)] + return cls(segments=list(segments), direction=direction) + + @property + def head(self) -> Vec2: + return self.segments[0] + + @property + def length(self) -> int: + return len(self.segments) + + @property + def species(self) -> SpeciesLevel: + return get_species(self.level_index) + + @property + def head_size(self) -> int: + return self.species.head_size + + @property + def is_poisonous(self) -> bool: + return self.poison_meter >= POISON_THRESHOLD + + @property + def tail_length(self) -> int: + return self.length - 1 + + @property + def tail_ratio(self) -> float: + if self.length <= 1: + return 0.0 + return self.tail_length / self.length + + def move(self, direction: Optional[Direction] = None) -> Vec2: + if direction is not None: + self.direction = direction + head_x, head_y = self.head + if self.direction == Direction.RIGHT: + new_head = (head_x + 1, head_y) + elif self.direction == Direction.LEFT: + new_head = (head_x - 1, head_y) + elif self.direction == Direction.UP: + new_head = (head_x, head_y - 1) + else: + new_head = (head_x, head_y + 1) + self.segments.insert(0, new_head) + self.segments.pop() + return new_head + + def grow(self, amount: int = 1) -> None: + if amount <= 0: + return + tail = self.segments[-1] + for _ in range(amount): + self.segments.append(tail) + + def apply_food(self, food: Food) -> None: + self.grow(food.definition.growth_segments) + self.poison_meter += food.definition.poison_value + + def should_shed(self) -> bool: + threshold = self.species.shed_length_threshold + return self.length >= threshold + + def shed_skin(self) -> int: + """Линька: сброс длины, повышение вида (уровень сохраняется и растёт).""" + target = max(INITIAL_SNAKE_LENGTH, self.species.shed_length_threshold // 2) + removed = max(0, self.length - target) + if removed > 0: + self.segments = self.segments[:target] + self.length_at_last_shed = self.length + self.shed_count += 1 + if self.level_index + 1 < len(SPECIES_LEVELS): + self.level_index += 1 + return removed + + def lose_segments(self, count: int) -> int: + count = max(0, min(count, self.length - 1)) + if count <= 0: + return 0 + self.segments = self.segments[:-count] + return count + + def would_die_from_loss(self, segments_lost: int) -> bool: + if segments_lost <= 0 or self.length <= 0: + return False + return segments_lost / self.length > DEATH_LENGTH_LOSS_RATIO + + def survives_crush_damage(self, crush_damage: int) -> bool: + """Если потеря при раздавливании не более половины длины — змея выживает.""" + return not self.would_die_from_loss(crush_damage) + + def can_break_wall(self) -> bool: + species = self.species + return ( + self.length >= species.wall_break_min_length + and species.wall_break_min_length > 0 + ) + + def break_wall_cost(self) -> int: + return max(1, int(self.length * WALL_BREAK_LENGTH_LOSS_RATIO)) + + def apply_wall_break(self) -> int: + lost = self.lose_segments(self.break_wall_cost()) + return lost + + def to_carcass_food(self) -> Food: + carcass_size = max(BASE_HEAD_SIZE, min(self.head_size + self.length // 5, 8)) + return Food( + food_type_id=food_type_for_size(carcass_size), + position=self.head, + size=carcass_size, + is_alive=False, + ) + + def body_positions(self) -> set[Vec2]: + return set(self.segments[1:]) + + def collides_with_self(self) -> bool: + return self.head in self.body_positions() diff --git a/game/food_types.py b/game/food_types.py new file mode 100644 index 0000000..4cc4eb6 --- /dev/null +++ b/game/food_types.py @@ -0,0 +1,112 @@ +"""Типы еды и их характеристики.""" + +from dataclasses import dataclass + +from game.constants import FoodCategory, Mobility + + +@dataclass(frozen=True) +class FoodTypeDef: + """Шаблон сущности еды.""" + + id: str + name: str + category: FoodCategory + mobility: Mobility + size: int + growth_segments: int + poison_value: int + scent_range: int + sound_range: int + symbol: str + speed: int = 0 + crush_size: int = 0 + score: int = 10 + makes_sound_on_move: bool = False + + +FOOD_TYPES: dict[str, FoodTypeDef] = { + "fly": FoodTypeDef( + "fly", "Муха", FoodCategory.INSECT, Mobility.MOBILE, + size=1, growth_segments=1, poison_value=0, + scent_range=2, sound_range=4, symbol="·", speed=2, makes_sound_on_move=True, + ), + "beetle": FoodTypeDef( + "beetle", "Жук", FoodCategory.INSECT, Mobility.MOBILE, + size=1, growth_segments=1, poison_value=-1, + scent_range=1, sound_range=2, symbol="b", speed=1, + ), + "mouse": FoodTypeDef( + "mouse", "Мышь", FoodCategory.RODENT, Mobility.MOBILE, + size=2, growth_segments=2, poison_value=0, + scent_range=4, sound_range=6, symbol="m", speed=1, makes_sound_on_move=True, + ), + "bird": FoodTypeDef( + "bird", "Птица", FoodCategory.BIRD, Mobility.MOBILE, + size=3, growth_segments=2, poison_value=0, + scent_range=5, sound_range=8, symbol="v", speed=2, makes_sound_on_move=True, + ), + "berry": FoodTypeDef( + "berry", "Ягода", FoodCategory.BERRY, Mobility.STATIC, + size=1, growth_segments=1, poison_value=-2, + scent_range=3, sound_range=0, symbol="*", + ), + "apple": FoodTypeDef( + "apple", "Яблоко", FoodCategory.FRUIT, Mobility.STATIC, + size=2, growth_segments=1, poison_value=-1, + scent_range=4, sound_range=0, symbol="o", + ), + "carrot": FoodTypeDef( + "carrot", "Морковь", FoodCategory.VEGETABLE, Mobility.STATIC, + size=2, growth_segments=1, poison_value=-2, + scent_range=2, sound_range=0, symbol="c", + ), + "carcass_small": FoodTypeDef( + "carcass_small", "Тушка мелкая", FoodCategory.CARCASS, Mobility.STATIC, + size=2, growth_segments=2, poison_value=0, + scent_range=6, sound_range=0, symbol="%", + ), + "goat": FoodTypeDef( + "goat", "Коза", FoodCategory.HERBIVORE, Mobility.MOBILE, + size=5, growth_segments=4, poison_value=0, + scent_range=8, sound_range=10, symbol="G", speed=1, + crush_size=6, makes_sound_on_move=True, + ), + "antelope": FoodTypeDef( + "antelope", "Антилопа", FoodCategory.HERBIVORE, Mobility.MOBILE, + size=6, growth_segments=5, poison_value=0, + scent_range=10, sound_range=12, symbol="A", speed=2, + crush_size=7, makes_sound_on_move=True, + ), + "viper": FoodTypeDef( + "viper", "Гадюка", FoodCategory.PREDATOR, Mobility.MOBILE, + size=4, growth_segments=3, poison_value=5, + scent_range=5, sound_range=4, symbol="s", speed=1, + ), + "elephant": FoodTypeDef( + "elephant", "Слон", FoodCategory.PREDATOR, Mobility.MOBILE, + size=10, growth_segments=0, poison_value=0, + scent_range=15, sound_range=20, symbol="E", speed=1, + crush_size=12, + ), + "snake_carcass": FoodTypeDef( + "snake_carcass", "Тело змеи", FoodCategory.SNAKE_CARCASS, Mobility.STATIC, + size=3, growth_segments=0, poison_value=0, + scent_range=8, sound_range=0, symbol="S", + ), + "rock": FoodTypeDef( + "rock", "Камень", FoodCategory.CARCASS, Mobility.STATIC, + size=8, growth_segments=0, poison_value=0, + scent_range=0, sound_range=0, symbol="#", + crush_size=0, + ), +} + + +def food_type_for_size(size: int) -> str: + """Подбор типа еды по размеру (для трупа змеи).""" + if size <= 2: + return "carcass_small" + if size <= 5: + return "snake_carcass" + return "carcass_small" diff --git a/game/levels.py b/game/levels.py new file mode 100644 index 0000000..b350743 --- /dev/null +++ b/game/levels.py @@ -0,0 +1,150 @@ +"""Конфигурация уровней и условия прохождения.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Callable, Optional + +from game.entities import Snake + + +class LevelGoalType(Enum): + REACH_LENGTH = "reach_length" + REACH_SPECIES = "reach_species" + EAT_COUNT = "eat_count" + SURVIVE_TICKS = "survive_ticks" + BREAK_INNER_WALL = "break_inner_wall" + + +@dataclass(frozen=True) +class LevelGoal: + goal_type: LevelGoalType + target_value: int + description: str + + +@dataclass(frozen=True) +class LevelConfig: + index: int + name: str + field_size: tuple[int, int] + food_spawn_table: dict[str, int] + inner_wall_count: int + breakable_wall_count: int + goals: tuple[LevelGoal, ...] + unlock_min_length: int + unlock_min_level: int + + +LEVELS: tuple[LevelConfig, ...] = ( + LevelConfig( + index=0, + name="Луговая поляна", + field_size=(30, 18), + food_spawn_table={"fly": 6, "beetle": 4, "berry": 5, "mouse": 2}, + inner_wall_count=3, + breakable_wall_count=2, + goals=( + LevelGoal(LevelGoalType.REACH_LENGTH, 12, "Дорасти до длины 12"), + LevelGoal(LevelGoalType.EAT_COUNT, 5, "Съешь 5 существ или ягод"), + ), + unlock_min_length=0, + unlock_min_level=0, + ), + LevelConfig( + index=1, + name="Кустарник", + field_size=(35, 20), + food_spawn_table={"mouse": 4, "bird": 2, "apple": 4, "viper": 1, "berry": 3}, + inner_wall_count=5, + breakable_wall_count=3, + goals=( + LevelGoal(LevelGoalType.REACH_SPECIES, 1, "Достигни вида «Травяная»"), + LevelGoal(LevelGoalType.EAT_COUNT, 8, "Съешь 8 единиц еды"), + ), + unlock_min_length=12, + unlock_min_level=0, + ), + LevelConfig( + index=2, + name="Степь", + field_size=(40, 22), + food_spawn_table={"goat": 2, "mouse": 3, "bird": 3, "carrot": 4, "carcass_small": 2}, + inner_wall_count=6, + breakable_wall_count=4, + goals=( + LevelGoal(LevelGoalType.REACH_LENGTH, 30, "Дорасти до длины 30"), + LevelGoal(LevelGoalType.BREAK_INNER_WALL, 1, "Пробей внутреннюю стену"), + ), + unlock_min_length=20, + unlock_min_level=1, + ), + LevelConfig( + index=3, + name="Саванна", + field_size=(45, 24), + food_spawn_table={"antelope": 2, "goat": 3, "bird": 4, "apple": 3, "elephant": 1}, + inner_wall_count=8, + breakable_wall_count=5, + goals=( + LevelGoal(LevelGoalType.REACH_SPECIES, 3, "Достигни вида «Крысиная»"), + LevelGoal(LevelGoalType.SURVIVE_TICKS, 200, "Выживи 200 тиков"), + ), + unlock_min_length=35, + unlock_min_level=2, + ), +) + + +def get_level(index: int) -> LevelConfig: + if index < 0: + return LEVELS[0] + if index >= len(LEVELS): + return LEVELS[-1] + return LEVELS[index] + + +@dataclass +class LevelProgress: + eaten_count: int = 0 + walls_broken: int = 0 + ticks_survived: int = 0 + + def check_goals(self, snake: Snake, progress: "LevelProgress") -> list[LevelGoal]: + completed: list[LevelGoal] = [] + return completed + + +def evaluate_goals( + level: LevelConfig, + snake: Snake, + progress: LevelProgress, +) -> tuple[bool, list[LevelGoal]]: + """Возвращает (все цели выполнены, список выполненных целей).""" + completed: list[LevelGoal] = [] + for goal in level.goals: + done = False + if goal.goal_type == LevelGoalType.REACH_LENGTH: + done = snake.length >= goal.target_value + elif goal.goal_type == LevelGoalType.REACH_SPECIES: + done = snake.level_index >= goal.target_value + elif goal.goal_type == LevelGoalType.EAT_COUNT: + done = progress.eaten_count >= goal.target_value + elif goal.goal_type == LevelGoalType.SURVIVE_TICKS: + done = progress.ticks_survived >= goal.target_value + elif goal.goal_type == LevelGoalType.BREAK_INNER_WALL: + done = progress.walls_broken >= goal.target_value + if done: + completed.append(goal) + all_done = len(completed) == len(level.goals) + return all_done, completed + + +def can_unlock_next_level(snake: Snake, current_level: LevelConfig) -> bool: + next_index = current_level.index + 1 + if next_index >= len(LEVELS): + return False + next_level = LEVELS[next_index] + return ( + snake.length >= next_level.unlock_min_length + and snake.level_index >= next_level.unlock_min_level + ) diff --git a/game/mechanics.py b/game/mechanics.py new file mode 100644 index 0000000..386af17 --- /dev/null +++ b/game/mechanics.py @@ -0,0 +1,253 @@ +"""Игровые механики: поедание, урон, линька, смерть, раздавливание.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +from game.abilities import AbilityState +from game.constants import Direction +from game.entities import Food, Snake, Vec2 +from game.world import World + + +class EventType(Enum): + ATE = "ate" + DAMAGED = "damaged" + CRUSHED = "crushed" + SHED = "shed" + DIED = "died" + WALL_HIT = "wall_hit" + WALL_BROKEN = "wall_broken" + LEVEL_COMPLETE = "level_complete" + UNIVERSE_ENTERED = "universe_entered" + SELF_COLLISION = "self_collision" + + +@dataclass +class GameEvent: + event_type: EventType + message: str + segments_lost: int = 0 + food: Optional[Food] = None + + +@dataclass +class TickResult: + events: list[GameEvent] = field(default_factory=list) + snake_alive: bool = True + level_completed: bool = False + universe_transition: bool = False + + +def process_snake_move( + snake: Snake, + world: World, + direction: Optional[Direction], + abilities: AbilityState, +) -> TickResult: + result = TickResult() + old_length = snake.length + + if direction is not None and not _is_reverse(snake.direction, direction): + snake.direction = direction + + new_head = _compute_new_head(snake.head, snake.direction) + target_tile = world.tile_at(new_head) + food = world.food_at(new_head) + + if world.is_immovable_obstacle(new_head) and not ( + world.is_breakable_wall(new_head) and snake.can_break_wall() + ): + if world.is_outer_wall(new_head) and snake.can_break_wall(): + return _handle_outer_wall_break(snake, world, new_head, result) + result.events.append( + GameEvent(EventType.WALL_HIT, "Удар головой о неподвижное препятствие") + ) + return _handle_death(snake, world, result, "Смерть от удара о препятствие") + + if world.is_breakable_wall(new_head) and snake.can_break_wall(): + world.break_tile(new_head) + lost = snake.apply_wall_break() + snake.move() + result.events.append( + GameEvent(EventType.WALL_BROKEN, f"Пробита стена, потеряно {lost} сегментов", lost) + ) + return result + + snake.move() + + if food is not None: + _resolve_food_interaction(snake, world, food, abilities, result) + else: + _check_pass_through_damage(snake, world, abilities, result) + + if snake.collides_with_self(): + result.events.append(GameEvent(EventType.SELF_COLLISION, "Самопересечение")) + if snake.would_die_from_loss(2): + return _handle_death(snake, world, result, "Смерть от самопересечения") + + _check_crush_from_heavy_entities(snake, world, result) + + if snake.should_shed(): + removed = snake.shed_skin() + result.events.append( + GameEvent(EventType.SHED, f"Линька: сброшено {removed} сегментов", removed) + ) + + segments_lost = max(0, old_length - snake.length) + if segments_lost > 0 and snake.would_die_from_loss(segments_lost): + return _handle_death( + snake, world, result, + f"Потеряно более половины длины ({segments_lost} сегментов)", + ) + + if not result.snake_alive: + return result + + result.snake_alive = snake.length >= 1 + return result + + +def _resolve_food_interaction( + snake: Snake, + world: World, + food: Food, + abilities: AbilityState, + result: TickResult, +) -> None: + if food.is_immovable_obstacle: + result.events.append(GameEvent(EventType.WALL_HIT, "Удар о камень")) + _handle_death(snake, world, result, "Смерть от удара о камень") + return + + if snake.head_size >= food.effective_size: + growth = food.definition.growth_segments + abilities.growth_bonus() + if growth > 0: + snake.grow(growth) + snake.apply_food(food) + if food.definition.poison_value > 0: + snake.poison_meter += abilities.poison_bonus() + world.remove_food(food.id) + result.events.append( + GameEvent(EventType.ATE, f"Съедено: {food.definition.name}", food=food) + ) + elif food.damages_snake_on_pass(snake.head_size): + damage = max(1, food.effective_size - snake.head_size) + damage = max(1, int(damage * (1.0 - abilities.damage_reduction()))) + lost = snake.lose_segments(damage) + result.events.append( + GameEvent( + EventType.DAMAGED, + f"Повреждение при прохождении через {food.definition.name}", + lost, + food, + ) + ) + + +def _check_pass_through_damage( + snake: Snake, + world: World, + abilities: AbilityState, + result: TickResult, +) -> None: + for food in world.foods: + if food.position != snake.head or not food.is_alive: + continue + if food.damages_snake_on_pass(snake.head_size): + damage = max(1, food.effective_size - snake.head_size) + damage = max(1, int(damage * (1.0 - abilities.damage_reduction()))) + lost = snake.lose_segments(damage) + result.events.append( + GameEvent(EventType.DAMAGED, "Повреждение от крупной добычи", lost, food) + ) + + +def _check_crush_from_heavy_entities( + snake: Snake, + world: World, + result: TickResult, +) -> None: + for food in world.foods: + if not food.is_alive or food.crush_size <= 0: + continue + if food.position not in snake.segments[1:]: + continue + if food.crush_size <= snake.head_size + 2: + continue + + crush_damage = max(2, food.crush_size - snake.head_size) + if snake.survives_crush_damage(crush_damage): + lost = snake.lose_segments(crush_damage) + result.events.append( + GameEvent( + EventType.CRUSHED, + f"Раздавлено {food.definition.name}, змея теряет длину", + lost, + food, + ) + ) + else: + result.events.append( + GameEvent(EventType.CRUSHED, f"Раздавлено {food.definition.name}", crush_damage, food) + ) + _handle_death(snake, world, result, f"Раздавлена {food.definition.name}") + return + + +def _handle_outer_wall_break( + snake: Snake, + world: World, + pos: Vec2, + result: TickResult, +) -> TickResult: + if not snake.can_break_wall(): + return _handle_death(snake, world, result, "Не хватает сил пробить внешнюю стену") + + world.break_tile(pos) + lost = snake.apply_wall_break() + snake.move() + result.events.append( + GameEvent(EventType.WALL_BROKEN, f"Пробита внешняя стена, потеряно {lost} сегментов", lost) + ) + result.universe_transition = True + result.events.append( + GameEvent(EventType.UNIVERSE_ENTERED, "Переход в параллельную вселенную") + ) + return result + + +def _handle_death( + snake: Snake, + world: World, + result: TickResult, + message: str, +) -> TickResult: + carcass = snake.to_carcass_food() + world.add_food(carcass) + result.events.append(GameEvent(EventType.DIED, message)) + result.snake_alive = False + return result + + +def _compute_new_head(head: Vec2, direction: Direction) -> Vec2: + x, y = head + if direction == Direction.RIGHT: + return (x + 1, y) + if direction == Direction.LEFT: + return (x - 1, y) + if direction == Direction.UP: + return (x, y - 1) + return (x, y + 1) + + +def _is_reverse(current: Direction, new: Direction) -> bool: + pairs = { + (Direction.LEFT, Direction.RIGHT), + (Direction.RIGHT, Direction.LEFT), + (Direction.UP, Direction.DOWN), + (Direction.DOWN, Direction.UP), + } + return (current, new) in pairs diff --git a/game/senses.py b/game/senses.py new file mode 100644 index 0000000..d1150ec --- /dev/null +++ b/game/senses.py @@ -0,0 +1,119 @@ +"""Система чувств змеи: слабое зрение, сильные обоняние и слух.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from game.constants import Mobility + +if TYPE_CHECKING: + from game.entities import Food, Snake, Vec2 + + +@dataclass +class PerceivedEntity: + """Сущность, воспринятая змеёй.""" + + food_id: str + position: Vec2 + perceived_by: str + symbol: str + size: int + distance: int + + +@dataclass +class SenseProfile: + """Параметры чувств (прокачиваемые).""" + + smell_range: int = 6 + hearing_range: int = 8 + vision_range: int = 2 + smell_sensitivity: float = 1.0 + hearing_sensitivity: float = 1.0 + + +@dataclass +class PerceptionState: + """Результат восприятия за тик.""" + + perceived: list[PerceivedEntity] = field(default_factory=list) + visible_positions: set[Vec2] = field(default_factory=set) + + +def manhattan(a: Vec2, b: Vec2) -> int: + return abs(a[0] - b[0]) + abs(a[1] - b[1]) + + +class SenseSystem: + """Змея плохо видит, но хорошо чувствует запах и звук.""" + + def __init__(self, profile: SenseProfile | None = None) -> None: + self.profile = profile or SenseProfile() + + def perceive( + self, + snake: Snake, + foods: list[Food], + tick: int, + ) -> PerceptionState: + head = snake.head + result = PerceptionState() + seen_ids: set[str] = set() + + for food in foods: + if not food.is_alive and food.food_type_id == "rock": + continue + + distance = manhattan(head, food.position) + definition = food.definition + + smell_range = int(definition.scent_range * self.profile.smell_sensitivity) + smell_range = min(smell_range, self.profile.smell_range + definition.scent_range // 2) + + sound_range = int(definition.sound_range * self.profile.hearing_sensitivity) + sound_range = min(sound_range, self.profile.hearing_range + definition.sound_range // 2) + + perceived_by: str | None = None + + if distance <= self.profile.vision_range: + perceived_by = "vision" + elif distance <= smell_range and definition.scent_range > 0: + perceived_by = "smell" + elif distance <= sound_range and ( + food.squeaked_this_tick + or (food.moved_this_tick and definition.makes_sound_on_move) + or (definition.mobility == Mobility.MOBILE and food.moved_this_tick) + ): + perceived_by = "sound" + elif ( + definition.mobility == Mobility.MOBILE + and food.moved_this_tick + and distance <= max(sound_range, smell_range) + ): + perceived_by = "movement" + + if perceived_by and food.id not in seen_ids: + seen_ids.add(food.id) + result.perceived.append( + PerceivedEntity( + food_id=food.id, + position=food.position, + perceived_by=perceived_by, + symbol=food.symbol, + size=food.effective_size, + distance=distance, + ) + ) + result.visible_positions.add(food.position) + + return result + + def filter_foods_for_display( + self, + foods: list[Food], + perception: PerceptionState, + ) -> list[Food]: + visible_ids = {p.food_id for p in perception.perceived} + return [f for f in foods if f.id in visible_ids or f.is_immovable_obstacle] diff --git a/game/species.py b/game/species.py new file mode 100644 index 0000000..667e41d --- /dev/null +++ b/game/species.py @@ -0,0 +1,50 @@ +"""Уровни змеи — названия видов и параметры роста.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SpeciesLevel: + """Один уровень (вид) змеи.""" + + index: int + name: str + head_size: int + shed_length_threshold: int + max_prey_size: int + wall_break_min_length: int + + +# Уровни подписываются названиями видов; рост бесконечен через линьку. +SPECIES_LEVELS: tuple[SpeciesLevel, ...] = ( + SpeciesLevel(0, "Червеобразная", 1, 8, 1, 0), + SpeciesLevel(1, "Травяная", 2, 14, 2, 20), + SpeciesLevel(2, "Полоз", 3, 22, 3, 35), + SpeciesLevel(3, "Крысиная", 4, 32, 4, 55), + SpeciesLevel(4, "Удав", 5, 45, 5, 80), + SpeciesLevel(5, "Королевская кобра", 6, 60, 6, 110), + SpeciesLevel(6, "Анаконда", 7, 80, 7, 150), + SpeciesLevel(7, "Титанобоа", 8, 105, 8, 200), + SpeciesLevel(8, "Мифическая", 9, 135, 9, 260), + SpeciesLevel(9, "Космическая", 10, 170, 10, 330), +) + + +def get_species(level_index: int) -> SpeciesLevel: + if level_index < 0: + return SPECIES_LEVELS[0] + if level_index >= len(SPECIES_LEVELS): + return SPECIES_LEVELS[-1] + return SPECIES_LEVELS[level_index] + + +def species_for_length(length: int, current_level: int) -> int: + """Повышение уровня при достижении порога линьки предыдущего вида.""" + level = current_level + while level + 1 < len(SPECIES_LEVELS): + next_species = SPECIES_LEVELS[level + 1] + if length >= next_species.shed_length_threshold: + level += 1 + else: + break + return level diff --git a/game/world.py b/game/world.py new file mode 100644 index 0000000..bef381e --- /dev/null +++ b/game/world.py @@ -0,0 +1,224 @@ +"""Процедурная генерация мира по зерну.""" + +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from typing import Optional + +from game.constants import Direction, TileType +from game.entities import Food, Snake, Vec2 +from game.food_types import FOOD_TYPES +from game.levels import LevelConfig + + +@dataclass +class World: + """Игровой мир: сетка тайлов и сущности.""" + + width: int + height: int + seed: int + universe_index: int + tiles: list[list[TileType]] + foods: list[Food] = field(default_factory=list) + tick: int = 0 + + def in_bounds(self, pos: Vec2) -> bool: + x, y = pos + return 0 <= x < self.width and 0 <= y < self.height + + def tile_at(self, pos: Vec2) -> TileType: + x, y = pos + if not self.in_bounds(pos): + return TileType.OUTER_WALL + return self.tiles[y][x] + + def is_passable(self, pos: Vec2) -> bool: + tile = self.tile_at(pos) + return tile in (TileType.EMPTY, TileType.WATER) + + def is_outer_wall(self, pos: Vec2) -> bool: + return self.tile_at(pos) == TileType.OUTER_WALL + + def is_breakable_wall(self, pos: Vec2) -> bool: + return self.tile_at(pos) == TileType.BREAKABLE_WALL + + def is_immovable_obstacle(self, pos: Vec2) -> bool: + tile = self.tile_at(pos) + return tile in (TileType.WALL, TileType.INNER_WALL, TileType.OUTER_WALL) + + def food_at(self, pos: Vec2) -> Optional[Food]: + for food in self.foods: + if food.position == pos and food.is_alive: + return food + return None + + def remove_food(self, food_id: str) -> None: + self.foods = [f for f in self.foods if f.id != food_id] + + def add_food(self, food: Food) -> None: + self.foods.append(food) + + def occupied_positions(self, snake: Snake) -> set[Vec2]: + occupied = set(snake.segments) + for food in self.foods: + if food.is_alive: + occupied.add(food.position) + return occupied + + def break_tile(self, pos: Vec2) -> None: + x, y = pos + if self.in_bounds(pos): + self.tiles[y][x] = TileType.EMPTY + + def advance_tick(self) -> None: + self.tick += 1 + for food in self.foods: + food.moved_this_tick = False + food.squeaked_this_tick = False + + +def generate_world( + level: LevelConfig, + seed: int, + universe_index: int = 0, +) -> World: + rng = random.Random(seed + universe_index * 9973) + width, height = level.field_size + tiles = _generate_tiles(width, height, level, rng, universe_index) + world = World( + width=width, + height=height, + seed=seed, + universe_index=universe_index, + tiles=tiles, + ) + world.foods = _spawn_food_entities(level, world, rng) + return world + + +def _generate_tiles( + width: int, + height: int, + level: LevelConfig, + rng: random.Random, + universe_index: int, +) -> list[list[TileType]]: + tiles = [[TileType.EMPTY for _ in range(width)] for _ in range(height)] + + for x in range(width): + tiles[0][x] = TileType.OUTER_WALL + tiles[height - 1][x] = TileType.OUTER_WALL + for y in range(height): + tiles[y][0] = TileType.OUTER_WALL + tiles[y][width - 1] = TileType.OUTER_WALL + + inner_wall_count = level.inner_wall_count + universe_index * 2 + for _ in range(inner_wall_count): + x = rng.randint(2, width - 3) + y = rng.randint(2, height - 3) + length = rng.randint(2, 6) + horizontal = rng.choice([True, False]) + for i in range(length): + px = x + i if horizontal else x + py = y if horizontal else y + i + if 1 < px < width - 2 and 1 < py < height - 2: + tiles[py][px] = TileType.INNER_WALL + + breakable_count = level.breakable_wall_count + for _ in range(breakable_count): + x = rng.randint(1, width - 2) + y = rng.randint(1, height - 2) + if tiles[y][x] == TileType.EMPTY: + tiles[y][x] = TileType.BREAKABLE_WALL + + if universe_index > 0 and universe_index % 2 == 1: + water_patches = 3 + universe_index + for _ in range(water_patches): + x = rng.randint(2, width - 3) + y = rng.randint(2, height - 3) + tiles[y][x] = TileType.WATER + + return tiles + + +def _spawn_food_entities( + level: LevelConfig, + world: World, + rng: random.Random, +) -> list[Food]: + foods: list[Food] = [] + occupied: set[Vec2] = set() + + for food_id, count in level.food_spawn_table.items(): + for _ in range(count): + pos = _random_empty_position(world, occupied, rng) + if pos is None: + continue + occupied.add(pos) + definition = FOOD_TYPES[food_id] + direction = None + if definition.speed > 0: + direction = Direction(rng.choice([Direction.LEFT, Direction.RIGHT, Direction.UP, Direction.DOWN])) + foods.append( + Food( + food_type_id=food_id, + position=pos, + direction=direction, + is_immovable_obstacle=(food_id == "rock"), + ) + ) + + return foods + + +def _random_empty_position( + world: World, + occupied: set[Vec2], + rng: random.Random, +) -> Optional[Vec2]: + for _ in range(100): + x = rng.randint(1, world.width - 2) + y = rng.randint(1, world.height - 2) + pos = (x, y) + if world.is_passable(pos) and pos not in occupied: + return pos + return None + + +def move_mobile_entities(world: World, rng: random.Random) -> None: + for food in world.foods: + if not food.is_alive or food.definition.speed <= 0: + continue + if rng.randint(0, max(1, 3 - food.definition.speed)) != 0: + continue + + directions = [Direction.LEFT, Direction.RIGHT, Direction.UP, Direction.DOWN] + if food.direction is not None: + directions.insert(0, food.direction) + + moved = False + for direction in directions: + new_pos = _offset(food.position, direction) + if world.is_passable(new_pos) and world.food_at(new_pos) is None: + food.position = new_pos + food.direction = direction + food.moved_this_tick = True + if food.definition.makes_sound_on_move and rng.random() < 0.4: + food.squeaked_this_tick = True + moved = True + break + if not moved and food.definition.makes_sound_on_move and rng.random() < 0.2: + food.squeaked_this_tick = True + + +def _offset(pos: Vec2, direction: Direction) -> Vec2: + x, y = pos + if direction == Direction.RIGHT: + return (x + 1, y) + if direction == Direction.LEFT: + return (x - 1, y) + if direction == Direction.UP: + return (x, y - 1) + return (x, y + 1) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..3a55014 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,191 @@ +"""Тесты ядра игры «Змейка».""" + +import unittest + +from game.abilities import AbilityId, AbilityState +from game.constants import Direction +from game.engine import GameEngine +from game.entities import Food, Snake +from game.food_types import FOOD_TYPES +from game.levels import LevelProgress, evaluate_goals, get_level +from game.mechanics import EventType, process_snake_move +from game.senses import SenseSystem +from game.world import World, generate_world + + +class SnakeEntityTests(unittest.TestCase): + def test_growth_and_species_level(self): + snake = Snake.create((5, 5)) + self.assertEqual(snake.length, 3) + self.assertEqual(snake.species.name, "Червеобразная") + + for _ in range(6): + snake.grow(1) + self.assertTrue(snake.should_shed()) + snake.shed_skin() + self.assertGreaterEqual(snake.level_index, 1) + + def test_poison_meter(self): + snake = Snake.create((5, 5)) + viper = Food("viper", (6, 5)) + snake.apply_food(viper) + self.assertGreater(snake.poison_meter, 0) + + for _ in range(5): + berry = Food("berry", (6, 5)) + snake.apply_food(berry) + self.assertFalse(snake.is_poisonous) + + for _ in range(10): + snake.apply_food(Food("viper", (6, 5))) + self.assertTrue(snake.is_poisonous) + + def test_shed_resets_length_keeps_level(self): + snake = Snake.create((5, 5)) + snake.level_index = 2 + for _ in range(25): + snake.grow(1) + self.assertTrue(snake.should_shed()) + level_before = snake.level_index + removed = snake.shed_skin() + self.assertGreater(removed, 0) + self.assertEqual(snake.level_index, level_before + 1) + self.assertLess(snake.length, 25) + + def test_death_on_major_length_loss(self): + snake = Snake.create((5, 5)) + for _ in range(17): + snake.grow(1) + self.assertTrue(snake.would_die_from_loss(11)) + self.assertFalse(snake.would_die_from_loss(10)) + + def test_tail_survival_on_crush(self): + snake = Snake.create((5, 5)) + for _ in range(7): + snake.grow(1) + self.assertTrue(snake.survives_crush_damage(3)) + self.assertFalse(snake.survives_crush_damage(6)) + + def test_carcass_on_death(self): + snake = Snake.create((5, 5)) + snake.level_index = 3 + for _ in range(10): + snake.grow(1) + carcass = snake.to_carcass_food() + self.assertFalse(carcass.is_alive) + self.assertGreater(carcass.effective_size, 0) + + +class MechanicsTests(unittest.TestCase): + def _make_world(self) -> tuple[World, Snake]: + level = get_level(0) + world = generate_world(level, seed=123) + snake = Snake.create((5, 5)) + return world, snake + + def test_eat_smaller_prey(self): + world, snake = self._make_world() + fly = Food("fly", (6, 5)) + world.foods = [fly] + abilities = AbilityState() + result = process_snake_move(snake, world, Direction.RIGHT, abilities) + ate = [e for e in result.events if e.event_type == EventType.ATE] + self.assertEqual(len(ate), 1) + self.assertGreater(snake.length, 3) + + def test_damage_from_larger_prey(self): + world, snake = self._make_world() + elephant = Food("elephant", (6, 5)) + world.foods = [elephant] + abilities = AbilityState() + old_len = snake.length + result = process_snake_move(snake, world, Direction.RIGHT, abilities) + damaged = [e for e in result.events if e.event_type == EventType.DAMAGED] + self.assertTrue(len(damaged) > 0 or snake.length < old_len) + + def test_wall_hit_death(self): + world, snake = self._make_world() + snake.segments[0] = (1, 5) + snake.direction = Direction.LEFT + abilities = AbilityState() + result = process_snake_move(snake, world, Direction.LEFT, abilities) + died = [e for e in result.events if e.event_type == EventType.DIED] + self.assertEqual(len(died), 1) + self.assertFalse(result.snake_alive) + + +class SensesTests(unittest.TestCase): + def test_static_food_only_by_smell(self): + snake = Snake.create((10, 10)) + berry = Food("berry", (13, 10)) + senses = SenseSystem() + perception = senses.perceive(snake, [berry], tick=1) + self.assertEqual(len(perception.perceived), 1) + self.assertEqual(perception.perceived[0].perceived_by, "smell") + + def test_mobile_prey_visible_on_move(self): + snake = Snake.create((10, 10)) + mouse = Food("mouse", (15, 10)) + mouse.moved_this_tick = True + senses = SenseSystem() + perception = senses.perceive(snake, [mouse], tick=1) + self.assertGreater(len(perception.perceived), 0) + + def test_out_of_range_invisible(self): + snake = Snake.create((10, 10)) + berry = Food("berry", (25, 25)) + senses = SenseSystem() + perception = senses.perceive(snake, [berry], tick=1) + self.assertEqual(len(perception.perceived), 0) + + +class WorldTests(unittest.TestCase): + def test_deterministic_generation(self): + level = get_level(0) + w1 = generate_world(level, seed=42) + w2 = generate_world(level, seed=42) + self.assertEqual(len(w1.foods), len(w2.foods)) + self.assertEqual(w1.tiles, w2.tiles) + + def test_different_universe_differs(self): + level = get_level(0) + w1 = generate_world(level, seed=42, universe_index=0) + w2 = generate_world(level, seed=42, universe_index=1) + self.assertNotEqual(w1.tiles, w2.tiles) + + +class EngineTests(unittest.TestCase): + def test_new_game_starts(self): + engine = GameEngine(seed=99) + state = engine.new_game() + self.assertIsNotNone(state.snake) + self.assertIsNotNone(state.world) + self.assertEqual(state.phase.value, "playing") + + def test_tick_advances(self): + engine = GameEngine(seed=99) + engine.new_game() + result = engine.tick(Direction.RIGHT) + self.assertTrue(result.snake_alive) + + def test_level_goals_evaluation(self): + snake = Snake.create((5, 5)) + for _ in range(10): + snake.grow(1) + level = get_level(0) + progress = LevelProgress(eaten_count=5) + done, goals = evaluate_goals(level, snake, progress) + self.assertTrue(done) + self.assertEqual(len(goals), 2) + + +class AbilitiesTests(unittest.TestCase): + def test_upgrade_smell(self): + abilities = AbilityState(skill_points=10) + self.assertTrue(abilities.upgrade(AbilityId.SMELL)) + profile = abilities.to_sense_profile() + self.assertGreater(profile.smell_range, 6) + + +if __name__ == "__main__": + unittest.main() From 41e6e5c652710efc1643d84c887070f88fb22c9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Jul 2026 19:15:19 +0000 Subject: [PATCH 2/4] feat: add runnable CLI prototype for game core Co-authored-by: Timofey P. Samodurov --- README.md | 18 +++- prototype_cli.py | 251 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 prototype_cli.py diff --git a/README.md b/README.md index 48ca724..b954952 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,26 @@ game/ | Вселенные | После внешних стен — параллельный мир с другими правилами | | Мир | Процедурная генерация по `seed` | +## Запуск прототипа + +Авто-режим (удобно в CI и облаке): + +```bash +python3 prototype_cli.py --demo --ticks 150 --fps 10 --seed 42 +``` + +Интерактивно (локальный терминал): + +```bash +python3 prototype_cli.py +``` + +Управление: `WASD` / стрелки, `p` — пауза, `q` — выход. + ## Запуск тестов ```bash -python -m unittest discover -s tests -v +python3 -m unittest discover -s tests -v ``` ## CLI-прототип diff --git a/prototype_cli.py b/prototype_cli.py new file mode 100644 index 0000000..e74d986 --- /dev/null +++ b/prototype_cli.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Запускаемый CLI-прототип на игровом ядре.""" + +from __future__ import annotations + +import argparse +import os +import random +import sys +import time +from typing import Optional + +from game.constants import Direction, GamePhase, TileType +from game.engine import GameEngine +from game.entities import Vec2 +from game.mechanics import EventType + + +WALL = "█" +INNER = "▓" +BREAK = "░" +WATER = "~" +EMPTY = " " +SNAKE_BODY = "▒" +SNAKE_HEAD = "○" +FOG = "·" + + +def clear_screen() -> None: + if os.name == "posix": + os.system("clear") + else: + os.system("cls") + + +def direction_from_char(ch: str) -> Optional[Direction]: + mapping = { + "w": Direction.UP, + "W": Direction.UP, + "s": Direction.DOWN, + "S": Direction.DOWN, + "a": Direction.LEFT, + "A": Direction.LEFT, + "d": Direction.RIGHT, + "D": Direction.RIGHT, + } + return mapping.get(ch) + + +def tile_char(tile: TileType) -> str: + if tile == TileType.OUTER_WALL: + return WALL + if tile == TileType.INNER_WALL: + return INNER + if tile == TileType.BREAKABLE_WALL: + return BREAK + if tile == TileType.WATER: + return WATER + return EMPTY + + +def render(engine: GameEngine, view_w: int = 48, view_h: int = 22) -> str: + state = engine.state + world = state.world + snake = state.snake + if world is None or snake is None: + return "Нет активной игры" + + head = snake.head + hx, hy = head + x0 = max(0, hx - view_w // 2) + y0 = max(0, hy - view_h // 2) + x1 = min(world.width, x0 + view_w) + y1 = min(world.height, y0 + view_h) + if x1 - x0 < view_w: + x0 = max(0, x1 - view_w) + if y1 - y0 < view_h: + y0 = max(0, y1 - view_h) + + visible_foods = {f.position: f for f in engine.get_visible_foods()} + snake_set = set(snake.segments) + + lines: list[str] = [] + for y in range(y0, y1): + row: list[str] = [] + for x in range(x0, x1): + pos = (x, y) + if pos == head: + row.append(SNAKE_HEAD) + elif pos in snake_set: + row.append(SNAKE_BODY) + elif pos in visible_foods: + row.append(visible_foods[pos].symbol) + else: + row.append(tile_char(world.tile_at(pos))) + lines.append("".join(row)) + + hud = [ + engine.status_text(), + "Управление: WASD, q — выход, p — пауза", + f"Seed: {state.seed} | Тик: {world.tick} | Фаза: {state.phase.value}", + ] + if state.last_tick_result: + events = ", ".join(e.event_type.value for e in state.last_tick_result.events) + if events: + hud.append(f"События: {events}") + + return "\n".join(hud) + "\n\n" + "\n".join(lines) + + +def simple_ai_direction(engine: GameEngine) -> Direction: + """Ищет ближайшую видимую еду; иначе держит курс или случайный поворот.""" + state = engine.state + snake = state.snake + if snake is None: + return Direction.RIGHT + + head = snake.head + perceived = state.perception.perceived + if not perceived: + return state.direction + + target = min(perceived, key=lambda p: p.distance) + tx, ty = target.position + hx, hy = head + candidates: list[Direction] = [] + if tx > hx: + candidates.append(Direction.RIGHT) + elif tx < hx: + candidates.append(Direction.LEFT) + if ty > hy: + candidates.append(Direction.DOWN) + elif ty < hy: + candidates.append(Direction.UP) + + if not candidates: + return state.direction + + for direction in candidates: + if direction == state.direction: + return direction + + return candidates[0] + + +def run_demo(engine: GameEngine, ticks: int, fps: float, seed: int) -> None: + engine.new_game(seed=seed) + delay = 1.0 / max(fps, 1.0) + + for _ in range(ticks): + if engine.state.phase != GamePhase.PLAYING: + break + direction = simple_ai_direction(engine) + engine.tick(direction) + clear_screen() + print(render(engine)) + time.sleep(delay) + + if engine.state.phase == GamePhase.PLAYING: + print("\nДемо завершено (лимит тиков). Змейка жива.") + else: + print("\nДемо завершено. Игра окончена.") + + +def read_key_blocking() -> str: + """Чтение одной клавиши без зависимости keyboard (Unix).""" + if os.name != "posix": + return sys.stdin.readline().strip()[:1] or " " + + import termios + import tty + + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = sys.stdin.read(1) + if ch == "\x1b": + ch2 = sys.stdin.read(1) + if ch2 == "[": + ch3 = sys.stdin.read(1) + arrows = {"A": "w", "B": "s", "C": "d", "D": "a"} + return arrows.get(ch3, " ") + return ch + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +def run_interactive(engine: GameEngine, fps: float, seed: int) -> None: + engine.new_game(seed=seed) + delay = 1.0 / max(fps, 1.0) + paused = False + + print("Интерактивный режим. WASD / стрелки, p — пауза, q — выход.") + time.sleep(1) + + while engine.state.phase == GamePhase.PLAYING: + direction = engine.state.direction + if not paused: + try: + if sys.stdin.isatty(): + ch = read_key_blocking() + else: + ch = sys.stdin.readline().strip()[:1] or "d" + except (EOFError, KeyboardInterrupt): + break + + if ch in ("q", "Q"): + break + if ch in ("p", "P"): + paused = not paused + continue + + mapped = direction_from_char(ch) + if mapped is not None: + direction = mapped + + engine.tick(direction) + else: + time.sleep(0.05) + + clear_screen() + print(render(engine)) + if paused: + print("\n[ПАУЗА]") + time.sleep(delay) + + clear_screen() + print(render(engine)) + print("\nВыход из прототипа.") + + +def main() -> None: + parser = argparse.ArgumentParser(description="CLI-прототип змейки на игровом ядре") + parser.add_argument("--demo", action="store_true", help="Авто-режим (AI + анимация)") + parser.add_argument("--ticks", type=int, default=120, help="Число тиков в demo") + parser.add_argument("--fps", type=float, default=8.0, help="Скорость кадров") + parser.add_argument("--seed", type=int, default=None, help="Seed мира") + args = parser.parse_args() + + seed = args.seed if args.seed is not None else random.randint(1, 999999) + engine = GameEngine(seed=seed) + + if args.demo or not sys.stdin.isatty(): + run_demo(engine, ticks=args.ticks, fps=args.fps, seed=seed) + else: + run_interactive(engine, fps=args.fps, seed=seed) + + +if __name__ == "__main__": + main() From 321324ca8e299a3998a80f878bd260df39dd7ed2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Jul 2026 19:20:54 +0000 Subject: [PATCH 3/4] fix: Windows keyboard input for interactive prototype Co-authored-by: Timofey P. Samodurov --- prototype_cli.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/prototype_cli.py b/prototype_cli.py index e74d986..94b850a 100644 --- a/prototype_cli.py +++ b/prototype_cli.py @@ -163,7 +163,21 @@ def run_demo(engine: GameEngine, ticks: int, fps: float, seed: int) -> None: def read_key_blocking() -> str: - """Чтение одной клавиши без зависимости keyboard (Unix).""" + """Чтение одной клавиши без зависимости keyboard.""" + if os.name == "nt": + import msvcrt + + while True: + if not msvcrt.kbhit(): + time.sleep(0.01) + continue + ch = msvcrt.getwch() + if ch in ("\x00", "\xe0"): + ch = msvcrt.getwch() + arrows = {"H": "w", "P": "s", "M": "d", "K": "a"} + return arrows.get(ch, " ") + return ch + if os.name != "posix": return sys.stdin.readline().strip()[:1] or " " From 874dfa504e94a3cc87e73f599b3521acbfda2653 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Jul 2026 19:26:37 +0000 Subject: [PATCH 4/4] feat: auto-loop terminal demo and folder-open task Co-authored-by: Timofey P. Samodurov --- .vscode/settings.json | 3 ++ .vscode/tasks.json | 30 +++++++++++++++++ prototype_cli.py | 76 +++++++++++++++++++++++++++++++++++++++++-- scripts/run_auto.ps1 | 2 ++ scripts/run_auto.sh | 3 ++ 5 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 scripts/run_auto.ps1 create mode 100644 scripts/run_auto.sh diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1dcba76 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "task.allowAutomaticTasks": "on" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a14e146 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,30 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Snake: auto demo (terminal)", + "type": "shell", + "command": "python prototype_cli.py --demo --loop --ticks 200 --fps 8 --loop-delay 1 --random-seed", + "windows": { + "command": "python prototype_cli.py --demo --loop --ticks 200 --fps 8 --loop-delay 1 --random-seed" + }, + "linux": { + "command": "python3 prototype_cli.py --demo --loop --ticks 200 --fps 8 --loop-delay 1 --random-seed" + }, + "osx": { + "command": "python3 prototype_cli.py --demo --loop --ticks 200 --fps 8 --loop-delay 1 --random-seed" + }, + "runOptions": { + "runOn": "folderOpen" + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": false, + "clear": false + }, + "isBackground": true, + "problemMatcher": [] + } + ] +} diff --git a/prototype_cli.py b/prototype_cli.py index 94b850a..ab5d6d0 100644 --- a/prototype_cli.py +++ b/prototype_cli.py @@ -143,7 +143,14 @@ def simple_ai_direction(engine: GameEngine) -> Direction: return candidates[0] -def run_demo(engine: GameEngine, ticks: int, fps: float, seed: int) -> None: +def run_demo( + engine: GameEngine, + ticks: int, + fps: float, + seed: int, + *, + quiet_end: bool = False, +) -> None: engine.new_game(seed=seed) delay = 1.0 / max(fps, 1.0) @@ -153,15 +160,52 @@ def run_demo(engine: GameEngine, ticks: int, fps: float, seed: int) -> None: direction = simple_ai_direction(engine) engine.tick(direction) clear_screen() - print(render(engine)) + print(render(engine), flush=True) time.sleep(delay) + if quiet_end: + return + if engine.state.phase == GamePhase.PLAYING: print("\nДемо завершено (лимит тиков). Змейка жива.") else: print("\nДемо завершено. Игра окончена.") +def run_demo_loop( + ticks: int, + fps: float, + seed: int, + loop_delay: float, + randomize_seed: bool, +) -> None: + """Бесконечный auto-demo: экран обновляется сам, команды не нужны.""" + round_num = 0 + current_seed = seed + + while True: + round_num += 1 + if randomize_seed and round_num > 1: + current_seed = random.randint(1, 999999) + + engine = GameEngine(seed=current_seed) + run_demo( + engine, + ticks=ticks, + fps=fps, + seed=current_seed, + quiet_end=True, + ) + + if loop_delay > 0: + clear_screen() + print( + f"Раунд {round_num} завершён. Следующий старт через {loop_delay:.0f} c…", + flush=True, + ) + time.sleep(loop_delay) + + def read_key_blocking() -> str: """Чтение одной клавиши без зависимости keyboard.""" if os.name == "nt": @@ -247,15 +291,41 @@ def run_interactive(engine: GameEngine, fps: float, seed: int) -> None: def main() -> None: parser = argparse.ArgumentParser(description="CLI-прототип змейки на игровом ядре") parser.add_argument("--demo", action="store_true", help="Авто-режим (AI + анимация)") + parser.add_argument( + "--loop", + action="store_true", + help="Бесконечно перезапускать demo (не нужно вводить команду снова)", + ) parser.add_argument("--ticks", type=int, default=120, help="Число тиков в demo") parser.add_argument("--fps", type=float, default=8.0, help="Скорость кадров") parser.add_argument("--seed", type=int, default=None, help="Seed мира") + parser.add_argument( + "--loop-delay", + type=float, + default=1.5, + help="Пауза между раундами в --loop", + ) + parser.add_argument( + "--random-seed", + action="store_true", + help="Новый seed на каждый раунд (--loop)", + ) args = parser.parse_args() seed = args.seed if args.seed is not None else random.randint(1, 999999) engine = GameEngine(seed=seed) - if args.demo or not sys.stdin.isatty(): + auto_mode = args.demo or args.loop or not sys.stdin.isatty() + + if args.loop: + run_demo_loop( + ticks=args.ticks, + fps=args.fps, + seed=seed, + loop_delay=args.loop_delay, + randomize_seed=args.random_seed, + ) + elif auto_mode: run_demo(engine, ticks=args.ticks, fps=args.fps, seed=seed) else: run_interactive(engine, fps=args.fps, seed=seed) diff --git a/scripts/run_auto.ps1 b/scripts/run_auto.ps1 new file mode 100644 index 0000000..443f00a --- /dev/null +++ b/scripts/run_auto.ps1 @@ -0,0 +1,2 @@ +Set-Location $PSScriptRoot\.. +python prototype_cli.py --demo --loop --ticks 200 --fps 8 --loop-delay 1 --random-seed diff --git a/scripts/run_auto.sh b/scripts/run_auto.sh new file mode 100644 index 0000000..ef8a2c5 --- /dev/null +++ b/scripts/run_auto.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +cd "$(dirname "$0")/.." +exec python3 prototype_cli.py --demo --loop --ticks 200 --fps 8 --loop-delay 1 --random-seed