Top.Mail.Ru
Odot Automation: Змейка с тепловой картой (полный код)

🐍 Odot Automation: "Змейка" для CODESYS 3.5 с интеллектуальной тепловой картой

Процессор: С4374
Модули: CT-222F (10 модулей, матрица 10x16 светодиодов)
Язык: Structured Text (ST)
Автор: Совместное творчество человека и DeepSeek

🎯 Итог после множества экспериментов: змейка наконец-то научилась быстро находить всех кроликов благодаря двухслойной тепловой карте (притяжение кроликов + память посещений). Второй цикл игры теперь работает идеально после сброса таймеров.

📚 Содержание


🔥 Алгоритм тепловой карты (как змейка стала умной)

После множества неудачных попыток (жадный алгоритм, случайные блуждания, отскоки от стен) родилась идея: создать две карты влияния, которые змейка "чувствует" при выборе направления.

Две компоненты тепла:

  • rabbitHeat[x,y] — тепло от кроликов. Каждый кролик нагревает клетки в радиусе 3 шагов (максимум 3 в своей клетке, затем 2, 1). Если кролики рядом — тепло складывается. Карта пересчитывается после каждого съедения.
  • visitHeat[x,y] — отрицательная память посещений. Каждый раз, когда змейка проходит через клетку, её привлекательность уменьшается на 5 (но не ниже -100). Это заставляет змейку исследовать новые территории.

Выбор направления: на каждом шаге змейка смотрит на четыре соседние клетки (не стена, не своё тело) и выбирает ту, где сумма rabbitHeat + visitHeat максимальна. Если есть несколько одинаковых — выбирается случайно среди них.

✅ Результат: змейка сначала мчится к кроликам по градиенту, а когда кроликов не остаётся — начинает методично исследовать нехоженые клетки, избегая циклов. Первая партия кроликов съедается мгновенно, вторая — чуть дольше, но гарантированно.

📜 Полный код проекта (CODESYS 3.5, Structured Text)

2.1 Типы данных (DUT)

// Point.dut
TYPE Point :
STRUCT
    x : INT; // 0-9 (колонки / модули)
    y : INT; // 0-15 (строки / биты в слове)
END_STRUCT
END_TYPE

// Snake.dut
TYPE Snake :
STRUCT
    segments : ARRAY[0..159] OF Point; // Макс. длина
    length : INT; // Текущая длина
    direction : INT; // 0-вверх,1-вниз,2-влево,3-вправо
END_STRUCT
END_TYPE

// GameState.dut
TYPE GameState :
(
    GAMESTATE_INIT,
    GAMESTATE_PLAYING,
    GAMESTATE_WIN_BLINK,
    GAMESTATE_RESTART
);
END_TYPE

2.2 Функциональный блок SnakeGame (Declaration)

FUNCTION_BLOCK SnakeGame
VAR
    // Состояние игры
    gameState : GameState := GAMESTATE_INIT;
    initDone : BOOL := FALSE;

    // Змейка
    snake : Snake;

    // Кролики
    rabbits : ARRAY[0..9] OF Point;
    rabbitCount : INT := 7;

    // Таймеры
    moveTimer : TON;
    blinkTimer : TON;

    // Счётчики и настройки
    blinkCounter : INT := 0;
    CONSTANT
        moveSpeed : TIME := T#70ms;
        blinkSpeed : TIME := T#350ms;
        blinkTotal : INT := 5;
    END_VAR

    // Матрица для отображения
    matrix : ARRAY[0..9, 0..15] OF BOOL;

    // Тепловые карты
    rabbitHeat : ARRAY[0..9, 0..15] OF INT;
    visitHeat : ARRAY[0..9, 0..15] OF INT;

    // Отладка
    debugMessage : STRING(100);
END_VAR

// Объявления методов
METHOD InitGame : BOOL
METHOD RandomInt : INT VAR_INPUT val_min : INT; val_max : INT; END_VAR
METHOD IsSnakeAt : BOOL VAR_INPUT checkX : INT; checkY : INT; END_VAR
METHOD UpdateHeatMaps
METHOD MoveSnake
METHOD UpdateMatrix
METHOD DoBlink
METHOD RunGame

