Жизнь Конвея на F# + OpenGL

в 11:11, , рубрики: .net, .net 4.5, fsharp, functional programming, OpenGL, Песочница, метки: , , ,

Почему F#?

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

Кому интересно — добро пожаловать под кат.

Сразу оговорюсь. Я не являюсь специалистом в области функционального программирования или OpenGL, поэтому буду рад любым осмысленным комментариям и подсказкам как сделать лучше/быстрее/красивее.

Описывать сам язык смысла не имеет. Уже есть достаточно много материала по теме.
Можно, для начала, посетить Wiki, F# Development Center, What's new in F# 3.0.

Итак, приступим

Первым делом определим типы для нашей клетки:

type Sex = |Male|Female
type Cell = { Sex: Sex; Age: int; Position: (int * int)}

Пол будем использовать при отрисовке ячеек. В дальнейшем я планирую использовать его для экспериментов с алгоритмом.
Объявим глобальные переменные игры:

//Globals
let mutable (field:Cell[]) = [||] //поле с клетками
let mutable pause = false         //Признак паузы игры
let mutable isInProc = false      //признак того, что поле рассчитывается
let mutable generation = 0        //текущий шаг
let mutable ftime = DateTime.Now  //
let mutable fps = 0.0             // будут использоваться для вычисления fps
//Матрица для отрисовки openGL
let mutable modelview = Matrix4.LookAt(Vector3.UnitZ, Vector3.Zero, Vector3.UnitY)
let mutable Size = 100            //размерность поля
let mutable CellCount = 2000      //стартовое количество клеток
let mutable LifeLength = 50      //время жизни клетки
let mutable ScreenWidth = 500     //размер окна по умолчанию

Основной класс, отвечающий за игровой процесс

Первым делом напишем методы для генерации новых ячеек:

type Life() = 
    member this.genCell (rnd:Random) = {
            Sex = match rnd.Next(2) with |0 -> Sex.Male |_ -> Sex.Female
            Age=0
            Position=(rnd.Next(Size), rnd.Next(Size))}
 
    member this.genCells = 
        let rnd = new System.Random()
        let rec lst (l:list<Cell>) = 
            match l.Length with
            |c when c = CellCount -> l
            |_ -> 
                match this.genCell rnd with
                |c when not (this.existCell (l |> List.toArray) c.Position) ->  lst (c::l)
                |_ -> lst l
	List.Empty |> lst |> List.toArray

genCell создает новую клетку используя объект Random.
genCells заполняет список новыми клетками.
Рекурсивная функция let rec lst (l:list<Cell>) создает новый список и вставляет туда новую клетку, если координаты свободны.

Вычисляем координаты соседних клеток:

member this.allNeighbourCells (position:int*int) = 
        let nCells (point:int*int) =
            let x,y = point
            [|  (x-1, y-1); (x-1, y); (x-1, y+1);
                (x, y-1); (x, y+1); 
                (x+1, y-1); (x+1, y); (x+1, y+1); |]
        let (!) pos =
            let tx = match fst pos with 
                        |x when x < 0 -> Size - 1 
                        |x when x >= Size -> 0 
                        |_ -> fst pos
            let ty = match snd pos with 
                        |y when y < 0 -> Size - 1 
                        |y when y >= Size -> 0 
                        |_ -> snd pos
            (tx, ty)
        nCells position |> Array.map ((!))

Здесь присутствует 2 локальные функции:
nCells возвращает все соседние ячейки для point
! переопределенный операнд, который возвращает координаты, выходящие за границы игрового поля обратно, формируя «бесконечное» игровое пространство.

Определим еще несколько вспомогательных функций:

member this.existCell field c = field |> Array.exists (fun af -> c = af.Position)

Проверка на наличие клетки с указанными координатами.

member this.partitionfield field = this.allNeighbourCells >> Array.partition(fun c -> this.existCell field c)
member this.pFree field = this.partitionfield field >> snd
member this.pExist field = this.partitionfield field >> fst

Partitionfield делит все соседние клетки на занятые и свободные создавая tuple из двух массивов.
pFree, pExist соответственно получают доступ к списку свободных и занятых клеток

