Пишу о том, как сделал первый проект — библиотеку для логики морского боя на Python — с какими трудностями столкнулся, как налаживал архитектуру и как не бросил
Начало
Всем привет! Начну сразу с предыстории. Учу Python не так давно, уровень простой — циклы, условия, списки, даже функции — простую базу знаю, но не более. Этот проект для меня стал испытанием, ведь когда решаешь обычные задачки «Вывести каждый чётный элемент массива, кратный 5...» — это одно, а если пишешь продукт (который возможно будет использоваться разработчиками), то ощущения совсем другие.
Однажды я придумал интересную идею — написать морской бой. Согласен, очень даже тривиально)). А почему я вообще про это подумал? Таких проектов на GitHub тьма тьмущая, оно и понятно — игра не сложная, а для начала как раз подходит. Но тогда мне были интересны не столько игра и правила, а сколько программирование движка, который будет играть математически оптимально. Вдохновившись статьёй на Хабре — Оптимальный алгоритм игры в морской бой (рекомендую, красиво написано), я понял, что не всё так просто и есть где разгуляться. Погуглив, нашел еще необычных стратегий.
Встал вопрос — писать всё в одном файле? Это ж и доску создавать, и корабли ставить, и стрелять научиться — а потом только алгоритм. И тут что‑то щелкнуло! Может написать библиотеку, которая возьмёт на себя эту работу? Идеей я загорелся, но подступиться боялся — хотелось написать хорошо, с грамотной архитектурой и чистым для своих знаний кодом. Ходил вокруг да около, и в голове сложился план.
Идея
Когда я закончил размышлять, то у меня была такая структура:
Класс Field — поле в виде матрицы ( список списков), где пробел — пустота, '1' — корабль, '.' — выстрел мимо, а 'X' — попадание (на самом деле, дальше оказалось не всё так просто). Поле должно иметь систему координат и обрабатывать их правильность. Также функции добавления корабля и выстрела
Класс Ship — сами корабли со своими параметрами (живые и раненые координаты, буферная зона и так далее)
Чёткие правила. Так как это библиотека, то в ней должны быть заложены правила «из коробки» — чтоб корабли могли встать только по правилам, не касаясь даже углами, фиксированные длины кораблей и учёт их количества.
Чистый код — хотелось с самого начала называть переменные нормально, чтобы через неделю можно было вникнуть в суть функции всего за пару минут
Тогда я совсем не умел работать с классами — я понимал зачем они нужны, но никак не мог понять, что за self и init... С этой отправной точки я и начал писать свой код
Первые шаги
Первым делом я создал класс Field, и в нём self.grid — физическое поле в виде матрицы, и первый метод display() для печатания доски в консоль, но везде были нюансы...
class Field: def __init__(self): height = 10 weight = 10 self.grid = [[' ' for i in range (weight)] for i in range(height)] def display(self): # Закралась ошибка! print (' 1 2 3 4 5 6 7 8 9 10') letters = 'ABCDEFGHIJ' count_letters = 0 for line in self.grid: line.insert(0, letters[count_letters]) # Меняет саму сетку print (*line) count_letters += 1
Тут сразу выскочила ошибка (с первым багом!) — из‑за insert‑а менялся исходный список grid, что означало, что при втором вызове в каждой строке оказывалось уже по две буквы — ведь я каждый раз добавлял новую букву в начало.

