Длинные нарды (нард) - настольная игра для двух игроков на доске из 24 позиций (пунктов). Каждый игрок имеет 15 шашек. Цель игры - провести все свои шашки в дом и снять их с доски быстрее противника.
- Backend: NestJS + Colyseus
- Платформа: Telegram Mini Apps
- Количество игроков: 2
Доска состоит из 24 пунктов, пронумерованных от 1 до 24:
- Пункты 1-6: дом белых (white)
- Пункты 7-12: двор белых
- Пункты 13-18: дом черных (black)
- Пункты 19-24: двор черных
Визуализация нумерации (вид для белых):
13 14 15 16 17 18 19 20 21 22 23 24 ← СТАРТ белых
12 11 10 9 8 7 6 5 4 3 2 1 ← ДОМ белых
const INITIAL_POSITION = {
white: {
24: 15 // все 15 белых шашек на пункте 24
},
black: {
12: 15 // все 15 черных шашек на пункте 12
}
}-
Белые: движутся от пункта 24 к пункту 1 (по убыванию)
- Маршрут: 24 → 23 → 22 → ... → 2 → 1
- Дом: пункты 1-6
-
Черные: движутся от пункта 12 через пункт 1 к пункту 13 (против часовой стрелки)
- Маршрут: 12 → 11 → 10 → 9 → 8 → 7 → 6 → 5 → 4 → 3 → 2 → 1 → 24 → 23 → 22 → 21 → 20 → 19 → 18 → 17 → 16 → 15 → 14 → 13
- Дом: пункты 13-18
Важно: Оба игрока движутся в одном направлении (против часовой стрелки), но стартуют с противоположных точек доски.
Каждый игрок видит доску "со своей стороны". Доска поворачивается на 180° для игрока с черными шашками.
Для игрока WHITE (видит доску нормально):
[Соперник вверху]
13 14 15 16 17 18 19 20 21 22 23 24 ← МОИ шашки (старт)
↓ ↓ ↓ ↓ ↓ ↓ (движение против часовой)
12 11 10 9 8 7 6 5 4 3 2 1 ← МОЙ дом (финиш)
[Я внизу]
Для игрока BLACK (видит доску повернутой на 180°):
[Соперник вверху]
24 23 22 21 20 19 18 17 16 15 14 13 ← МОИ шашки (старт, логически пункт 12)
↓ ↓ ↓ ↓ ↓ ↓ (движение против часовой)
1 2 3 4 5 6 7 8 9 10 11 12 ← МОЙ дом (финиш, логически пункты 13-18)
[Я внизу]
Результат: Оба игрока видят свои шашки стартующими в верхнем правом углу и двигающимися против часовой стрелки к своему дому в нижнем правом углу.
enum GameState {
WAITING_FOR_PLAYER = 'waiting_for_player',
PLAYING = 'playing',
FINISHED = 'finished'
}interface DiceRoll {
dice1: number; // 1-6
dice2: number; // 1-6
}
// При дубле (dice1 === dice2) игрок получает 4 хода с этим значением.
// Например, при броске 3-3, игрок может сделать четыре хода на 3.В отличие от простой пошаговой валидации, сервер использует упреждающую генерацию всех возможных последовательностей ходов.
- После броска костей сервер рекурсивно вычисляет все возможные полные последовательности ходов, которые игрок может совершить.
- Эти последовательности учитывают все правила игры:
- Запрет на ход в занятый противником пункт.
- Обязательное использование максимального количества костей.
- Приоритет большего хода: Если из двух костей можно сыграть только одной (но не обеими), игрок обязан использовать большую кость, если ею возможен ход.
- Завершение игры: Алгоритм корректно обрабатывает выигрышный ход (сброс последней фишки), даже если у игрока остались неиспользованные кости.
- Правила снятия с головы в начале игры.
- Правила снятия шашек с доски (bearing off).
- Сгенерированный список валидных последовательностей ходов сохраняется в
state.possibleMoves. - Клиент получает этот список и использует его для отображения подсвеченных ходов, не производя собственной валидации.
Голова — стартовая позиция, где игрок начинает игру со всеми 15 шашками:
- Белые: пункт 24
- Черные: пункт 12
Основное правило: По умолчанию с головы можно снять только одну шашку за ход.
Исключение для специальных дублей: При выпадении дубля 3-3, 4-4 или 6-6 в первом ходу каждого игрока (turnCount ≤ 2) разрешается снять две шашки с головы вместо одной.
Важные детали:
- Правило применяется к первому ходу каждого игрока: turnCount=1 (белые) и turnCount=2 (черные)
- После снятия двух шашек с головы при специальном дубле, оставшиеся ходы (если они есть) делаются этими двумя фишками
- Критический сценарий: При дубле 6-6 в первом ходу:
- Снимаются 2 фишки: 24→18, 24→18 (для белых) или 12→6, 12→6 (для черных)
- Оставшиеся 2 хода невозможны (позиция 12/24 заблокирована противником)
- Ход валиден и завершается после 2 ходов (правило "2 с головы" физически ограничивает возможности)
- Правило "использовать максимум костей" соблюдено: 2 хода — это максимум возможных в данной ситуации
Реализация: Счетчик turnMovesFromHead отслеживает количество фишек, снятых с головы в текущем ходу. Лимит проверяется в findAllSingleMoves и учитывается в рекурсивной генерации последовательностей.
Если после броска костей сервер определяет, что список возможных последовательностей ходов пуст (игрок заблокирован), ход автоматически завершается, и право хода передается сопернику. Это предотвращает "зависание" игры.
-
Снятие разрешено только тогда, когда все 15 шашек игрока находятся в его доме.
-
Для белых (дом 1-6):
- Можно снимать шашки с пунктов 1-6.
- Белые двигаются по убыванию (24 → 1), поэтому снимают с младших номеров.
- Если значение кости точно соответствует номеру пункта, шашка снимается (например, с пункта 5 костью 5).
- Если значение кости больше номера самого дальнего пункта с шашкой, можно снять шашку с этого самого дальнего пункта.
-
Для черных (дом 13-18):
- Можно снимать шашки с пунктов 13-18.
- Черные двигаются к пункту 13 (маршрут: ...→15→14→13), поэтому снимают с младших номеров в диапазоне 13-18.
- Если значение кости точно соответствует расстоянию от пункта до выхода (пункт 13 = выход), шашка снимается.
- Пример: шашка на пункте 15, кость 2 → можно снять (15-2=13, выход).
- Если значение кости больше расстояния от самого дальнего пункта с шашкой до выхода, можно снять шашку с этого самого дальнего пункта.
-
Сервер генерирует ходы со специальным пунктом назначения
'off'(например,from: 5, to: 'off'для белых илиfrom: 15, to: 'off'для черных), которые клиент затем использует для отображения возможности снятия шашки.
Условие применения: Если у игрока нет ни одной фишки в его доме, то соперник не может создать блок из 6 пунктов подряд на маршруте этого игрока.
Определение блока:
- Блок = 6 пунктов подряд (по маршруту игрока), на каждом из которых стоит хотя бы одна фишка соперника
- Проверяется по полному маршруту игрока от старта до дома
Маршруты для проверки:
- Белые (дом 1-6): маршрут 24 → 23 → 22 → ... → 2 → 1
- Черные (дом 13-18): маршрут 12 → 11 → 10 → 9 → 8 → 7 → 6 → 5 → 4 → 3 → 2 → 1 → 24 → 23 → 22 → 21 → 20 → 19 → 18 → 17 → 16 → 15 → 14 → 13
Логика проверки:
- После генерации всех возможных ходов (
possibleMoves) - Для каждого хода в последовательности:
- Виртуально применяем ход к доске
- Проверяем: есть ли у противника хотя бы одна фишка в его доме?
- Если НЕТ: проверяем, создает ли этот ход блок из 6 пунктов подряд на маршруте противника
- Если создает блок: исключаем этот ход из
possibleMoves
Пример:
BLACK (старт: пункт 12, дом: 13-18) еще не имеет ни одной фишки в доме.
WHITE занимает пункты: 10, 9, 8, 7, 6, 5 (6 пунктов подряд на маршруте BLACK)
→ Это блок! Ход WHITE, создающий эту ситуацию, должен быть исключен из possibleMoves.
Важно: Это правило защищает игрока от полной блокировки маршрута до тех пор, пока он не введет хотя бы одну фишку в свой дом.
Структуры данных реализованы с помощью @colyseus/schema для автоматической синхронизации с клиентом.
import { Schema, type, MapSchema, ArraySchema } from '@colyseus/schema';
// Описывает шашки на одном пункте (треугольнике)
export class Point extends Schema {
@type('string') player: string; // 'white' или 'black'
@type('number') checkers: number; // Количество шашек
}
// Основная схема состояния игры
export class GameState extends Schema {
@type({ map: Point })
board = new MapSchema<Point>();
@type({ map: 'number' })
bar = new MapSchema<number>({ white: 0, black: 0 });
@type({ map: 'number' })
off = new MapSchema<number>({ white: 0, black: 0 });
@type('string')
currentPlayer: string;
@type(['number'])
dice = new ArraySchema<number>();
@type('string')
winner: string | null = null;
// Массив ВСЕХ возможных последовательностей ходов.
// Каждый элемент - строка, представляющая полную последовательность.
// Пример: ["24-20,20-18", "24-22,22-18"]
@type(['string'])
possibleMoves = new ArraySchema<string>();
@type({ map: 'string' })
players = new MapSchema<string>(); // e.g., { "sessionId1": "white" }
// ... другие поля, такие как playerProfiles, turnCount и т.д.
}Сервер слушает следующие сообщения от клиента:
onMessage("rollDice", (client) => { ... })
- Инициирует бросок костей для текущего игрока.
- Запускает на сервере генерацию
possibleMoves. - Если ходов нет, автоматически передает ход сопернику.
onMessage("move", (client, message: { from: number | 'bar'; to: number | 'off' }) => { ... })
- Получает один шаг хода от клиента (например, с 24 на 20).
- Сервер находит этот шаг в одной из валидных последовательностей в
possibleMoves. - Применяет ход, обновляет состояние (
board,dice). - Пересчитывает
possibleMovesна основе оставшихся костей. - Если после этого шага больше нет возможных ходов, завершает ход.
Основное состояние синхронизируется через room.onStateChange. Однако, для ключевых событий конца игры сервер отправляет клиентам прямые сообщения:
onMessage("game_over", (message: { result: 'win' | 'lose', message: string }) => { ... })
- Отправляется обоим игрокам по нормальному завершению игры (когда один из игроков сбросил все фишки).
result: 'win' для победителя, 'lose' для проигравшего.message: Текстовое сообщение с результатом (например,WIN 100 TON.илиLOST 100 TON.).
onMessage("opponent_left", (message: { message: string }) => { ... })
- Отправляется оставшемуся игроку, если его соперник покинул игру.
Помимо этого, клиент, как и прежде, реагирует на изменения в GameState через onStateChange.
Сервер использует рекурсивный алгоритм для поиска всех валидных последовательностей ходов.
calculatePossibleMoves(): Основная функция, которая запускает процесс и применяет правила принуждения (использовать все кости, играть больший ход).findMoveSequences(board, dice, player, ...): Рекурсивная функция.- В начале рекурсии проверяет, не убраны ли уже все фишки игрока с доски. Если да, то это выигрышная позиция, и она считается валидным концом последовательности ходов.
- Для каждой кости она находит все возможные одиночные ходы (
findAllSingleMoves). - Для каждого найденного хода она:
- Создает виртуальную доску с примененным ходом.
- Рекурсивно вызывает сама себя с оставшимися костями.
- Собирает и возвращает полные последовательности ходов.
findAllSingleMoves(...): Находит все возможные одиночные ходы для одной кости с текущей позиции, включая ходы с бара и снятие шашек (bearing off). Эта функция также содержит логику для "правила головы".
Этот подход гарантирует, что клиент всегда получает полный и точный список всех легальных действий, что исключает десинхронизацию и зависание игры.