Как стать автором
Обновить

Как я открыл WebSocket для Сомников из Чёрного Зеркала, а они начали водить хороводы

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров5.7K

Это моя небольшая история про создание примитивного пет-проекта.

Откуда растут ноги: Я посмотрел 4 эпизод 7 сезона сериала «Чёрное зеркало», где описывалась компьютерная игра с искусственным интеллектом, механизм взаимодействия с реальным миром которого ограничивался мельканием на мониторе и издаванием птичьих (скорее трубных) звуков.

Введение

С приходом популярности генеративных нейросетей, другие виды искусственного интеллекта тоже получили своё место под солнцем. Однако, ещё до этого уже существовали проекты, где искусственный интеллект играет в различные игры, получает очки за достижение целей и обучается на основе своих результатов. При этом, у каждого игрового ИИ есть свой массив доступных ему действий, который может быть постоянным или изменяемым согласно правилам. Я решил собрать небольшой проект, описать механизм взаимодействия ИИ с игровым пространством, а уже потом наполнить игру правилами и смыслом.

Инструменты

  • Java

  • Spring (WebFlux)

  • JS + HTML

Подготовка

Главной задачей была всё же примитивность "существ". Я дал им расположение, идентификатор, имя, тип, текстуру и некое состояние, которое бы описывало текущие действия существа (следование до точки, отдых) и время, необходимое на это действие перед переходом к другому.

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Creature {
        private int id;
        private float x;
        private float y;
        private final String type = "yellow";
        private CreatureState state;
        private String name = "Console";
        private String picture = Picture.IMAGE.toString();

Далее мной был добавлен класс состояния игры, где я самым обычным списком включил перечисление существ и вписал объект-окружения, который на самом деле описывает внешний вид фона веб-страницы:

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class GameState {
        private List<Creature> creatures;
        private Environment environment;
    }

Далее был создан GameStateManager, который первым делом я наполнил методом спавна новых существ и обновлением самого состояния игры, которое будет передаваться в клиент.

    private final DataModels.GameState gameState;
    public GameStateManager(DataModels.GameState gameState){
        this.gameState = gameState;
    }

    private final Queue<DataModels.Creature> toSpawn = new ConcurrentLinkedDeque<>();

    public void spawnCreature(String name){
        toSpawn.add(new DataModels.Creature(name));
    }

    public void updateGameState() {
        // Обновляем позиции существ
        toSpawn.forEach(creature -> gameState.getCreatures().add(creature));
        toSpawn.clear();
        gameState.getCreatures().forEach(creature -> {
            creature.setX(creature.getX() + creature.getState().getVectorX());
            creature.setY(creature.getY() + creature.getState().getVectorY());
            creature.getState().add();
            creature.updateFrame();
            if(creature.getState().getT() > 1){
                //Меняем действие
                if((creature.getState().sx == creature.getState().fx) &&
                        (creature.getState().sy == creature.getState().fy))
                {
                    creature.updateState();
                }else{
                    creature.setTimeout();
                }
            }
            // Проверка границ
            creature.setX(Math.max(0, Math.min(800, creature.getX())));
            creature.setY(Math.max(0, Math.min(600, creature.getY())));
        });
    }

Настройка WebSocket

Продолжая путь нашей игры, мы должны связать сервер с клиентом. Наиболее примитивным способом мы создаём обновление состояния игры, инициализируя перед этим нашу первую сущность "Vlad".

@Component
public class GameWebSocketHandler implements WebSocketHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final Sinks.Many<String> gameStateSink = Sinks.many().multicast().directBestEffort();
    private final GameStateManager gameStateManager = SomWorldApplication.gameStateManager;

    public GameWebSocketHandler() {
        initializeGameState();
        startGameLoop();
    }

    private void initializeGameState() {
        gameStateManager.spawnCreature("Vlad");
    }

    private void startGameLoop() {
        Flux.interval(Duration.ofMillis(16)) // ~60 FPS
                .subscribe(tick -> {
                    gameStateManager.updateGameState();
                    broadcastGameState();
                });
    }
    private void broadcastGameState() {
        try {
            String jsonState = objectMapper.writeValueAsString(gameStateManager.getGameState());
            gameStateSink.tryEmitNext(jsonState);
        } catch (JsonProcessingException e) {
            System.err.println("Error serializing game state: " + e.getMessage());
        }
    }

    @Override
    public Mono<Void> handle(WebSocketSession session) {
        // 1. Подписка на обновления состояния
        Flux<WebSocketMessage> output = gameStateSink.asFlux()
                .map(session::textMessage);

        // 2. Обработка входящих сообщений
        Mono<Void> input = session.receive()
                .map(WebSocketMessage::getPayloadAsText)
                .doOnNext(message -> {
                    System.out.println("Received from client: " + message);
                })
                .then();

        return session.send(output).and(input);
    }
}
@Configuration
public class WebSocketConfig {