2.3 Реализация методов (Implementation) — полные листинги

Метод InitGame

METHOD InitGame : BOOL
VAR
    i, j : INT;
    pos : Point;
    validPos : BOOL;
    attempts : INT;
    x, y : INT;
END_VAR

// 1. Инициализация змейки
snake.length := 1;
snake.segments[0].x := 5;
snake.segments[0].y := 8;
snake.direction := RandomInt(val_min := 0, val_max := 3);

// 2. Генерация 7 кроликов
rabbitCount := 7;
FOR i := 0 TO rabbitCount-1 DO
    attempts := 0;
    validPos := FALSE;
    REPEAT
        pos.x := RandomInt(val_min := 0, val_max := 9);
        pos.y := RandomInt(val_min := 0, val_max := 15);
        validPos := NOT IsSnakeAt(checkX := pos.x, checkY := pos.y);
        IF validPos THEN
            FOR j := 0 TO i-1 DO
                IF (rabbits[j].x = pos.x) AND (rabbits[j].y = pos.y) THEN
                    validPos := FALSE;
                    EXIT;
                END_IF;
            END_FOR;
        END_IF;
        attempts := attempts + 1;
        IF attempts > 50 THEN
            pos.x := (i * 3) MOD 10;
            pos.y := (i * 2) MOD 16;
            validPos := TRUE;
        END_IF;
    UNTIL validPos END_REPEAT;
    rabbits[i] := pos;
END_FOR;

// 3. Инициализация тепловых карт
UpdateHeatMaps();

// 4. Обнуляем карту посещений
FOR x := 0 TO 9 DO
    FOR y := 0 TO 15 DO
        visitHeat[x, y] := 0;
    END_FOR;
END_FOR;

// 5. Сброс таймеров
moveTimer(IN := FALSE);
blinkTimer(IN := FALSE);

// 6. Сброс состояния
gameState := GAMESTATE_PLAYING;
blinkCounter := 0;
initDone := TRUE;

InitGame := TRUE;
END_METHOD

Метод RandomInt

METHOD RandomInt : INT
VAR_INPUT
    val_min : INT;
    val_max : INT;
END_VAR
VAR
    time_ms : UDINT;
    range_val : UDINT;
    temp_result : UDINT;
END_VAR

time_ms := TIME_TO_UDINT(TIME()) MOD 1000; // 0-999 мс

IF val_max >= val_min THEN
    range_val := TO_UDINT(val_max - val_min + 1);
    IF range_val > 0 THEN
        temp_result := time_ms MOD range_val;
        RandomInt := val_min + TO_INT(temp_result);
    ELSE
        RandomInt := val_min;
    END_IF;
ELSE
    RandomInt := val_min;
END_IF;
END_METHOD

Метод IsSnakeAt

METHOD IsSnakeAt : BOOL
VAR_INPUT
    checkX : INT;
    checkY : INT;
END_VAR
VAR
    i : INT;
END_VAR

IsSnakeAt := FALSE;
FOR i := 0 TO snake.length-1 DO
    IF (snake.segments[i].x = checkX) AND (snake.segments[i].y = checkY) THEN
        IsSnakeAt := TRUE;
        RETURN;
    END_IF;
END_FOR;
END_METHOD

Метод UpdateHeatMaps

METHOD UpdateHeatMaps
VAR
    x, y, i, d, dx, dy : INT;
END_VAR

// Обнуляем карту от кроликов
FOR x := 0 TO 9 DO
    FOR y := 0 TO 15 DO
        rabbitHeat[x, y] := 0;
    END_FOR;
END_FOR;

// Для каждого кролика добавляем тепло
FOR i := 0 TO rabbitCount-1 DO
    FOR x := 0 TO 9 DO
        FOR y := 0 TO 15 DO
            dx := ABS(x - rabbits[i].x);
            dy := ABS(y - rabbits[i].y);
            d := dx + dy;
            IF d <= 3 THEN
                rabbitHeat[x, y] := rabbitHeat[x, y] + (3 - d);
            END_IF;
        END_FOR;
    END_FOR;