Заметив, исправил, чтобы поле оставалось неизменным — просто отдельно стал печатать сначала букву, а затем и список:
def display(self): print (' 1 2 3 4 5 6 7 8 9 10') letters = 'ABCDEFGHIJ' count_letters = 0 for line in self.grid: print (letters[count_letters], *line) count_letters += 1
Починив эту функцию, я пошёл к реализации одного из самых главных (как я тогда думал) элементов — поиск координаты. Но тогда я не совсем понимал, что это за зверь такой и что он должен будет делать, и поэтому назвал метод find_coordinate(), и попытался вместить туда всё, что придумал:
def find_coordinate(self, coordinate): columns = { '1' : 0, '2' : 1, '3' : 2, '4' : 3, '5' : 4, '6' : 5, '7' : 6, '8' : 7, '9' : 8, '10' : 9, } rows = { 'a' : '1', 'b' : '2', 'c' : '3', 'd' : '4', 'e' : '5', 'f' : '6', 'g' : '7', 'h' : '8', 'i' : '9', 'j' : '10', } rows_letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] columns_numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] if len(coordinate) < 2 or len(coordinate) >= 4: # Контроль длины координаты return 'Error: Invalid coordinate length' if coordinate[0] not in rows_letters:# Проверка правильности ряда return 'Error: Invalid rows name' if int(coordinate[1:]) not in columns_numbers: # Проверка правильности столбца return 'Error: Invalid column number' print ('Valid Coordinate') square_data = self.grid[int(rows[coordinate[0]])][columns[coordinate[1:]]] square_condition = 'clear' if square_data == ' ': return square_condition if square_data == 'x': square_condition = 'hitted' return square_condition if square_data == 'X': square_condition = 'destroyed' return square_condition if square_data == '1': square_condition = 'not_damaged' return square_condition if square_data == '.': square_condition = 'was beaten' return square_condition
Эта функция получала на вход строку в виде 'a1', и сначала валидировала — передали ли строку допустимой длины, определяли правильность буквы и цифры. Дальше она искала в grid данные о этой клетке и их возвращала.
Уже на этом этапе чувствуется перегруз — метод выполняет много задач. Тем более еще что‑то печатает. Тут стал вопрос — а надо ли вообще валидировать внутри? Ведь проверка координаты одна на весь класс, не дублировать же везде. Поэтому я переместил этот блок в другую функцию — validation_coordinate(coordinate), а rows и columns переместил в self, чтобы их можно было использовать везде
def _validation_coordinate(self, coordinate): rows_letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] columns_numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] #--- код дальше ---# return True
Итак, немного было написано, но первый вывод сделан
Вывод для себя номер 1: не нужно перегружать код — если что‑то можно вынести в отдельную и нужную в других местах функцию, то лучше это вынести
Новый класс Ship
Сначала не было идеи, как ставить корабль. Потом придумал через строку в формате «а1-а2-а3». Но тогда нужно отдельно валидировать каждую координату, и еще проверять — строятся ли эти координаты в линию. Появился метод _ship_line_validation(self, coordinate_line), который дробил линию из строки, обрабатывал каждую координату отдельно, а потом проверял на наличие последовательности там букв или цифр (горизонталь и вертикаль) — всё по плану.
Пора бы создавать класс Ship, чтобы ставить его на поле. изначально он выглядел вот так:
class Ship: def __init__(self): self.parameters = { 'alive coordinates': [], 'hitted coordinates' : [], 'alive' : True, # 'buffer zone' : [] } self.field = None # Ссылка будет дальше в коде def set_field(self, field): self.field = field
Тут было сложно. На самом деле я нарушил один принцип — у меня Field знает о Ship, а Ship знает о Field. Это не очень хорошо (потом прочитал, что базовые слои не должны знать про верхние), но тогда мне хотелось, чтобы при добавлении корабля на поле автоматически связывался и сам корабль с полем. Это был сложный момент для меня, поэтому сам механизм привязки написал DeepSeek.
Но! Код я хотел почти полностью писать сам, потому что тогда имею право говорить, что код мой. Значит я смогу в нём разбираться, и получать удовольствие, исправляя и улучшая его структуру. Именно из‑за этого моего убеждения DeepSeek в основном отлавливал баги, которые на простых тестах в конце файла не были заметны, и я пробовал исправить их без помощи ИИ. Главное, что прислушиваться всё время его не надо — иногда советует неправильные вещи и путается в логике.
Так что да, идея set_field(self, field) и ссылка на него в self (self.field = None), на которых завязана работа других методов, написана ИИ. Я в этом разобрался, и теперь знаю, что мог бы сделать и сам.
Вывод для себя номер 2: по моему личному мнению, для таких учебных проектов лучше писать код самому, а если уж и пользоваться ИИ, то по крайней мере вникать в то, что написано.
Добавляем корабли
Метод называется add_ship(self, ship, coordinate). Для его реализации у меня уже была валидация строки, но пока решил сам сделать простую логику для одной координаты. А значит нужен помощник - set_coordinate_in_ship(self, coordinate), который будет добавлять координату в параметры объекта
class Field: #---код выше def add_ship(self, ship, coordinate): # Привязываем корабль к полю ship.set_field(self) # 🛑 Тот самый метод - передаёт поле в parameters у корабля if not self._validation_coordinate(coordinate): return # Добавляем координату в корабль result = ship.set_coordinate_in_ship(coordinate) if result: # Отмечаем корабль на поле (ставим '1') rows = self.rows[coordinate[0]] columns = self.columns[coordinate[1:]] self.grid[rows][columns] = '1' return f"Корабль установлен на {coordinate}" return "Не удалось установить корабль" class Ship: #---код выше def set_coordinate_in_ship(self, coordinate): coordinate_validated = self.field._validation_coordinate(coordinate) if not coordinate_validated: # Если координата не валидна return self.parameters['alive coordinates'].append(coordinate) return self.parameters
Уже здесь многие могут заметить хаос с возвращаемыми значениями, но не стоит торопиться — дальше я расскажу, как с этим боролся.
После того, как справился с одной координатой, переписал под целую строку координат:
def add_ship(self, ship, coordinate_line, length): # Привязываем корабль к полю ship.set_field(self) if not self._ship_line_validation(coordinate_line): return coordinates = coordinate_line.split('-') # Выделили каждую координату if length != len(coordinates): # Обработал длину корабля print("Error: invalid length of coordinate line") return False for coordinate in coordinates: # Добавляем координату в корабль result = ship.set_coordinate_in_ship(coordinate) if result: # Отмечаем корабль на поле rows = self.rows[coordinate[0]] columns = self.columns[coordinate[1:]] self.grid[rows][columns] = '1' return True
Дальше я не буду очень подробно описывать все изменения, которые вносил в эти методы — статья всё же не резиновая. Я подумал тогда о другом — а не слишком ли сложно? Мы должны, используя библиотеку, сначала создавать, а потом и указывать объект класса Ship. И ведь очень трудно отдельно по 10 кораблей указывать... Тем более, если мы будем по ошибке использовать один корабль для всего, то все координаты пойдут в его же параметры!
ship = Ship() # один корабль на всё field.add_ship(ship, 'a1-b1-c1', 3) # сюда же field.add_ship(ship, 'e5-e6-e7-e8', 4) # и сюда же field.add_ship(ship, 'e1-e2-e3-e4-e5', 5) # и сюда
Всё, решено — надо исправлять. Я сделал логику отката, которая при добавлении корабля на клетку увидит, что клетка уже занята, и уберёт все расставленные координаты обратно. Сделал второй метод — auto_add_ship(self, coordinate_line, length), чтобы объект корабль создавался сам. Тогда я сделал метод удаления координаты из параметров (delete_coordinate_in_ship(coordinate)), чтобы поставленные клетки при откате можно было удалить из параметров корабля, а так же forbidden_squares — чтобы нельзя было поставить на занятую клетку. Позже, когда буду рассказывать про буферную зону, я обязательно раскрою пользу этого списка.
def auto_add_ship(self, coordinate_line, length): if length != len(coordinates): # Обработал длину корабля print("Error: invalid length of coordinate line") return False ship = Ship() ship.get_id() if length == 1: ship_name = 'speedboat' if length == 2: ship_name = 'destroyer' if length == 3: ship_name = 'cruiser' if length == 4: ship_name = 'battleship' # Привязываем корабль к полю ship.set_field(self) if not self._ship_line_validation(coordinate_line): return coordinates = coordinate_line.split('-') # Выделили каждую координату added_coordinates = [] # координаты, которые выставлены (чтобы удалить только их, не трогая другое) for coordinate in coordinates: if coordinate in self.forbidden_squares: # Проверка на наличие в запрещённом списке print(f'Error: square {coordinate} is employed') for coordinate in added_coordinates: rows = self.rows[coordinate[0]] columns = self.columns[coordinate[1:]] self.grid[rows][columns] = ' ' self.forbidden_squares.remove(coordinate) ship.delete_coordinate_in_ship(coordinate) break # Добавляем координату в корабль result = ship.set_coordinate_in_ship(coordinate) if result: # Отмечаем корабль на поле (например, ставим '1') rows = self.rows[coordinate[0]] columns = self.columns[coordinate[1:]] self.grid[rows][columns] = '1' self.forbidden_squares.add(coordinate) # Добавляем в запрещенные клетки added_coordinates.append(coordinate) return True
Да, сейчас (на момент написания статьи) этот метод выглядит гораздо оптимальнее, где учтены множество других параметров. Но тогда у меня были два почти одинаковых метода — ручной и автоматический. Когда я улучшал новый метод, то пытался поднять старый до того же уровня, постоянно его дописывая до актуального. Хватит, подумал я. Ручной метод наверное никому не пригодится, а всё время его обновлять — лишняя трата времени и сил. Поэтому add_ship() был нещадно удалён, а auto_add_ship() остался. Вот из‑за чего у него такое название — это оптимальная версия удалённого «предка».
Вывод для себя номер 3: если можно что‑то автоматизировать с пользой и упрощением для пользователя, то лучше это сделать, а не оставлять «костыльные» решения.
Время пострелять
На самом деле, все сложные методы у меня мутировали по полной, но в разные периоды (поэтому достаточно сложно описать полностью правильную хронологию). Одна из самых главных функций в морском бое — выстрел. Я назвал это простой функцией shot (self, coordinate).
def shot(self, coordinate): if not self._validation_coordinate(coordinate): return square_condition = self.find_coordinate(coordinate) if square_condition == 'clear': self.grid[self.rows[coordinate[0]]][self.columns[coordinate[1:]]] = '.' return 'Blunder' if square_condition == 'ship': self.grid[self.rows[coordinate[0]]][self.columns[coordinate[1:]]] = 'X' return 'Hit'
Логика максимально простая, но этого оказалось недостаточно. Почему? Мы просто меняем отображение поля, а значит кроме него ничего не переписывается. И когда мы убьём корабль, то сам объект останется. Но для начала нужно подготовить класс Ship для приятного редактирования параметров и обработки изменений.
class Ship: def __init__(self): self.id = random.randint(1, 1000) self.parameters = { 'alive coordinates': [], 'hitted coordinates' : [], 'alive' : True, 'ID': self.id # 'buffer zone' : [] } self.field = None # Ссылка будет дальше в коде def set_field(self, field): self.field = field def display_parameters(self): print(self.parameters) return self.parameters def set_coordinate_in_ship(self, coordinate): #---- КОД ДАЛЬШЕ ----# def delete_coordinate_in_ship(self, coordinate): #---- КОД ДАЛЬШЕ ----# def kill_coordinate_in_ship(self, coordinate): coordinate_validated = self.field._validation_coordinate(coordinate) if not coordinate_validated: return self.parameters['hitted coordinates'].append(coordinate) self.parameters['alive coordinates'].remove(coordinate) return self.parameters def ship_in_coordinate(self, coordinate): coordinate_validated = self.field._validation_coordinate(coordinate) if not coordinate_validated: return if coordinate in self.parameters['alive coordinates']: return True return False def _is_alive(self): if len(self.parameters['alive coordinates']) == 0: return False return True def get_died_coordinate(self): return self.parameters['hitted coordinates'] def get_id(self): return self.parameters['ID']
И тут много изменений. Я ввёл систему ID (рандомное число от 1 до 1000), и в классе Field создал 2 списка — self.ships (хранит просто объекты — корабли, находящиеся на поле) и self.ships_on_field (я вынес определение названия корабля в отдельную функцию, и теперь храню список кортежей вида (имя, id)). В Field теперь есть функция find_from_id (self, ID), которая возвращает корабль по уникальному ID.
Но это не самые главные новости! Теперь есть два новых метода внутри Ship: ship_in_coordinate(self, coordinate) и kill_coordinate_in_ship(self, coordinate). Может быть, у вас возникнет вопрос — а зачем нам kill и delete? Не одно ли тоже? Ан‑нет, разница есть, и очень важная. delete_coordinate_in_ship просто избавляет корабль от координаты, её удаляя. А вот kill_coordinate_in_ship(self, coordinate) осуществляет часть игрового процесса — перемещает координату из живых в раненые координаты корабля. Также добавил проверку, жив ли ещё корабль.
Пора бы уже к выстрелу.
def shot(self, coordinate): if not self._validation_coordinate(coordinate): return square_condition = self.find_coordinate(coordinate) if square_condition == 'clear': self.grid[self.rows[coordinate[0]]][self.columns[coordinate[1:]]] = '.' return 'Blunder' if square_condition == 'ship': for ship in self.ships: # Для каждого корабля if ship.ship_in_coordinate(coordinate): # Если координата принадлежит кораблю ship.kill_coordinate_in_ship(coordinate) ID = ship.get_id() if ship._is_alive(): self.grid[self.rows[coordinate[0]]][self.columns[coordinate[1:]]] = 'x' else: replace_list = ship.get_died_coordinate() for coord in replace_list: self.grid[self.rows[coord[0]]][self.columns[coord[1:]]] = 'X' # Меняем "х" на "Х" self.ships.remove(ship) # Удаляем из списка ships for i in reversed(range(len(self.ships_on_field))): # ships_on_field if self.ships_on_field[i][0] == ID: self.ships_on_field.pop(i) return 'Target destroyed' return 'Hit'
Вот тут было очень радостно! Система работала как надо! К этому времени я успел побороть много багов, не без объяснений от DeepSeek‑а, конечно. Что вообще в коде сверху происходит? Теперь каждый раз, когда square_condition == 'ship', мы ищем среди всех кораблей именно тот, которому принадлежит эта координата, и «раним» его как раз с помощью kill‑метода. Там же сразу идёт проверка — корабль‑то живой? Если живой, то отмечаем 'x' на поле (это важный момент — так пользователь сможет понять, что корабль ещё не уничтожен), а в противном случае заменяем все 'x', принадлежащие кораблю, на «X» и удаляем корабль из всех списков.

Вывод для себя номер 4: чем продуманнее, тем лучше. Если оставлять реализацию важных механик без должного внимания, то скорее всего потом в 1000 строках будет трудно исправлять постоянные баги.
Буферная зона
Это было для меня одной из самых трудных частей, труднее только была автоматическая расстановка.
В чём смысл буферной зоны? По правилам морского боя, корабли не могут соприкасаться сторонами и углами. И когда корабль погибает, вокруг него расставляют точки — там и так не может быть других судов, а значит и стрелять бессмысленно.
Я захотел это реализовать и одним выстрелом убить двух зайцев — настроить красивое отображение по правилам (выставление точек вокруг) и через forbidden_squares контролировать расстановку: если корабль будет пытаться встать в буферную зону, то не получится из‑за списков запрещённых клеток.
Для более удобной работы я создал методы конвертации координаты — в два индекса из строки и из индексов в строку:
def coord_to_index(self, coordinate): # coordinate[0] — буква ('a'), ищем её индекс в rows x = self.rows[coordinate[0]] # 'a' → 0 # coordinate[1:] — число ('1' или '10'), ищем индекс в columns y = self.columns[coordinate[1:]] # '1' → 0 return x, y def index_to_coord(self, x, y): letter = None for l, index in self.rows.items(): if index == x: letter = l break # Ищем число: перебираем columns, где значение равно y number = None for n, idx in self.columns.items(): if idx == y: number = n break if letter != None and number != None: return letter + number else: return None
Дальше _create_buffer_zone(self, coordinate_line) - после получения строки функция возвращает множество координат вокруг
def _create_buffer_zone(self, coordinate_line): coordinates = coordinate_line.split('-') # Выделили каждую координату result_buffer = set() for coordinate in coordinates: x = self._coord_to_index(coordinate)[0] y = self._coord_to_index(coordinate)[1] result_buffer.add(self._index_to_coord(x + 1, y + 1)) result_buffer.add(self._index_to_coord(x - 1, y - 1)) result_buffer.add(self._index_to_coord(x + 1, y - 1)) result_buffer.add(self._index_to_coord(x - 1, y + 1)) result_buffer.add(self._index_to_coord(x + 1, y)) result_buffer.add(self._index_to_coord(x - 1, y)) result_buffer.add(self._index_to_coord(x, y + 1)) result_buffer.add(self._index_to_coord(x, y - 1)) for result in coordinates: # Удаляем коордианты самого корабля if result in result_buffer: result_buffer.remove(result) if None in result_buffer: # Удалям None из перечня, которые возникли при конвертации result_buffer.remove(None) return result_buffer
Тут я научился работать со множествами — очень удобно, что элементы там не повторяются. Потом в процессе улучшения кода во всех местах, где использован remove() со множествами, я стал писать discard() — если элемента нет в множестве, discard просто ничего не делает и не возвращает ошибку.
Буфер создается очень просто — мы смещаемся во всех направлениях от указанной клетки. У нас поступает целая линия, но ничего страшного — когда мы добавляем всех «соседей» для каждой координаты, множество само удаляет дубликаты. В коде видно, что мы удаляем None. А почему оно там? А ноги растут еще из функции конвертации в строку, ведь если сборка не удалась, то return None.
И теперь последний шаг — связываю shot() и auto_add_ship() с буферной зоной. Когда создаётся корабль, то автоматически в его параметры добавляется перечень координат. А что же с shot? Я написал простую функцию, чтобы можно было проще изменять сетку self.grid
def _write_coordinate(self, coordinate, DATA: str) -> bool: if not self._validation_coordinate(coordinate): return False rows = self.rows[coordinate[0]] columns = self.columns[coordinate[1:]] self.grid[rows][columns] = DATA return True
Зачем? Теперь во всех местах, где вызывалась длинная строка с приравниванием, теперь просто self._write_coordinate(coordinate, CLEAR)
def shot(self, coordinate): #--- много кода до ---# else: replace_list = ship.get_died_coordinate() for coord in replace_list: self._write_coordinate(coord, DESTROYED) # Меняем "х" на "Х" for buffer in ship.parameters['buffer zone']: # Отмечаем буфферную зону self._write_coordinate(buffer, WAS_BEATEN) self.ships.remove(ship) # Удаляем из списка кораблей for i in reversed(range(len(self.ships_on_field))): # Тоже список if self.ships_on_field[i][0] == ID: self.ships_on_field.pop(i) return SHOT_KILL return SHOT_HIT

Здесь можно заметить, что появились какие-то переменные капсом и непонятные стрелочки - но обо всё по порядку, и про это я обязательно расскажу дальше...))
Рандомная расстановка
Самый сложный этап - целый вечер я пытался сделать (без тестов, конечно...) расстановку, сделал три метода - и всё рухнуло. Терминал горел красным - я исправлял ошибки, но сразу вылезали уже в другом месте. Всё из-за громоздкой и сложной логики новых функций, в которых с первых строчек была путаница в индексах... Я пробовал исправить с DeepSeek-ом, но потом сдался и скопировал его решение.
Но на следующий день сел заново и с нуля переписал логику, так как уже говорил, что лучше писать самому. Получилось не очень идеально, но главное оно работало.
def _generate_horizontal_coords(self, length: int) -> str: """Генерирует случайную горизонтальную линию""" letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] row = random.choice(letters) max_start = 11 - length start = random.randint(1, max_start) coordinate_line = [f"{row}{start + i}" for i in range(length)] return '-'.join(coordinate_line) def _generate_vertical_coords(self, length: int) -> str: """Генерирует случайную вертикальную линию""" letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] max_start = len(letters) - length start_letter = random.randint(0, max_start) our_number = random.choice(numbers) our_letters = letters[start_letter : start_letter + length] # Срез из букв coordinate_line = [] for letter in our_letters: coordinate_line.append(letter + str(our_number)) return '-'.join(coordinate_line)
Для рандомной расстановки нужны случайные, но правильно составленные линии координат. К тому моменту я добавил уже в код can_place_ship(coordinate_line, length) - метод, частично дублирующий добавление корабля, но только в виде проверки возможности добавления. Здесь долго задерживаться не буду. Самое интересное начинается в random_placing(self)
# в самом начале - FLEET_LENGTHS = [1, 1, 1, 1, 2, 2, 2, 3, 3, 4] def random_placing(self) -> bool: # Сохраняем копию длин lengths_to_place = self.fleet_lengths.copy() # копия FLEET_LENGTHS # Очищаем поле self.field_game_reset() all_placed = True for length in lengths_to_place: placed = False our_strategy = random.choice(['horizontal', 'vertical']) # выбираем, как ставить for _ in range (500): # 500 попыток на постановку if our_strategy == 'horizontal': coordinate_line = self._generate_horizontal_coords(length) if self.can_place_ship(coordinate_line, length): self.auto_add_ship(coordinate_line, length) placed = True break else: coordinate_line = self._generate_vertical_coords(length) if self.can_place_ship(coordinate_line, length): self.auto_add_ship(coordinate_line, length) placed = True break if placed == False: all_placed = False break if all_placed == False: self.field_game_reset() return False return True def find_from_id(self, id): for ship in self.ships: if id == ship.get_id(): return ship return None
И вроде как - всё работает. Я на это потратил в сумме около 3 вечеров, но где-то была ошибка - когда я 10 раз вызывал метод рандомной генерации, 5 из них выводил пустое поле. И вроде бы - что не так работает? Доска очищается в случае, если корабль один поставить нельзя... А почему за 500 попыток так и не удалось? Долго я думал, пробовал разные исправления - всё равно не работало. Даже DeepSeek не смог найти причины. А потом как гром среди ясного неба - неправильный порядок! В самом начале файла у меня есть список допустимых значений кораблей (и при добавлении корабля его длина валидируется на наличие длины в этом списке). Выглядел он так — [1, 1, 1, 1, 2, 2, 2, 3, 3, 4]. Проблема крылась в том, что расставив все корабли, на доске физически может не найтись места для последнего четырёхпалубника. и когда я просто изменил список — от большого к меньшему, всё стало работать как часы. Сказать, что я был рад — ничего не сказать)
Вывод для себя номер 5: еще раз убедился в том, что лучше писать самостоятельно, пока учишься. Но главный вывод после такого опыта — внимательность, ведь ошибка крылась не в алгоритме, а переданных ему данных
Что добавил из глобального
Решил реализовать полезную для пользователя функцию — переместить корабль (replace_ship(self, coordinate_line_from, coordinate_line_to)). Пришлось повозиться, так как при удалении одного корабля и создании другого меняется ID, но эту проблему я смог решить присваиванием старого ID и жёстким контролем длины.
И добавил красоты - добавил display для параметров корабля (специально подбирал эмодзи, потому что так красиво, хоть и похоже на нейронку)