Собственно главный метод, пересчитывающий игровое поле весьма лаконичен:

    member this.Iterate (field:Cell[]) =
        //Список свободных соседних ячеек
        let freeNeighb = field 	|> PSeq.collect (fun c -> this.pFree field c.Position) 
				|> Seq.distinct 
        let born = freeNeighb 
                    |> Seq.filter (fun c -> this.pExist field c |> Array.length = 3)
                    |> Seq.map(fun c ->
                                let rnd = new System.Random()
                                {this.genCell(rnd) with Position = c})
        let alive = field   |> PSeq.filter(fun c -> let neighb = this.pExist field c.Position |> Array.length
                                                    neighb <= 3 && neighb >= 2)
                            |> PSeq.map (fun c -> {c with Age = (c.Age + 1)})
        let res = alive |> Seq.append born |> Seq.toArray
        res

Что здесь происходит:

  1. Получаем список всех свободных ячеек, которые являются соседями клеток (freeNeighb)
  2. Фильтруем их и создаем новые клетки для тех ячеек, для которых выполняется условие (кол-во соседей = 3) (born)
  3. Для существующих клеток фильтруем тех, кто выжил
  4. Объединяем 2 списка в один и возвращаем результат работы

Код, связанный с OpenGL я в тексте статьи опущу, т.к. он достаточно простой.
Остановлюсь только на 2х методах:

doNextStep

Асинхронно вычисляет следующее поколение и заполняет список клеток, когда вычисление окончено:

        member this.doNextStep =
            async{
                let res = (this.life.Iterate field)
                field <- res |> Array.filter(fun c -> c.Age < LifeLength)
                isInProc <- false
                generation <- generation + 1
                let delta = DateTime.Now - ftime
                ftime <- DateTime.Now 
                fps <- Math.Round ((fps + 1000.0 / delta.TotalMilliseconds) / 2.0, 1)
            }

OnRenderFrame

Функция OpenGL, отрисовывающая кадр:

        override o.OnRenderFrame(e) =
            base.OnRenderFrame e
            match (pause, isInProc) with
                | (false, false) -> isInProc <- true; Async.Start(o.doNextStep)
                | _ -> ()
                    
            GL.Clear(ClearBufferMask.ColorBufferBit ||| ClearBufferMask.DepthBufferBit)
            GL.MatrixMode(MatrixMode.Modelview)
            GL.LoadMatrix(&modelview)
            field |> Seq.iter (fun c -> o.DrawCell c)
            if not pause then
                base.Title <- String.Format("F# Life cell count: {0} Generation: {1} FPS: {2}", (field |> Seq.length), generation, fps)
            else
                base.Title <- String.Format("F# Life cell count: {0} Generation: {1} Paused", (field |> Seq.length), generation)
            base.SwapBuffers()

Я провел небольшой эксперимент, добавив время жизни для клетки и удаляя те, которые старше LifeLength:

member this.doNextStep =
....
               field <- res |> Array.filter(fun c -> c.Age < LifeLength
...

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

        member this.DrawCell (cell:Cell) = 
            let cellWidth = float32(this.ClientSize.Width) / float32 Size
            let alpha = match (1.f - float32 cell.Age / float32 LifeLength) with 
                        |c when c < 0.f -> 0.f 
                        | c -> c
            let color = match cell.Sex with 
                        |Male -> [|0.5f; 0.f; 0.f; alpha|] 
                        |Female -> [|0.7f; 0.f; 0.f; alpha|]
            let pos = (float32 (fst cell.Position) * cellWidth, float32 (snd cell.Position) * cellWidth)
            GL.Begin(BeginMode.Triangles)
            GL.Color4 (color)
            GL.Vertex3(fst pos + 0.5f, snd pos + 0.5f, 1.f)
            GL.Vertex3(fst pos + 0.5f, cellWidth + snd pos - 0.5f, 1.f)
            GL.Vertex3(cellWidth + fst pos - 0.5f, cellWidth + snd pos - 0.5f, 1.f)
            GL.Vertex3(cellWidth + fst pos - 0.5f, snd pos + 0.5f, 1.f)
            GL.Vertex3(fst pos + 0.5f, snd pos + 0.5f, 1.f)
            GL.Vertex3(cellWidth + fst pos - 0.5f, cellWidth + snd pos - 0.5f, 1.f)
            GL.End()

Скриншоты работы:
image
Самая стабильная фигура при включенном времени жизни — мерцающий квадрат:
image

Что надо доделать

Есть что оптимизировать.
Код не стал сильно рефакторить что бы получить более полезные комментарии :)
Не хватает «рисования» с помощью мыши, загрузки/сохранения данных в файл

Исходники можно скачать по адресу bitbucket
С нетерпением жду отзывов!..

Автор: msmaximuss

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js