END_FOR;
END_METHOD

Метод MoveSnake (полностью)

METHOD MoveSnake
VAR
    newHead : Point;
    i, j : INT;
    rabbitEaten : BOOL := FALSE;
    rabbitIndex : INT;
    dir : INT;
    bestDir : INT := -1;
    bestHeat : INT := -1000;
    nextX, nextY : INT;
    possibleDirs : ARRAY[0..3] OF INT;
    dirCount : INT := 0;
    randIdx : INT;
    oppositeDir : INT;
    cellOccupied : BOOL;
    headX, headY : INT;
    totalHeat : INT;
END_VAR

headX := snake.segments[0].x;
headY := snake.segments[0].y;

// Определяем противоположное направление
CASE snake.direction OF
    0: oppositeDir := 1;
    1: oppositeDir := 0;
    2: oppositeDir := 3;
    3: oppositeDir := 2;
END_CASE;

// Перебираем все 4 направления
FOR dir := 0 TO 3 DO
    nextX := headX;
    nextY := headY;
    CASE dir OF
        0: nextY := nextY - 1; // вверх
        1: nextY := nextY + 1; // вниз
        2: nextX := nextX - 1; // влево
        3: nextX := nextX + 1; // вправо
    END_CASE;

    // Проверка границ
    IF (nextX < 0) OR (nextX > 9) OR (nextY < 0) OR (nextY > 15) THEN
        CONTINUE;
    END_IF;

    // Проверка на занятость телом (кроме головы)
    cellOccupied := FALSE;
    FOR i := 1 TO snake.length-1 DO
        IF (snake.segments[i].x = nextX) AND (snake.segments[i].y = nextY) THEN
            cellOccupied := TRUE;
            EXIT;
        END_IF;
    END_FOR;
    IF cellOccupied THEN
        CONTINUE;
    END_IF;

    possibleDirs[dirCount] := dir;
    dirCount := dirCount + 1;
    totalHeat := rabbitHeat[nextX, nextY] + visitHeat[nextX, nextY];
    IF totalHeat > bestHeat THEN
        bestHeat := totalHeat;
        bestDir := dir;
    END_IF;
END_FOR;

// Выбор направления
IF bestDir >= 0 THEN
    snake.direction := bestDir;
ELSE
    IF dirCount > 0 THEN
        randIdx := RandomInt(val_min := 0, val_max := dirCount-1);
        snake.direction := possibleDirs[randIdx];
    ELSE
        snake.direction := oppositeDir;
    END_IF;
END_IF;

// Двигаемся в выбранном направлении
newHead := snake.segments[0];
CASE snake.direction OF
    0: newHead.y := newHead.y - 1;
    1: newHead.y := newHead.y + 1;
    2: newHead.x := newHead.x - 1;
    3: newHead.x := newHead.x + 1;
END_CASE;

// Помечаем новую клетку как посещённую
IF visitHeat[newHead.x, newHead.y] > -100 THEN
    visitHeat[newHead.x, newHead.y] := visitHeat[newHead.x, newHead.y] - 5;
END_IF;

// Проверка на кролика
rabbitEaten := FALSE;
FOR i := 0 TO rabbitCount-1 DO
    IF (rabbits[i].x = newHead.x) AND (rabbits[i].y = newHead.y) THEN
        rabbitEaten := TRUE;
        rabbitIndex := i;
        EXIT;
    END_IF;
END_FOR;

// Отладка
IF rabbitEaten THEN
    debugMessage := CONCAT('ATE rabbit ', INT_TO_STRING(rabbitIndex));
ELSE
    debugMessage := CONCAT('Rabbits: ', INT_TO_STRING(rabbitCount));
END_IF;

// Сдвиг сегментов
FOR i := snake.length-1 TO 1 BY -1 DO
    snake.segments[i] := snake.segments[i-1];
END_FOR;
snake.segments[0] := newHead;