На пути к красивой архитектуре
В статье я упоминал, что хронология немного запутана, так как некоторые процессы шли частями и параллельно.
Я очень хотел написать красивый и легко читаемый код, но так как новичок, получалось так себе. Попросил DeepSeek‑а подробно и честно пройтись по качеству кода, и меня разнесли. Но за дело.
Итак, чего у меня не было вообще:
Нормальных констант — в коде были «магические» числа и строчки
Единой системы вывода — один метод выводил то одно, то другое
Дублирование кода в некоторых местах
Не было Docstring‑ов (я и не знал, что это)
Отсутствовали «нотации для указания типа возвращаемого значения» (стрелочки ->)
Смешанное и грязное API (в моём контексте api - публичные и приватные методы, по крайней мере так сказал DeepSeek)
Я последовал советам, и начал с констант
# ============ КОНСТАНТЫ ============== # Состояния клеток CLEAR = ' ' HITTED = 'x' DESTROYED = 'X' SHIP = '1' WAS_BEATEN = '.' # Результаты выстрелов SHOT_MISS = 'Blunder' SHOT_HIT = 'Hit' SHOT_KILL = 'Target destroyed' SHOT_WAS_BEATEN = 'Was beaten' SHOT_ERROR = 'Invalid shot' # Доступные длины кораблей FLEET_LENGTHS = [4, 3, 3, 2, 2, 2, 1, 1, 1, 1]
Потом я сделал вообще одно из самых правильных, как сейчас считаю, решений. В классе нет разницы, в каком порядке идут методы — а когда число строк уже около 800, то начинаешь буквально теряться в огромном полотне кода. Тогда я свернул (в Visual Studio Code можно скрыть содержимое функции, оставив только строчку с def) все функции, и полетел работать над структурой. Так как у меня есть похожие по смыслу/области методы, то можно разбить всё на группы. Итак (барабанная дробь):

