Русский
Русский
English
Статистика
Реклама

Змейка

Змейка, мышь и Гамильтон

01.03.2021 02:23:51 | Автор: admin
Добрый времени суток. Давайте научим компьютер играть в змейку.

Собственно, это первая часть статьи о том, как можно попробовать решить эту задачку. Скажем так разминочная, перед основным боем.


С чего всё началось


Дабы быть полностью откровенным, началось все с просмотра видео чувака из Австрали именующего себя "Code Bullet"
Он в нем пытается решить с помощь различных алгоритмов AI простую игру в змейку.
У него толком ничего не получается, и вот почему Текущие алгоритмы, которые предоставляет сейчас сообщество AI решают только две задачи либо Классификации, либо Регрессии (предсказания). А вот змейка ни туда ни туда не вписывается. Ибо идея о том, что съесть мышь это хорошо, а врезаться плохо. Разбивается об хвост, который постоянно растет и растет пока не займет всё поле. Так вот написать полноценный самообучающий AI для такой задачи показалось прикольной идей, но для начала я решил размяться и написать просто алгоритм, который решал бы задачку в лоб, без обучения. Собственно, о таком алгоритме и пойдёт речь.

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

Пишем игру


Перед тем как, играть, напишем саму игру.

Все расчеты будем производить на сервере, отображать змейку в браузере, а инфой будем обмениваться через WebSocket (SignalR).

Скучный код без которого ничего не работает
Отображение максимально простое. У нас есть Store который получает информацию об положении змеи и размере поля и состоянии игры.

    isLife: boolean,    isWin: boolean,    xSize: number,    ySize: number,    mouse: Vector2,    piton: Vector2[]

snake.ts

import { HubConnection, HubConnectionBuilder } from "@microsoft/signalr";import { observable } from "mobx";import { start } from "repl";export enum Cell {    None = "None",    Mouse = "Mouse",    Snake = "Snake"}export interface Vector2 {    x: number,    y: number}interface StateBoard {    isLife: boolean,    isWin: boolean,    xSize: number,    ySize: number,    mouse: Vector2,    piton: Vector2[]    hamiltonPath: Vector2[]}class Snake {    @observable    public state?: StateBoard;    private connection: HubConnection;    constructor() {        this.connection = new HubConnectionBuilder()            .withUrl("http://localhost:5000/snake")            .withAutomaticReconnect()            .build();        this.start();    }    private start = async () => {        await this.connection.start();        this.connection.on("newState", (board: string) => {            var state = JSON.parse(board) as StateBoard;            if (state.isLife) {                this.state = state;            } else {                this.state!.isLife = false;                this.state!.isWin = state.isWin;                if (this.state!.isWin) {                    this.state = state;                }            }        });    }}export const snake = new Snake();

И React компонент, который просто все это дело рисует.

App.tsx

import { snake } from './shores/snake';import { useObserver } from 'mobx-react-lite';import cs from 'classnames';const App = (): JSX.Element => {  const cellRender = (indexRow: number, indexColumn: number): JSX.Element => {    const state = snake.state!;    const isMouse = state.mouse.x == indexColumn && state.mouse.y == indexRow;    if (isMouse) {      return <div key={`${indexRow}_${indexColumn}`} className='cell mouse'></div>;    }    const indexCellSnake = state.piton.findIndex(p => p.x == indexColumn && p.y == indexRow);    if (indexCellSnake == -1) {      return <div key={`${indexRow}_${indexColumn}`} className='cell'></div>;    }    const prewIndexCellSnake = indexCellSnake - 1;    const prewCellPiton = 0 <= prewIndexCellSnake ? state.piton[prewIndexCellSnake] : null;    const cellPiton = state.piton[indexCellSnake];    const nextIndexCellSnake = indexCellSnake + 1;    const nextCellPiton = nextIndexCellSnake < state.piton.length ? state.piton[nextIndexCellSnake] : null;    let up = false, left = false, down = false, rigth = false;    if (!!prewCellPiton) {      if (prewCellPiton.x < cellPiton.x) {        left = true;      }      if (prewCellPiton.y < cellPiton.y) {        up = true;      }      if (cellPiton.x < prewCellPiton.x) {        rigth = true;      }      if (cellPiton.y < prewCellPiton.y) {        down = true;      }    }    if (!!nextCellPiton) {      if (cellPiton.x < nextCellPiton.x) {        rigth = true;      }      if (cellPiton.y < nextCellPiton.y) {        down = true;      }      if (nextCellPiton.x < cellPiton.x) {        left = true;      }      if (nextCellPiton.y < cellPiton.y) {        up = true;      }    }    return <div key={`${indexRow}_${indexColumn}`} className='cell'>      <table className='shake'>        <tbody>          <tr>            <td width="10%" height="10%" />            <td height="10%" className={cs({              'shake-segment': up            })} />            <td width="10%" height="10%" />          </tr>          <tr>            <td width="10%" className={cs({              'shake-segment': left            })} />            <td className='shake-segment' />            <td width="10%" className={cs({              'shake-segment': rigth            })} />          </tr>          <tr>            <td width="10%" height="10%" />            <td height="10%" className={cs({              'shake-segment': down            })} />            <td width="10%" height="10%" />          </tr>        </tbody>      </table>    </div>;  }  const rowRender = (indexRow: number): JSX.Element => {    const state = snake.state!;    const cells: JSX.Element[] = [];    for (let j = 0; j < state.ySize; j++) {      cells.push(cellRender(indexRow, j));    }    return (<>{cells}</>);  }  const tableRender = (): JSX.Element => {    const state = snake.state!;    const rows: JSX.Element[] = [];    for (let i = 0; i < state.ySize; i++) {      rows.push(        (<div key={i.toString()} className="row">          {rowRender(i)}        </div>)      );    }    return (<>{rows}</>);  }  return useObserver(() => {    console.log(snake.state);    if (!snake.state) {      return <div />    }    let state: string = "идет игра";    if (snake.state.isLife == false) {      state = snake.state.isWin ? "Победа" : "Поражение"    }    return (<>      <span className="state">{state}</span>      <div className="table">        {tableRender()}      </div>    </>);  });}export default App;

С беком тоже мудрить не будем:

    public class SnakeSender    {        class Vector2        {            public int X { get; set; }            public int Y { get; set; }            public Vector2(int x, int y)            {                this.X = x;                this.Y = y;            }        }        class Vector2Comparer : IEqualityComparer<Vector2>        {            public bool Equals([AllowNull] Vector2 value1, [AllowNull] Vector2 value2)            {                return value1.X == value2.X && value1.Y == value2.Y;            }            public int GetHashCode([DisallowNull] Vector2 obj)            {                return 0;            }        }        private readonly static Vector2Comparer vector2Comparer = new Vector2Comparer();        [JsonConverter(typeof(StringEnumConverter))]        enum Cell        {            None,            Mouse,            Snake        }        enum Directions        {            Up,            Left,            Down,            Rigth        }        class StateBoard        {            public bool IsLife { get; set; }            public bool IsWin { get; set; }            public int XSize { get; set; }            public int YSize { get; set; }            public Vector2 Mouse { get; set; }            public List<Vector2> Piton { get; set; }            public List<Vector2> HamiltonPath { get; set; }        }        const int xSize = 12, ySize = 12;      ...private void CheckDead()        {            Vector2 head = this.Piton.Last();            if (head.X < 0             || head.Y < 0             || xSize <= head.X             || ySize <= head.Y             || this.Piton.SkipLast(1).Contains(head, vector2Comparer))            {                this.IsLife = false;                this.IsWin = false;                return;            }        } private void Render()        {            var hubContext = (IHubContext<SnakeHub>)this.ServiceProvider.GetService(typeof(IHubContext<SnakeHub>));            var piton = this.Piton.ToList();            piton.Reverse();            hubContext.Clients?.All.SendAsync("newState", JsonConvert.SerializeObject(new StateBoard()            {                IsLife = this.IsLife,                IsWin = this.IsWin,                XSize = xSize,                YSize = ySize,                Mouse = this.Mouse,                Piton = piton,                HamiltonPath = HamiltonPath            }));        }private List<Vector2> GetEmptyCells()        {            List<Vector2> emptyCells = new List<Vector2>(xSize * ySize);            for (int i = 0; i < ySize; i++)            {                for (int j = 0; j < xSize; j++)                {                    if (!this.Piton.Contains(new Vector2(j, i), vector2Comparer))                    {                        emptyCells.Add(new Vector2(j, i));                    }                }            }            return emptyCells;        }}


В начале игры у нас есть одна красная мышь, а одноклеточная змея.

А теперь нам, как-то надо начать играть.

Вообще играть в змейку очень просто нужно просто проходить по один раз по каждой клеточке в матрице. И всё задача решена Happy End.

Если быть более точным, наше поле представляет собой Связный граф. Т.е. каждая клеточка на доске это вершина. Из каждой вершины идут ребра это переход на соседнюю вершину.
Таких ребер либо 2, либо 4. Для крайней клеточки и для центральной соответственно.