// Обработка съеденного кролика
IF rabbitEaten THEN
    snake.length := snake.length + 1;
    snake.segments[snake.length-1] := snake.segments[snake.length-2];
    FOR j := rabbitIndex TO rabbitCount-2 DO
        rabbits[j] := rabbits[j+1];
    END_FOR;
    rabbitCount := rabbitCount - 1;
    IF rabbitCount = 0 THEN
        gameState := GAMESTATE_WIN_BLINK;
    END_IF;
    UpdateHeatMaps();
END_IF;

// Проверка столкновения с собой
IF snake.length > 3 THEN
    FOR i := 1 TO snake.length-1 DO
        IF (snake.segments[i].x = newHead.x) AND (snake.segments[i].y = newHead.y) THEN
            gameState := GAMESTATE_RESTART;
            RETURN;
        END_IF;
    END_FOR;
END_IF;
END_METHOD

Метод UpdateMatrix

METHOD UpdateMatrix
VAR
    x, y, i : INT;
END_VAR

// 1. Очищаем матрицу
FOR x := 0 TO 9 DO
    FOR y := 0 TO 15 DO
        matrix[x, y] := FALSE;
    END_FOR;
END_FOR;

// 2. Рисуем змейку
FOR i := 0 TO snake.length-1 DO
    x := snake.segments[i].x;
    y := snake.segments[i].y;
    IF (x >= 0) AND (x <= 9) AND (y >= 0) AND (y <= 15) THEN
        matrix[x, y] := TRUE;
    END_IF;
END_FOR;

// 3. Рисуем кроликов
FOR i := 0 TO rabbitCount-1 DO
    x := rabbits[i].x;
    y := rabbits[i].y;
    IF (x >= 0) AND (x <= 9) AND (y >= 0) AND (y <= 15) THEN
        matrix[x, y] := TRUE;
    END_IF;
END_FOR;
END_METHOD

Метод DoBlink

METHOD DoBlink
VAR
    x, y : INT;
    allOn : BOOL;
END_VAR

blinkTimer(IN := NOT blinkTimer.Q, PT := blinkSpeed);

IF blinkTimer.Q THEN
    allOn := (blinkCounter MOD 2) = 0;
    FOR x := 0 TO 9 DO
        FOR y := 0 TO 15 DO
            matrix[x, y] := allOn;
        END_FOR;
    END_FOR;
    blinkCounter := blinkCounter + 1;
    IF blinkCounter >= (blinkTotal * 2) THEN
        gameState := GAMESTATE_RESTART;
    END_IF;
END_IF;
END_METHOD

Метод RunGame