Стало невероятно удобно пользоваться! Так что всем большим (для кого как, конечно) проектам это можно использовать
Много времени потратил на единую системы возврата для функций, удалил все print‑ы из функций, кроме специально для этого задуманных (все display()). Почти везде записал через двоеточие ожидаемые типы данных на вход. Конечно, по‑моему, это больше косметика, но реальная польза чувствуется сразу.
Все функции упростил — большинство логики разбивал на несколько методов (например, проверки линий корабля используются только в _create_buffer_zone(), но хуже от вынесения не стало — только лучше)
А что там с API? Это было сложно. Моя библиотека создана для того, чтобы предоставлять инструментарий для алгоритма или простого пользователя. Но если им будут доступны методы добавления одной координаты в корабль, то не факт, что это здорово. И к тому же, чем больше методов — тем иногда сложнее (так как называл я их не всегда идеально). В реальности, они будут доступны, но всё же будут находиться в последних строках выпадающего списка (говорят, что среди разработчиков это сигнал — «не предназначено для общего использования, так что на свой страх и риск»). Тут я добавил get‑методы для поля — чтобы не печатать, а возвращать саму матрицу алгоритму, который будет с ней работать.
Разобрался, что такое docstring‑и, когда случайно навёл на функцию с ними (раньше думал, что это просто комментарий в тройных кавычках, но не больше). Суммарно на добавление всех таких строк ушёл вечер. Но так как я не знаю, как их правильно составлять, но писал простые методы, а для сложных использовал ИИ. Тут не страшно — сложные функции должны быть понятны.

Исправил названия некоторых методов — find_from_coordinate() была вообще непонятной — что мы ищем, если выводится состояние? Вы уже знаете, откуда ноги растут — это одна их самых первых функций, когда я не понимал, что она будет делать, а просто реализовывал систему координат. Теперь она называется cell_state()
Вывод для себя номер 6 (почти главный): хорошая архитектура решает много проблем. Может у меня и не очень хорошая, но как минимум логичная. Система навигации помогает не путаться в сложных проектах, а названия переменных и подсказки типов данных — полезная вещь
Что по итогу
Пост был длинный. Если Вы дочитали до этого момента — спасибо огромное! Это меня очень радует
Первая часть моего pet‑проекта закончена — библиотеку SeaBattle я написал. Остался движок на этой библиотеке). С DeepSeek написал 28 тестов, так что вроде всё обкатано
Полная версия актуального кода — на моём GitHub‑репозитории
Ещё раз спасибо за прочтение, если хотите поделиться мыслями — добро пожаловать в комментарии!