Где-то 4 августа 1805 2 сентября 1865 в Ирландии жил некий Гамильтон Уильям Роуэн, исследовал задачу кругосветного путешествия по додекаэдру. В этой задаче вершины додекаэдра символизировали известные города, такие как Брюссель, Амстердам, Эдинбург, Пекин, Прага, Дели, Франкфурт и др., а рёбра соединяющие их дороги. Путешествующий должен пройти вокруг света, найдя путь, который проходит через все вершины ровно один раз. Чтобы сделать задачу более интересной, порядок прохождения городов устанавливался заранее. А чтобы было легче запомнить, какие города уже соединены, в каждую вершину додекаэдра был вбит гвоздь, и проложенный путь отмечался небольшой верёвкой, которая могла обматываться вокруг гвоздя. Однако такая конструкция оказалась слишком громоздкой, и Гамильтон предложил новый вариант игры, заменив додекаэдр плоским графом, изоморфным графу, построенному на рёбрах додекаэдра.

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

Визуально это можно представить как


В нашем случае так.


Только вот нюанс Если мы от балды попробуем построить такой вот цикл, мы получим перебор такого количества вариантов, что ждать можно будет до второго пришествия.

Суть в том, что общий подход к нахождению Гамильтонов цикла подразумевает полный перебор и ничего более оптимального вроде как нет. А у нас при матрице 12 на 12 только вершин 144 и для каждой нужно проверить от 2, до 4-х ребер. В общем, там где-то сложность n!..

Но т.к. мы решаем задачку для матрицы в которой каждая вершина связана со всем соседями, можно сделать просто проход по часовой стрелке.

Тогда построить Гамильтонов цикл не составляет труда.

private void CreateHamiltonPath()        {            this.HamiltonPath.Clear();            this.HamiltonPath.Add(new Vector2(0, 0));            HamiltonStep(this.HamiltonPath.Last());        }        private bool HamiltonStep(Vector2 current)        {            if (HamiltonPath.Count == HamiltonPath.Capacity)            {                var first = HamiltonPath.First();                return (first.X == current.X && first.Y == current.Y - 1)                    || (first.X == current.X && first.Y == current.Y + 1)                    || (first.X - 1 == current.X && first.Y == current.Y)                    || (first.X + 1 == current.X && first.Y == current.Y);            }            foreach (var direction in new[] { Directions.Down, Directions.Rigth, Directions.Up, Directions.Left })            {                Vector2 newElement = null;                switch (direction)                {                    case Directions.Up:                        newElement = new Vector2(current.X, current.Y - 1);                        break;                    case Directions.Left:                        newElement = new Vector2(current.X - 1, current.Y);                        break;                    case Directions.Down:                        newElement = new Vector2(current.X, current.Y + 1);                        break;                    case Directions.Rigth:                        newElement = new Vector2(current.X + 1, current.Y);                        break;                }                if (0 <= newElement.X && newElement.X < xSize                    && 0 <= newElement.Y && newElement.Y < ySize                    && !HamiltonPath.Contains(newElement, vector2Comparer))                {                    HamiltonPath.Add(newElement);                    if (HamiltonStep(newElement))                    {                        return true;                    }                    HamiltonPath.Remove(newElement);                }            }            return false;        }

И да это идею я заимствовал эту идею у Code Bullet, а он её еще у одного чувака в интеренте.

Короче, как сказал Пабло Пикассо:
Хорошие художники копируют, великие художники воруют



И так, мы получаем змейку которая ходит поэтому циклу до победы:


В целом задача решена! Но как же убого выглядит, когда одноклеточная змея ползет от точки. Хотя никаких препятствий взять её нет

А с точки зрения математики мы получаем сложность в (O)n^n-1

Т.к. каждый раз Каждый. Нам нужно обходить всё по циклу

Проще говоря устанем ждать Так, что решение хоть и хорошее, но есть проблема времени требует много

Оптимизация


Что мы знаем о змее. Её длина изменяется при поедании мыши. И идеальной траекторией движения для нее является Гамельтонов путь. А самое короткое расстояние между двумя точками это прямая.

Начнем с вызова рекурсии:

private void CalculatePath()        {            this.StepsCountAfterCalculatePath = 0;            int finalIndexPoint = this.HamiltonPath.FindIndex(p => p.X == this.Mouse.X && p.Y == this.Mouse.Y);            List<Vector2> tempPath = new List<Vector2>();            List<Vector2> stepPiton = new List<Vector2>(this.Piton);            Debug.WriteLine($"Piton length: {this.Piton.Count}");            int index = 0;            var result = StepTempPath(ref index, GetInvert(stepPiton, this.Mouse), this.Piton.Last(), finalIndexPoint, stepPiton, tempPath);            if (result.PathIsFound)            {                this.TempPath = new Queue<Vector2>(tempPath);                this.InvertHamiltonPath = result.InvertHamiltonPath;            }        }

А вот рекурсивную часть разберем по отдельности.

Основная часть максимально простая.

Мы упорно приближаемся к цели:

if (current.X < finalPoint.X)                {                    newElement = new Vector2(current.X + 1, current.Y);                }                else if (finalPoint.X < current.X)                {                    newElement = new Vector2(current.X - 1, current.Y);                }                else if (current.Y < finalPoint.Y)                {                    newElement = new Vector2(current.X, current.Y + 1);                }                else if (finalPoint.Y < current.Y)                {                    newElement = new Vector2(current.X, current.Y - 1);                }

Змея не умеет ползать по диагонали, то сейчас мы сначала выравниваемся по вертикали, а потом уже идем к цели по горизонтали. И дальше можно заходить на новую итерацию для проверки.

Проверка финального состояния выглядит как-то так для начала.

if (current.X == finalPoint.X && current.Y == finalPoint.Y)            {                var tempPiton = stepPiton.TakeLast(this.Piton.Count).ToList();                for (int i = 1; i < this.Piton.Count; i++)                {                    var hamiltonPoint = (finalIndexPoint + i < this.HamiltonPath.Count) ? this.HamiltonPath[finalIndexPoint + i] : this.HamiltonPath[finalIndexPoint + i - this.HamiltonPath.Count];                    if (tempPiton.TakeLast(this.Piton.Count).Contains(hamiltonPoint, vector2Comparer))                    {                        return false;                    }                    tempPiton.Add(hamiltonPoint);                }                return true;            }

Что мы тут собственно делаем.

Когда мы находим на виртуальному пути нашу мышь. Мы дополнительно, продолжаем строим путь, но уже по идеальному пути Гамильтона и если он не пересекается с телом нашей змеи, то мы точно знаем, что по такому пути до текущей мыши можно спокойно пустить змею т.к. после съедения мыши, где бы не находилась следующая, мы сможем пройти по пусти полного цикла и съесть следующую.

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

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

Т.е. змея может двигаться не только по часовой стрелке по которой строился путь, но и в обратном направлении по этому пути. Путь от это не изменить, а вот направление движения да.

Изменим проверку.

if (current.X == finalPoint.X && current.Y == finalPoint.Y)            {                if (this.Piton.Count == 1)                {                    return new ResultAnlaizePath(true);                }                foreach (var d in new[] { false, true })                {                    var tempPiton = stepPiton.TakeLast(this.Piton.Count).ToList();                    bool isFound = true;                    bool invertHamiltonPath = d;                    for (int j = 1; j < this.Piton.Count; j++)                    {                        Vector2 hamiltonPoint;                        if (invertHamiltonPath)                        {                            hamiltonPoint = (finalIndexPoint - j >= 0) ? this.HamiltonPath[finalIndexPoint - j] : this.HamiltonPath[this.HamiltonPath.Count - j];                        }                        else                        {                            hamiltonPoint = (finalIndexPoint + j < this.HamiltonPath.Count) ? this.HamiltonPath[finalIndexPoint + j] : this.HamiltonPath[finalIndexPoint + j - this.HamiltonPath.Count];                        }                        if (tempPiton.TakeLast(this.Piton.Count).Contains(hamiltonPoint, vector2Comparer))                        {                            isFound = false;                            break;                        }                        tempPiton.Add(hamiltonPoint);                    }                    if (isFound)                    {                        return new ResultAnlaizePath(true, invertHamiltonPath);                    }                }                return new ResultAnlaizePath(false);            }

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



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