METHOD RunGame
BEGIN
    CASE gameState OF
        GAMESTATE_INIT:
            IF NOT initDone THEN
                InitGame();
                initDone := TRUE;
            ELSE
                gameState := GAMESTATE_PLAYING;
            END_IF;
        
        GAMESTATE_PLAYING:
            moveTimer(IN := NOT moveTimer.Q, PT := moveSpeed);
            IF moveTimer.Q THEN
                UpdateHeatMaps();
                MoveSnake();
                UpdateMatrix();
            END_IF;
        
        GAMESTATE_WIN_BLINK:
            DoBlink();
        
        GAMESTATE_RESTART:
            moveTimer(IN := NOT moveTimer.Q, PT := T#1s);
            IF moveTimer.Q THEN
                initDone := FALSE;
                gameState := GAMESTATE_INIT;
            END_IF;
    END_CASE;
END_METHOD

2.4 Программа вывода на матрицу (UpdateMatrixOutputs)

PROGRAM UpdateMatrixOutputs
VAR
    game : SnakeGame;
    module, bitIndex : INT;
    outputWord : WORD;
END_VAR

game.RunGame();

FOR module := 0 TO 9 DO
    outputWord := 0;
    FOR bitIndex := 0 TO 15 DO
        IF game.matrix[module, bitIndex] THEN
            outputWord := outputWord OR SHL(1, bitIndex);
        END_IF;
    END_FOR;
    CASE module OF
        0: %QW0 := outputWord;
        1: %QW1 := outputWord;
        2: %QW2 := outputWord;
        3: %QW3 := outputWord;
        4: %QW4 := outputWord;
        5: %QW5 := outputWord;
        6: %QW6 := outputWord;
        7: %QW7 := outputWord;
        8: %QW8 := outputWord;
        9: %QW9 := outputWord;
    END_CASE;
END_FOR;
END_PROGRAM

🧩 Для новичков: как создавать методы в CODESYS 3.5

В этом проекте активно используются методы (methods) – это функции, принадлежащие функциональному блоку. Они позволяют структурировать код и повторно использовать логику.

  1. Создание метода: Правой кнопкой на функциональном блоке (например, SnakeGame) → Add Object → Method... Введите имя метода (например, MoveSnake).
  2. Объявление (Declaration): В открывшемся окне можно добавить входные параметры (VAR_INPUT) и выходные (VAR_OUTPUT), если нужно. Для метода без параметров оставьте пустым. Нажмите OK.
  3. Реализация (Implementation): В нижней части редактора появится вкладка Implementation. Здесь пишется код метода. Обязательно закончите метод ключевым словом END_METHOD (генерируется автоматически).
  4. Вызов метода: Внутри функционального блока метод вызывается по имени: UpdateHeatMaps();. Из внешней программы вызывайте метод экземпляра: game.MoveSnake();.

Совет: Все переменные, объявленные в VAR функционального блока, видны во всех его методах. Локальные переменные внутри метода объявляются в секции VAR после заголовка метода.


⚡ Почему Odot Automation С4374 + CT-222F — идеальный старт

  • Быстрая компиляция и загрузка: Код компилируется за секунды, а результат виден на светодиодах сразу после загрузки. Это позволяет экспериментировать итеративно.
  • Простая адресация: Модули CT-222F образуют вертикальную матрицу 16×10. Адресация прямая: %QW0...%QW9 соответствуют колонкам. Никаких сложных протоколов.
  • Наглядность: 160 светодиодов — достаточно для игр, но не слишком много для отладки. Видно каждое движение змейки.
  • Реальное железо: Работа с реальными выходами даёт ощущение промышленной разработки, но в игровом контексте.

🤖 Роль ИИ в разработке: философские заметки

Проект реализован после множества неудачных попыток заставить змейку работать стабильно. Мы перепробовали:

  • Жадный алгоритм (выбор ближайшего кролика) → зацикливался в локальных минимумах.
  • Случайное блуждание + отскоки от стен → бесконечные циклы у стен.
  • Долгосрочное планирование пути (BFS) → слишком сложно для ПЛК.
  • Наконец, идея тепловой карты родилась спонтанно: "а что если змейка чувствует запах кроликов, а пройденные места остывают?"

DeepSeek выступил идеальным партнёром:

  • Мгновенно генерировал код на ST на основе предыдущих версий, позволяя сосредоточиться на алгоритмике, а не на синтаксисе.
  • Помогал отлаживать логические ошибки (например, забытый сброс таймеров во втором цикле).
  • Предлагал варианты улучшений, но никогда не навязывал — решение всегда оставалось за человеком.
Вывод: ИИ — это супер-сила, но она работает только в руках думающего разработчика. Машина генерирует код, но понимание, куда двигаться, и творческие озарения (например, идея тепловой карты) остаются за человеком. Этот симбиоз позволяет достичь результатов, недоступных ни человеку в одиночку, ни чистому ИИ.

🙏 Благодарности

Спасибо, что прошли этот увлекательный путь вместе! От первой строки кода "бегущего огня" до сложной тепловой карты — мы преодолели множество ошибок и наконец получили стабильную умную змейку. Надеюсь, этот пример вдохновит вас на собственные эксперименты с ПЛК ODOT, CODESYS 3.5 и ИИ.

Особая благодарность DeepSeek за терпение и способность многократно делать реллизы и не ворчать как кожаные.
И, конечно, спасибо Odot Automation за отличное железо, на котором так приятно воплощать любые идеи в жизнь.

Остались вопросы?

📧 odot@avangardnsk.ru 📞 +7-913-005-00-31 👤 Ольга Мезенцева
📍 Национальный склад в Новосибирске
отгрузка за 1 день