    @Bean
    public HandlerMapping webSocketMapping(GameWebSocketHandler handler) {
        Map<String, WebSocketHandler> map = new HashMap<>();
        map.put("/game", handler);
        SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
        mapping.setOrder(-1);
        mapping.setUrlMap(map);
        return mapping;
    }

    @Bean
    public WebSocketHandlerAdapter handlerAdapter() {
        return new WebSocketHandlerAdapter();
    }

    @Bean
    public WebSocketClient webSocketClient() {
        return new ReactorNettyWebSocketClient();
    }
}

Создаём клиент

Простой HTML-макет с canvas для отображения спрайтов существ и описываем в JS подключение к сокету и рендеринг игры

<div id="game-container">
    <canvas id="gameCanvas"></canvas>
    <div id="ui">
        <span id="connection-status"></span>
        <span id="status-text">Соединение...</span>
    </div>
    <div id="creature-name" style="position:absolute; display:none; background:#fff; padding:4px 8px; border-radius:4px; box-shadow:0 2px 5px rgba(0,0,0,0.2); font-size:14px; pointer-events:none;"></div>
</div>
    function connect() {
        const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
        const serverUrl = wsProtocol + window.location.host + '/game';

        socket = new WebSocket(serverUrl);
sortedCreatures.forEach(creature => {
            const creatureImage = new Image();
            creatureImage.src = creature.picture;

            const x = creature.x * canvas.width / 800;
            const y = creature.y * canvas.height / 600;
            const size = 10 * Math.min(canvas.width / 800, canvas.height / 600);
            const imgSize = size * 8; // Подберите масштаб под изображение

            // drawImage(image, dx, dy, dWidth, dHeight)
            // dx и dy — это координаты, где нужно рисовать
            const shadow = new Image();
            shadow.src = "shadow.png";
            loadImage(shadow.src).then(img => {
                ctx.drawImage(img, x - imgSize / 2, y - imgSize / 2, imgSize, imgSize);
            });
            loadImage(creatureImage.src).then(img => {
                ctx.drawImage(img, x - imgSize / 2, y - imgSize / 2, imgSize, imgSize);
                });
            });

Запускаем

Запущенный клиент с существами, авторство спрайтов которых принадлежит моей любимой жене
Запущенный клиент с существами, авторство спрайтов которых принадлежит моей любимой жене
Скрытый текст

На этом этапе любые изменения в экземпляре GameState или даже в Creature будут изображены на клиенте. Для теста я уже сделал движение по случайным направлениям, добавил несколько вариантов отображения существ и другие мелочи.

Внедрение хаоса и порядка средствами ИИ

В пустом мире, где нет ничего кроме созданных существ, очень сложно сформировать какую-то обоснованную цель. Я решил вернуться к первоисточнику своего вдохновения и обратить внимание, что существа в нём ставят себе единственной задачей повышение своей численности, для этого они выбрали необычный способ осуществлять влияние на человека-наблюдателя.

Было решено создать подобные условия, в которых ИИ (по алгоритму Q-learning) будет вынужден создавать сценарии поведения, пытаясь внушить наблюдателю, что тот должен кликнуть на существ.

Q-learning на основе получаемого от среды вознаграждения формирует функцию полезности Q, что впоследствии даёт ему возможность уже не случайно выбирать стратегию поведения, а учитывать опыт предыдущего взаимодействия со средой.

Первая попытка

  1. Каждое существо обладает полем stress, значение которого увеличивается пропорционально совершённым существом действий за шаг.

  2. Каждое существо обладает полем dopamine_layers, значение которого уменьшает на 1 каждый шаг (но не меньше 0), а также сокращает stress на своё значение.

  3. Если stress < 0, то он становится 50, но на поле появляется новое существо. (ИИ получает награду)

  4. Если stress > 100, то оно пропадает. (ИИ получает штраф)

  5. Клик пользователя по существу, увеличивает его dopamine_layers. (ИИ получает награду)

Итог первой попытки

Каждое существо выбирает себе маршрут (10% - экспериментальный; 90% - самый эффективный) на 100 ближайших шагов, награда полученная за эти шаги, откладывается до завершения всего маршрута.

Сначала существа не совершали никаких действий совсем, кроме тех разов, когда коэффициент экспериментирования не вызывал у них срабатывание случайных действий. В одно из таких случайных действий я кликнул по существу и это вызвало реакцию у всего племени. Моё внимание явно привлекло бы одно существо, которое совершает нестандартное движение, но когда за ним после клика повторяют остальные — это провал.

Я немного масштабировал награды, а потом совершив лишь пару кликов на существ, движения которых хоть немного привлекало моё внимание, я заметил, что двигаться существа начали реже, но на большие расстояния.

Так или иначе, на этом этапе я уже понимал, что если ИИ координирует действия каждого существа индивидуально, то интерфейса взаимодействия не хватит на поддержание численности (Гипотеза условно верна, ведь мы не можем доказать обратного). Поэтому я перешёл к формированию нового алгоритма.

Вторая попытка (Коллективный разум)

Создаём матрицу пути, здесь у нас есть ось x, y, t.

boolean[][][] path = new boolean[1600][900][100];


boolean[][][] path = new boolean[80][45][600]; //изменение с учётом оптимизации

Алгоритм заполняет матрицу (число заполненных ячеек равняется числу существ), которую можно визуализировать как чб изображение 80 на 45 для каждого кадра (максимум 600 возможных). А дальше общая матрица, созданная ИИ, разбивается на маршруты для каждого существа.

Итог второй попытки 2а

Первые попытки пространственного позиционирования были похожи на что-то такое: существ разбросало на края экрана, что я никак не поощрял. Награждение алгоритма кликами было здесь совсем неуместно, так как обучение занимало бы слишком много повторов для получения хоть каких-то внятных результатов.

Мною было принято решение автоматически получать матрицу и сравнивать гармоничность и последовательность каждого отдельного слоя, теперь я поощрял ИИ за то, что он создаёт узоры, которые существа в силах изобразить (как минимум, им нужно пройти минимум расстояния от слоя до слоя). Если первый слой матрицы более чем на половину повторяет второй, то ИИ штрафовался.

Итог второй попытки 2б

Существа приватизировали себе угол у нулевых координат и начали водить там "хоровод". При этом большинство существ стоит без движения для экономии очков, и лишь несколько из них образуют какой-либо узор. В моём понимании это был уже хоть какой-то успех.

Третья попытка (на основе второй)

Я начал штрафовать существ за безделье, мне были необходимы активные действия, а не медленный скринсейвер в виде жёлто-синей загрузки. А ещё существа теперь появлялись в разных частях экрана, что усложняло работу ИИ.

Итоги третьей попытки

Существа не отходили от своей привычки образовывать круги, именно круг позволяет существам создавать плавные переходы из одних слоёв матрицы в другие.

И тут мне стало интересно что будет, если их станет больше... я начал поощрять ИИ кликами.

Существ становилось больше, но узор не приходил к своему логическому завершению/зацикливанию.

Я продолжал кликать, так как думал, что существ недостаточно для осуществления идеи алгоритма.

Узор начал принимать очертание девятки, восьмёрки или английской буквы S

С каждым новым существом я склонялся к разному варианту итогового узора, но... его просто нет.

Алгоритм победил

Описывая первые попытки я написал следующие слова:

Было решено создать подобные условия, в которых ИИ (по алгоритму Q-learning) будет вынужден создавать сценарии поведения, пытаясь внушить наблюдателю, что тот должен кликнуть на существ.

Разработанный мною алгоритм записывал матрицы, которые он создаёт, сейчас предлагаю на них взглянуть.

Внимание, топ-3 матрицы по частоте использования алгоритмом:

Половинка сердца?
Половинка сердца?

Алгоритм формировал матрицы с прерывными линиями, однако из-за скорости передвижения существ, на клиенте наблюдались будто бы непрерывные узоры, но для завершения которых якобы было мало юнитов. Этот эффект и заставил меня посодействовать увеличению числа существ.

Заключение

Мой эксперимент подошёл к концу, я ожидал, что научу несложный ИИ‑алгоритм рисованию причудливых символов или фракталов, которые украсят эту статью, и тогда можно было бы сказать: «Я создал виртуальных существ и на них можно смотреть вечно», хотя на деле скорее всего я обманул сам себя, когда попытался увидеть порядок в беспорядочном размещении жёлтых дурачков.

Думаю, что можно продолжить эксперименты, изменив условия игры и добавив новые объекты окружения, поэтому буду рад вашим предложениям в комментариях!

Теги:
Хабы:
+4
Комментарии2

Публикации

Работа

Java разработчик
174 вакансии

Ближайшие события