if (!stepPiton.TakeLast(this.Piton.Count).Contains(newElement, vector2Comparer))            {                tempPath.Add(newElement);                stepPiton.Add(newElement);                var retult = StepTempPath(ref index, !invert, newElement, finalIndexPoint, stepPiton, tempPath);                if (retult.PathIsFound)                {                    return retult;                }                if (this.HamiltonPath.Count < index)                {                    return new ResultAnlaizePath(false);                }                tempPath.Remove(newElement);                stepPiton.Remove(newElement);            }            Vector2 nextFinalPoint;            if (this.InvertHamiltonPath)            {                nextFinalPoint = (finalIndexPoint - 1 < 0) ? this.HamiltonPath[this.HamiltonPath.Count - 1] : this.HamiltonPath[finalIndexPoint - 1];            }            else            {                nextFinalPoint = (finalIndexPoint + 1 == this.HamiltonPath.Count) ? this.HamiltonPath[0] : this.HamiltonPath[finalIndexPoint + 1];            }            List<Directions> directions = new List<Directions>(4);            directions.Add(finalPoint.Y < nextFinalPoint.Y ? Directions.Up : Directions.Down);            directions.Add(finalPoint.X < nextFinalPoint.X ? Directions.Left : Directions.Rigth);            directions.Add(finalPoint.Y < nextFinalPoint.Y ? Directions.Down : Directions.Up);            directions.Add(finalPoint.X < nextFinalPoint.X ? Directions.Rigth : Directions.Left);            foreach (var direction in directions)            {                switch (direction)                {                    case Directions.Up:                        newElement = new Vector2(current.X, current.Y - 1);                        break;                    case Directions.Left:                        newElement = new Vector2(current.X - 1, current.Y);                        break;                    case Directions.Down:                        newElement = new Vector2(current.X, current.Y + 1);                        break;                    case Directions.Rigth:                        newElement = new Vector2(current.X + 1, current.Y);                        break;                }                if (0 <= newElement.X && newElement.X < xSize                 && 0 <= newElement.Y && newElement.Y < ySize                 && !stepPiton.TakeLast(this.Piton.Count).Contains(newElement, vector2Comparer))                {                    tempPath.Add(newElement);                    stepPiton.Add(newElement);                    var retult = StepTempPath(ref index, GetInvert(stepPiton, finalPoint), newElement, finalIndexPoint, stepPiton, tempPath);                    if (retult.PathIsFound)                    {                        return retult;                    }                    if (this.HamiltonPath.Count < index)                    {                        return new ResultAnlaizePath(false);                    }                    tempPath.Remove(newElement);                    stepPiton.Remove(newElement);                }            }            return new ResultAnlaizePath(false);

В целом ничего сложного.

Можно остановиться только на этом.

Здесь я пытаюсь пусть поиск в противоход прямому движению к мыше описанному выше.

            List<Directions> directions = new List<Directions>(4);            directions.Add(finalPoint.Y < nextFinalPoint.Y ? Directions.Up : Directions.Down);            directions.Add(finalPoint.X < nextFinalPoint.X ? Directions.Left : Directions.Rigth);            directions.Add(finalPoint.Y < nextFinalPoint.Y ? Directions.Down : Directions.Up);            directions.Add(finalPoint.X < nextFinalPoint.X ? Directions.Rigth : Directions.Left);

Т.е. сделать шаг в другую строну дабы обойти препятствие которое было найдено на прямом пути.

Ниже полный код, да его можно было разбить на под файлики сделать красиво, но сейчас так наглядней для статьи.

