🐍 Odot Automation: "Змейка" для CODESYS 3.5 с интеллектуальной тепловой картой
Процессор: С4374
Модули: CT-222F (10 модулей, матрица 10x16 светодиодов)
Язык: Structured Text (ST)
Автор: Совместное творчество человека и DeepSeek
📚 Содержание
- 1. Алгоритм тепловой карты
- 2. Полный код проекта
- 2.1 Типы данных (DUT)
- 2.2 Функциональный блок SnakeGame (Declaration)
- 2.3 Реализация методов (Implementation)
- 2.4 Программа вывода на матрицу
- 3. Для новичков: как создавать методы в CODESYS
- 4. Почему Odot C4374 + CT-222F — отличный старт
- 5. Роль ИИ в разработке: мысли вслух
- 6. Благодарности
🔥 Алгоритм тепловой карты (как змейка стала умной)
После множества неудачных попыток (жадный алгоритм, случайные блуждания, отскоки от стен) родилась идея: создать две карты влияния, которые змейка "чувствует" при выборе направления.
Две компоненты тепла:
- rabbitHeat[x,y] — тепло от кроликов. Каждый кролик нагревает клетки в радиусе 3 шагов (максимум 3 в своей клетке, затем 2, 1). Если кролики рядом — тепло складывается. Карта пересчитывается после каждого съедения.
- visitHeat[x,y] — отрицательная память посещений. Каждый раз, когда змейка проходит через клетку, её привлекательность уменьшается на 5 (но не ниже -100). Это заставляет змейку исследовать новые территории.
Выбор направления: на каждом шаге змейка смотрит на четыре соседние клетки (не стена, не своё тело) и выбирает ту, где сумма rabbitHeat + visitHeat максимальна. Если есть несколько одинаковых — выбирается случайно среди них.
📜 Полный код проекта (CODESYS 3.5, Structured Text)
2.1 Типы данных (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)
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
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
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
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
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 (полностью)
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
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
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
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)
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) – это функции, принадлежащие функциональному блоку. Они позволяют структурировать код и повторно использовать логику.
- Создание метода: Правой кнопкой на функциональном блоке (например,
SnakeGame) → Add Object → Method... Введите имя метода (например,MoveSnake). - Объявление (Declaration): В открывшемся окне можно добавить входные параметры (
VAR_INPUT) и выходные (VAR_OUTPUT), если нужно. Для метода без параметров оставьте пустым. Нажмите OK. - Реализация (Implementation): В нижней части редактора появится вкладка Implementation. Здесь пишется код метода. Обязательно закончите метод ключевым словом
END_METHOD(генерируется автоматически). - Вызов метода: Внутри функционального блока метод вызывается по имени:
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 за отличное железо, на котором так приятно воплощать любые идеи в жизнь.
Остались вопросы?
отгрузка за 1 день