Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
rewrite everything using typed actors; improve performance by having …
…organisms track their own neighbors
  • Loading branch information
awwsmm committed Nov 21, 2021
1 parent b3dab34 commit 390f574
Show file tree
Hide file tree
Showing 9 changed files with 2,261 additions and 2,266 deletions.
10 changes: 3 additions & 7 deletions build.sbt
@@ -1,23 +1,19 @@
name := "ConwayScalaJS"
version := "0.1"
version := "1.0"
scalaVersion := "2.13.3"

enablePlugins(ScalaJSPlugin)

// This is an application with a main method
scalaJSUseMainModuleInitializer := true
mainClass in Compile := Some("conway.Main") // start with the `main` in Main
Compile / mainClass := Some("com.awwsmm.conway.Main") // start with the `main` in Main

// DOM -- npm install
libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "1.1.0"
jsEnv := new org.scalajs.jsenv.jsdomnodejs.JSDOMNodeJSEnv()

// uTest
libraryDependencies += "com.lihaoyi" %%% "utest" % "0.7.4" % "test"
testFrameworks += new TestFramework("utest.runner.Framework")

// ScalaTags -- depends on scalajs-dom
libraryDependencies += "com.lihaoyi" %%% "scalatags" % "0.9.2"

// Akka Actors
libraryDependencies += "org.akka-js" %%% "akkajsactor" % "2.2.6.9"
libraryDependencies += "org.akka-js" %%% "akkajsactortyped" % "2.2.6.14"
@@ -1,7 +1,8 @@
package conway
package com.awwsmm.conway

import akka.actor.ActorSystem
import conway.actors.GameActor
import akka.actor.typed.ActorSystem
import akka.actor.typed.scaladsl.Behaviors
import com.awwsmm.conway.actors.GameActor
import org.scalajs.dom
import org.scalajs.dom.{document, window}
import scalatags.JsDom.all._
Expand All @@ -10,7 +11,7 @@ import scala.util.Random

object Main {

implicit val system: ActorSystem = ActorSystem()
implicit val typedSystem: ActorSystem[Nothing] = ActorSystem[Nothing](Behaviors.ignore, "typedSystem")

val logging: Boolean = false

Expand All @@ -24,8 +25,9 @@ object Main {
val nRows: Int = ((window.innerHeight - 2*margin) / (organismSize + padding)).toInt

val gameMap = randomMap(nRows, nCols)
val state = GameActor.State(gameMap, organismSize, padding, margin, logging)
val game = system.actorOf(GameActor.props(state, logging))
val typedState = GameActor.Landscape(gameMap)
val typedConfig = GameActor.Config(organismSize, padding, margin)
val typedGame = typedSystem.systemActorOf(GameActor(typedConfig, typedState), "gameActor")

// help menu
val menu = div(
Expand All @@ -39,35 +41,34 @@ object Main {
p("Click (or tap on mobile) on a square to change its color / state."),
div(
button("Tick",
onclick := { () => game ! GameActor.Tick }
onclick := { () => typedGame ! GameActor.Tick }
),
button("Toggle",
onclick := { () => game ! GameActor.Toggle }
onclick := { () => typedGame ! GameActor.Toggle }
),
),
p("Or, on desktop, press 't' to step, press 's' to toggle auto-stepping on / off.")
).render

document.addEventListener("DOMContentLoaded", (_: dom.Event) => {
println(s"gameMap is\n$gameMap")
state.organisms
document.body.appendChild(menu)
})

document.addEventListener("keydown", (k: dom.KeyboardEvent) => {
if (k.key == "t") game ! GameActor.Tick
if (k.key == "s") game ! GameActor.Toggle
if (k.key == "t") typedGame ! GameActor.Tick
if (k.key == "s") typedGame ! GameActor.Toggle
})
}

def randomMap (rows: Int, cols: Int): GameActor.GameMap = {
def randomMap (rows: Int, cols: Int): String = {
def randomRow(): String = {
val bools = (1 to cols).map(_ => Random.nextBoolean())
val chars = bools.map(if (_) "_" else "X")
chars mkString ""
}

GameActor.GameMap((1 to rows).map(_ => randomRow()) mkString "\n")
(1 to rows).map(_ => randomRow()) mkString "\n"
}

}
Expand Down
127 changes: 127 additions & 0 deletions src/main/scala/com/awwsmm/conway/actors/GameActor.scala
@@ -0,0 +1,127 @@
package com.awwsmm.conway.actors

import akka.actor.typed.scaladsl.Behaviors
import akka.actor.typed.{ActorRef, Behavior}

import java.util.UUID

object GameActor {

case class Config(organismSize: Int, padding: Int, margin: Int)

// TODO this can DEFINITELY be optimised
case class Landscape (map: String) {
private val lines = map.stripMargin.split('\n')
val nRows: Int = lines.length
require(nRows > 0, "must have at least one row")

private val colsPerRow = lines.map(_.length)
require(colsPerRow.distinct.length == 1, "all rows must have the same number of cols")

val nCols: Int = colsPerRow(0)
require(nCols > 0, "must have at least one col")

require(lines.mkString("").matches("[_X]+"), "map can only contain _ and X")

// TODO should be decoupled from OrganismActor?
private val state = lines.map(line => line.map(x => if (x == 'X') (OrganismActor.Alive, UUID.randomUUID()) else (OrganismActor.Dead, UUID.randomUUID())))

def apply(row: Int, col: Int): (OrganismActor.Status, UUID) = {
state(Math.floorMod(row, nRows))(Math.floorMod(col, nCols))
}

override def toString: String = {
"GameMap(\n" + lines.mkString(" ", "\n ", "") + "\n)"
}
}

sealed trait Command

case object Tick extends Command

case class OrganismState(state: OrganismActor.State) extends Command

case object Toggle extends Command

def apply(config: Config, landscape: Landscape): Behavior[Command] = {

Behaviors.setup { context =>

val organisms: Map[UUID, ActorRef[OrganismActor.Command]] = {

def colToX (col: Int): Int = config.margin + (col * (config.organismSize + config.padding))
def rowToY (row: Int): Int = config.margin + (row * (config.organismSize + config.padding))

def findNeighbors(row: Int, col: Int): Set[UUID] = {
(for {
rowDelta <- -1 to 1
colDelta <- -1 to 1
if !(rowDelta == 0 && colDelta == 0)
(_, uuid) = landscape(row+rowDelta, col+colDelta)
} yield {
uuid
}).toSet
}

(for {
row <- 0 until landscape.nRows
col <- 0 until landscape.nCols
(x, y, (status, uuid), neighbors) = (colToX(col), rowToY(row), landscape(row, col), findNeighbors(row, col))
} yield {
val organismConfig = OrganismActor.Config(uuid, x, y, config.organismSize)
uuid -> context.spawn(OrganismActor(organismConfig, status, neighbors), uuid.toString)
}).toMap

}

val organismStateAdapter: ActorRef[OrganismActor.State] =
context.messageAdapter(organismState => OrganismState(organismState))

def continue(landscape: Map[UUID, OrganismActor.State], automatic: Boolean): Behavior[Command] = {

// if we know the entire current landscape, calculate the next landscape
if (landscape.keySet == organisms.keySet) {
landscape.foreach { case (_, OrganismActor.State(uuid, status, neighbors)) =>

val nAliveNeighbors = neighbors.map(landscape).count(_.status == OrganismActor.Alive)

if (status == OrganismActor.Alive) {

// this organism dies from underpopulation
if (nAliveNeighbors < 2) organisms(uuid) ! OrganismActor.Die

// this organism dies from overpopulation
else if (nAliveNeighbors > 3) organisms(uuid) ! OrganismActor.Die

} else if (nAliveNeighbors == 3) {

// a new organism is born
organisms(uuid) ! OrganismActor.Live

}
}

// if the landscape is updating automatically, calculate the next one immediately
if (automatic) context.self ! Tick
continue(Map.empty, automatic)

} else {

Behaviors.receiveMessage {
case Tick =>
organisms.foreach { case (_, ref) => ref ! OrganismActor.ReportState(organismStateAdapter) }
Behaviors.same
case OrganismState(organismState) =>
continue(landscape + (organismState.id -> organismState), automatic)
case Toggle =>
if (!automatic) context.self ! Tick
continue(landscape, !automatic)
}
}
}

continue(Map.empty, false)
}
}

}
86 changes: 86 additions & 0 deletions src/main/scala/com/awwsmm/conway/actors/OrganismActor.scala
@@ -0,0 +1,86 @@
package com.awwsmm.conway.actors

import akka.actor.typed.scaladsl.Behaviors
import akka.actor.typed.{ActorRef, Behavior}
import org.scalajs.dom.document
import org.scalajs.dom.raw.MouseEvent
import scalatags.JsDom.all._

import java.util.UUID

object OrganismActor {

sealed trait Status {
def color: String
}

case object Alive extends Status {
override def color: String = "black"
}

case object Dead extends Status {
override def color: String = "#DDDDDD"
}

/** [[neighbors]] are the 8 nearest neighbors */
case class State(id: UUID, status: Status, neighbors: Set[UUID])

sealed trait Command

case object Die extends Command

case object Live extends Command

case object Toggle extends Command

case class ReportState(sender: ActorRef[State]) extends Command

object State {
def oppositeOf(state: Status): Status = state match {
case Alive => Dead
case Dead => Alive
}
}

case class Config(id: UUID, x: Int, y: Int, size: Int)

def apply(config: Config, initialStatus: Status, neighbors: Set[UUID]): Behavior[Command] = {

Behaviors.setup { context =>

val rendered = div(
position := "absolute",
top := config.y,
left := config.x,
width := config.size,
height := config.size
).render

// change the organism's state when the user clicks (or taps, on mobile) on it.
rendered.addEventListener("click", (_: MouseEvent) => {
context.self ! Toggle
})

// add the rendered organism (just a colored square) to the document
document.body.appendChild(rendered)

def become(status: Status): Behavior[Command] = {
import State._

rendered.style.backgroundColor = status.color

Behaviors.receiveMessage {
case Die => become(Dead)
case Live => become(Alive)
case Toggle => become(oppositeOf(status))
case ReportState(sender) =>
sender ! State(config.id, status, neighbors)
Behaviors.same
}
}

become(initialStatus)
}
}

}

0 comments on commit 390f574

Please sign in to comment.