Полный код файла логики
using Microsoft.AspNetCore.SignalR;using Newtonsoft.Json;using Newtonsoft.Json.Converters;using System;using System.Collections;using System.Collections.Generic;using System.Diagnostics;using System.Diagnostics.CodeAnalysis;using System.Linq;using System.Runtime.ExceptionServices;using System.Threading.Tasks;using System.Timers;namespace SnakeApplication.WebApp.Hubs{    public class SnakeHub : Hub    {    }    public class SnakeSender    {        class Vector2        {            public int X { get; set; }            public int Y { get; set; }            public Vector2(int x, int y)            {                this.X = x;                this.Y = y;            }        }        class Vector2Comparer : IEqualityComparer<Vector2>        {            public bool Equals([AllowNull] Vector2 value1, [AllowNull] Vector2 value2)            {                return value1.X == value2.X && value1.Y == value2.Y;            }            public int GetHashCode([DisallowNull] Vector2 obj)            {                return 0;            }        }        private readonly static Vector2Comparer vector2Comparer = new Vector2Comparer();        [JsonConverter(typeof(StringEnumConverter))]        enum Cell        {            None,            Mouse,            Snake        }        enum Directions        {            Up,            Left,            Down,            Rigth        }        class StateBoard        {            public bool IsLife { get; set; }            public bool IsWin { get; set; }            public int XSize { get; set; }            public int YSize { get; set; }            public Vector2 Mouse { get; set; }            public List<Vector2> Piton { get; set; }            public List<Vector2> HamiltonPath { get; set; }        }        const int xSize = 12, ySize = 12;        private Random Rand { get; }        private IServiceProvider ServiceProvider { get; }        private bool IsLife { get; set; }        private bool IsWin { get; set; }        Directions Direction { get; set; }        private Vector2 Mouse { get; set; }        private Queue<Vector2> Piton { get; set; }        private bool InvertHamiltonPath { get; set; }        private List<Vector2> HamiltonPath { get; }        private Queue<Vector2> TempPath { get; set; }        private int StepsCountAfterCalculatePath { get; set; }    public SnakeSender(IServiceProvider serviceProvider)        {            this.Rand = new Random();            this.ServiceProvider = serviceProvider;            this.TempPath = new Queue<Vector2>();            this.HamiltonPath = new List<Vector2>(xSize * ySize);            this.CreateHamiltonPath();            this.CreateBoard();        }        private void CreateHamiltonPath()        {            this.HamiltonPath.Clear();            this.HamiltonPath.Add(new Vector2(0, 0));            HamiltonStep(this.HamiltonPath.Last());        }        private bool HamiltonStep(Vector2 current)        {            if (HamiltonPath.Count == HamiltonPath.Capacity)            {                var first = HamiltonPath.First();                return (first.X == current.X && first.Y == current.Y - 1)                    || (first.X == current.X && first.Y == current.Y + 1)                    || (first.X - 1 == current.X && first.Y == current.Y)                    || (first.X + 1 == current.X && first.Y == current.Y);            }            foreach (var direction in new[] { Directions.Down, Directions.Rigth, Directions.Up, Directions.Left })            {                Vector2 newElement = null;                switch (direction)                {                    case Directions.Up:                        newElement = new Vector2(current.X, current.Y - 1);                        break;                    case Directions.Left:                        newElement = new Vector2(current.X - 1, current.Y);                        break;                    case Directions.Down:                        newElement = new Vector2(current.X, current.Y + 1);                        break;                    case Directions.Rigth:                        newElement = new Vector2(current.X + 1, current.Y);                        break;                }                if (0 <= newElement.X && newElement.X < xSize                    && 0 <= newElement.Y && newElement.Y < ySize                    && !HamiltonPath.Contains(newElement, vector2Comparer))                {                    HamiltonPath.Add(newElement);                    if (HamiltonStep(newElement))                    {                        return true;                    }                    HamiltonPath.Remove(newElement);                }            }            return false;        }        private void CreateBoard()        {            Task.Run(async () =>            {                this.Piton = new Queue<Vector2>();                //for (int i = 0; i < 1; i++)                //{                //    this.Piton.Enqueue(new Vector2(ySize / 2, xSize / 2 - i));                //}                this.Piton.Enqueue(new Vector2(0, 0));                this.IsLife = true;                this.Direction = Directions.Up;                this.CreateMouse();                while (this.IsLife)                {                    this.LifeCycle();                    await Task.Delay(100);                }            });        }        private void LifeCycle()        {            this.SetDirection();            this.Step();            this.CheckDead();            this.Render();        }        private void SetDirection()        {            Vector2 head = this.Piton.Last();            int currentIndnex = this.HamiltonPath.FindIndex(p => p.X == head.X && p.Y == head.Y);            Vector2 currentElement = this.HamiltonPath[currentIndnex];            Vector2 nextElement = null;            if (this.TempPath.Count > 0)            {                nextElement = this.TempPath.Dequeue();            }            else            {                this.StepsCountAfterCalculatePath++;                if (this.InvertHamiltonPath)                {                    nextElement = (currentIndnex - 1 < 0) ? this.HamiltonPath[this.HamiltonPath.Count - 1] : this.HamiltonPath[currentIndnex - 1];                }                else                {                    nextElement = (currentIndnex + 1 == this.HamiltonPath.Count) ? this.HamiltonPath[0] : this.HamiltonPath[currentIndnex + 1];                }            }            if (currentElement.X == nextElement.X && currentElement.Y < nextElement.Y)            {                this.Direction = Directions.Down;                return;            }            if (currentElement.X == nextElement.X && nextElement.Y < currentElement.Y)            {                this.Direction = Directions.Up;                return;            }            if (currentElement.X < nextElement.X && currentElement.Y == nextElement.Y)            {                this.Direction = Directions.Rigth;                return;            }            if (nextElement.X < currentElement.X && currentElement.Y == nextElement.Y)            {                this.Direction = Directions.Left;                return;            }            throw new NotImplementedException();        }        private void Step()        {            Vector2 head = this.Piton.Last();            switch (this.Direction)            {                case Directions.Up:                    this.Piton.Enqueue(new Vector2(head.X, head.Y - 1));                    break;                case Directions.Left:                    this.Piton.Enqueue(new Vector2(head.X - 1, head.Y));                    break;                case Directions.Down:                    this.Piton.Enqueue(new Vector2(head.X, head.Y + 1));                    break;                case Directions.Rigth:                    this.Piton.Enqueue(new Vector2(head.X + 1, head.Y));                    break;            }            if (this.Piton.Contains(this.Mouse, vector2Comparer))            {                CreateMouse();             }            else            {                this.Piton.Dequeue();            }            if (this.Piton.Count < this.StepsCountAfterCalculatePath) {                this.CalculatePath();            }        }        private void CheckDead()        {            Vector2 head = this.Piton.Last();            if (head.X < 0             || head.Y < 0             || xSize <= head.X             || ySize <= head.Y             || this.Piton.SkipLast(1).Contains(head, vector2Comparer))            {                this.IsLife = false;                this.IsWin = false;                return;            }        }        private void Render()        {            var hubContext = (IHubContext<SnakeHub>)this.ServiceProvider.GetService(typeof(IHubContext<SnakeHub>));            var piton = this.Piton.ToList();            piton.Reverse();            hubContext.Clients?.All.SendAsync("newState", JsonConvert.SerializeObject(new StateBoard()            {                IsLife = this.IsLife,                IsWin = this.IsWin,                XSize = xSize,                YSize = ySize,                Mouse = this.Mouse,                Piton = piton,                HamiltonPath = HamiltonPath            }));        }        private void CreateMouse()        {            List<Vector2> emptyCells = GetEmptyCells();            if (emptyCells.Count > 0)            {                this.Mouse = emptyCells[this.Rand.Next(emptyCells.Count)];                this.CalculatePath();            }            else            {                this.IsLife = false;                this.IsWin = true;            }        }        private void CalculatePath()        {            this.StepsCountAfterCalculatePath = 0;            int finalIndexPoint = this.HamiltonPath.FindIndex(p => p.X == this.Mouse.X && p.Y == this.Mouse.Y);            List<Vector2> tempPath = new List<Vector2>();            List<Vector2> stepPiton = new List<Vector2>(this.Piton);            Debug.WriteLine($"Piton length: {this.Piton.Count}");            int index = 0;            var result = StepTempPath(ref index, GetInvert(stepPiton, this.Mouse), this.Piton.Last(), finalIndexPoint, stepPiton, tempPath);            if (result.PathIsFound)            {                this.TempPath = new Queue<Vector2>(tempPath);                this.InvertHamiltonPath = result.InvertHamiltonPath;            }        }        private bool GetInvert(List<Vector2> stepPiton, Vector2 finalPoint)        {            if (this.Piton.Count > 1)            {                int pitonDirection = stepPiton.Last().Y - stepPiton[stepPiton.Count - 2].Y;                int mouseDirection = stepPiton.Last().Y - finalPoint.Y;                return (pitonDirection < 0 && mouseDirection < 0) || (pitonDirection > 0 && mouseDirection > 0);            }            return false;        }        class ResultAnlaizePath        {            public bool PathIsFound { get; set; }            public bool InvertHamiltonPath { get; set; }            public ResultAnlaizePath(bool pathIsFound, bool invertHamiltonPath = false)            {                PathIsFound = pathIsFound;                InvertHamiltonPath = invertHamiltonPath;            }        }        private ResultAnlaizePath StepTempPath(ref int index, bool invert, Vector2 current, int finalIndexPoint, List<Vector2> stepPiton, List<Vector2> tempPath)        {            index++;            if (this.HamiltonPath.Count < index)            {                return new ResultAnlaizePath(false);            }            Debug.WriteLine($"index {index} {tempPath.Count}");            var finalPoint = this.HamiltonPath[finalIndexPoint];            if (current.X == finalPoint.X && current.Y == finalPoint.Y)            {                if (this.Piton.Count == 1)                {                    return new ResultAnlaizePath(true);                }                foreach (var d in new[] { false, true })                {                    var tempPiton = stepPiton.TakeLast(this.Piton.Count).ToList();                    bool isFound = true;                    bool invertHamiltonPath = d;                    for (int j = 1; j < this.Piton.Count; j++)                    {                        Vector2 hamiltonPoint;                        if (invertHamiltonPath)                        {                            hamiltonPoint = (finalIndexPoint - j >= 0) ? this.HamiltonPath[finalIndexPoint - j] : this.HamiltonPath[this.HamiltonPath.Count - j];                        }                        else                        {                            hamiltonPoint = (finalIndexPoint + j < this.HamiltonPath.Count) ? this.HamiltonPath[finalIndexPoint + j] : this.HamiltonPath[finalIndexPoint + j - this.HamiltonPath.Count];                        }                        if (tempPiton.TakeLast(this.Piton.Count).Contains(hamiltonPoint, vector2Comparer))                        {                            isFound = false;                            break;                        }                        tempPiton.Add(hamiltonPoint);                    }                    if (isFound)                    {                        return new ResultAnlaizePath(true, invertHamiltonPath);                    }                }                return new ResultAnlaizePath(false);            }            if ((xSize + ySize * 2) <= tempPath.Count)            {                return new ResultAnlaizePath(false);            }            Vector2 newElement = null;            if (invert)            {                if (current.X < finalPoint.X)                {                    newElement = new Vector2(current.X + 1, current.Y);                }                else if (finalPoint.X < current.X)                {                    newElement = new Vector2(current.X - 1, current.Y);                }                else if (current.Y < finalPoint.Y)                {                    newElement = new Vector2(current.X, current.Y + 1);                }                else if (finalPoint.Y < current.Y)                {                    newElement = new Vector2(current.X, current.Y - 1);                }            }            else            {                if (current.Y < finalPoint.Y)                {                    newElement = new Vector2(current.X, current.Y + 1);                }                else if (finalPoint.Y < current.Y)                {                    newElement = new Vector2(current.X, current.Y - 1);                }                else if (current.X < finalPoint.X)                {                    newElement = new Vector2(current.X + 1, current.Y);                }                else if (finalPoint.X < current.X)                {                    newElement = new Vector2(current.X - 1, current.Y);                }            }            if (!stepPiton.TakeLast(this.Piton.Count).Contains(newElement, vector2Comparer))            {                tempPath.Add(newElement);                stepPiton.Add(newElement);                var retult = StepTempPath(ref index, !invert, newElement, finalIndexPoint, stepPiton, tempPath);                if (retult.PathIsFound)                {                    return retult;                }                if (this.HamiltonPath.Count < index)                {                    return new ResultAnlaizePath(false);                }                tempPath.Remove(newElement);                stepPiton.Remove(newElement);            }            Vector2 nextFinalPoint;            if (this.InvertHamiltonPath)            {                nextFinalPoint = (finalIndexPoint - 1 < 0) ? this.HamiltonPath[this.HamiltonPath.Count - 1] : this.HamiltonPath[finalIndexPoint - 1];            }            else            {                nextFinalPoint = (finalIndexPoint + 1 == this.HamiltonPath.Count) ? this.HamiltonPath[0] : this.HamiltonPath[finalIndexPoint + 1];            }            List<Directions> directions = new List<Directions>(4);            directions.Add(finalPoint.Y < nextFinalPoint.Y ? Directions.Up : Directions.Down);            directions.Add(finalPoint.X < nextFinalPoint.X ? Directions.Left : Directions.Rigth);            directions.Add(finalPoint.Y < nextFinalPoint.Y ? Directions.Down : Directions.Up);            directions.Add(finalPoint.X < nextFinalPoint.X ? Directions.Rigth : Directions.Left);            foreach (var direction in directions)            {                switch (direction)                {                    case Directions.Up:                        newElement = new Vector2(current.X, current.Y - 1);                        break;                    case Directions.Left:                        newElement = new Vector2(current.X - 1, current.Y);                        break;                    case Directions.Down:                        newElement = new Vector2(current.X, current.Y + 1);                        break;                    case Directions.Rigth:                        newElement = new Vector2(current.X + 1, current.Y);                        break;                }                if (0 <= newElement.X && newElement.X < xSize                 && 0 <= newElement.Y && newElement.Y < ySize                 && !stepPiton.TakeLast(this.Piton.Count).Contains(newElement, vector2Comparer))                {                    tempPath.Add(newElement);                    stepPiton.Add(newElement);                    var retult = StepTempPath(ref index, GetInvert(stepPiton, finalPoint), newElement, finalIndexPoint, stepPiton, tempPath);                    if (retult.PathIsFound)                    {                        return retult;                    }                    if (this.HamiltonPath.Count < index)                    {                        return new ResultAnlaizePath(false);                    }                    tempPath.Remove(newElement);                    stepPiton.Remove(newElement);                }            }            return new ResultAnlaizePath(false);        }        private List<Vector2> GetEmptyCells()        {            List<Vector2> emptyCells = new List<Vector2>(xSize * ySize);            for (int i = 0; i < ySize; i++)            {                for (int j = 0; j < xSize; j++)                {                    if (!this.Piton.Contains(new Vector2(j, i), vector2Comparer))                    {                        emptyCells.Add(new Vector2(j, i));                    }                }            }            return emptyCells;        }    }}


Собственно как всё это ползает.


Всем спасибо за уделенное внимание.

Теперь осталось написать для нее нормальный AI.
Подробнее..

Змейка на Haskell с циклом Гамильтона

08.04.2021 20:08:42 | Автор: admin

После прохождения курса по Haskell решил закрепить знания первым проектом. Писать будем змейку для терминала. Чтобы придать игре уникальности, добавим бота, который сам будет проходить игру.

Проект написан на haskell-platform, Ubuntu 20.04.

GitHub проекта

Игровой цикл

Начнем с реализации игрового цикла. Змея может двигаться независимо от нажатия клавиш, следовательно нам понадобится два параллельных потока. Используем модуль Control.Concurrent. Ответвляемся от основного процесса при помощи forkIO и синхронизируем потоки через MVar. С каждой итерацией игрового цикла, tryInput будет содержать Maybe Char значение, в зависимости от ввода пользователя. Потоки при этом не блокируются и работают параллельно. Для настройки буферизации ввода воспользуемся System.IO - отключим ожидание EOL символа при вводе и уберем отображение пользовательского вывода. Интересно, что hSetBuffering stdin NoBuffering не работает для Windows консоли - getChar будет ждать EOL и запустить игру в форточках в текущем виде не получится. Также подключим System.Console.ANSI для очистки экрана и перемещения курсора терминала.

import Control.Concurrentimport System.Console.ANSIimport System.IOgameLoop :: ThreadId -> MVar Char -> IO ()gameLoop inputThId input = do  tryInput <- tryTakeMVar input  gameLoop inputThId input  inputLoop :: MVar Char -> IO ()inputLoop input = (putMVar input =<< getChar) >> inputLoop input main = do  hSetBuffering stdin NoBuffering   hSetEcho stdin False  clearScreen  input <- newEmptyMVar  inputThId <- forkIO $ inputLoop input  gameLoop inputThId input

Мир для змеи

Определим типы данных. У игры будет 4 состояния: Process - змеей управляет игрок, Bot - змеей рулит игра, GameOver и Quit. Мир игры определен типом data World, он будет каким-то образом меняться в игровом цикле gameLoop. Сейчас он содержит змею, направление ее движения, координату фрукта и текущее игровое состояние. Далее по мере разработки будет добавлять в него новые поля. Начальная точка (0,0) будет верхним левым краем консоли. Змея двигается параллельно осям, следовательно у нас 4 возможных направления движения.

data StepDirection  =  DirUp                    | DirDown                     | DirLeft                    | DirRight deriving (Eq)   type Point = (Int, Int)type Snake = [Point]data WorldState = Process                    | GameOver                   | Quit                    | Bot deriving (Eq)   data World = World  { snake :: Snake                    , direction :: StepDirection                    , fruit :: Point                    , worldState :: WorldState                    }        gameLoop :: ThreadId -> MVar Char -> World -> IO (){--  --}

Таймер

Для анимации движения змеи нам потребуются функции работы со временем. Воспользуемся модулем Data.Time.Clock. Добавим в наш мир 3 поля: lastUpdateTime - время последнего обновления мира, updateDelay - сколько ждем до следующего обновления и isUpdateIteration - флаг необходимости обновить мир в текущей итерации. Укажем начальные значения мира и напишем для него первый обработчик timerController. Он принимает текущее время и устанавливает флаг isUpdateIteration, если пришло время обновляться.

import Data.Time.Clockdata World  = World  {                              {--  --}                                  , lastUpdateTime :: UTCTime                                  , updateDelay :: NominalDiffTime                                  , isUpdateIteration :: Bool                                  }  initWorld :: UTCTime -> WorldinitWorld timePoint = World { snake = [(10, y) | y <- [3..10]]                             , direction = DirRight                            , fruit = (3, 2)                            , lastUpdateTime = timePoint                            , updateDelay = 0.3                            , isUpdateIteration = True                            , worldState = Process                            }  timerController :: UTCTime -> World -> WorldtimerController timePoint world  | isUpdateTime timePoint world = world { lastUpdateTime = timePoint                                         , isUpdateIteration = True                                          }  | otherwise                    = world where    isUpdateTime timePoint world =     diffUTCTime timePoint (lastUpdateTime world) >= updateDelay worldgameLoop inputThId input oldWorld = do {--  --}  timePoint <- getCurrentTime  let newWorld = timerController timePoint oldWorld  gameLoop inputThId input newWorld { isUpdateIteration = False }  main = do  {--  --}  timePoint <- getCurrentTime  gameLoop inputThId input (initWorld timePoint)

Контроллер ввода

Далее добавим обработчик ввода inputController. Клавиши WSAD меняют направление нашей змеи. Стоит обратить внимание, что змея не может двигаться назад, за исключением случая, когда она состоит из 1 сегмента. Поэтому если новое направление ведет ко второму от головы сегменту змеи, мы игнорируем такой ввод. Также если текущее направление совпадает с предыдущим, то есть пользователь зажал клавишу управления, ускорим движение змеи уменьшив updateDelay. Функция pointStep принимает направление и точку, возвращая новую точку, перемещенную на один шаг в заданном направлении.

pointStep :: StepDirection -> Point -> PointpointStep direction (x, y) = case direction of  DirUp -> (x, y - 1)   DirDown -> (x, y + 1)  DirLeft -> (x - 1, y)  DirRight -> (x + 1, y)inputController :: Maybe Char -> World -> WorldinputController command world = let  boost dir1 dir2 = if dir1 == dir2 then 0.05 else 0.3  filterSecondSegmentDir (x:[]) dirOld dirNew = dirNew  filterSecondSegmentDir (x:xs) dirOld dirNew | pointStep dirNew x == head xs = dirOld                                              | otherwise = dirNew in     case command of    Just 'w' -> world { direction = filterSecondSegmentDir (snake world) (direction world) DirUp                       , updateDelay = boost (direction world) DirUp                      , worldState = Process                      }    Just 's' -> world { direction = filterSecondSegmentDir (snake world) (direction world) DirDown                      , updateDelay = boost (direction world) DirDown                      , worldState = Process                      }    Just 'a' -> world { direction = filterSecondSegmentDir (snake world) (direction world) DirLeft                       , updateDelay = boost (direction world) DirLeft                      , worldState = Process                      }    Just 'd' -> world { direction = filterSecondSegmentDir (snake world) (direction world) DirRight                       , updateDelay = boost (direction world) DirRight                      , worldState = Process                      }    Just 'q' -> world { worldState = Quit }    Just 'h' -> world { worldState = Bot }    _        -> world { updateDelay =  0.3 }

Двигаем змею

Следующий контроллер moveController сдвинет нашу змею, если пришло время isUpdateIteration для обновления мира.

snakeStep :: StepDirection -> Snake ->  SnakesnakeStep direction snake = (pointStep direction $ head snake):(init snake)moveController :: World -> WorldmoveController world   | not $ isUpdateIteration world = world  | otherwise = world { snake = snakeStep (direction world) (snake world) }

Столкновения с препятствиями

Границы поля

Последний контроллер будет обрабатывать столкновения змеи с препятствиями. В текущий момент ими могут быть только сама змея и фрукт, поэтому добавим стены. Они статичны, поэтому не будет добавлять их в мир и объявим отдельно. Первый элемент пары (1,1) верхняя левая точка стены и (20,20) нижняя правая.

initWalls :: WallsinitWalls = ((1,1),(20,20))

ГСЧ

Фрукт появляется на поле в случайном месте, следовательно нам нужен ГСЧ. В Haskell он реализован в модуле System.Random, функция randomR. Так как мы работаем с чистыми функциями, которые возвращают при одинаковых аргументах одинаковый результат, вторым аргументом randomR служит генератор, который обновляется с каждым вызовом. Добавим его как поле нашего мира и зададим ему начальное значение. Когда змея ест фрукт, она растет в хвосте. Сохраним координату крайней точки хвоста при обновлении мира.

import System.Randomdata World = World  {                     {--  --}                    , oldLast :: Point                    , rand :: StdGen                    }    initWorld timePoint = World   {                              {--  --}                              , oldLast = (0, 0)                              , rand = mkStdGen 0                              } {--  --}timerController timePoint world  | isUpdateTime timePoint world  = world {                                           {--  --}                                          , oldLast = last $ snake world                                          }{--  --}                                       

Контроллер столкновений

Добавим функции проверки столкновений змеи с телом и головы со стеной.

collisionSnake :: Snake -> BoolcollisionSnake (x:xs) = any (== x)  xscollisionWall :: Point -> Walls -> BoolcollisionWall (sx,sy) ((wx1,wy1),(wx2,wy2)) = sx <= wx1 || sx >= wx2 || sy <= wy1 || sy >= wy2

Все готово для контроллера столкновений collisionController. Переводим состояние мира в GameOver при столкновении с препятствиями и увеличим длину змеи в хвосте при съедании фрукта, также сгенерив новый в пределах стен. Если координата нового фрукта является координатой тела змеи, пробуем новую координату.

collisionController :: World -> WorldcollisionController world  | not $ isUpdateIteration world                = world  | collisionSnake $ snake world                 = world { worldState = GameOver }   | collisionWall (head $ snake world) initWalls = world { worldState = GameOver }   | collisionFruit (snake world) (fruit world)   = world { snake =                                                            (snake world) ++ [oldLast world]                                                         , fruit = newFruit                                                         , rand = newRand                                                         }  | otherwise                                    = world where    collisionFruit snake fruit = fruit == head snake    (newFruit, newRand) = freeRandomPoint world (rand world)    randomPoint ((minX, minY), (maxX, maxY)) g = let      (x, g1) = randomR (minX + 1, maxX - 1) g      (y, g2) = randomR (minY + 1, maxY - 1) g1 in         ((x, y), g2)    freeRandomPoint world g | not $ elem point ((fruit world):(snake world)) =                                 (point, g1)                            | otherwise = freeRandomPoint world g1 where                                (point, g1) = randomPoint initWalls g

Графика

Перейдем к отображению мира. Базовая функция нашей графики drawPoint принимает символ и отображает его в заданной координате экрана. Функция renderWorld отображает наш мир. Без установленного флага isUpdateIteration, контроллеры moveController, collisionController и renderWorld не производят никаких изменений. Рендер отображает наш фрукт, новое положение змеи и затирает ее хвост. Стены отображаются один раз при старте и не обновляются.

renderWorld :: World -> IO ()renderWorld world  | not $ isUpdateIteration world = return ()  | otherwise = do    drawPoint '@' (fruit world)      drawPoint ' ' (oldLast world)    mapM_ (drawPoint 'O') (snake world)    setCursorPosition 0 0 drawPoint :: Char -> Point -> IO ()drawPoint char (x, y) = setCursorPosition y x >> putChar char   drawWalls :: Char -> Walls -> IO ()drawWalls char ((x1, y1),(x2, y2)) = do  mapM_ (drawPoint char) [(x1, y)| y <- [y1..y2]]  mapM_ (drawPoint char) [(x, y1)| x <- [x1..x2]]  mapM_ (drawPoint char) [(x2, y)| y <- [y1..y2]]   mapM_ (drawPoint char) [(x, y2)| x <- [x1..x2]]main = do  {--  --}  drawWalls '#' initWalls  {--  --}

Подключаем все контроллеры и добавляем рендер в игровом цикле.

gameLoop inputThId input oldWorld = do  {--  --}  let newWorld = collisionController . moveController $ timerController timePoint (inputController tryInput oldWorld)   renderWorld newWorld  {--  --}

На текущем этапе у нас есть рабочая змейка с контролем от пользователя. Добавим возможность игре проходить себя самостоятельно. Задачу идеального прохождения змейки очень подробно в своих видео разобрал австралийский кодер CodeBullet. Также об этом можно почитать у RussianDragon тут. Позаимствуем идею с циклом Гамильтона и приступим.

Цикл Гамильтона

Немного теории: цикл Гамильтона в контексте нашей игры - замкнутый путь, проходящий через каждую точку поля ровно один раз. Мы сможем его найти сделав проход по часовой стрелке. Добавим пару синонимов типов: Path - путь по игровому полю, ClosedPath - замкнутый путь или цикл Гамильтона для нашего поля.

type Path = [Point]type ClosedPath = [Point]

Напишем несколько вспомогательных функций, wallsFirstPoint вернет нулевую точку игрового поля внутри стен. С нее мы начнем составление цикла Гамильтона и соответственно в нее мы должны вернуться. isPathContain аналогично проверки столкновения змеи с телом, проверяет содержит ли путь точку. clockwise вернет список возможных направлений по часовой стрелке. distBetweenPoints - расстояние между двумя точками, учитывая что змея не может двигаться по диагонали.

clockwise = [DirUp, DirRight, DirDown, DirLeft]wallsFirstPoint :: PointwallsFirstPoint = ((fst $ fst initWalls) + 1, (snd $ fst initWalls) + 1)isPathContain :: Path -> Point -> BoolisPathContain path point = any (== point) pathdistBetweenPoints :: Point -> Point -> IntdistBetweenPoints (x1, y1) (x2, y2) = abs (x1 - x2) + abs (y1 - y2)

И сама функция поиска цикла Гамильтона getHamPath. Она принимает начальную точку цикла, второй аргумент является аккумулятором рекурсии, при вызове указываем пустой список. Функция проверяет, равна ли площадь нашего поля длине найденного пути Гамильтона и равно ли расстояние между первой и последней точкой пути единице, то есть пусть замкнулся и является циклом. Если нет, ищем следующую точку при помощи nextHamPathPoint. Даем ей текущий найденный путь и 4 возможных направления движения. Если точка не имеет коллизий со стеной и найденным путем, выбираем ее и включаем в путь. Вариант, что nextHamPathPoint не нашел ни одной точки крашит программу, так как цикл Гамильтона гарантированно должен быть найден. В нашем случае это может произойти только при условии, что у поля обе стороны четные и у пути нет возможности вернуться к начальной точке.

getHamPath :: Point -> ClosedPath -> ClosedPathgetHamPath currentPoint hamPath  | hamPathCapacity initWalls == length (currentPoint:hamPath)                                    && distBetweenPoints currentPoint (last hamPath) == 1                                      = currentPoint:hamPath                                 | otherwise = getHamPath newPoint (currentPoint:hamPath) where                                    newPoint = nextHamPathPoint (currentPoint:hamPath) clockwise                                    hamPathCapacity ((x1, y1),(x2, y2)) = (x2 - x1 - 1) * (y2 - y1 - 1) nextHamPathPoint :: Path -> [StepDirection] -> PointnextHamPathPoint _       []         = error "incorrect initWalls"nextHamPathPoint hamPath (dir:dirs) | isPathContain hamPath virtualPoint                                       || collisionWall virtualPoint initWalls =                                       nextHamPathPoint hamPath dirs                                     | otherwise = virtualPoint where  virtualPoint = pointStep dir (head hamPath)

Добавим найденный цикл Гамильтона в наш мир.

data World = World  {                     {--  --}                     , hamPath :: ClosedPath                    }    initWorld timePoint = World   {                              {--  --}                              , hamPath = getHamPath wallsFirstPoint []                              } 

Внутри замкнутого пути наша змея может двигаться в 2х направлениях. Несмотря на то, что путь зациклен, он представляет из себя список с головой и хвостом. Будем считать движение от головы к хвосту DirFromHead и DirFromTail в обратном направлении.

data PathDirection = DirFromHead | DirFromTail deriving (Eq)

Добавим к контроллеру движения змеи управление ботом при помощи функции nextDirOnPath, которую опишем позже. Она возвращает пару (botStepDir, botPathDir) первый элемент дает нам предложенное ботом направление змеи на поле. Второй указывает на движение внутри цикла Гамильтона. Если вернулось DirFromHead, то есть обратное текущему, переворачиваем цикл.

moveController world   {--  --}  | worldState world == Process = world {snake = snakeStep (direction world) (snake world)}  | otherwise                   = world { snake = snakeStep botStepDir (snake world)                                        , hamPath = if botPathDir == DirFromTail then hamPath world else reverse $ hamPath world                                          } where                                            (botStepDir, botPathDir) = nextDirOnPath (snake world) (hamPath world) nextDirOnPath :: Snake -> ClosedPath -> (StepDirection, PathDirection)nextDirOnPath = undefined

Добавим пару вспомогательных функций: dirBetweenPoints вернет нам направление между двумя точками по меньшему расстоянию по оси и pointNeighborsOnPath вернет пару соседей точки внутри замкнутого пути.

dirBetweenPoints :: Point -> Point -> StepDirectiondirBetweenPoints (x1, y1) (x2, y2)  | x1 == x2 = if y1 > y2 then DirUp else DirDown                                    | y1 == y2 = if x1 > x2 then DirLeft else DirRight                                     | otherwise = if abs (x1 - x2) < abs (y1 - y2) then                                         dirBetweenPoints (x1, 0) (x2, 0) else                                        dirBetweenPoints (0, y1) (0, y2)  pointNeighborsOnPath :: Point -> ClosedPath -> (Point, Point)pointNeighborsOnPath point path | not $ isPathContain path point || length path < 4 = error "incorrect initWalls"                                | point == head path = (last path, head $ tail path)                                | point == last path = (last $ init path, head path)                                | otherwise = _pointNeighborsOnPath point path where                                  _pointNeighborsOnPath point (a:b:c:xs) = if point == b then (a,c) else _pointNeighborsOnPath point (b:c:xs)

Найдем на нашем замкнутом пути соседние точки для головы змеи, определим ту, которая является частью змеи и вернем направление к противоположному соседу.

nextDirOnPath :: Snake -> ClosedPath -> (StepDirection, PathDirection)nextDirOnPath (snakeHead:snakeTail)  path   | snakeTail == [] = (dirBetweenPoints snakeHead point1, DirFromTail)                                             | point1 == head snakeTail = (dirBetweenPoints snakeHead point2, DirFromHead)                                            | otherwise = (dirBetweenPoints snakeHead point1, DirFromTail) where                                                 (point1, point2) = pointNeighborsOnPath snakeHead path

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

Ускоряем бота

Попробуем ускорить бота, добавив еще пару функций: collisionSnakeOnPath проверит, свободен ли замкнутый путь начиная с точки в заданном направлении от тела змеи и distBetweenPointsOnPath которая вернет пару расстояний от точки до точки на замкнутом пути. Первый элемент будет расстоянием для DirFromTail направления, второй для DirFromHead.

collisionSnakeOnPath :: Snake -> Point -> ClosedPath -> PathDirection -> BoolcollisionSnakeOnPath snake point path pathDir | null $ common snake pathPart = False                                              | otherwise = True where  pathPart = takePathPart point (if pathDir == DirFromHead then path else reverse path) (length snake)  common xs ys = [ x | x <- xs , y <- ys, x == y]  takePathPart point path len = _takePathPart point (path ++ (take len path)) len where    _takePathPart _     []     _    = []    _takePathPart point (x:xs) len  | x == point = x:(take (len - 1) xs)                                    | otherwise = _takePathPart point xs lendistBetweenPointsOnPath :: Point -> Point -> ClosedPath -> (Int, Int)distBetweenPointsOnPath point1 point2 path  | point1 == point2 = (0, 0)                                            | id1 < id2 = (length path - id2 + id1,id2 - id1)                                            | otherwise = (id1 - id2, length path - id1 + id2) where  (id1,id2) = pointIndexOnPath (point1,point2) path 0 (0,0)  pointIndexOnPath _               []     _   ids                     = ids  pointIndexOnPath (point1,point2) (x:xs) acc (id1,id2) | x == point1 = pointIndexOnPath (point1,point2) xs (acc+1) (acc,id2)                                                        | x == point2 = pointIndexOnPath (point1,point2) xs (acc+1) (id1,acc)                                                         | otherwise   = pointIndexOnPath (point1,point2) xs (acc+1) (id1,id2)  

Сведем все в новую функцию управления змеей. Находим точку для сокращения цикла Гамильтона enterPointBypass в направлении фрукта, ищем самый короткий путь до фрукта в прямом и обратном направлении и проверяем ляжет ли туда змея. Если ничего не нашли, двигаемся дальше по циклу через nextDirOnPath.

nextDirBot :: Snake -> Point -> ClosedPath -> (StepDirection, PathDirection)nextDirBot snake fruit path | distBypass1 < distBypass2 && distBypass1 < distToFruit1                               && not (collisionSnakeOnPath snake enterPointBypass path DirFromTail)                                 = (dirBetweenPoints (head snake) enterPointBypass, DirFromTail)                            | distBypass2 < distToFruit1                               && not (collisionSnakeOnPath snake enterPointBypass path DirFromHead)                                 = (dirBetweenPoints (head snake) enterPointBypass, DirFromHead)                             | otherwise = nextDirOnPath snake path where  dirBypass = dirBetweenPoints (head snake) fruit  enterPointBypass = pointStep dirBypass (head snake)  (distBypass1, distBypass2) = distBetweenPointsOnPath enterPointBypass fruit path  (distToFruit1, _) = distBetweenPointsOnPath (head snake) fruit path

Предложенный алгоритм сокращения пути не является оптимальным и имеет как минимум 2 явных проблемы. Мы рассматриваем точку входа для сокращения пути enterPointBypass только по направлению к фрукту, следовательно если цикл полностью заблокировал видимость фрукта, змея пойдет по полному пути. Также, если змея и фрукт расположены на достаточно большом расстоянии, как на картинке, бот выберет зеленое направление вместо белого, так как длину пути до фрукта мы сравниваем не по фактическому расстоянию на поле, а по длине цикла.

Подключим наш nextDirBot к контроллеру движения змеей, добавим меню и смотрим на результат.

Подробнее..

Перевод Как искусственный интеллект играет в Змейку

28.09.2020 16:10:09 | Автор: admin


Рассказываем о нейросети, которая применяет глубокое обучение и обучение с подкреплением, чтобы играть в Змейку. Код на Github, разбор ошибок, демонстрации иры искусственного интеллекта и эксперименты над ним вы найдете под катом.

С тех пор, как я посмотрела документальный фильм Netflix об AlphaGo, я была очарована обучением с подкреплением. Такое обучение сравнимо с человеческим: вы видите что-то, делаете что-то и у ваших действий есть последствия. Хорошие или не очень. Вы учитесь на последствиях и корректируете действия. У обучения с подкреплением множество приложений: автономное вождение, робототехника, торговля, игры. Если обучение с подкреплением вам знакомо, пропустите следующие два раздела.

Обучение с подкреплением


Принцип простой. Агент учится через взаимодействие со средой. Он выбирает действие и получает отклик от среды в виде состояний (или наблюдений) и наград. Этот цикл продолжается постоянно или до состояния прерывания. Затем наступает новый эпизод. Схематично это выглядит так:



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

Глубокое обучение с подкреплением


Глубокое обучение использует нейронные сети, чтобы из входных данных получать выходные. Всего один скрытый слой и глубокое обучение может приближать любую функцию. Как это работает? Нейронная сеть это слои с узлами. Первый слой это слой входных данных. Скрытый второй слой преобразует данные с помощью весов и функции активации. Последний слой это слой прогноза.



Как следует из названия, глубокое обучение с подкреплением это комбинация глубокого обучения и обучения с подкреплением. Агент учится прогнозировать лучшее действие для данного состояния, используя состояния как входные данные, значения для действий как выходные данные и награды для настройки весов в правильном направлении. Давайте напишем Змейку с применением глубокого обучения с подкреплением.



Определяем действия, награды и состояния


Чтобы подготовить игру для агента, формализуем проблему. Определить действия просто. Агент может выбирать направление: вверх, вправо, вниз или влево. Награды и состояние пространства немного сложнее. Есть много решений и одно будет работать лучше, а другое хуже. Одно из них опишу ниже и давайте попробуем его.

Если Змейка подбирает яблоко, ее награда 10 баллов. Если Змейка умирает, отнимаем от награды 100 баллов. Чтобы помочь агенту, добавляем 1 балл, когда Змейка проходит близко к яблоку и отнимаем один балл, когда Змейка удаляется от яблока.

У состояния много вариантов. Можно взять координаты Змейки и яблока или направления к яблоку. Важно добавить расположение препятствий, то есть стен и тела Змейки, чтобы агент учился выживать. Ниже резюме действий, состояний и наград. Позже мы увидим, как корректировка состояния влияет на производительность.



Создаем среду и агента


Добавляя методы в программу Змейки, мы создаём среду обучения с подкреплением. Методы будут такими: reset(self), step(self, action) и get_state(self). Кроме того, нужно рассчитывать награду на каждом шаге агента. Посмотрите на run_game(self).

Агент работает с сетью Deep Q, чтобы найти лучшие действия. Параметры модели ниже:

# epsilon sets the level of exploration and decreases over timeparams['epsilon'] = 1params['gamma'] = .95params['batch_size'] = 500params['epsilon_min'] = .01params['epsilon_decay'] = .995params['learning_rate'] = 0.00025params['layer_sizes'] = [128, 128, 128]


Если интересно посмотреть на код, вы найдёте его на GitHub.

Агент играет в Змейку


А теперь ключевой вопрос! Научиться ли агент играть? Понаблюдаем, как он взаимодействует со средой. Ниже первые игры. Агент ничего не понимает:



Первое яблоко! Но по-прежнему выглядит так, будто нейросеть не знает, что делает.



Находит первое яблоко и чуть позже ударяется о стену. Начало четырнадцатой игры:



Агент учится: его путь к яблоку не самый короткий, но он находит яблоко. Ниже тридцатая игра:



После всего 30 игр Змейка избегает столкновений с самой собой и находит быстрый путь к яблоку.

Поиграем с пространством


Может быть, возможно изменить пространство состояний и достичь похожей или лучшей производительности. Ниже возможные варианты.

  1. Без направлений: не сообщать агенту направления, в которых движется Змейка.
  2. Состояние с координатами: замени положение яблока (вверх, вправо, вниз и / или влево) координатами яблока (x, y) и змеи (x, y). Значения координат находятся на шкале от 0 до 1.
  3. Состояние направление 0 или 1.
  4. Состояние только стены: сообщает только о том, есть ли стена. Но не о том, где находится тело: внизу, наверху, справа или слева.




Ниже графики производительности разных состояний:



Найдем пространство, ускоряющее обучение. График показывает средние достижения последних 12 игр с разными состояниями.

Понятно, что когда пространство состояний имеет направления, агент учится быстро, достигая наилучших результатов. Но пространство с координатами лучше. Может быть, можно достичь лучших результатов, дольше тренируя сеть. Причиной медленного обучения может быть число возможных состояний: 20*2*4 = 1,024,000. Поле 20 на 20, 64 варианта для препятствий и 4 варианта текущего направления. Для исходного пространства вариантов 3*2*4 = 576. Это более чем в 1700 раз меньше, чем 1,024,000 и, конечно, влияет на обучение.

Поиграем с наградами


Есть ли лучшая внутренняя логика награждения? Напоминаю, Змейка награждается так:



Первая ошибка. Хождение по кругу

Что, если изменить -1 на +1? Это может замедлить обучение, но в конце концов Змейка не умирает. И это очень важно для игры. Агент быстро учится избегать смерти.



На одном временном отрезке агент получает один балл за выживание.

Вторая ошибка. Удар о стену

Изменим количество баллов за прохождение около яблока на -1. Награду за само яблоко установим в 100 баллов. Что произойдет? Агент получает штраф за каждое движение, поэтому двигается к яблоку максимально быстро. Так может случиться, но есть и другой вариант.



ИИ проходит по ближайшей стене, чтобы минимизировать потери.

Опыт


Нужно только 30 игр. Секрет искусственного интеллекта опыт предыдущих игр, который учитывается, чтобы нейросеть училась быстрее. На каждом обычном шаге выполняется ряд шагов переигрывания (параметр batch_size). Это так хорошо работает потому, что для данной пары действия и состояния разница в награде и следующем состоянии небольшая.

Ошибка 3. Нет опыта

Опыт действительно так важен? Давайте уберём его. И возьмём награду за яблоко в 100 баллов. Ниже агент без опыта, сыгравший 2500 игр.



Хотя агент сыграл 2500 (!) игр, в змейку он не играет. Игра быстро заканчивается. Иначе 10 000 игр заняли бы дни. После 3000 игру у нас только 3 яблока. После 10 000 игр яблок по-прежнему 3. Это удача или результат обучения?

Действительно, опыт очень помогает. Хотя бы опыт, учитывающий награды и тип пространства. Как много нужно переигрываний на шаг? Ответ может удивить. Чтобы ответить на этот вопрос, поиграем с параметром batch_size. В исходном эксперименте он установлен в 500. Обзор результатов с разным опытом:



200 игр с разным опытом: 1 игра (опыта нет), 2 и 4. Среднее за 20 игр.

Даже с опытом в 2 игры агент уже учится играть. В графе вы видите влияние batch_size, та же производительность достигается на 100 игр, если вместо 2 используется 4. Решение в статье дает результат. Агент учится играть в Змейку и достигает хороших результатов, собирая от 40 до 60 яблок за 50 игр.

Внимательный читатель может сказать: максимум яблок в змейке 399. Почему ИИ не выигрывает? Разница между 60 и 399, в сущности, небольшая. И это верно. И здесь есть проблема: Змейка не избегает столкновений при замыкании на себя.



Интересный способ решить проблему использовать CNN для поля игры. Так ИИ может увидеть всю игру, а не только ближайшие препятствия. Он сможет распознавать места, которые нужно обойти, чтобы победить.

Библиография
[1] K. Hornik, M. Stinchcombe, H. White, Multilayer feedforward networks are universal approximators (1989), Neural networks 2.5: 359366
[2] Mnih et al, Playing Atari with Deep Reinforcement Learning (2013)

image

Узнайте подробности, как получить востребованную профессию с нуля или Level Up по навыкам и зарплате, пройдя онлайн-курсы SkillFactory:



Подробнее..

We need to go deeper как пасхалка в приложении Delivery Club сократила субъективное время ожидания еды

11.06.2021 16:11:37 | Автор: admin


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

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

Сашин скриншот того, как всё начиналось.

Саша:
У меня есть свой небольшой проект словесно-карточная игра Кто из нас? На одном из очередных разборов игры родилась мысль, что можно внутри игры спрятать ещё одну игру. Люди найдут её и скажут: Ого, я играл в одну, а сейчас играю в совершенно другую. Но игра, которую я делаю, не настолько популярна, поэтому если туда прятать ещё одну игру, то её просто никто не увидит. Возникла вторая идея. Я работаю в Delivery Club, и у нас миллионы пользователей, у которых есть общая проблема время ожидания заказа. Нужно либо сокращать время доставки, либо как-то развлечь пользователя, пока он ожидает. И я пошёл по второму пути: придумал решение в виде небольшой игры внутри основного приложения.
Дизайн для первого прототипа змейки сделала девушка Саши, а звуки Саша создал сам в интернет-синтезаторе.


Прототипы первых экранов.

Уже в январе ребята буквально за полдня в коворкинге написали MVP на Swift и SpriteKit. Там были крупные кнопки и небольшой игровой экран, как в тетрисе. В финальной версии от кнопок на экране отказались: из-за того, что они плоские, а не объёмные, как на настоящей консоли, змейкой было неудобно управлять.
Саша:
Мы собрались с Сахеем и начали писать приложение в духе лучших стартапов. Заказали пиццу. Пива там не было, поэтому мы пили кофе. За один день у нас был готов прототип, который уже выполнял свою задачу. Игра запускалась, был первый квадратик, который начинал ездить. Он поедал эмодзи, змейка росла; врезавшись в стенку, она умирала. Осталось её только докрутить: добавить включение и выключение звука, доработать экраны начала и конца игры. Этим мы занялись уже в свободное время дома.
Сахей:
Очень понравился этот режим стартапа. Обычно на работе есть все спецификации и документация протоптанная тропинка, по которой ты идёшь. А тут был полный полёт мысли. Мы ещё хотели изначально поменять рендеринг на SwiftUI или на UIKit, но не стали так извращаться, SpriteKit отлично подходил для нашей задачи. Он оптимизирован под отрисовку спрайтов. Если в UIKit 100-200 вьюшек, то FPS очень сильно проседает, а в SpriteKit нет. Но мы фана ради хотели попробовать.


Разработка MVP.

В феврале ребята пришли с готовым прототипом к руководителю направления и показали, как змейка могла бы выглядеть внутри приложения Delivery Club. Мы собрали небольшую рабочую группу, в которую, помимо ребят, вошла дизайнер продукта Лера Зуйкова. И решили доводить проект до совершенства.

Финальную версию змейки пришлось достаточно сильно упростить ребята увлеклись и придумали много дополнительных возможностей. Решили оставить простой и красивый вариант, чтобы не отвлекать пользователя от основной функции приложения.
Саша:
Когда Лера показала свой дизайн, у нас с Сахеем загорелись глаза. Мы такие: Вау! Можно же настолько красиво и современно всё сделать! У нас появилась куча новых идей. Можно нарабатывать опыт у змейки с каждой игрой и набирать очки, за них покупать змейке какой-то апгрейд, типа шапочки или щита, чтобы она врезалась в стенку, а у неё на одну жизнь больше было. Но всё-таки история со змейкой должна была где-то кончиться, чтобы дойти до релиза. И мы решили упростить игру, чтобы не смещать фокус с основного функционала Delivery Club.
Лера:
На самом деле, когда в работе есть много рутинных задач, и внезапно кто-то приходит и предлагает сделать игру, это очень воодушевляет. Поэтому мы сразу активно включились в историю со змейкой и стали делать супер-красивые дизайны, продумывать механики. Все механики не были добавлены в финальный релиз ещё и потому, что вначале мы хотели всё проверить. Просто потратить кучу времени на разработку и сделать фичу, которую потом нашли бы три человека, было бы странно. Поэтому мы сошлись на простом решении, которое можно было быстро сделать, запустить и посмотреть реакцию пользователей. Если это окажется интересным, мы будем развивать игру: делать рейтинги, баллы и так далее.
Георгий:
После того, как ребята реализовали версию под iOS, мы принялись адаптировать приложение под Android. Разработка велась на Kotlin, всё сделали нативно, без использования сторонних библиотек. В конечном итоге версию под Android получилось написать всего за четыре дня. Идея была творческой и разнообразила череду рутинных задач.
Реализацию Android-версии Змейки можно посмотреть на GitHub.



Промежуточные варианты дизайна экранов.

18 мая змейку добавили в приложение Delivery Club. Чтобы в неё поиграть, нужно потрясти телефон на экране с заказом. Мы нигде не писали про пасхалку, но уже сейчас в неё играют 5-7 тысяч человек в день со средним временем игры 3 минуты 10 секунд. Нескольким тысячам пользователей игра скрасила минуты ожидания заказа. Надеемся, теперь игроков станет больше.

А если после прочтения у вас появились собственные идеи для пасхалок в Delivery Club, принимайте участие в нашем конкурсе. Лучшие идеи будут опубликованы на N + 1 и, возможно, реализованы в нашем приложении.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru