├── project ├── build.properties └── plugins.sbt ├── core └── src │ ├── test │ └── scala │ │ └── dev │ │ └── atedeg │ │ └── ecscala │ │ ├── fixtures │ │ ├── WorldFixture.scala │ │ ├── SystemBuilderFixture.scala │ │ ├── ComponentsFixture.scala │ │ ├── SystemFixture.scala │ │ └── ViewFixture.scala │ │ ├── CListTagTest.scala │ │ ├── ComponentTagTest.scala │ │ ├── CListTest.scala │ │ ├── EntityTest.scala │ │ ├── ViewTest.scala │ │ ├── SystemBuilderTest.scala │ │ ├── WorldTest.scala │ │ ├── util │ │ └── mutable │ │ │ └── ComponentsContainerTest.scala │ │ ├── SystemTest.scala │ │ └── dsl │ │ └── ECScalaDSLTest.scala │ └── main │ └── scala │ └── dev │ └── atedeg │ └── ecscala │ ├── dsl │ ├── Conversions.scala │ ├── ExtensionMethods.scala │ ├── Syntax.scala │ └── ECScalaDSL.scala │ ├── Component.scala │ ├── ComponentTag.scala │ ├── CListTag.scala │ ├── Entity.scala │ ├── CList.scala │ ├── View.scala │ ├── util │ └── mutable │ │ └── ComponentsContainer.scala │ ├── World.scala │ ├── System.scala │ └── SystemBuilder.scala ├── demo └── src │ ├── test │ └── scala │ │ └── dev │ │ └── atedeg │ │ └── ecscalademo │ │ ├── fixtures │ │ ├── WorldFixture.scala │ │ ├── AutoPauseSystemFixture.scala │ │ ├── BallSelectionSystemFixture.scala │ │ ├── WorldStateFixture.scala │ │ ├── BallCreationSystemFixture.scala │ │ ├── BallCreationRenderingSystemFixture.scala │ │ ├── DragBallSystemFixture.scala │ │ ├── VelocityFixture.scala │ │ ├── VelocityArrowSystemFixture.scala │ │ ├── RenderSystemFixture.scala │ │ ├── MovementSystemFixture.scala │ │ ├── CollisionsFixture.scala │ │ ├── FrictionSystemFixture.scala │ │ ├── WallCollisionsFixture.scala │ │ └── RegionAssignmentFixture.scala │ │ ├── systems │ │ ├── RenderSystemTest.scala │ │ ├── RegionAssignmentSystemTest.scala │ │ ├── MovementSystemTest.scala │ │ ├── DragBallSystemTest.scala │ │ ├── VelocityArrowSystemTest.scala │ │ ├── VelocityEditingSystemTest.scala │ │ ├── BallCreationRenderingSystemTest.scala │ │ ├── AutoPauseSystemTest.scala │ │ ├── BallSelectionSystemTest.scala │ │ ├── WallCollisionSystemTest.scala │ │ ├── FrictionSystemTest.scala │ │ ├── CollisionSystemTest.scala │ │ └── BallCreationSystemTest.scala │ │ ├── ColorTest.scala │ │ ├── util │ │ ├── SpacePartitionContainerTest.scala │ │ └── PreconditionChecks.scala │ │ ├── MathTest.scala │ │ └── gui │ │ └── GUITest.scala │ └── main │ ├── scala │ └── dev │ │ └── atedeg │ │ └── ecscalademo │ │ ├── systems │ │ ├── ClearCanvasSystem.scala │ │ ├── DragBallSystem.scala │ │ ├── VelocityArrowSystem.scala │ │ ├── BallCreationRenderingSystem.scala │ │ ├── MovementSystem.scala │ │ ├── AutoPauseSystem.scala │ │ ├── RenderSystem.scala │ │ ├── VelocityEditingSystem.scala │ │ ├── FrictionSystem.scala │ │ ├── BallSelectionSystem.scala │ │ ├── BallCreationSystem.scala │ │ ├── RegionAssignmentSystem.scala │ │ ├── WallCollisionSystem.scala │ │ └── CollisionSystem.scala │ │ ├── ECScalaDemo.scala │ │ ├── Components.scala │ │ ├── controller │ │ └── GameLoop.scala │ │ ├── Math.scala │ │ ├── SimulationStatus.scala │ │ ├── ECSCanvas.scala │ │ └── util │ │ └── SpacePartitionContainer.scala │ └── resources │ └── MainView.fxml ├── benchmarks ├── src │ └── main │ │ └── scala │ │ └── ecscala │ │ ├── utils │ │ ├── ComponentsClass.scala │ │ └── JmhSettings.scala │ │ ├── ViewBenchmark.scala │ │ ├── ComponentsContainerBenchmark.scala │ │ └── SystemBenchmark.scala └── README.md ├── .github ├── ISSUE_TEMPLATE │ └── product-backlog-template.md └── workflows │ ├── clean.yml │ └── ci.yml ├── .codecov.yml ├── .scalafmt.conf ├── LICENSE ├── README.md └── .gitignore /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.5.5 2 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/fixtures/WorldFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.fixtures 2 | 3 | import dev.atedeg.ecscala.World 4 | 5 | trait WorldFixture { 6 | val world: World = World() 7 | } 8 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/WorldFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import dev.atedeg.ecscala.World 4 | 5 | trait WorldFixture { 6 | val world: World = World() 7 | } 8 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/ecscala/utils/ComponentsClass.scala: -------------------------------------------------------------------------------- 1 | package ecscala.utils 2 | 3 | import dev.atedeg.ecscala.Component 4 | 5 | case class Position(x: Int, y: Int) extends Component 6 | case class Velocity(x: Int, y: Int) extends Component 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/product-backlog-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Product Backlog Template 3 | about: Template to be used when making a product backlog item 4 | title: '' 5 | labels: 'product backlog item' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Initial estimate of effort:** 11 | 12 | #### Sprint tasks 13 | - [ ] task 1 14 | 15 | #### Additional resources 16 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/fixtures/SystemBuilderFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.fixtures 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.World 5 | 6 | trait SystemBuilderFixture { 7 | val world = World() 8 | val entity = world.createEntity() 9 | entity setComponent Position(1, 1) 10 | entity setComponent Velocity(1, 1) 11 | } 12 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/ClearCanvasSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.{ DeltaTime, System, World } 4 | import dev.atedeg.ecscalademo.ECSCanvas 5 | 6 | class ClearCanvasSystem(private val ecsCanvas: ECSCanvas) extends System { 7 | override def update(deltaTime: DeltaTime, world: World): Unit = ecsCanvas.clear() 8 | } 9 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/AutoPauseSystemFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscalademo.systems.AutoPauseSystem 5 | import dev.atedeg.ecscala.dsl.ECScalaDSL 6 | 7 | trait AutoPauseSystemFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 8 | val autoPauseSystem = AutoPauseSystem(playState) 9 | world hasA system(autoPauseSystem) 10 | } 11 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.sbt" % "sbt-jacoco" % "3.3.0") 2 | addSbtPlugin("com.codecommit" % "sbt-github-actions" % "0.13.0") 3 | addSbtPlugin("com.geirsson" % "sbt-ci-release" % "1.5.7") 4 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3") 5 | addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.3") 6 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0") 7 | resolvers += Resolver.jcenterRepo 8 | addSbtPlugin("net.aichler" % "sbt-jupiter-interface" % "0.8.4") 9 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/BallSelectionSystemFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.dsl.ECScalaDSL 5 | import dev.atedeg.ecscalademo.systems.BallSelectionSystem 6 | 7 | trait BallSelectionSystemFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 8 | val ballSelectionSystem = BallSelectionSystem(playState, mouseState) 9 | world hasA system(ballSelectionSystem) 10 | } 11 | -------------------------------------------------------------------------------- /benchmarks/README.md: -------------------------------------------------------------------------------- 1 | # Benchmarks 2 | Benchmarks are implemented using [JMH](https://openjdk.java.net/projects/code-tools/jmh/) and run with [sbt-jmh](https://github.com/sbt/sbt-jmh) plugin. 3 | 4 | To execute set of benchmarks from one class type following in `sbt` terminal: 5 | ```console 6 | benchamrks / Jmh / run .*ClassName 7 | ``` 8 | 9 | Please consult [JMH](https://openjdk.java.net/projects/code-tools/jmh/) and [sbt-jmh](https://github.com/sbt/sbt-jmh) websites for more details about creating and running benchmarks. -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/WorldStateFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import org.scalatestplus.mockito.MockitoSugar.mock 4 | import dev.atedeg.ecscalademo.{ ECSCanvas, EnvironmentState, MouseState, PlayState, StartingState } 5 | 6 | trait WorldStateFixture { 7 | val playState = PlayState() 8 | val mouseState = MouseState() 9 | val environmentState = mock[EnvironmentState] 10 | val canvas = mock[ECSCanvas] 11 | val startingState = StartingState(canvas) 12 | } 13 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/fixtures/ComponentsFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.fixtures 2 | 3 | import dev.atedeg.ecscala.Component 4 | import dev.atedeg.ecscala.util.mutable.ComponentsContainer 5 | 6 | case class Mass(m: Int) extends Component 7 | case class Position(x: Int, y: Int) extends Component 8 | case class Velocity(vx: Int, vy: Int) extends Component 9 | case class Gravity(g: Int) extends Component 10 | 11 | trait ComponentsFixture { 12 | var componentsContainer: ComponentsContainer = ComponentsContainer() 13 | } 14 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/BallCreationSystemFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.dsl.ECScalaDSL 5 | import dev.atedeg.ecscalademo.systems.BallCreationSystem 6 | 7 | trait BallCreationSystemFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 8 | val entity1 = world hasAn entity 9 | val ballCreationSystem = BallCreationSystem(playState, mouseState, startingState) 10 | world hasA system(ballCreationSystem) 11 | } 12 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/BallCreationRenderingSystemFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.dsl.ECScalaDSL 5 | import dev.atedeg.ecscalademo.systems.BallCreationRenderingSystem 6 | 7 | trait BallCreationRenderingSystemFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 8 | val ballCreationRenderingSystem = BallCreationRenderingSystem(playState, mouseState, startingState, canvas) 9 | world hasA system(ballCreationRenderingSystem) 10 | } 11 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/ecscala/ViewBenchmark.scala: -------------------------------------------------------------------------------- 1 | package ecscala 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, Component, World } 5 | import ecscala.utils.{ JmhSettings, Position, Velocity } 6 | import org.openjdk.jmh.annotations.Benchmark 7 | 8 | import java.util.concurrent.TimeUnit 9 | 10 | class ViewBenchmark extends JmhSettings { 11 | 12 | @Benchmark 13 | def viewIterationBenchmark: Unit = { 14 | val view = world.getView[Position &: Velocity &: CNil] 15 | view foreach (_.head setComponent Position(2, 3)) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/DragBallSystemFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.dsl.ECScalaDSL 5 | import dev.atedeg.ecscalademo.Position 6 | import dev.atedeg.ecscalademo.systems.DragBallSystem 7 | 8 | trait DragBallSystemFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 9 | val entity1 = world hasAn entity withComponent Position(0, 0) 10 | val dragBallSystem = DragBallSystem(playState, mouseState) 11 | world hasA system(dragBallSystem) 12 | } 13 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/VelocityFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.dsl.ECScalaDSL 5 | import dev.atedeg.ecscalademo.{ Position, Velocity } 6 | import dev.atedeg.ecscalademo.systems.VelocityEditingSystem 7 | 8 | trait VelocityFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 9 | val velocityEditingSystem = VelocityEditingSystem(playState, mouseState) 10 | val entity1 = world hasAn entity withComponents { Position(0, 0) &: Velocity(0, 0) } 11 | world hasA system(velocityEditingSystem) 12 | } 13 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/VelocityArrowSystemFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.dsl.ECScalaDSL 5 | import dev.atedeg.ecscalademo.{ Position, Velocity } 6 | import dev.atedeg.ecscalademo.systems.VelocityArrowSystem 7 | 8 | trait VelocityArrowSystemFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 9 | val entity1 = world hasAn entity withComponents { Position(0, 0) &: Velocity(10, 10) } 10 | val arrowSystem = VelocityArrowSystem(playState, mouseState, canvas) 11 | world hasA system(arrowSystem) 12 | } 13 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/fixtures/SystemFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.fixtures 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, IteratingSystem } 5 | import dev.atedeg.ecscala.dsl.ECScalaDSL 6 | 7 | trait SystemFixture extends ECScalaDSL { 8 | 9 | val mySystem1 = IteratingSystem[Position &: CNil] { (_, comps, _) => 10 | val Position(x, y) &: CNil = comps 11 | Position(x + 3, y + 3) &: CNil 12 | } 13 | 14 | val mySystem2 = IteratingSystem[Position &: CNil] { (_, comps, _) => 15 | val Position(x, y) &: CNil = comps 16 | Position(x + 1, y + 1) &: CNil 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: true 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "70..100" 8 | status: 9 | project: 10 | default: 11 | target: auto 12 | threshold: 5 13 | patch: 14 | default: 15 | target: auto 16 | threshold: 5 17 | informational: true 18 | 19 | parsers: 20 | gcov: 21 | branch_detection: 22 | conditional: true 23 | loop: true 24 | method: false 25 | macro: false 26 | 27 | comment: 28 | layout: "reach,diff,flags,files,footer" 29 | behavior: default 30 | require_changes: true 31 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/ECScalaDemo.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo 2 | 3 | import javafx.fxml.FXMLLoader 4 | import javafx.scene.Parent 5 | import scalafx.application.JFXApp3 6 | import scalafx.application.JFXApp3.PrimaryStage 7 | import scalafx.Includes.* 8 | import scalafx.scene.Scene 9 | 10 | object ECScalaDemo extends JFXApp3 { 11 | 12 | override def start(): Unit = { 13 | val root: Parent = FXMLLoader.load(getClass.getResource("/MainView.fxml")) 14 | 15 | stage = new JFXApp3.PrimaryStage() { 16 | title = "ECScala Demo" 17 | scene = new Scene(root) 18 | } 19 | stage.setMinHeight(540) 20 | stage.setMinWidth(960) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/RenderSystemFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import org.scalatestplus.mockito.MockitoSugar.mock 4 | import dev.atedeg.ecscala.given 5 | import dev.atedeg.ecscala.dsl.ECScalaDSL 6 | import dev.atedeg.ecscalademo.{ Circle, Color, Position } 7 | import dev.atedeg.ecscalademo.systems.RenderSystem 8 | 9 | trait RenderSystemFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 10 | val position = Position(0, 0) 11 | val circle = Circle(20, Color(255, 0, 0)) 12 | val ball = world hasAn entity withComponents { circle &: position } 13 | val renderSystem = RenderSystem(playState, canvas) 14 | world hasA system(renderSystem) 15 | } 16 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/MovementSystemFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import org.mockito.Mockito.when 4 | import dev.atedeg.ecscala.given 5 | import dev.atedeg.ecscala.dsl.ECScalaDSL 6 | import dev.atedeg.ecscalademo.{ Position, Velocity } 7 | import dev.atedeg.ecscalademo.systems.MovementSystem 8 | 9 | trait MovementSystemFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 10 | val ball = world hasAn entity withComponents { Position(0, 0) &: Velocity(300, 0) } 11 | val movementSystem = MovementSystem(playState) 12 | world hasA system(movementSystem) 13 | when(environmentState.frictionCoefficient) thenReturn 0.05 14 | when(environmentState.wallRestitution) thenReturn 0.5 15 | } 16 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/ecscala/ComponentsContainerBenchmark.scala: -------------------------------------------------------------------------------- 1 | package ecscala 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil } 5 | import ecscala.utils.{ JmhSettings, Position, Velocity } 6 | import org.openjdk.jmh.annotations.Benchmark 7 | 8 | class ComponentsContainerBenchmark extends JmhSettings { 9 | 10 | @Benchmark 11 | def componentsContainerBenchmark: Unit = { 12 | val view = world.getView[Position &: Velocity &: CNil] 13 | view foreach { (entity, comps) => 14 | val pos = entity.getComponent[Position].get 15 | val vel = entity.getComponent[Velocity].get 16 | entity.setComponent(Position(pos.x + 1, pos.y + 1)) 17 | entity.setComponent(Velocity(vel.x + 1, vel.y + 1)) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/ecscala/SystemBenchmark.scala: -------------------------------------------------------------------------------- 1 | package ecscala 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, IteratingSystem, World } 5 | import ecscala.utils.{ JmhSettings, Position, Velocity } 6 | import org.openjdk.jmh.annotations.{ Benchmark, Setup } 7 | 8 | import java.util.concurrent.TimeUnit 9 | 10 | class SystemBenchmark extends JmhSettings { 11 | 12 | @Setup 13 | def init: Unit = { 14 | world.addSystem(IteratingSystem[Position &: Velocity &: CNil]((_, comps, _) => { 15 | val Position(x, y) &: Velocity(v1, v2) &: CNil = comps 16 | Position(x + 1, y) &: Velocity(v1, v2) 17 | })) 18 | } 19 | 20 | @Benchmark 21 | def systemIterationBenchmark: Unit = { 22 | world.update(10) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/dsl/Conversions.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.dsl 2 | 3 | import dev.atedeg.ecscala.{ &:, CNil, Component, ComponentTag, Entity } 4 | 5 | trait Conversions { 6 | 7 | /** 8 | * This conversion enables the removal of a single component from an entity with the following syntax: 9 | * 10 | * remove { myComponent } from myEntity 11 | */ 12 | given componentToClist[C <: Component: ComponentTag]: Conversion[C, C &: CNil] with 13 | def apply(component: C): C &: CNil = component &: CNil 14 | 15 | /** 16 | * This conversion enables the removal of a single entity from the world with the following syntax: 17 | * 18 | * remove { myEntity } from world 19 | */ 20 | given Conversion[Entity, Seq[Entity]] = Seq(_) 21 | } 22 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/CollisionsFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import scala.language.implicitConversions 4 | import dev.atedeg.ecscala.given 5 | import dev.atedeg.ecscala.dsl.ECScalaDSL 6 | import dev.atedeg.ecscalademo.systems.{ CollisionSystem, RegionAssignmentSystem } 7 | import dev.atedeg.ecscalademo.util.WritableSpacePartitionContainer 8 | 9 | trait CollisionsFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 10 | private val spacePartition = WritableSpacePartitionContainer() 11 | val regionAssignmentSystem = RegionAssignmentSystem(playState, spacePartition) 12 | val collisionSystem = CollisionSystem(playState, spacePartition) 13 | 14 | world hasA system(regionAssignmentSystem) 15 | world hasA system(collisionSystem) 16 | } 17 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/FrictionSystemFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import org.mockito.Mockito.when 4 | import dev.atedeg.ecscala.given 5 | import dev.atedeg.ecscala.dsl.ECScalaDSL 6 | import dev.atedeg.ecscalademo.Velocity 7 | import dev.atedeg.ecscalademo.systems.FrictionSystem 8 | 9 | trait FrictionSystemFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 10 | val initialVelocity = Velocity(300, 0) 11 | val ball = world hasAn entity withComponent initialVelocity 12 | val frictionSystem = FrictionSystem(playState, environmentState) 13 | world hasA system(frictionSystem) 14 | when(environmentState.frictionCoefficient) thenReturn 1.0 15 | when(environmentState.wallRestitution) thenReturn 0.5 16 | when(environmentState.gravity) thenReturn 9.81 17 | } 18 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/DragBallSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ DeltaTime, System, World } 5 | import dev.atedeg.ecscalademo.given 6 | import dev.atedeg.ecscalademo.{ MouseState, PlayState, Position, State } 7 | 8 | /** 9 | * This [[System]] is used to update the selected ball's [[Position]] according to the mouse pointer. 10 | */ 11 | class DragBallSystem(private val playState: PlayState, private val mouseState: MouseState) extends System { 12 | 13 | override def shouldRun: Boolean = playState.gameState == State.Dragging 14 | 15 | override def update(deltaTime: DeltaTime, world: World): Unit = { 16 | playState.selectedBall match { 17 | case Some(entity) => entity setComponent Position(mouseState.coordinates) 18 | case _ => () 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/Component.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | /** 4 | * This trait represents an [[Entity]] 's feature. 5 | */ 6 | trait Component { 7 | private var _entity: Option[Entity] = Option.empty 8 | def entity: Option[Entity] = _entity 9 | 10 | private[ecscala] def entity_=(entity: Option[Entity]): Unit = { 11 | _entity = entity 12 | } 13 | } 14 | 15 | /** 16 | * A special [[Component]] type that is used to represent a component removal inside a [[System]] 17 | */ 18 | sealed trait Deleted extends Component 19 | case object Deleted extends Deleted 20 | 21 | object Component { 22 | 23 | extension [A <: Component: ComponentTag, B <: Component: ComponentTag](head: A) 24 | /** 25 | * Convert two [[Component]] in a [[CList]]. 26 | */ 27 | def &:(otherComp: B): A &: B &: CNil = dev.atedeg.ecscala.&:(head, otherComp &: CNil) 28 | } 29 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/fixtures/ViewFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.fixtures 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.fixtures.{ Mass, Position, Velocity } 5 | 6 | trait ViewFixture extends ComponentsFixture with WorldFixture { 7 | val entity1 = world.createEntity() 8 | val entity2 = world.createEntity() 9 | val entity3 = world.createEntity() 10 | val entity4 = world.createEntity() 11 | val entity5 = world.createEntity() 12 | 13 | entity1 setComponent Position(1, 1) 14 | entity1 setComponent Velocity(1, 1) 15 | 16 | entity2 setComponent Mass(1) 17 | 18 | entity3 setComponent Position(1, 1) 19 | entity3 setComponent Velocity(1, 1) 20 | entity3 setComponent Mass(1) 21 | 22 | entity4 setComponent Position(1, 1) 23 | entity4 setComponent Velocity(1, 1) 24 | 25 | entity5 setComponent Position(1, 1) 26 | entity5 setComponent Mass(1) 27 | } 28 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/VelocityArrowSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | 5 | import dev.atedeg.ecscala.given 6 | import dev.atedeg.ecscala.{ DeltaTime, System, World } 7 | import dev.atedeg.ecscalademo.given 8 | import dev.atedeg.ecscalademo.{ Color, ECSCanvas, MouseState, PlayState, Position, State } 9 | 10 | class VelocityArrowSystem( 11 | private val playState: PlayState, 12 | private val mouseState: MouseState, 13 | private val canvas: ECSCanvas, 14 | ) extends System { 15 | private val arrowColor = Color(255, 0, 0) 16 | private val arrowWidth = 2 17 | 18 | override def shouldRun = playState.gameState == State.ChangeVelocity 19 | 20 | override def update(deltaTime: DeltaTime, world: World): Unit = { 21 | val ballPosition = playState.selectedBall.get.getComponent[Position].get 22 | canvas.drawLine(ballPosition, mouseState.coordinates, arrowColor, arrowWidth) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/BallCreationRenderingSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ DeltaTime, System, World } 5 | import dev.atedeg.ecscalademo.{ ECSCanvas, MouseState, PlayState, StartingState, State } 6 | 7 | /** 8 | * This [[System]] is used to render the ball that is about to be added to the [[World]]. 9 | * @param canvas 10 | * the canvas to draw in. 11 | */ 12 | class BallCreationRenderingSystem( 13 | private val playState: PlayState, 14 | private val mouseState: MouseState, 15 | private val startingState: StartingState, 16 | private val canvas: ECSCanvas, 17 | ) extends System { 18 | 19 | override def shouldRun: Boolean = playState.gameState == State.AddBalls 20 | 21 | override def update(deltaTime: DeltaTime, world: World): Unit = { 22 | canvas.drawCircle(mouseState.coordinates, startingState.startingRadius, startingState.startingColor, 1) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/MovementSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, Deletable, DeltaTime, Entity, IteratingSystem, View, World } 5 | import dev.atedeg.ecscalademo.{ PlayState, Position, State, Velocity } 6 | 7 | /** 8 | * The [[System]] that updates the balls Positions given the updated Velocities 9 | */ 10 | class MovementSystem(private val playState: PlayState) extends IteratingSystem[Position &: Velocity &: CNil] { 11 | override def shouldRun: Boolean = playState.gameState == State.Play 12 | 13 | override def update(entity: Entity, components: Position &: Velocity &: CNil)( 14 | deltaTime: DeltaTime, 15 | world: World, 16 | view: View[Position &: Velocity &: CNil], 17 | ): Deletable[Position &: Velocity &: CNil] = { 18 | val Position(position) &: Velocity(velocity) &: CNil = components 19 | val newPosition = position + (velocity * deltaTime) 20 | Position(newPosition) &: Velocity(velocity) &: CNil 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/Components.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo 2 | 3 | import dev.atedeg.ecscala.Component 4 | import dev.atedeg.ecscalademo.{ Point, Vector } 5 | 6 | case class Color(r: Int, g: Int, b: Int) { 7 | require(r >= 0 && r <= 255) 8 | require(g >= 0 && g <= 255) 9 | require(b >= 0 && b <= 255) 10 | } 11 | 12 | case class Position(position: Point) extends Component { 13 | def x: Double = position.x 14 | def y: Double = position.y 15 | } 16 | 17 | object Position { 18 | def apply(x: Double, y: Double): Position = Position(Point(x, y)) 19 | } 20 | 21 | given Conversion[Position, Point] = _.position 22 | 23 | case class Velocity(velocity: Vector) extends Component { 24 | def x: Double = velocity.x 25 | def y: Double = velocity.y 26 | } 27 | 28 | object Velocity { 29 | def apply(x: Double, y: Double): Velocity = Velocity(Vector(x, y)) 30 | } 31 | 32 | given Conversion[Velocity, Vector] = _.velocity 33 | 34 | case class Circle(radius: Double, color: Color) extends Component 35 | case class Mass(mass: Double) extends Component 36 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/WallCollisionsFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import scala.language.implicitConversions 4 | import org.mockito.Mockito.when 5 | import dev.atedeg.ecscala.given 6 | import dev.atedeg.ecscala.dsl.ECScalaDSL 7 | import dev.atedeg.ecscalademo.{ Circle, Color, Mass, Position, Velocity } 8 | import dev.atedeg.ecscalademo.systems.WallCollisionSystem 9 | 10 | trait WallCollisionsFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 11 | val wallCollisionSystem = WallCollisionSystem(playState, environmentState, canvas) 12 | world hasA system(wallCollisionSystem) 13 | 14 | val entities = for { 15 | x <- Seq(-1.0, 50.0, 101.0) 16 | y <- Seq(-1.0, 50.0, 101.0) 17 | } yield { 18 | world hasAn entity withComponents { 19 | Position(x, y) &: Velocity(1, 1) &: Circle(10, Color(0, 0, 0)) &: Mass(1) 20 | } 21 | } 22 | 23 | when(environmentState.frictionCoefficient) thenReturn 0.05 24 | when(environmentState.wallRestitution) thenReturn 1.0 25 | when(environmentState.gravity) thenReturn 9.81 26 | } 27 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/AutoPauseSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, Deletable, DeltaTime, Entity, IteratingSystem, View, World } 5 | import dev.atedeg.ecscalademo.given 6 | import dev.atedeg.ecscalademo.{ PlayState, State, Vector, Velocity } 7 | 8 | /** 9 | * Pause the simulation when the system's energy is zero (all the balls have velocity = 0). 10 | */ 11 | class AutoPauseSystem(private val playState: PlayState) extends IteratingSystem[Velocity &: CNil] { 12 | private val epsilon = 0.001 13 | 14 | override def shouldRun: Boolean = playState.gameState == State.Play 15 | 16 | override def update( 17 | entity: Entity, 18 | components: Velocity &: CNil, 19 | )(deltaTime: DeltaTime, world: World, view: View[Velocity &: CNil]): Deletable[Velocity &: CNil] = { 20 | val systemEnergy = view.map(_._2.h).foldLeft(0.0)(_ + _.velocity.squaredNorm) 21 | if (systemEnergy <= epsilon) { 22 | playState.gameState = State.Pause 23 | } 24 | components 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | # 2 | # .scalafmt.conf 3 | # Copyright (C) 2021 Giacomo Cavalieri, Nicolò Di Domenico Nicolas Farabegoli, Linda Vitali 4 | # 5 | # Distributed under terms of the MIT license. 6 | # 7 | 8 | version = 3.0.0 9 | runner.dialect = scala3 10 | 11 | maxColumn = 120 12 | includeCurlyBraceInSelectChains = false 13 | 14 | # Newlines 15 | newlines.penalizeSingleSelectMultiArgList = false 16 | newlines.topLevelStatementBlankLines = [ 17 | { 18 | blanks { before = 1 } 19 | } 20 | ] 21 | 22 | # Docstring 23 | docstrings.style = Asterisk 24 | docstrings.wrap = yes 25 | 26 | # Project 27 | project.git = true 28 | project.excludeFilters = ["target/"] 29 | 30 | # Indent 31 | indent.main = 2 32 | 33 | # Alignment 34 | align.openParenCallSite = false 35 | align.preset = none 36 | align.openParenDefnSite = false 37 | 38 | # Rewrite 39 | rewrite.rules = [SortModifiers, PreferCurlyFors, Imports] 40 | rewrite.imports.sort = scalastyle 41 | rewrite.sortModifiers.order = [ 42 | "implicit", "private", "sealed", "abstract", 43 | "override", "final", "protected", "lazy" 44 | ] 45 | 46 | # Space 47 | spaces.inImportCurlyBraces = true 48 | 49 | 50 | trailingCommas = always 51 | -------------------------------------------------------------------------------- /benchmarks/src/main/scala/ecscala/utils/JmhSettings.scala: -------------------------------------------------------------------------------- 1 | package ecscala.utils 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, System, World } 5 | import org.openjdk.jmh.annotations.{ 6 | Benchmark, 7 | BenchmarkMode, 8 | Fork, 9 | Level, 10 | Measurement, 11 | Mode, 12 | OutputTimeUnit, 13 | Param, 14 | Scope, 15 | Setup, 16 | State, 17 | Threads, 18 | Timeout, 19 | Warmup, 20 | } 21 | 22 | import java.util.concurrent.TimeUnit 23 | 24 | @State(Scope.Thread) 25 | @BenchmarkMode(Array(Mode.SampleTime)) 26 | @Threads(1) 27 | @Fork(1) 28 | @OutputTimeUnit(TimeUnit.MILLISECONDS) 29 | @Warmup(iterations = 50, time = 100, timeUnit = TimeUnit.MILLISECONDS) 30 | @Measurement(iterations = 100, time = 100, timeUnit = TimeUnit.MILLISECONDS) 31 | class JmhSettings { 32 | 33 | @Param(Array("1024", "2048", "4096", "10000")) 34 | var nEntities: Int = _ 35 | val world: World = World() 36 | 37 | @Setup 38 | def setup: Unit = { 39 | val entities = (0 until nEntities) map (_ => world.createEntity()) 40 | entities foreach (_ setComponent Position(1, 2)) 41 | entities foreach (_ setComponent Velocity(3, 4)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nicolas Farabegoli - Linda Vitali - Giacomo Cavalieri - Nicolò Di Domenico 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/RenderSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import org.mockito.Mockito.verify 4 | import org.scalatest.matchers.should.Matchers 5 | import org.scalatest.wordspec.AnyWordSpec 6 | import org.scalatestplus.mockito.MockitoSugar.mock 7 | import dev.atedeg.ecscala.dsl.ECScalaDSL 8 | import dev.atedeg.ecscalademo.{ Circle, ECSCanvas } 9 | import dev.atedeg.ecscalademo.fixtures.RenderSystemFixture 10 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 11 | 12 | class RenderSystemTest extends AnyWordSpec with Matchers with ECScalaDSL { 13 | 14 | "A RenderSystem" should { 15 | "always run" in { 16 | checkAllStates((playState, _) => RenderSystem(playState, mock[ECSCanvas]))( 17 | (AnyValue, AnyValue, AnyValue, AnyValue), 18 | ) 19 | } 20 | 21 | "call the drawCircle method with the correct parameters" in new RenderSystemFixture { 22 | world.update(10) 23 | verify(canvas).drawCircle(position.position, circle.radius, circle.color, 1) 24 | playState.selectedBall = Some(ball) 25 | world.update(10) 26 | verify(canvas).drawCircle(position.position, circle.radius, circle.color, 3) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/RenderSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, Deletable, DeltaTime, Entity, IteratingSystem, View, World } 5 | import dev.atedeg.ecscalademo.{ Circle, ECSCanvas, PlayState, Position } 6 | 7 | /** 8 | * The [[System]] that renders the balls on their updated Positions. 9 | * @param ecsCanvas 10 | */ 11 | class RenderSystem(private val playState: PlayState, private val ecsCanvas: ECSCanvas) 12 | extends IteratingSystem[Circle &: Position &: CNil] { 13 | private val selectedBallLineWidth = 3 14 | private val regularBallLineWidth = 1 15 | 16 | override def update(entity: Entity, components: Circle &: Position &: CNil)( 17 | deltaTime: DeltaTime, 18 | world: World, 19 | view: View[Circle &: Position &: CNil], 20 | ): Deletable[Circle &: Position &: CNil] = { 21 | val lineWidth = playState.selectedBall match { 22 | case Some(`entity`) => selectedBallLineWidth; case _ => regularBallLineWidth 23 | } 24 | val Circle(radius, color) &: Position(point) &: CNil = components 25 | ecsCanvas.drawCircle(point, radius, color, lineWidth) 26 | components 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/controller/GameLoop.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.controller 2 | 3 | import javafx.animation.AnimationTimer as JfxAnimationTimer 4 | import scalafx.beans.property.IntegerProperty 5 | 6 | trait GameLoop { 7 | val fps = IntegerProperty(0) 8 | def start: Unit 9 | def stop: Unit 10 | } 11 | 12 | object GameLoop { 13 | def apply(handler: Double => Unit): GameLoop = GameLoopImpl(handler) 14 | 15 | private class GameLoopImpl(handler: Double => Unit) extends GameLoop { 16 | 17 | private val animationTimer = new JfxAnimationTimer() { 18 | private var prevFrameTime = 0L 19 | private var count = 0 20 | 21 | override def handle(now: Long): Unit = { 22 | val delta = (now - prevFrameTime) / 1e9 23 | count += 1 24 | if (count >= 20) { 25 | fpsCount(delta) 26 | count = 0 27 | } 28 | handler(delta) 29 | prevFrameTime = now 30 | } 31 | 32 | private def fpsCount(delta: Double): Int = { 33 | val currentFps = (1 / delta).toInt 34 | fps.value = currentFps 35 | currentFps 36 | } 37 | } 38 | 39 | override def start: Unit = animationTimer.start() 40 | 41 | override def stop: Unit = animationTimer.stop() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/VelocityEditingSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | import dev.atedeg.ecscala.given 5 | import dev.atedeg.ecscala.{ DeltaTime, System, World } 6 | import dev.atedeg.ecscalademo.given 7 | import dev.atedeg.ecscalademo.{ clamped, MouseState, PlayState, Position, State, Velocity } 8 | 9 | class VelocityEditingSystem(private val playState: PlayState, private val mouseState: MouseState) extends System { 10 | val minVelocityIntensity = 0 11 | val maxVelocityIntensity = 1000 12 | val intensityMultiplier = 2 13 | 14 | override def shouldRun = playState.gameState == State.ChangeVelocity && mouseState.clicked 15 | 16 | override def update(deltaTime: DeltaTime, world: World): Unit = { 17 | val selectedBall = playState.selectedBall.get 18 | val selectedBallPosition = selectedBall.getComponent[Position].get 19 | val newVelocity = mouseState.coordinates - selectedBallPosition 20 | val newDirection = newVelocity.normalized 21 | val newIntensity = newVelocity.norm clamped (minVelocityIntensity, maxVelocityIntensity) 22 | selectedBall setComponent Velocity(newDirection * newIntensity * intensityMultiplier) 23 | playState.gameState = State.Pause 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/FrictionSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, Deletable, DeltaTime, Entity, IteratingSystem, View, World } 5 | import dev.atedeg.ecscalademo.{ EnvironmentState, PlayState, State, Velocity } 6 | 7 | /** 8 | * The [[System]] that applies the friction to the balls that have a Velocity. 9 | */ 10 | class FrictionSystem(private val playState: PlayState, private val environmentState: EnvironmentState) 11 | extends IteratingSystem[Velocity &: CNil] { 12 | 13 | override def shouldRun: Boolean = playState.gameState == State.Play 14 | 15 | override def update( 16 | entity: Entity, 17 | components: Velocity &: CNil, 18 | )(deltaTime: DeltaTime, world: World, view: View[Velocity &: CNil]): Deletable[Velocity &: CNil] = { 19 | val Velocity(velocity) &: CNil = components 20 | if (velocity.norm > 0) { 21 | val frictionDirection = velocity.normalized * -1 22 | val friction = frictionDirection * (environmentState.frictionCoefficient * environmentState.gravity) 23 | val newVelocity = velocity + friction 24 | if (velocity dot newVelocity) < 0 then Velocity(0, 0) &: CNil else Velocity(newVelocity) &: CNil 25 | } else { 26 | components 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/RegionAssignmentSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import org.scalatestplus.mockito.MockitoSugar.mock 6 | import dev.atedeg.ecscala.given 7 | import dev.atedeg.ecscalademo.State 8 | import dev.atedeg.ecscalademo.fixtures.RegionAssignmentFixture 9 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue, WritableSpacePartitionContainer } 10 | 11 | class RegionAssignmentSystemTest extends AnyWordSpec with Matchers { 12 | 13 | "A RegionAssignmentSystem" should { 14 | "run" when { 15 | "in an enabled state" in 16 | checkAllStates((playState, _) => RegionAssignmentSystem(playState, mock[WritableSpacePartitionContainer]))( 17 | (State.Play, AnyValue, AnyValue, AnyValue), 18 | ) 19 | } 20 | } 21 | 22 | "A RegionAssignmentSystem" should { 23 | "assign a region to each entity" in new RegionAssignmentFixture { 24 | playState.gameState = State.Play 25 | world.update(0) 26 | spacePartition get (0, 0) should contain theSameElementsAs List(entity1, entity2) 27 | spacePartition get (1, 1) should contain theSameElementsAs List(entity3) 28 | spacePartition get (2, 2) shouldBe empty 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/MovementSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import org.mockito.Mockito.when 6 | import dev.atedeg.ecscala.given 7 | import dev.atedeg.ecscala.{ &:, CNil } 8 | import dev.atedeg.ecscala.dsl.ECScalaDSL 9 | import dev.atedeg.ecscalademo.{ Position, State, Velocity } 10 | import dev.atedeg.ecscalademo.fixtures.MovementSystemFixture 11 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 12 | 13 | class MovementSystemTest extends AnyWordSpec with Matchers with ECScalaDSL { 14 | 15 | "A MovementSystem" should { 16 | "run" when { 17 | "in an enabled state" in 18 | checkAllStates((playState, _) => MovementSystem(playState))( 19 | (State.Play, AnyValue, AnyValue, AnyValue), 20 | ) 21 | } 22 | } 23 | 24 | "A MovementSystem" should { 25 | "update an entity Position" when { 26 | "the game is playing" in new MovementSystemFixture { 27 | playState.gameState = State.Play 28 | world.update(10) 29 | getView[Position &: Velocity &: CNil] from world should contain theSameElementsAs List( 30 | (ball, Position(3000, 0) &: Velocity(300, 0) &: CNil), 31 | ) 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/BallSelectionSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, DeltaTime, Entity, System, World } 5 | import dev.atedeg.ecscalademo.{ isOverlappedWith, Circle, MouseState, PlayState, Position, State } 6 | 7 | /** 8 | * This [[System]] is used to identify the selected ball. If a ball were selected, the [[PlayState.selectedBall]] 9 | * contains the [[Entity]] associated to the selected ball. 10 | */ 11 | class BallSelectionSystem(private val playState: PlayState, private val mouseState: MouseState) extends System { 12 | 13 | override def shouldRun: Boolean = 14 | (playState.gameState == State.Pause || playState.gameState == State.SelectBall) && mouseState.down 15 | 16 | override def update(deltaTime: DeltaTime, world: World): Unit = { 17 | val selectedEntity: Option[Entity] = world.getView[Position &: Circle &: CNil] find { e => 18 | val Position(point) &: Circle(radius, _) &: CNil = e._2 19 | mouseState.coordinates.isOverlappedWith(point, 0, radius) 20 | } map (_._1) 21 | 22 | if (selectedEntity.isEmpty) { 23 | playState.selectedBall = None 24 | playState.gameState = State.Pause 25 | } else { 26 | playState.selectedBall = Some(selectedEntity.get) 27 | playState.gameState = State.SelectBall 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/ColorTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | 6 | class ColorTest extends AnyWordSpec with Matchers { 7 | 8 | "A Color" should { 9 | "have red capped between 0 and 255" in { 10 | noException should be thrownBy Color(0, 0, 0) 11 | noException should be thrownBy Color(128, 0, 0) 12 | noException should be thrownBy Color(255, 0, 0) 13 | an[IllegalArgumentException] should be thrownBy Color(-1, 0, 0) 14 | an[IllegalArgumentException] should be thrownBy Color(256, 0, 0) 15 | } 16 | "have green capped between 0 and 255" in { 17 | noException should be thrownBy Color(0, 0, 0) 18 | noException should be thrownBy Color(0, 128, 0) 19 | noException should be thrownBy Color(0, 255, 0) 20 | an[IllegalArgumentException] should be thrownBy Color(0, -1, 0) 21 | an[IllegalArgumentException] should be thrownBy Color(0, 256, 0) 22 | } 23 | "have blue capped between 0 and 255" in { 24 | noException should be thrownBy Color(0, 0, 0) 25 | noException should be thrownBy Color(0, 0, 128) 26 | noException should be thrownBy Color(0, 0, 255) 27 | an[IllegalArgumentException] should be thrownBy Color(0, 0, -1) 28 | an[IllegalArgumentException] should be thrownBy Color(0, 0, 256) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/util/SpacePartitionContainerTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.util 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import dev.atedeg.ecscalademo.fixtures.RegionAssignmentFixture 6 | import dev.atedeg.ecscalademo.util.WritableSpacePartitionContainer 7 | 8 | class SpacePartitionContainerTest extends AnyWordSpec with Matchers { 9 | 10 | "The space partition container" should { 11 | "add entities with the required components" in new RegionAssignmentFixture { 12 | val container = WritableSpacePartitionContainer() 13 | container add (entity1, entity1Components) 14 | container add (entity2, entity2Components) 15 | container add (entity3, entity3Components) 16 | noException should be thrownBy container.build() 17 | } 18 | "get added entities by their region" in new RegionAssignmentFixture { 19 | val container = WritableSpacePartitionContainer() 20 | container add (entity1, entity1Components) 21 | container add (entity2, entity2Components) 22 | container add (entity3, entity3Components) 23 | container.build() 24 | container.regionSize shouldBe 20 25 | container get (0, 0) should contain theSameElementsAs List(entity1, entity2) 26 | container get (1, 1) should contain theSameElementsAs List(entity3) 27 | container get (2, 2) shouldBe empty 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/DragBallSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | import org.scalatest.matchers.should.Matchers 5 | import org.scalatest.wordspec.AnyWordSpec 6 | import dev.atedeg.ecscala.given 7 | import dev.atedeg.ecscala.dsl.ECScalaDSL 8 | import dev.atedeg.ecscalademo.{ Point, Position, State } 9 | import dev.atedeg.ecscalademo.fixtures.DragBallSystemFixture 10 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 11 | 12 | class DragBallSystemTest extends AnyWordSpec with Matchers with ECScalaDSL { 13 | 14 | "A DragBallSystem" should { 15 | "run" when { 16 | "in an enabled state" in 17 | checkAllStates(DragBallSystem(_, _))( 18 | (State.Dragging, AnyValue, AnyValue, AnyValue), 19 | ) 20 | } 21 | } 22 | 23 | "A DragBallSystem" when { 24 | "the game is in drag mode" should { 25 | "update the selectes entity's position" in new DragBallSystemFixture { 26 | playState.gameState = State.Dragging 27 | playState.selectedBall = Some(entity1) 28 | mouseState.coordinates = Point(10.0, 10.0) 29 | 30 | world.update(10) 31 | 32 | entity1.getComponent[Position] match { 33 | case Some(position) => position 34 | case _ => fail("A component should be defined") 35 | } shouldBe Position(10.0, 10.0) 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/VelocityArrowSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | import org.mockito.ArgumentMatchers.{ any, anyDouble, eq as is } 5 | import org.mockito.Mockito.verify 6 | import org.scalatest.matchers.should.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | import org.scalatestplus.mockito.MockitoSugar.mock 9 | import dev.atedeg.ecscala.given 10 | import dev.atedeg.ecscala.dsl.ECScalaDSL 11 | import dev.atedeg.ecscalademo.given 12 | import dev.atedeg.ecscalademo.{ ECSCanvas, Point, State, Velocity } 13 | import dev.atedeg.ecscalademo.fixtures.VelocityArrowSystemFixture 14 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 15 | 16 | class VelocityArrowSystemTest extends AnyWordSpec with Matchers with ECScalaDSL { 17 | 18 | "A VelocityArrowSystem" should { 19 | "run" when { 20 | "in an enabled state" in 21 | checkAllStates(VelocityArrowSystem(_, _, mock[ECSCanvas]))( 22 | (State.ChangeVelocity, AnyValue, AnyValue, AnyValue), 23 | ) 24 | } 25 | 26 | "draw an arrow in the canvas" in new VelocityArrowSystemFixture { 27 | playState.gameState = State.ChangeVelocity 28 | playState.selectedBall = Some(entity1) 29 | mouseState.coordinates = (10.0, 10.0) 30 | world.update(10) 31 | verify(canvas).drawLine(is(Point(0, 0)), is(Point(10, 10)), any(), anyDouble()) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/VelocityEditingSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | import org.scalatest.matchers.should.Matchers 5 | import org.scalatest.wordspec.AnyWordSpec 6 | import dev.atedeg.ecscala.given 7 | import dev.atedeg.ecscalademo.{ Point, State, Velocity } 8 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 9 | import dev.atedeg.ecscalademo.fixtures.VelocityFixture 10 | 11 | class VelocityEditingSystemTest extends AnyWordSpec with Matchers { 12 | 13 | "A VelocityEditingSystem" should { 14 | "run" when { 15 | "in an enabled state" in 16 | checkAllStates(VelocityEditingSystem(_, _))( 17 | (State.ChangeVelocity, true, AnyValue, AnyValue), 18 | ) 19 | } 20 | "correctly update the velocity" in testNewExpectedVelocity(Point(1, 2), Velocity(2, 4)) 21 | "limit the maximum velocity" in testNewExpectedVelocity(Point(3000, 0), Velocity(2000, 0)) 22 | } 23 | 24 | private def testNewExpectedVelocity(mouseCoordinates: Point, expectedVelocity: Velocity): Unit = new VelocityFixture { 25 | playState.gameState = State.ChangeVelocity 26 | playState.selectedBall = Some(entity1) 27 | mouseState.clicked = true 28 | mouseState.coordinates = mouseCoordinates 29 | velocityEditingSystem.shouldRun shouldBe true 30 | world.update(10) 31 | entity1.getComponent[Velocity].get shouldBe expectedVelocity 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/BallCreationRenderingSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scalafx.scene.canvas.Canvas 4 | import org.mockito.ArgumentMatchers.{ any, anyDouble } 5 | import org.mockito.Mockito.verify 6 | import org.scalatest.matchers.should.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | import org.scalatestplus.mockito.MockitoSugar 9 | import dev.atedeg.ecscala.given 10 | import dev.atedeg.ecscalademo.{ ECSCanvas, PlayState, StartingState, State } 11 | import dev.atedeg.ecscalademo.fixtures.BallCreationRenderingSystemFixture 12 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 13 | 14 | class BallCreationRenderingSystemTest extends AnyWordSpec with Matchers with MockitoSugar { 15 | 16 | "A BallCreationRenderingSystem" should { 17 | "run" when { 18 | "in an enabled state" in 19 | checkAllStates(BallCreationRenderingSystem(_, _, mock[StartingState], mock[ECSCanvas]))( 20 | (State.AddBalls, AnyValue, AnyValue, AnyValue), 21 | ) 22 | } 23 | } 24 | 25 | "A RenderingCreationBallSystem" when { 26 | "enabled" should { 27 | "render the ball" in new BallCreationRenderingSystemFixture { 28 | enableSystemCondition(playState) 29 | world.update(10) 30 | verify(canvas).drawCircle(any(), anyDouble(), any(), anyDouble()) 31 | } 32 | } 33 | } 34 | 35 | private def enableSystemCondition(playState: PlayState): Unit = playState.gameState = State.AddBalls 36 | } 37 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/ComponentTag.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import scala.quoted.{ Expr, Quotes, Type } 4 | 5 | /** 6 | * A ComponentTag is a trait used to describe types keeping information about the type that would otherwise be erased at 7 | * runtime. 8 | * 9 | * @tparam C 10 | * the type whose compiletime information are stored in the ComponentTag. 11 | */ 12 | sealed trait ComponentTag[C] 13 | 14 | inline given [C]: ComponentTag[C] = ${ deriveComponentTagImpl[C] } 15 | 16 | private def deriveComponentTagImpl[C: Type](using quotes: Quotes): Expr[ComponentTag[C]] = { 17 | import quotes.reflect.* 18 | val typeReprOfC = TypeRepr.of[C] 19 | if typeReprOfC =:= TypeRepr.of[Component] then 20 | report.error("Can only derive ComponentTags for subtypes of Component, not for Component itself.") 21 | else if typeReprOfC =:= TypeRepr.of[Nothing] then report.error("Cannot derive ComponentTag[Nothing]") 22 | else if !(typeReprOfC <:< TypeRepr.of[Component]) then 23 | report.error(s"${typeReprOfC.show} must be a subtype of Component") 24 | 25 | val computedString = typeReprOfC.show 26 | val computedHashCode = computedString.hashCode 27 | '{ 28 | new ComponentTag[C] { 29 | override def toString: String = ${ Expr(computedString) } 30 | override def hashCode: Int = ${ Expr(computedHashCode) } 31 | override def equals(obj: Any) = obj match { 32 | case that: ComponentTag[_] => 33 | (this eq that) || (this.hashCode == that.hashCode && this.toString == that.toString) 34 | case _ => false 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/AutoPauseSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.dsl.ECScalaDSL 5 | import org.scalatest.matchers.should.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | import dev.atedeg.ecscalademo.{ State, Velocity } 8 | import dev.atedeg.ecscalademo.fixtures.AutoPauseSystemFixture 9 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 10 | 11 | class AutoPauseSystemTest extends AnyWordSpec with Matchers with ECScalaDSL { 12 | 13 | "An AutoPauseSystem" should { 14 | "run" when { 15 | "in an enabled state" in { 16 | checkAllStates((playState, _) => AutoPauseSystem(playState))( 17 | (State.Play, AnyValue, AnyValue, AnyValue), 18 | ) 19 | } 20 | } 21 | "pause the game" when { 22 | "the energy of the system is 0" in new AutoPauseSystemFixture { 23 | playState.gameState = State.Play 24 | world hasAn entity withComponent Velocity(0.0, 0.0) 25 | world hasAn entity withComponent Velocity(0.0, 0.0) 26 | world.update(10) 27 | playState.gameState shouldBe State.Pause 28 | } 29 | } 30 | "do nothing" when { 31 | "the energy of the system is not 0" in new AutoPauseSystemFixture { 32 | playState.gameState = State.Play 33 | world hasAn entity withComponent Velocity(10.0, 10.0) 34 | world hasAn entity withComponent Velocity(0.0, 0.0) 35 | world.update(10) 36 | playState.gameState shouldBe State.Play 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/BallCreationSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, DeltaTime, System, World } 5 | import dev.atedeg.ecscala.dsl.ECScalaDSL 6 | import dev.atedeg.ecscalademo.{ 7 | isOverlappedWith, 8 | Circle, 9 | Mass, 10 | MouseState, 11 | PlayState, 12 | Position, 13 | StartingState, 14 | State, 15 | Velocity, 16 | } 17 | 18 | /** 19 | * This [[System]] is used to add a new ball into the [[World]]. If the mouse pointer is in the area of another ball, no 20 | * ball will be added. 21 | */ 22 | class BallCreationSystem( 23 | private val playState: PlayState, 24 | private val mouseState: MouseState, 25 | private val startingState: StartingState, 26 | ) extends System 27 | with ECScalaDSL { 28 | 29 | override def shouldRun: Boolean = mouseState.clicked && playState.gameState == State.AddBalls 30 | 31 | override def update(deltaTime: DeltaTime, world: World): Unit = { 32 | val canBeCreated = world.getView[Position &: Circle &: CNil] map (_._2) forall { cl => 33 | val Position(point) &: Circle(radius, _) &: CNil = cl 34 | !point.isOverlappedWith(mouseState.coordinates, radius, startingState.startingRadius) 35 | } 36 | if (canBeCreated) { 37 | world hasAn entity withComponents { 38 | Position(mouseState.coordinates) &: 39 | Circle(startingState.startingRadius, startingState.startingColor) &: 40 | Velocity(startingState.startingVelocity) &: 41 | Mass(startingState.startingMass) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/CListTagTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import dev.atedeg.ecscala.fixtures.{ ComponentsFixture, Position, Velocity } 6 | import dev.atedeg.ecscala.{ &:, CList, CNil } 7 | 8 | class CListTagTest extends AnyWordSpec with Matchers { 9 | 10 | "A CListTag" should { 11 | "have the correct tags" in { 12 | summon[CListTag[Position &: CNil]].tags shouldBe Seq(summon[ComponentTag[Position]]) 13 | summon[CListTag[Position &: Velocity &: CNil]].tags shouldBe 14 | Seq(summon[ComponentTag[Position]], summon[ComponentTag[Velocity]]) 15 | } 16 | "have no tags" when { 17 | "defined on a CNil components list" in { 18 | summon[CListTag[CNil]].tags shouldBe empty 19 | } 20 | } 21 | } 22 | 23 | "The compiler" should { 24 | "not derive a CListTag[Nothing]" in assertCListTagIsNotDerived("Nothing") 25 | "not derive a CListTag[CList]" in assertCListTagIsNotDerived("CList") 26 | "not derive a CListTag with duplicates" in assertCListTagIsNotDerived("Velocity &: Position &: Position &: CNil") 27 | "derive a correct CListTag" in assertCListTagIsDerived("Velocity &: Position &: CNil") 28 | } 29 | 30 | inline def assertCListTagIsNotDerived(inline tagType: String): ComponentsFixture = 31 | new ComponentsFixture { "summon[CListTag[" + tagType + "]]" shouldNot typeCheck } 32 | 33 | inline def assertCListTagIsDerived(inline tagType: String): ComponentsFixture = 34 | new ComponentsFixture { "summon[CListTag[" + tagType + "]]" should compile } 35 | } 36 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/Math.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo 2 | 3 | case class Point(x: Double, y: Double) { 4 | def +(vector: Vector) = Point(x + vector.x, y + vector.y) 5 | def -(vector: Vector) = Point(x - vector.x, y - vector.y) 6 | def -(point: Point) = Vector(x - point.x, y - point.y) 7 | } 8 | 9 | extension (point: Point) { 10 | 11 | def isOverlappedWith(otherPoint: Point, thisRadius: Double, otherRadius: Double): Boolean = 12 | (point - otherPoint).squaredNorm < Math.pow(thisRadius + otherRadius, 2) 13 | } 14 | 15 | given Conversion[(Double, Double), Point] = tuple => Point(tuple._1, tuple._2) 16 | 17 | case class Vector(x: Double, y: Double) { 18 | def +(vector: Vector) = Vector(x + vector.x, y + vector.y) 19 | def -(vector: Vector) = Vector(x - vector.x, y - vector.y) 20 | def unary_- = Vector(-x, -y) 21 | def *(scalar: Double) = Vector(x * scalar, y * scalar) 22 | def /(scalar: Double) = Vector(x / scalar, y / scalar) 23 | def dot(vector: Vector) = x * vector.x + y * vector.y 24 | def squaredNorm = this dot this 25 | def norm = math.sqrt(squaredNorm) 26 | def normalized = this / norm 27 | } 28 | 29 | given Conversion[(Double, Double), Vector] = tuple => Vector(tuple._1, tuple._2) 30 | 31 | extension (scalar: Double) { 32 | def *(vector: Vector) = vector * scalar 33 | } 34 | 35 | extension [T](element: T)(using ord: Ordering[T]) { 36 | 37 | def clamped(lowerBound: T, upperBound: T): T = 38 | if ord.gt(element, upperBound) then upperBound else if ord.lt(element, lowerBound) then lowerBound else element 39 | def clamped(bounds: (T, T)): T = clamped(bounds._1, bounds._2) 40 | } 41 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/RegionAssignmentSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ &:, CNil, Deletable, DeltaTime, Entity, IteratingSystem, View, World } 5 | import dev.atedeg.ecscalademo.{ Circle, Mass, PlayState, Position, State, Velocity } 6 | import dev.atedeg.ecscalademo.util.WritableSpacePartitionContainer 7 | 8 | /** 9 | * This system populates the [[SpacePartitionContainer]] with all the required entities. This system is to be run before 10 | * any collision system. 11 | * @param regions 12 | * the [[WritableSpacePartitionContainer]] that will be populated. 13 | */ 14 | class RegionAssignmentSystem(private val playState: PlayState, val regions: WritableSpacePartitionContainer) 15 | extends IteratingSystem[Position &: Velocity &: Circle &: Mass &: CNil] { 16 | 17 | override def shouldRun = playState.gameState == State.Play 18 | 19 | override def before( 20 | deltaTime: DeltaTime, 21 | world: World, 22 | view: View[Position &: Velocity &: Circle &: Mass &: CNil], 23 | ): Unit = regions.clear() 24 | 25 | override def update( 26 | entity: Entity, 27 | components: Position &: Velocity &: Circle &: Mass &: CNil, 28 | )( 29 | deltaTime: DeltaTime, 30 | world: World, 31 | view: View[Position &: Velocity &: Circle &: Mass &: CNil], 32 | ): Deletable[Position &: Velocity &: Circle &: Mass &: CNil] = { 33 | regions add (entity, components) 34 | components 35 | } 36 | 37 | override def after( 38 | deltaTime: DeltaTime, 39 | world: World, 40 | view: View[Position &: Velocity &: Circle &: Mass &: CNil], 41 | ): Unit = regions.build() 42 | } 43 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/ComponentTagTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import dev.atedeg.ecscala.fixtures.{ ComponentsFixture, Position, Velocity } 6 | 7 | class ComponentTagTest extends AnyWordSpec with Matchers { 8 | 9 | "A ComponentTag[Position]" should new ComponentsFixture { 10 | "be equal to ComponentTag[Position]" in assertTagsEqual[Position, Position] 11 | "not be equal to ComponentTag[Velocity]" in assertTagsNotEqual[Position, Velocity] 12 | } 13 | 14 | def assertTagsEqual[A, B](using ctA: ComponentTag[A], ctB: ComponentTag[B]): Unit = { 15 | ctA shouldEqual ctB 16 | ctB shouldEqual ctA 17 | } 18 | 19 | def assertTagsNotEqual[A, B](using ctA: ComponentTag[A], ctB: ComponentTag[B]): Unit = { 20 | ctA should not equal ctB 21 | ctB should not equal ctA 22 | } 23 | 24 | "The compiler" should { 25 | "not derive a ComponentTag[List[Position]]" in assertComponentTagIsNotDerived("List[Position]") 26 | "not derive a ComponentTag[Nothing]" in assertComponentTagIsNotDerived("Nothing") 27 | "not derive a ComponentTag[Component]" in assertComponentTagIsNotDerived("Component") 28 | "derive a ComponentTag[Position]" in assertComponentTagIsDerived("Position") 29 | "derive a ComponentTag[Velocity]" in assertComponentTagIsDerived("Velocity") 30 | } 31 | 32 | inline def assertComponentTagIsNotDerived(inline tagType: String): ComponentsFixture = 33 | new ComponentsFixture { "summon[ComponentTag[" + tagType + "]]" shouldNot typeCheck } 34 | 35 | inline def assertComponentTagIsDerived(inline tagType: String): ComponentsFixture = 36 | new ComponentsFixture { "summon[ComponentTag[" + tagType + "]]" should compile } 37 | } 38 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/fixtures/RegionAssignmentFixture.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.fixtures 2 | 3 | import scala.language.implicitConversions 4 | import dev.atedeg.ecscala.given 5 | import dev.atedeg.ecscala.{ &:, CNil, World } 6 | import dev.atedeg.ecscala.dsl.ECScalaDSL 7 | import dev.atedeg.ecscalademo.{ Circle, Color, Mass, Position, Velocity } 8 | import dev.atedeg.ecscalademo.systems.RegionAssignmentSystem 9 | import dev.atedeg.ecscalademo.util.WritableSpacePartitionContainer 10 | 11 | trait RegionAssignmentFixture extends ECScalaDSL with WorldFixture with WorldStateFixture { 12 | private val color = Color(0, 0, 0) 13 | 14 | val entity1 = world hasAn entity withComponents { 15 | Position(0, 0) &: Velocity(0, 0) &: Circle(2, color) &: Mass(1) 16 | } 17 | 18 | val entity1Components = entity1.getComponent[Position].get 19 | &: entity1.getComponent[Velocity].get 20 | &: entity1.getComponent[Circle].get 21 | &: entity1.getComponent[Mass].get 22 | &: CNil 23 | 24 | val entity2 = world hasAn entity withComponents { 25 | Position(19, 19) &: Velocity(0, 0) &: Circle(10, color) &: Mass(1) 26 | } 27 | 28 | val entity2Components = entity2.getComponent[Position].get 29 | &: entity2.getComponent[Velocity].get 30 | &: entity2.getComponent[Circle].get 31 | &: entity2.getComponent[Mass].get 32 | &: CNil 33 | 34 | val entity3 = world hasAn entity withComponents { 35 | Position(20, 20) &: Velocity(0, 0) &: Circle(5, color) &: Mass(1) 36 | } 37 | 38 | val entity3Components = entity3.getComponent[Position].get 39 | &: entity3.getComponent[Velocity].get 40 | &: entity3.getComponent[Circle].get 41 | &: entity3.getComponent[Mass].get 42 | &: CNil 43 | 44 | val spacePartition = WritableSpacePartitionContainer() 45 | val regionAssignmentSystem = RegionAssignmentSystem(playState, spacePartition) 46 | world hasA system(regionAssignmentSystem) 47 | } 48 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/CListTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import dev.atedeg.ecscala.given 6 | import dev.atedeg.ecscala.fixtures.{ ComponentsFixture, Mass, Position, Velocity } 7 | 8 | class CListTest extends AnyWordSpec with Matchers { 9 | 10 | "A CList" must { 11 | "be created with apply()" in { 12 | CList() shouldBe CNil 13 | } 14 | "be created from apply with Component" in { 15 | CList(Position(1, 1)) shouldBe Position(1, 1) &: CNil 16 | } 17 | "only contain elements of type T <: Component" in new ComponentsFixture { 18 | "val t = 1 &: CNil" shouldNot typeCheck 19 | "val t = Position(1, 1) &: Velocity(2, 2)" should compile 20 | } 21 | // This test is temporarily ignored due to an issue with scalatest. 22 | // Issue link: https://github.com/scalatest/scalatest/issues/2062 23 | "fail to compile when trying to do an invalid unpacking" ignore /*in new ComponentsFixture with*/ { 24 | "val a &: CNil = Position(1, 1) &: Velocity(1, 1) &: CNil" shouldNot typeCheck 25 | "val a &: b &: CNil = Position(1, 1) &: CNil" shouldNot typeCheck 26 | } 27 | } 28 | 29 | "A CList" when { 30 | "empty" should { 31 | "return an empty iterator" in { 32 | CNil.iterator.hasNext shouldBe false 33 | an[Exception] should be thrownBy CNil.iterator.next 34 | CNil.iterator shouldBe Iterator.empty 35 | } 36 | "be printed as 'CNil'" in { 37 | CNil.toString shouldBe "CNil" 38 | } 39 | } 40 | "has elements" should { 41 | "iterate over its elements" in new ComponentsFixture { 42 | val cList: Position &: Velocity &: Mass &: CNil = Position(1, 2) &: Velocity(2, 2) &: Mass(3) 43 | cList.iterator.hasNext shouldBe true 44 | cList.iterator.next shouldBe Position(1, 2) 45 | cList.toList shouldBe List(Position(1, 2), Velocity(2, 2), Mass(3)) 46 | } 47 | "print its elements" in new ComponentsFixture { 48 | val cList = Position(1, 2) &: Velocity(2, 2) 49 | cList.toString shouldBe "Position(1,2) &: Velocity(2,2)" 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/BallSelectionSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | import org.scalatest.matchers.should.Matchers 5 | import org.scalatest.wordspec.AnyWordSpec 6 | import dev.atedeg.ecscala.given 7 | import dev.atedeg.ecscala.dsl.ECScalaDSL 8 | import dev.atedeg.ecscalademo.{ Circle, MouseState, PlayState, Point, Position, State } 9 | import dev.atedeg.ecscalademo.fixtures.BallSelectionSystemFixture 10 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 11 | 12 | class BallSelectionSystemTest extends AnyWordSpec with Matchers with ECScalaDSL { 13 | 14 | "A BallSelectionSystem" should { 15 | "run" when { 16 | "in an enabled state" in 17 | checkAllStates(BallSelectionSystem(_, _))( 18 | (State.Pause, AnyValue, true, AnyValue), 19 | (State.SelectBall, AnyValue, true, AnyValue), 20 | ) 21 | } 22 | } 23 | 24 | "A BallSelectionSystem" when { 25 | "a ball is selected" should { 26 | "set the ball as currently selected" in new BallSelectionSystemFixture { 27 | enableSystemCondition(playState, mouseState) 28 | playState.selectedBall = None 29 | 30 | val entity1 = world hasAn entity withComponents { 31 | Position(10.0, 10.0) &: Circle(startingState.startingRadius, startingState.startingColor) 32 | } 33 | val entity2 = world hasAn entity withComponents { 34 | Position(70.0, 70.0) &: Circle(startingState.startingRadius, startingState.startingColor) 35 | } 36 | 37 | mouseState.coordinates = Point(10.0, 10.0) 38 | world.update(10) 39 | playState.selectedBall shouldBe Some(entity1) 40 | 41 | mouseState.coordinates = Point(65.0, 65.0) 42 | world.update(10) 43 | playState.selectedBall shouldBe Some(entity2) 44 | } 45 | } 46 | } 47 | 48 | private def enableSystemCondition(playState: PlayState, mouseState: MouseState): Unit = { 49 | playState.gameState = State.SelectBall 50 | mouseState.down = true 51 | } 52 | 53 | private def disableSystemCondition(playState: PlayState, mouseState: MouseState): Unit = { 54 | playState.gameState = State.Pause 55 | mouseState.down = false 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/WallCollisionSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | import org.mockito.Mockito.when 5 | import org.scalatest.BeforeAndAfterEach 6 | import org.scalatest.Inspectors.forAll 7 | import org.scalatest.matchers.should.Matchers 8 | import org.scalatest.wordspec.AnyWordSpec 9 | import org.scalatestplus.mockito.MockitoSugar.mock 10 | import dev.atedeg.ecscala.given 11 | import dev.atedeg.ecscala.{ &:, CNil } 12 | import dev.atedeg.ecscala.dsl.ECScalaDSL 13 | import dev.atedeg.ecscalademo.{ ECSCanvas, EnvironmentState, Position, State, Velocity } 14 | import dev.atedeg.ecscalademo.fixtures.WallCollisionsFixture 15 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 16 | 17 | class WallCollisionSystemTest 18 | extends AnyWordSpec 19 | with BeforeAndAfterEach 20 | with Matchers 21 | with ECScalaDSL 22 | with WallCollisionsFixture { 23 | 24 | override protected def beforeEach(): Unit = { 25 | playState.gameState = State.Play 26 | when(canvas.width) thenReturn 100.0 27 | when(canvas.height) thenReturn 100.0 28 | world.update(1) 29 | } 30 | 31 | "A WallCollisionSystem" should { 32 | "run" when { 33 | "in an enabled state" in 34 | checkAllStates((playState, _) => WallCollisionSystem(playState, mock[EnvironmentState], mock[ECSCanvas]))( 35 | (State.Play, AnyValue, AnyValue, AnyValue), 36 | ) 37 | } 38 | 39 | "keep entities inside the canvas's borders" in 40 | checkViewElements { (position, _) => 41 | position.x should (be >= 10.0 and be <= 90.0) 42 | position.y should (be >= 10.0 and be <= 90.0) 43 | } 44 | "change velocities to entities that collide with the canvas's borders" in 45 | checkViewElements { (position, velocity) => 46 | velocity shouldBe Velocity(if position.x == 90.0 then -1 else 1, if position.y == 90.0 then -1 else 1) 47 | } 48 | } 49 | 50 | private def checkViewElements(f: (Position, Velocity) => Unit): Unit = { 51 | val view = getView[Position &: Velocity &: CNil] from world 52 | forAll(view map (_._2)) { comps => 53 | val position &: velocity &: CNil = comps 54 | f(position, velocity) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/FrictionSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | import org.mockito.Mockito.when 5 | import org.scalatest.matchers.should.Matchers 6 | import org.scalatest.wordspec.AnyWordSpec 7 | import org.scalatestplus.mockito.MockitoSugar 8 | import dev.atedeg.ecscala.given 9 | import dev.atedeg.ecscala.{ &:, CNil, View } 10 | import dev.atedeg.ecscala.dsl.ECScalaDSL 11 | import dev.atedeg.ecscalademo.given 12 | import dev.atedeg.ecscalademo.{ EnvironmentState, State, Velocity } 13 | import dev.atedeg.ecscalademo.fixtures.FrictionSystemFixture 14 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 15 | 16 | class FrictionSystemTest extends AnyWordSpec with Matchers with ECScalaDSL with MockitoSugar { 17 | 18 | "A FrictionSystem" should { 19 | "run" when { 20 | "in an enabled state" in 21 | checkAllStates((playState, _) => FrictionSystem(playState, mock[EnvironmentState]))( 22 | (State.Play, AnyValue, AnyValue, AnyValue), 23 | ) 24 | } 25 | } 26 | 27 | "A FrictionSystem" when { 28 | "the simulation is playing" should { 29 | "update a ball's Velocity considering the friction" in new FrictionSystemFixture { 30 | playState.gameState = State.Play 31 | (0 to 2) foreach { _ => world.update(10) } 32 | val view: View[Velocity &: CNil] = getView[Velocity &: CNil] from world 33 | val Velocity(vector) &: CNil = view.head._2 34 | vector.x should be < initialVelocity.velocity.x 35 | } 36 | "not update the component's Velocity if its initial Velocity is 0" in new FrictionSystemFixture { 37 | ball setComponent Velocity(0, 0) 38 | playState.gameState = State.Play 39 | world.update(10) 40 | getView[Velocity &: CNil] from world should contain theSameElementsAs List((ball, Velocity(0, 0) &: CNil)) 41 | } 42 | } 43 | "the simulation is not playing" should { 44 | "not update the components" in new FrictionSystemFixture { 45 | playState.gameState = State.Pause 46 | world.update(10) 47 | getView[Velocity &: CNil] from world should contain theSameElementsAs List((ball, Velocity(300, 0) &: CNil)) 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/WallCollisionSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | import dev.atedeg.ecscala.given 5 | import dev.atedeg.ecscala.{ &:, CNil, Deletable, DeltaTime, Entity, IteratingSystem, View, World } 6 | import dev.atedeg.ecscalademo.given 7 | import dev.atedeg.ecscalademo.{ 8 | clamped, 9 | Circle, 10 | ECSCanvas, 11 | EnvironmentState, 12 | PlayState, 13 | Point, 14 | Position, 15 | State, 16 | Vector, 17 | Velocity, 18 | } 19 | import dev.atedeg.ecscalademo.util.WritableSpacePartitionContainer 20 | 21 | /** 22 | * This system handles the collisions of balls on the walls. It should be run after the [[RegionAssignmentSystem]]. 23 | * @param canvas 24 | * the [[SpacePartitionContainer]] that will be read. 25 | */ 26 | class WallCollisionSystem( 27 | private val playState: PlayState, 28 | private val environmentState: EnvironmentState, 29 | private val canvas: ECSCanvas, 30 | ) extends IteratingSystem[Position &: Velocity &: Circle &: CNil] { 31 | 32 | override def shouldRun: Boolean = playState.gameState == State.Play 33 | 34 | override def update(entity: Entity, components: Position &: Velocity &: Circle &: CNil)( 35 | deltaTime: DeltaTime, 36 | world: World, 37 | view: View[Position &: Velocity &: Circle &: CNil], 38 | ): Deletable[Position &: Velocity &: Circle &: CNil] = { 39 | val Position(Point(x, y)) &: Velocity(Vector(vx, vy)) &: Circle(radius, color) &: CNil = components 40 | val collidesLeft = x < radius 41 | val collidesRight = x > canvas.width - radius 42 | val collidesTop = y < radius 43 | val collidesBottom = y > canvas.height - radius 44 | lazy val mirroredHorizontalVelocity = -vx * environmentState.wallRestitution 45 | lazy val mirroredVerticalVelocity = -vy * environmentState.wallRestitution 46 | val newVelocity = Velocity( 47 | if (collidesLeft && vx < 0) || (collidesRight && vx > 0) then mirroredHorizontalVelocity else vx, 48 | if (collidesTop && vy < 0) || (collidesBottom && vy > 0) then mirroredVerticalVelocity else vy, 49 | ) 50 | val newPosition = Position( 51 | x clamped (radius, canvas.width - radius), 52 | y clamped (radius, canvas.height - radius), 53 | ) 54 | newPosition &: newVelocity &: Circle(radius, color) &: CNil 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/CListTag.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import scala.annotation.targetName 4 | import scala.quoted.{ Expr, Quotes, Type } 5 | 6 | sealed trait CListTag[L <: CList] { 7 | def tags: Seq[ComponentTag[Component]] 8 | } 9 | 10 | inline given [L <: CList]: CListTag[L] = ${ deriveCListTagImpl[L] } 11 | 12 | extension [L <: CList: CListTag](list: L) 13 | def taggedWith(clt: CListTag[L]): Iterable[(Component, ComponentTag[Component])] = list zip clt.tags 14 | 15 | extension [L <: CList: CListTag](list: Deletable[L]) 16 | 17 | @targetName("deletableTaggedWith") 18 | def taggedWith(clt: CListTag[L]): Iterable[(Component, ComponentTag[Component])] = list zip clt.tags 19 | 20 | private def deriveCListTagImpl[L <: CList: Type](using quotes: Quotes): Expr[CListTag[L]] = { 21 | import quotes.reflect.* 22 | val typeReprOfL = TypeRepr.of[L] 23 | if typeReprOfL =:= TypeRepr.of[Nothing] then report.error("Cannot derive CListTag for Nothing.") 24 | else if typeReprOfL =:= TypeRepr.of[CList] then 25 | report.error("Cannot derive CListTag for a generic CList, its exact components must be known at compile time.") 26 | else if containsDuplicates[L] then 27 | report.error("A CListTag cannot be derived from CLists with duplicate element types.") 28 | 29 | '{ 30 | new CListTag[L] { 31 | override def tags = getTags[L] 32 | override def toString: String = tags.toString 33 | override def hashCode: Int = tags.hashCode 34 | override def equals(obj: Any) = obj match { 35 | case that: CListTag[_] => that.tags == this.tags 36 | case _ => false 37 | } 38 | } 39 | } 40 | } 41 | 42 | inline private def getTags[L <: CList]: Seq[ComponentTag[Component]] = { 43 | import scala.compiletime.erasedValue 44 | inline erasedValue[L] match { 45 | case _: (head &: tail) => summon[ComponentTag[head]].asInstanceOf[ComponentTag[Component]] +: getTags[tail] 46 | case _ => Seq() 47 | } 48 | } 49 | 50 | private def containsDuplicates[L <: CList: Type](using quotes: Quotes): Boolean = 51 | Type.of[L] match { 52 | case '[head &: tail] => countRepetitions[L, head] > 1 || containsDuplicates[tail] 53 | case _ => false 54 | } 55 | 56 | private def countRepetitions[L <: CList: Type, C <: Component: Type](using quotes: Quotes): Int = 57 | Type.of[L] match { 58 | case '[C &: tail] => 1 + countRepetitions[tail, C] 59 | case '[_ &: tail] => countRepetitions[tail, C] 60 | case _ => 0 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | run: | 21 | # Customize those three lines with your repository and credentials: 22 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 23 | 24 | # A shortcut to call GitHub API. 25 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 26 | 27 | # A temporary file which receives HTTP response headers. 28 | TMPFILE=/tmp/tmp.$$ 29 | 30 | # An associative array, key: artifact name, value: number of artifacts of that name. 31 | declare -A ARTCOUNT 32 | 33 | # Process all artifacts on this repository, loop on returned "pages". 34 | URL=$REPO/actions/artifacts 35 | while [[ -n "$URL" ]]; do 36 | 37 | # Get current page, get response headers in a temporary file. 38 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 39 | 40 | # Get URL of next page. Will be empty if we are at the last page. 41 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 42 | rm -f $TMPFILE 43 | 44 | # Number of artifacts on this page: 45 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 46 | 47 | # Loop on all artifacts on this page. 48 | for ((i=0; $i < $COUNT; i++)); do 49 | 50 | # Get name of artifact and count instances of this name. 51 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 52 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 53 | 54 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 55 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 56 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 57 | ghapi -X DELETE $REPO/actions/artifacts/$id 58 | done 59 | done 60 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/SimulationStatus.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo 2 | 3 | import scalafx.beans.property.DoubleProperty 4 | import dev.atedeg.ecscala.Entity 5 | 6 | enum State { 7 | case Pause, Play, AddBalls, SelectBall, ChangeVelocity, Dragging 8 | } 9 | 10 | case class MouseState( 11 | var coordinates: Point = Point(0, 0), 12 | var clicked: Boolean = false, 13 | var down: Boolean = false, 14 | var up: Boolean = false, 15 | ) 16 | 17 | case class PlayState(var gameState: State = State.Pause, var selectedBall: Option[Entity] = None) 18 | 19 | trait StartingState { 20 | val startingRadius: Double = 20.0 21 | val startingColor: Color = Color(255, 255, 0) 22 | val startingMass: Double = 1 23 | val startingVelocity: Vector = Vector(0.0, 0.0) 24 | val startingPosition: Seq[Position] 25 | 26 | val startingVelocities = List( 27 | Velocity(1000, 0), 28 | Velocity(0, 0), 29 | Velocity(0, 0), 30 | Velocity(0, 0), 31 | Velocity(0, 0), 32 | Velocity(0, 0), 33 | Velocity(0, 0), 34 | ) 35 | 36 | val startingColors = List( 37 | Color(255, 255, 255), 38 | Color(255, 215, 0), 39 | Color(0, 0, 255), 40 | Color(255, 0, 0), 41 | Color(75, 0, 130), 42 | Color(255, 69, 0), 43 | Color(34, 139, 34), 44 | ) 45 | } 46 | 47 | object StartingState { 48 | 49 | def apply(canvas: ECSCanvas): StartingState = new StartingState { 50 | 51 | override val startingPosition = Seq( 52 | Position(canvas.width / 3, canvas.height / 2), 53 | Position(0.66 * canvas.width, canvas.height / 2), 54 | Position(0.66 * canvas.width, canvas.height / 2 - (2 * startingRadius + 4)), 55 | Position(0.66 * canvas.width, canvas.height / 2 + (2 * startingRadius + 4)), 56 | Position(0.66 * canvas.width - 2 * startingRadius, canvas.height / 2 - (startingRadius + 2)), 57 | Position(0.66 * canvas.width - 2 * startingRadius, canvas.height / 2 + (startingRadius + 2)), 58 | Position(0.66 * canvas.width - 4 * startingRadius, canvas.height / 2), 59 | ) 60 | } 61 | } 62 | 63 | trait EnvironmentState { 64 | def frictionCoefficient: Double 65 | def wallRestitution: Double 66 | val gravity: Double = 9.81 67 | } 68 | 69 | object EnvironmentState { 70 | 71 | def apply(frictionCoefficentProperty: DoubleProperty, wallRestitutionProperty: DoubleProperty): EnvironmentState = 72 | new EnvironmentState { 73 | override def frictionCoefficient: Double = frictionCoefficentProperty.value 74 | 75 | override def wallRestitution: Double = wallRestitutionProperty.value 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/EntityTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import dev.atedeg.ecscala.given 6 | import dev.atedeg.ecscala.fixtures.{ Mass, Position, WorldFixture } 7 | 8 | class EntityTest extends AnyWordSpec with Matchers { 9 | 10 | "An Entity" should { 11 | "have unique id" when { 12 | "generated from the world" in new WorldFixture { 13 | val entities = (0 until 1000) map { _ => world.createEntity() } 14 | entities shouldBe entities.distinct 15 | } 16 | } 17 | "forbid having an already assigned component" in new WorldFixture { 18 | val component = Position(0, 0) 19 | val entity1 = world.createEntity() 20 | val entity2 = world.createEntity() 21 | entity1 setComponent component 22 | an[IllegalArgumentException] shouldBe thrownBy(entity2 setComponent component) 23 | } 24 | "get its components correctly" in new WorldFixture { 25 | val entity1 = world.createEntity() 26 | val entity2 = world.createEntity() 27 | val c1 = Position(1, 1) 28 | val c2 = Position(2, 2) 29 | entity1 setComponent c1 30 | entity2 setComponent c2 31 | entity1.getComponent[Position] shouldBe Some(c1) 32 | entity1.getComponent[Position] flatMap (_.entity) shouldBe Some(entity1) 33 | entity2.getComponent[Position] shouldBe Some(c2) 34 | entity2.getComponent[Position] flatMap (_.entity) shouldBe Some(entity2) 35 | entity1.getComponent[Mass] shouldBe None 36 | } 37 | "remove its component correctly" when { 38 | def testComponentRemoval(removal: (Entity, Position) => Unit) = new WorldFixture { 39 | val entity = world.createEntity() 40 | val component = Position(1, 1) 41 | component.entity shouldBe empty 42 | entity setComponent component 43 | component.entity should contain(entity) 44 | removal(entity, component) 45 | component.entity shouldBe empty 46 | } 47 | "removing them by type" in { 48 | testComponentRemoval((entity, _) => entity.removeComponent[Position]) 49 | } 50 | "removing them by reference" in new WorldFixture { 51 | testComponentRemoval(_ removeComponent _) 52 | } 53 | } 54 | "do nothing when removing components that do not belong to it" in new WorldFixture { 55 | val entity = world.createEntity() 56 | entity.removeComponent[Position] 57 | entity removeComponent Position(1, 1) 58 | world.getComponents[Position] shouldBe empty 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/util/PreconditionChecks.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.util 2 | 3 | import org.scalatest.Assertions.withClue 4 | import org.scalatest.matchers.should.Matchers.shouldBe 5 | import org.scalatest.prop.TableDrivenPropertyChecks.* 6 | import dev.atedeg.ecscala.System 7 | import dev.atedeg.ecscalademo.{ MouseState, PlayState, State } 8 | 9 | sealed trait AnyValue 10 | case object AnyValue extends AnyValue 11 | type TestState[T] = T | AnyValue 12 | type ClickedState = TestState[Boolean] 13 | type DownState = TestState[Boolean] 14 | type UpState = TestState[Boolean] 15 | type StateDescription = (TestState[State], ClickedState, DownState, UpState) 16 | 17 | private given defaultBooleanValues: Set[Boolean] = Set(true, false) 18 | private given defaultStateValues: Set[State] = Set.from(State.values) 19 | 20 | extension [T](state: TestState[T]) { 21 | 22 | def values(using defaultValues: Set[T]): Set[T] = state match { 23 | case AnyValue => defaultValues 24 | case t => Set(t.asInstanceOf[T]) 25 | } 26 | } 27 | 28 | def checkAllStates(systemBuilder: (PlayState, MouseState) => System)(enabled: StateDescription*): Unit = { 29 | import StateUtils.* 30 | val enabledStates = expandStates(enabled*) 31 | val disabledStates = allStates -- enabledStates 32 | checkStates(systemBuilder)(enabledStates)(true) 33 | checkStates(systemBuilder)(disabledStates)(false) 34 | } 35 | 36 | private object StateUtils { 37 | 38 | def checkStates( 39 | systemBuilder: (PlayState, MouseState) => System, 40 | )(states: Set[(PlayState, MouseState)])(shouldRun: Boolean) = { 41 | val table = Table(("playState", "mouseState"), states.toSeq*) 42 | forAll(table) { (playState, mouseState) => 43 | val errorClue = 44 | s"System expected to be ${if shouldRun then "enabled, instead was disabled" else "disabled, instead was enabled"}." 45 | withClue(errorClue) { systemBuilder(playState, mouseState).shouldRun shouldBe shouldRun } 46 | } 47 | } 48 | 49 | def expandStates(states: StateDescription*): Set[(PlayState, MouseState)] = for { 50 | (stateValue, clickedValue, downValue, upValue) <- Set.from(states) 51 | state <- stateValue.values 52 | clicked <- clickedValue.values 53 | down <- downValue.values 54 | up <- upValue.values 55 | } yield (PlayState(state), MouseState(clicked = clicked, down = down, up = up)) 56 | 57 | def allStates: Set[(PlayState, MouseState)] = for { 58 | clicked <- Set(true, false) 59 | down <- Set(true, false) 60 | up <- Set(true, false) 61 | state <- Set.from(State.values) 62 | } yield (PlayState(state), MouseState(clicked = clicked, down = down, up = up)) 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ECScala 2 | An [Entity Component System](https://en.wikipedia.org/wiki/Entity_component_system) Scala framework 3 | 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![Maven Central](https://img.shields.io/maven-central/v/dev.atedeg/ecscala_3)](https://search.maven.org/artifact/dev.atedeg/ecscala_3) 6 | [![GitHub release](https://img.shields.io/github/release/atedeg/ecscala.svg)](https://gitHub.com/atedeg/ecscala/releases/) 7 | ![example workflow](https://github.com/atedeg/ecscala/workflows/CI/badge.svg) 8 | [![codecov](https://codecov.io/gh/atedeg/ecscala/branch/develop/graph/badge.svg?token=0XZ4XF71AY)](https://codecov.io/gh/atedeg/ecscala) 9 | [![javadoc](https://javadoc.io/badge2/dev.atedeg/ecscala_3/javadoc.svg)](https://javadoc.io/doc/dev.atedeg/ecscala_3) 10 | 11 | ## Getting Started 12 | 13 | ```scala 14 | libraryDependencies += "dev.atedeg" %% "ecscala" % "0.2.1" 15 | ``` 16 | 17 | ## Usage 18 | ECScala allows you to use the ECS architechtural pattern with ease: 19 | ```scala 20 | import dev.atedeg.ecscala.given 21 | 22 | case class Position(x: Float, y: Float) extends Component 23 | case class Velocity(vx: Float, vy: Float) extends Component 24 | 25 | object Example extends ECScalaDSL { 26 | val world = World() 27 | world hasAn entity withComponents { Position(1, 1) &: Velocity(2, 2) } 28 | val movementSystem = System[Position &: Velocity &: CNil] 29 | .withUpdate { (_, components, deltaTime) => 30 | val Position(x, y) &: Velocity(vx, vy) &: CNil = components 31 | Position(x + vx*deltaTime, y + vy*deltaTime) &: Velocity(vx, vy) &: CNil 32 | } 33 | world hasA system(movementSystem) 34 | world.update(10) 35 | } 36 | ``` 37 | To learn how to use ECScala you can start by reading [its wiki](https://github.com/nicolasfara/ecscala/wiki)! 38 | 39 | ## Contributing 40 | 41 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. 42 | 43 | 1. Fork the Project 44 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 45 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 46 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 47 | 5. Open a Pull Request 48 | 49 | ## Authors 50 | 51 | - **Giacomo Cavalieri** - [giacomocavalieri](https://github.com/giacomocavalieri) 52 | - **Nicolò Di Domenico** - [ndido98](https://github.com/ndido98) 53 | - **Nicolas Farabegoli** - [nicolasfara](https://github.com/nicolasfara) 54 | - **Linda Vitali** - [vitlinda](https://github.com/vitlinda) 55 | 56 | ## License 57 | 58 | Distributed under the MIT license. See [LICESE](LICENSE) for more information. 59 | 60 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/CollisionSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | import org.scalatest.matchers.should.Matchers 5 | import org.scalatest.wordspec.AnyWordSpec 6 | import org.scalatestplus.mockito.MockitoSugar.mock 7 | import dev.atedeg.ecscala.given 8 | import dev.atedeg.ecscala.dsl.ECScalaDSL 9 | import dev.atedeg.ecscalademo.given 10 | import dev.atedeg.ecscalademo.{ Circle, Color, Mass, Position, State, Vector, Velocity } 11 | import dev.atedeg.ecscalademo.fixtures.CollisionsFixture 12 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue, WritableSpacePartitionContainer } 13 | 14 | class CollisionSystemTest extends AnyWordSpec with Matchers with ECScalaDSL { 15 | private val black = Color(0, 0, 0) 16 | 17 | "A CollisionSystem" should { 18 | "run" when { 19 | "in an enabled state" in 20 | checkAllStates((playState, _) => CollisionSystem(playState, mock[WritableSpacePartitionContainer]))( 21 | (State.Play, AnyValue, AnyValue, AnyValue), 22 | ) 23 | } 24 | } 25 | 26 | "The CollisionSystem" should { 27 | "keep entities separated" in new CollisionsFixture { 28 | val stuckEntity1 = world hasAn entity withComponents { 29 | Position(0, 0) &: Velocity(0, 0) &: Circle(10, black) &: Mass(1) 30 | } 31 | val stuckEntity2 = world hasAn entity withComponents { 32 | Position(5, 0) &: Velocity(0, 0) &: Circle(10, black) &: Mass(1) 33 | } 34 | 35 | playState.gameState = State.Play 36 | world.update(1) 37 | val stuckPosition1 = stuckEntity1.getComponent[Position].get 38 | val stuckPosition2 = stuckEntity2.getComponent[Position].get 39 | val stuckRadius1 = stuckEntity1.getComponent[Circle].get.radius 40 | val stuckRadius2 = stuckEntity2.getComponent[Circle].get.radius 41 | 42 | val distanceBetweenStuckBalls = (stuckPosition1.position - stuckPosition2.position).norm 43 | distanceBetweenStuckBalls shouldBe (stuckRadius1 + stuckRadius2) +- 0.001 44 | } 45 | "compute the new velocities" in new CollisionsFixture { 46 | val collidingEntity1 = world hasAn entity withComponents { 47 | Position(20, 20) &: Velocity(100, 0) &: Circle(10, black) &: Mass(1) 48 | } 49 | val collidingEntity2 = world hasAn entity withComponents { 50 | Position(40, 20) &: Velocity(0, 0) &: Circle(10, black) &: Mass(1) 51 | } 52 | 53 | playState.gameState = State.Play 54 | world.update(1) 55 | val velocity1 = collidingEntity1.getComponent[Velocity].get 56 | val velocity2 = collidingEntity2.getComponent[Velocity].get 57 | 58 | velocity1.velocity shouldBe Vector(0, 0) 59 | velocity2.velocity shouldBe Vector(100, 0) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/dsl/ExtensionMethods.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.dsl 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ taggedWith, CList, CListTag, Component, ComponentTag, Entity, System, World } 5 | import dev.atedeg.ecscala.dsl.EntityWord 6 | 7 | trait ExtensionMethods { 8 | 9 | extension (entity: Entity) { 10 | 11 | /** 12 | * This method enables the following syntax: 13 | * 14 | * {{{ 15 | * entity withComponents { Component1() &: Component2() } 16 | * }}} 17 | */ 18 | def withComponents[L <: CList](componentList: L)(using clt: CListTag[L]): Entity = { 19 | componentList.taggedWith(clt) foreach { entity.setComponent(_)(using _) } 20 | entity 21 | } 22 | 23 | /** 24 | * This method enables the following syntax: 25 | * 26 | * {{{ 27 | * entity withComponent Component() 28 | * }}} 29 | */ 30 | def withComponent[C <: Component: ComponentTag](component: C): Entity = entity setComponent component 31 | 32 | /** 33 | * This method enables the following syntax: 34 | * 35 | * {{{ 36 | * entity += Component() 37 | * }}} 38 | */ 39 | def +=[C <: Component: ComponentTag](component: C): Entity = entity setComponent component 40 | 41 | /** 42 | * This method enables the following syntax: 43 | * 44 | * {{{ 45 | * entity -= Component() 46 | * }}} 47 | */ 48 | def -=[C <: Component: ComponentTag](component: C): Entity = entity removeComponent component 49 | } 50 | 51 | extension (world: World) { 52 | 53 | /** 54 | * This method enables the following syntax: 55 | * 56 | * {{{ 57 | * world -= myEntity 58 | * }}} 59 | */ 60 | def -=(entity: Entity) = world removeEntity entity 61 | 62 | /** 63 | * This method enables the following syntax: 64 | * 65 | * {{{ 66 | * world += mySystem 67 | * }}} 68 | */ 69 | def +=(system: System) = world addSystem system 70 | 71 | /** 72 | * This method enables the following syntax: 73 | * 74 | * {{{ 75 | * world -= mySystem 76 | * }}} 77 | */ 78 | def -=(system: System) = world removeSystem system 79 | 80 | /** 81 | * This method enables the following syntax: 82 | * 83 | * {{{ 84 | * world hasAn entity 85 | * }}} 86 | */ 87 | def hasAn(entityWord: EntityWord): Entity = world.createEntity() 88 | 89 | /** 90 | * This method enables the following syntax: 91 | * 92 | * {{{ 93 | * world hasA system[Component &: CNil]{ () => {} } 94 | * }}} 95 | */ 96 | def hasA(init: World ?=> Unit): Unit = { 97 | given w: World = world 98 | init 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/MathTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | 6 | class MathTest extends AnyWordSpec with Matchers { 7 | 8 | "A point" when { 9 | "inside a radius of another point" should { 10 | "overlapped" in { 11 | Point(1, 1).isOverlappedWith(Point(2, 2), 10, 10) shouldBe true 12 | } 13 | } 14 | "outside a radius of another point" should { 15 | "not overlapped" in { 16 | Point(1, 1).isOverlappedWith(Point(20, 20), 5, 5) shouldBe false 17 | } 18 | } 19 | "adding a vector" should { 20 | "return a point with summed components" in { 21 | Point(1, 1) + Vector(2, 3) shouldBe Point(3, 4) 22 | } 23 | } 24 | "subtracting a vector" should { 25 | "return a point with subtracted components" in { 26 | Point(1, 1) - Vector(2, 3) shouldBe Point(-1, -2) 27 | } 28 | } 29 | "subtracting a point" should { 30 | "return a vector with subtracted components" in { 31 | Point(2, 3) - Point(1, 1) shouldBe Vector(1, 2) 32 | } 33 | } 34 | } 35 | 36 | "A vector" when { 37 | "adding another vector" should { 38 | "return the correct result" in { 39 | Vector(1, 1) + Vector(2, 2) shouldBe Vector(3, 3) 40 | } 41 | "subtracting another vector" should { 42 | "return the correct result" in { 43 | Vector(1, 1) - Vector(2, 2) shouldBe Vector(-1, -1) 44 | } 45 | } 46 | "computing the dot product" should { 47 | "return the correct result" in { 48 | Vector(1, 2) dot Vector(3, 4) shouldBe 11 49 | } 50 | } 51 | } 52 | "inverted" should { 53 | "return the opposite vector" in { 54 | -Vector(1, 1) shouldBe Vector(-1, -1) 55 | } 56 | } 57 | "multiplied by a scalar" should { 58 | "return the scaled vector" in { 59 | Vector(1, 1) * 2 shouldBe Vector(2, 2) 60 | } 61 | "be commutative" in { 62 | 2 * Vector(1, 1) shouldBe Vector(1, 1) * 2 63 | } 64 | } 65 | "divided by a scalar" should { 66 | "return the scaled vector" in { 67 | Vector(1, 1) / 2 shouldBe Vector(0.5, 0.5) 68 | } 69 | } 70 | "normalized" should { 71 | "have the norm equal to 1" in { 72 | Vector(1, 1).normalized.norm shouldBe 1.0 +- 0.001 73 | } 74 | } 75 | } 76 | 77 | "A vector" must { 78 | "have a squared norm" in { 79 | Vector(2, 2).squaredNorm shouldBe 8 80 | } 81 | "have a norm" in { 82 | Vector(3, 4).norm shouldBe 5 83 | } 84 | } 85 | 86 | "A number" can { 87 | "be clamped between a minimum and a maximum" in { 88 | 5 clamped (1, 10) shouldBe 5 89 | 10 clamped (2, 5) shouldBe 5 90 | 0 clamped (10, 20) shouldBe 10 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/ECSCanvas.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo 2 | 3 | import scalafx.scene.canvas.{ Canvas, GraphicsContext } 4 | import scalafx.scene.paint.Color as ColorFx 5 | import dev.atedeg.ecscalademo.Color 6 | 7 | /** 8 | * This trait is an abstraction of a Canvas that can be used to draw the demo components. 9 | */ 10 | trait ECSCanvas { 11 | 12 | /** 13 | * @param coordinates 14 | * The point where to draw the ball. 15 | * @param radius 16 | * The ball radius. 17 | * @param color 18 | * The ball color. 19 | * @param lineWidth 20 | * The thickness of the ball's border. 21 | */ 22 | def drawCircle(coordinates: Point, radius: Double, color: Color, lineWidth: Double): Unit 23 | 24 | /** 25 | * @param from 26 | * The starting point of the line. 27 | * @param to 28 | * The end point of the line. 29 | * @param color 30 | * The line color. 31 | * @param lineWidth 32 | */ 33 | def drawLine(from: Point, to: Point, color: Color, lineWidth: Double): Unit 34 | 35 | /** 36 | * Remove all the elements from the Canvas. 37 | */ 38 | def clear(): Unit 39 | 40 | /** 41 | * @return 42 | * the width of the canvas. 43 | */ 44 | def width: Double 45 | 46 | /** 47 | * @return 48 | * the height of the canvas. 49 | */ 50 | def height: Double 51 | } 52 | 53 | /** 54 | * Object that uses the ScalaFX Canvas to draw the elements. 55 | */ 56 | object ScalaFXCanvas { 57 | def apply(canvas: Canvas): ECSCanvas = new ScalaFXCanvasImpl(canvas) 58 | 59 | private class ScalaFXCanvasImpl(canvas: Canvas) extends ECSCanvas { 60 | private val graphicsContext: GraphicsContext = canvas.graphicsContext2D 61 | private val defaultCanvasWidth = 760.0 62 | private val defaultCanvasHeight = 467.0 63 | 64 | override def drawCircle(coordinates: Point, radius: Double, color: Color, lineWidth: Double): Unit = { 65 | graphicsContext.beginPath() 66 | graphicsContext.arc(coordinates.x, coordinates.y, radius, radius, 0, 360) 67 | graphicsContext.setFill(ColorFx.rgb(color.r, color.g, color.b)) 68 | graphicsContext.setStroke(ColorFx.Black) 69 | graphicsContext.lineWidth = lineWidth 70 | graphicsContext.fill() 71 | graphicsContext.stroke() 72 | } 73 | 74 | override def drawLine(from: Point, to: Point, color: Color, lineWidth: Double): Unit = { 75 | graphicsContext.beginPath() 76 | graphicsContext.moveTo(from.x, from.y) 77 | graphicsContext.lineTo(to.x, to.y) 78 | graphicsContext.lineWidth = lineWidth 79 | graphicsContext.setStroke(ColorFx.rgb(color.r, color.g, color.b)) 80 | graphicsContext.stroke() 81 | } 82 | 83 | override def clear(): Unit = graphicsContext.clearRect(0, 0, canvas.getWidth, canvas.getHeight) 84 | 85 | override def width: Double = if canvas.getWidth == 0.0 then defaultCanvasWidth else canvas.getWidth 86 | 87 | override def height: Double = if canvas.getHeight == 0.0 then defaultCanvasHeight else canvas.getHeight 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/Entity.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import dev.atedeg.ecscala.util.mutable.ComponentsContainer 4 | 5 | /** 6 | * This trait represents an entity of ECS whose state is defined by its components. 7 | */ 8 | sealed trait Entity { 9 | 10 | /** 11 | * @param component 12 | * the [[Component]] to add to the [[Entity]]. 13 | * @tparam C 14 | * the type of the [[Component]]. 15 | * @return 16 | * itself. 17 | */ 18 | def setComponent[C <: Component: ComponentTag](component: C): Entity 19 | 20 | /** 21 | * @tparam C 22 | * the type of the [[Component]] to be retrieved. 23 | * @return 24 | * the requested component (if present). 25 | */ 26 | def getComponent[C <: Component: ComponentTag]: Option[C] 27 | 28 | /** 29 | * @tparam C 30 | * the type of the [[Component]] to be removed. 31 | * @return 32 | * itself. 33 | */ 34 | def removeComponent[C <: Component: ComponentTag]: Entity 35 | 36 | /** 37 | * @param component 38 | * the [[Component]] to remove from the [[Entity]]. 39 | * @tparam C 40 | * the type of the [[Component]] to be removed. 41 | * @return 42 | * itself. 43 | */ 44 | def removeComponent[C <: Component: ComponentTag](component: C): Entity 45 | } 46 | 47 | /** 48 | * Factory for [[dev.atedeg.ecscala.Entity]] instances. 49 | */ 50 | object Entity { 51 | opaque private type Id = Int 52 | 53 | protected[ecscala] def apply(world: World): Entity = EntityImpl(IdGenerator.nextId(), world) 54 | 55 | private case class EntityImpl(private val id: Id, private val world: World) extends Entity { 56 | 57 | override def setComponent[C <: Component](component: C)(using ct: ComponentTag[C]): Entity = { 58 | require( 59 | component.entity.isEmpty || component.entity.get == this, 60 | "The given component already belongs to a different entity", 61 | ) 62 | component.entity = Some(this) 63 | world addComponent (this -> component) 64 | this 65 | } 66 | 67 | override def getComponent[C <: Component](using ct: ComponentTag[C]): Option[C] = 68 | world.getComponents(using ct) flatMap (_ get this) 69 | 70 | override def removeComponent[C <: Component](using ct: ComponentTag[C]): Entity = { 71 | val componentToRemove = world.getComponents(using ct) flatMap (_.get(this)) 72 | componentToRemove.foreach(removeComponent(_)) 73 | this 74 | } 75 | 76 | override def removeComponent[C <: Component](component: C)(using ct: ComponentTag[C]): Entity = { 77 | component.entity match { 78 | case Some(entity) if entity == this => world removeComponent (this -> component) 79 | case _ => () 80 | } 81 | this 82 | } 83 | 84 | override def toString: String = s"Entity($id)" 85 | 86 | override def hashCode(): Id = id 87 | } 88 | 89 | private object IdGenerator { 90 | private var currentId: Id = 0 91 | 92 | def nextId(): Id = synchronized { 93 | val id = currentId 94 | currentId += 1 95 | id 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/CList.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import scala.annotation.showAsInfix 4 | 5 | /** 6 | * Represents a CList whose elements can either be a standard [[Component]] or a special [[Deleted]] component, 7 | * representing a component that was deleted. 8 | * @tparam L 9 | * the type of the [[CList]] to be wrapped. 10 | */ 11 | type Deletable[L <: CList] <: CList = L match { 12 | case h &: t => (h | Deleted) &: Deletable[t] 13 | case CNil => CNil 14 | } 15 | 16 | /** 17 | * A List of elements whose type must be a subtype of [[Component]]. 18 | */ 19 | sealed trait CList extends Product with Iterable[Component] { 20 | 21 | override def toString: String = this match { 22 | case head &: CNil => head.toString 23 | case head &: tail => s"$head &: $tail" 24 | case CNil => "CNil" 25 | } 26 | } 27 | 28 | object CList { 29 | 30 | /** 31 | * Create an empty [[CList]]. 32 | * @return 33 | * the empty [[CList]] 34 | */ 35 | def apply(): CNil.type = CNil 36 | 37 | /** 38 | * Create a [[CList]] from a [[Component]]. 39 | * @param component 40 | * the component to build the [[CList]]. 41 | * @tparam C 42 | * the [[Component]] class. 43 | * @return 44 | * the [[CList]] 45 | */ 46 | def apply[C <: Component: ComponentTag](component: C): C &: CNil = component &: CNil 47 | 48 | implicit class CListOps[L <: CList](list: L) { 49 | def &:[C <: Component: ComponentTag](head: C): C &: L = dev.atedeg.ecscala.&:(head, list) 50 | } 51 | // We ended up using the "old" implicit syntax since using the extension method 52 | // IntelliJ cannot correctly infer the type of a CList like 53 | // val l = Position(1, 2) &: Velocity(1, 2) &: CNil 54 | // and considers it as of type Any; however, the compiler seems to correctly infer its type. 55 | // We believe it may be due to a problem with IntelliJ Scala 3 plugin. 56 | // 57 | // The Scala 3 idiomatic way to write the same method could be: 58 | // extension [H <: Component, T <: CList](head: H) def &:(tail: T): H &: T = &:(head, tail) 59 | } 60 | 61 | /** 62 | * An empty [[CList]]. 63 | */ 64 | sealed trait CNil extends CList { 65 | def &:[C <: Component: ComponentTag](head: C): C &: CNil = dev.atedeg.ecscala.&:(head, this) 66 | } 67 | 68 | case object CNil extends CNil { 69 | override def iterator = Iterator.empty 70 | } 71 | 72 | /** 73 | * Constructor of a [[CList]] consisting of a head and another CList as tail. 74 | * @param h 75 | * the head of the [[CList]]. 76 | * @param t 77 | * the tail of the [[CList]]. 78 | * @tparam H 79 | * the type of the head of the [[CList]]. 80 | * @tparam T 81 | * the type of the tail of the [[CList]]. 82 | */ 83 | @showAsInfix 84 | final case class &:[+C <: Component: ComponentTag, +L <: CList](h: C, t: L) extends CList { 85 | 86 | override def iterator = new Iterator[Component] { 87 | private var list: CList = h &: t 88 | 89 | override def hasNext = list != CNil 90 | 91 | override def next() = { 92 | val head &: tail = list 93 | list = tail 94 | head 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/systems/BallCreationSystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import org.scalatestplus.mockito.MockitoSugar.mock 6 | import dev.atedeg.ecscala.given 7 | import dev.atedeg.ecscala.{ Entity, World } 8 | import dev.atedeg.ecscala.dsl.ECScalaDSL 9 | import dev.atedeg.ecscalademo.{ Circle, MouseState, PlayState, Point, Position, StartingState, State } 10 | import dev.atedeg.ecscalademo.fixtures.BallCreationSystemFixture 11 | import dev.atedeg.ecscalademo.util.{ checkAllStates, AnyValue } 12 | 13 | class BallCreationSystemTest extends AnyWordSpec with Matchers with ECScalaDSL { 14 | 15 | "A BallCreationSystem" should { 16 | "run" when { 17 | "in an enabled state" in 18 | checkAllStates(BallCreationSystem(_, _, mock[StartingState]))( 19 | (State.AddBalls, true, AnyValue, AnyValue), 20 | ) 21 | } 22 | } 23 | 24 | "A BallCreationSystem" when { 25 | "enabled" should { 26 | "create a ball in a free position" in new BallCreationSystemFixture { 27 | enableSystemCondition(playState, mouseState) 28 | simulateCreateBall( 29 | world, 30 | entity1, 31 | ballCreationSystem, 32 | Point(0.0, 0.0), 33 | Point(100.0, 100.0), 34 | mouseState, 35 | startingState, 36 | ) 37 | world.entitiesCount shouldBe 2 38 | } 39 | "not create a ball over another one" in new BallCreationSystemFixture { 40 | enableSystemCondition(playState, mouseState) 41 | simulateCreateBall( 42 | world, 43 | entity1, 44 | ballCreationSystem, 45 | Point(10.0, 10.0), 46 | Point(10.0, 10.0), 47 | mouseState, 48 | startingState, 49 | ) 50 | world.entitiesCount shouldBe 1 51 | } 52 | "not create a ball when the mouse is inside another ball" in new BallCreationSystemFixture { 53 | enableSystemCondition(playState, mouseState) 54 | simulateCreateBall( 55 | world, 56 | entity1, 57 | ballCreationSystem, 58 | Point(10.0, 10.0), 59 | Point(15.0, 15.0), 60 | mouseState, 61 | startingState, 62 | ) 63 | world.entitiesCount shouldBe 1 64 | } 65 | } 66 | } 67 | 68 | private def enableSystemCondition(playState: PlayState, mouseState: MouseState): Unit = { 69 | playState.gameState = State.AddBalls 70 | mouseState.clicked = true 71 | } 72 | 73 | private def simulateCreateBall( 74 | world: World, 75 | existingEntity: Entity, 76 | ballCreationSystem: BallCreationSystem, 77 | existingPosition: Point, 78 | mousePosition: Point, 79 | mouseState: MouseState, 80 | startingState: StartingState, 81 | ): Unit = { 82 | existingEntity withComponents { 83 | Position(existingPosition) &: Circle(startingState.startingRadius, startingState.startingColor) 84 | } 85 | mouseState.coordinates = mousePosition 86 | world hasA system(ballCreationSystem) 87 | world.update(10) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /demo/src/main/resources/MainView.fxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/ViewTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import dev.atedeg.ecscala.given 6 | import dev.atedeg.ecscala.fixtures.{ Gravity, Mass, Position, Velocity, ViewFixture } 7 | 8 | class ViewTest extends AnyWordSpec with Matchers { 9 | 10 | "A view" should { 11 | "be empty" when { 12 | "defined with a CNil components list" in new ViewFixture { 13 | world.getView[CNil] shouldBe empty 14 | } 15 | } 16 | "iterate over the correct entities" in new ViewFixture { 17 | world.getView[Mass &: CNil] should contain theSameElementsAs List( 18 | (entity2, Mass(1) &: CNil), 19 | (entity3, Mass(1) &: CNil), 20 | (entity5, Mass(1) &: CNil), 21 | ) 22 | world.getView[Position &: CNil] should contain theSameElementsAs List( 23 | (entity1, Position(1, 1) &: CNil), 24 | (entity3, Position(1, 1) &: CNil), 25 | (entity4, Position(1, 1) &: CNil), 26 | (entity5, Position(1, 1) &: CNil), 27 | ) 28 | world.getView[Velocity &: CNil] should contain theSameElementsAs List( 29 | (entity1, Velocity(1, 1) &: CNil), 30 | (entity3, Velocity(1, 1) &: CNil), 31 | (entity4, Velocity(1, 1) &: CNil), 32 | ) 33 | world.getView[Velocity &: Mass &: CNil] should contain theSameElementsAs List( 34 | (entity3, Velocity(1, 1) &: Mass(1)), 35 | ) 36 | world.getView[Position &: Mass &: CNil] should contain theSameElementsAs List( 37 | (entity3, Position(1, 1) &: Mass(1) &: CNil), 38 | (entity5, Position(1, 1) &: Mass(1) &: CNil), 39 | ) 40 | world.getView[Position &: Velocity &: CNil] should contain theSameElementsAs List( 41 | (entity1, Position(1, 1) &: Velocity(1, 1) &: CNil), 42 | (entity3, Position(1, 1) &: Velocity(1, 1) &: CNil), 43 | (entity4, Position(1, 1) &: Velocity(1, 1) &: CNil), 44 | ) 45 | world.getView[Position &: Mass &: Velocity &: CNil] should contain theSameElementsAs List( 46 | (entity3, Position(1, 1) &: Mass(1) &: Velocity(1, 1)), 47 | ) 48 | world.getView[Gravity &: CNil] shouldBe empty 49 | } 50 | "be commutative" in new ViewFixture { 51 | (world.getView[Mass &: Velocity &: CNil] map (_.head)) should contain theSameElementsAs 52 | (world.getView[Velocity &: Mass &: CNil] map (_.head)) 53 | } 54 | "allow to change the entities and reflect the changes on successive iteration" in new ViewFixture { 55 | val view = world.getView[Velocity &: Mass &: CNil] 56 | view foreach (_.head setComponent Mass(11)) 57 | view should contain theSameElementsAs List((entity3, Velocity(1, 1) &: Mass(11))) 58 | } 59 | } 60 | 61 | "An excluding view" should { 62 | "iterate over the correct entities" in new ViewFixture { 63 | world.getView[Position &: Velocity &: CNil, Mass &: CNil] should contain theSameElementsAs List( 64 | (entity1, Position(1, 1) &: Velocity(1, 1) &: CNil), 65 | (entity4, Position(1, 1) &: Velocity(1, 1) &: CNil), 66 | ) 67 | world.getView[Mass &: CNil, Position &: Velocity &: CNil] should contain theSameElementsAs List( 68 | (entity2, Mass(1) &: CNil), 69 | ) 70 | world.getView[Velocity &: CNil, Position &: CNil] shouldBe empty 71 | } 72 | "return nothing" when { 73 | "including and excluding the same component type" in new ViewFixture { 74 | world.getView[Position &: Velocity &: CNil, Position &: CNil] shouldBe empty 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/dsl/Syntax.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.dsl 2 | 3 | import dev.atedeg.ecscala.given 4 | import dev.atedeg.ecscala.{ taggedWith, CList, CListTag, Component, ComponentTag, Entity, System, View, World } 5 | 6 | /** 7 | * Trait that enables the syntax of the DSL. 8 | */ 9 | trait Syntax { 10 | 11 | /** 12 | * This trait enables the use of the word "from" in the dsl. 13 | */ 14 | sealed trait From[A, B] { 15 | def from(elem: A): B 16 | } 17 | 18 | /** 19 | * This case class enables the following syntax: 20 | * {{{ 21 | * * remove (entity1) from world 22 | * * remove (Seq(myEntity1, myEntity2)) from world 23 | * }}} 24 | */ 25 | case class EntitiesFromWorld(entities: Seq[Entity]) extends From[World, Unit] { 26 | override def from(world: World): Unit = entities foreach { world.removeEntity(_) } 27 | } 28 | 29 | /** 30 | * This case class enables the following syntax: 31 | * {{{ 32 | * remove (mySystem) from world 33 | * }}} 34 | */ 35 | case class SystemFromWorld(system: System) extends From[World, Unit] { 36 | override def from(world: World): Unit = world.removeSystem(system) 37 | } 38 | 39 | /** 40 | * This case class enables the following syntax: 41 | * {{{ 42 | * clearAll from world 43 | * }}} 44 | */ 45 | case class ClearAllFromWorld() extends From[World, Unit] { 46 | override def from(world: World): Unit = world.clearEntities() 47 | } 48 | 49 | /** 50 | * This case class enables the following syntax: 51 | * 52 | * {{{ 53 | * remove { myComponent1 &: myComponent2 } from entity1 54 | * }}} 55 | */ 56 | case class ComponentsFromEntity[L <: CList](componentList: L)(using clt: CListTag[L]) extends From[Entity, Unit] { 57 | 58 | override def from(entity: Entity): Unit = 59 | componentList.taggedWith(clt) foreach { entity.removeComponent(_)(using _) } 60 | } 61 | 62 | /** 63 | * This class enables the following syntax: 64 | * 65 | * {{{ 66 | * remove[Component] from entity1 67 | * }}} 68 | */ 69 | class ComponentTypeFromEntity[C <: Component](using ct: ComponentTag[C]) extends From[Entity, Unit] { 70 | override def from(entity: Entity): Unit = entity.removeComponent(using ct) 71 | } 72 | 73 | /** 74 | * This class enables the following syntax: 75 | * 76 | * {{{ 77 | * remove[Component1 &: Component2 &: CNil ] from entity1 78 | * }}} 79 | */ 80 | class ComponentsTypeFromEntity[L <: CList](using clt: CListTag[L]) extends From[Entity, Unit] { 81 | 82 | override def from(entity: Entity): Unit = clt.tags foreach { entity.removeComponent(using _) } 83 | } 84 | 85 | /** 86 | * This class enables the following syntax: 87 | * 88 | * {{{ 89 | * * getView[MyComponent1 &: MyComponent2 &: CNil] from world 90 | * * getView[MyComponent1 &: MyComponent2 &: CNil].exluding[MyComponent3 &: CNil] from world 91 | * }}} 92 | */ 93 | class ViewFromWorld[LA <: CList](using cltA: CListTag[LA]) extends From[World, View[LA]] { 94 | override def from(world: World): View[LA] = world.getView(using cltA) 95 | 96 | def excluding[LB <: CList](using cltB: CListTag[LB]): ExcludingViewFromWorld[LA, LB] = ExcludingViewFromWorld(using 97 | cltA, 98 | )(using cltB) 99 | } 100 | 101 | /** 102 | * This class enables the following syntax: 103 | * 104 | * {{{ 105 | * getView[MyComponent1 &: MyComponent2 &: CNil].exluding[MyComponent3 &: CNil] from world 106 | * }}} 107 | */ 108 | class ExcludingViewFromWorld[LA <: CList, LB <: CList](using cltA: CListTag[LA])(using cltB: CListTag[LB]) 109 | extends From[World, View[LA]] { 110 | def from(world: World) = world.getView(using cltA, cltB) 111 | } 112 | } 113 | 114 | /** 115 | * Trait that enables the words of the DSL. 116 | */ 117 | trait Words 118 | 119 | /** 120 | * This case class enables the following syntax: 121 | * 122 | * {{{ 123 | * world hasAn entity 124 | * }}} 125 | */ 126 | case class EntityWord() extends Words 127 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/util/SpacePartitionContainer.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.util 2 | 3 | import scala.collection.mutable 4 | import dev.atedeg.ecscala.given 5 | import dev.atedeg.ecscala.{ &:, CNil, Entity } 6 | import dev.atedeg.ecscalademo.{ Circle, Mass, Position, Velocity } 7 | 8 | /** 9 | * This trait represents a read-only version of a space partition container that assigns each added entity to a region 10 | * according to its position. 11 | */ 12 | trait SpacePartitionContainer extends Iterable[((Int, Int), Iterable[Entity])] { 13 | 14 | /** 15 | * Get the size of each region. 16 | * @return 17 | * the size of each region. 18 | */ 19 | def regionSize: Double 20 | 21 | /** 22 | * Get all entities that belong to the specified region, if present. 23 | * @param region 24 | * the specified region. 25 | * @return 26 | * the entities that belong the the specified region. 27 | */ 28 | def get(region: (Int, Int)): Iterable[Entity] 29 | 30 | /** 31 | * Return an iterator that iterates over the non-empty regions. 32 | * @return 33 | * the iterator. 34 | */ 35 | def regionsIterator: Iterator[(Int, Int)] 36 | } 37 | 38 | /** 39 | * This trait represents a writable version of the space partition container. 40 | */ 41 | trait WritableSpacePartitionContainer extends SpacePartitionContainer { 42 | 43 | type SpacePartitionComponents = Position &: Velocity &: Circle &: Mass &: CNil 44 | 45 | /** 46 | * Add an entity to this space partition container. The entity must have the [[Position]], [[Velocity]], [[Mass]] and 47 | * [[Circle]] components. 48 | * @param entity 49 | * the entity to be added. 50 | */ 51 | def add(entityComponentsPair: (Entity, SpacePartitionComponents)): Unit 52 | 53 | /** 54 | * Build the space partition container so that it can be used by other systems. 55 | */ 56 | def build(): Unit 57 | 58 | /** 59 | * Clear the contents of the space partition container. 60 | */ 61 | def clear(): Unit 62 | } 63 | 64 | object WritableSpacePartitionContainer { 65 | def apply(): WritableSpacePartitionContainer = new WritableSpacePartitionContainerImpl() 66 | 67 | private class WritableSpacePartitionContainerImpl() extends WritableSpacePartitionContainer { 68 | private val regions: mutable.Map[(Int, Int), mutable.Set[Entity]] = mutable.Map.empty 69 | private val entities: mutable.Map[Entity, SpacePartitionComponents] = mutable.Map.empty 70 | private var _regionSize: Double = 0 71 | private val regionSizeMultiplier = 2 72 | 73 | override def regionSize: Double = _regionSize 74 | 75 | override def add(entityComponentsPair: (Entity, SpacePartitionComponents)): Unit = { 76 | val (_, components) = entityComponentsPair 77 | val _ &: _ &: Circle(radius, _) &: _ &: CNil = components 78 | entities += entityComponentsPair 79 | _regionSize = math.max(_regionSize, radius * regionSizeMultiplier) 80 | } 81 | 82 | override def build(): Unit = { 83 | regions.clear() 84 | for ((entity, components) <- entities) { 85 | val position &: _ &: Circle(_, color) &: _ &: CNil = components 86 | val region = getRegionFromPosition(position) 87 | val regionEntities = regions get region 88 | val regionNewEntities = regionEntities match { 89 | case Some(entitiesInRegion) => entitiesInRegion += entity 90 | case None => mutable.Set(entity) 91 | } 92 | regions += region -> regionNewEntities 93 | } 94 | } 95 | 96 | override def clear(): Unit = { 97 | regions.clear() 98 | entities.clear() 99 | _regionSize = 0 100 | } 101 | 102 | override def get(region: (Int, Int)): Iterable[Entity] = regions getOrElse (region, mutable.Set()) 103 | 104 | override def iterator: Iterator[((Int, Int), Iterable[Entity])] = regions.iterator 105 | 106 | override def regionsIterator: Iterator[(Int, Int)] = regions.keysIterator 107 | 108 | private def getRegionFromPosition(positionComponent: Position): (Int, Int) = 109 | ( 110 | math.floor(positionComponent.position.x / _regionSize).toInt, 111 | math.floor(positionComponent.position.y / _regionSize).toInt, 112 | ) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/View.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import scala.annotation.targetName 4 | import scala.collection.Map 5 | import dev.atedeg.ecscala.given 6 | 7 | /** 8 | * A [[View]] on a [[World]] that allows to iterate over its entities with components of the type specified in L. 9 | * @tparam L 10 | * [[CList]] with the types of the components. 11 | */ 12 | trait View[L <: CList] extends Iterable[(Entity, L)] 13 | 14 | /** 15 | * A [[View]] on this [[World]] that allows to iterate over its entities with components of the type specified in 16 | * LIncluded, that do not have any of the components listed in LExcluded. 17 | * @tparam LIncluded 18 | * [[CList]] with the types of the components that must be present in all entities. 19 | * @tparam LExcluded 20 | * [[CList]] with the types of the components that must not be present in any entity. 21 | */ 22 | trait ExcludingView[LIncluded <: CList, LExcluded <: CList] extends View[LIncluded] 23 | 24 | private[ecscala] object View { 25 | 26 | def apply[L <: CList](world: World)(using clt: CListTag[L]): View[L] = new View[L] { 27 | override def iterator: Iterator[(Entity, L)] = ViewIterator(world)(using clt) 28 | } 29 | 30 | def apply[LIncluded <: CList, LExcluded <: CList]( 31 | world: World, 32 | )(using cltIncl: CListTag[LIncluded], cltExcl: CListTag[LExcluded]): ExcludingView[LIncluded, LExcluded] = 33 | new ExcludingView[LIncluded, LExcluded] { 34 | 35 | override def iterator: Iterator[(Entity, LIncluded)] = ExcludingViewIterator(world)(using cltIncl, cltExcl) 36 | } 37 | 38 | private abstract class BaseViewIterator[L <: CList](world: World)(using clt: CListTag[L]) 39 | extends Iterator[(Entity, L)] { 40 | 41 | protected val taggedMaps = getMaps(world)(using clt) sortBy (_.size) 42 | protected val maps = taggedMaps map (_._2) 43 | protected val otherMaps = if !maps.isEmpty then maps.tail else maps 44 | protected val smallerMap = if !maps.isEmpty then maps.head else Map() 45 | 46 | val innerIterator = for { 47 | entity <- smallerMap.keysIterator if isValid(entity) 48 | } yield (entity, getEntityComponents(taggedMaps)(entity)(using clt)) 49 | 50 | override def hasNext = innerIterator.hasNext 51 | 52 | override def next() = innerIterator.next 53 | 54 | protected def isValid(entity: Entity): Boolean 55 | } 56 | 57 | private class ViewIterator[L <: CList](world: World)(using clt: CListTag[L]) 58 | extends BaseViewIterator[L](world)(using clt) { 59 | override protected def isValid(entity: Entity): Boolean = otherMaps.forall(_ contains entity) 60 | } 61 | 62 | private class ExcludingViewIterator[LIncluded <: CList, LExcluded <: CList](world: World)(using 63 | cltIncl: CListTag[LIncluded], 64 | cltExcl: CListTag[LExcluded], 65 | ) extends ViewIterator[LIncluded](world)(using cltIncl) { 66 | 67 | private val taggedExcludingMaps = getMaps(world)(using cltExcl) 68 | private val excludingMaps = taggedExcludingMaps map (_._2) 69 | 70 | override protected def isValid(entity: Entity): Boolean = 71 | super.isValid(entity) && !excludingMaps.exists(_ contains entity) 72 | } 73 | 74 | private def getEntityComponents[L <: CList]( 75 | taggedMaps: Seq[(ComponentTag[? <: Component], Map[Entity, ? <: Component])], 76 | )(entity: Entity)(using clt: CListTag[L]): L = { 77 | // The val taggedComponents would otherwise be inferred as a Seq[(ComponentTag[? <: Component], Component)]. 78 | // This cast is necessary since ComponentTag cannot be made covariant (it would hinder the correct 79 | // compile time inference of ComponentTags' types). 80 | val taggedComponents = taggedMaps 81 | .map(taggedMap => taggedMap._1 -> taggedMap._2(entity)) 82 | .asInstanceOf[Seq[(ComponentTag[Component], Component)]] 83 | // The following cast is always safe since the CList of components is built as a CList 84 | // of type L (getting the ordered list of components specified with the CListTag[L]). 85 | taggedComponents 86 | .foldRight(CNil: CList)((compTag, acc) => &:(compTag._2, acc)(using compTag._1)) 87 | .asInstanceOf[L] 88 | } 89 | 90 | private def getMaps[L <: CList]( 91 | world: World, 92 | )(using clt: CListTag[L]): Seq[(ComponentTag[? <: Component], Map[Entity, Component])] = { 93 | val optionalMaps = clt.tags map (ct => ct -> world.getComponents(using ct)) 94 | // The cast is required because otherwise the compiler infers Map[Entity, Any] instead of Map[Entity, Component] 95 | if (optionalMaps.exists(_._2.isEmpty)) then Seq() 96 | else optionalMaps map ((taggedMap) => taggedMap._1 -> taggedMap._2.get.asInstanceOf[Map[Entity, Component]]) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /demo/src/main/scala/dev/atedeg/ecscalademo/systems/CollisionSystem.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.systems 2 | 3 | import scala.language.implicitConversions 4 | import dev.atedeg.ecscala.given 5 | import dev.atedeg.ecscala.{ DeltaTime, Entity, System, World } 6 | import dev.atedeg.ecscalademo.given 7 | import dev.atedeg.ecscalademo.{ Circle, Mass, PlayState, Point, Position, State, Vector, Velocity } 8 | import dev.atedeg.ecscalademo.util.SpacePartitionContainer 9 | 10 | class CollisionSystem(private val playState: PlayState, private val regions: SpacePartitionContainer) extends System { 11 | override def shouldRun: Boolean = playState.gameState == State.Play 12 | 13 | override def update(deltaTime: DeltaTime, world: World): Unit = { 14 | for { 15 | region <- regions.regionsIterator 16 | candidateColliders <- combinations2(entitiesInNeighborRegions(region)) 17 | } { 18 | val (candidateAEntity, candidateBEntity) = candidateColliders 19 | // We are sure we have those components because we checked for them when adding these entities to the space partition container 20 | var positionA = candidateAEntity.getComponent[Position].get 21 | val velocityA = candidateAEntity.getComponent[Velocity].get 22 | val circleA = candidateAEntity.getComponent[Circle].get 23 | val massA = candidateAEntity.getComponent[Mass].get 24 | var positionB = candidateBEntity.getComponent[Position].get 25 | val velocityB = candidateBEntity.getComponent[Velocity].get 26 | val circleB = candidateBEntity.getComponent[Circle].get 27 | val massB = candidateBEntity.getComponent[Mass].get 28 | if (isColliding((positionA, positionB), (circleA.radius, circleB.radius))) { 29 | if (isStuck((positionA, positionB), (circleA.radius, circleB.radius))) { 30 | val (newPositionA, newPositionB) = unstuck((positionA, positionB), (circleA.radius, circleB.radius)) 31 | candidateAEntity setComponent newPositionA 32 | candidateBEntity setComponent newPositionB 33 | // Update the positions for correctly calculating the collision velocities 34 | positionA = newPositionA 35 | positionB = newPositionB 36 | } 37 | val (newVelocityA, newVelocityB) = 38 | newVelocities((positionA, positionB), (velocityA, velocityB), (circleA.radius, circleB.radius)) 39 | candidateAEntity setComponent newVelocityA 40 | candidateBEntity setComponent newVelocityB 41 | } 42 | } 43 | } 44 | 45 | private def getComponents(entity: Entity): (Position, Velocity, Circle, Mass) = 46 | ( 47 | entity.getComponent[Position].get, 48 | entity.getComponent[Velocity].get, 49 | entity.getComponent[Circle].get, 50 | entity.getComponent[Mass].get, 51 | ) 52 | 53 | private def isColliding(positions: (Point, Point), radii: (Double, Double)) = 54 | compareDistances(positions, radii)(_ <= _) 55 | 56 | private def isStuck(positions: (Point, Point), radii: (Double, Double)) = 57 | compareDistances(positions, radii)(_ - _ < 0.001) 58 | 59 | private def compareDistances(positions: (Point, Point), radii: (Double, Double))( 60 | comparer: (Double, Double) => Boolean, 61 | ) = comparer((positions._1 - positions._2).norm, math.pow(radii._1 + radii._2, 1)) 62 | 63 | private def unstuck(positions: (Point, Point), radii: (Double, Double)) = { 64 | val distanceVector = positions._1 - positions._2 65 | val distanceDirection = distanceVector.normalized 66 | val moveFactor = radii._1 + radii._2 - distanceVector.norm 67 | val deltaPosition = distanceDirection * moveFactor / 2 68 | (Position(positions._1 + deltaPosition), Position(positions._2 - deltaPosition)) 69 | } 70 | 71 | private def newVelocities(positions: (Point, Point), velocities: (Vector, Vector), masses: (Double, Double)) = { 72 | val (posA, posB) = positions 73 | val (velA, velB) = velocities 74 | val (massA, massB) = masses 75 | val deltaPositions = posA - posB 76 | val deltaVelocities = velA - velB 77 | val projectedVelocity = deltaPositions * (deltaVelocities dot deltaPositions) / deltaPositions.squaredNorm 78 | ( 79 | Velocity(velA - projectedVelocity * (2 * massB / (massA + massB))), 80 | Velocity(velB + projectedVelocity * (2 * massA / (massA + massB))), 81 | ) 82 | } 83 | 84 | private def entitiesInNeighborRegions(region: (Int, Int)): Seq[Entity] = for { 85 | x <- -1 to 0 86 | y <- -1 to 1 87 | entity <- regions get (region._1 + x, region._2 + y) if x != 0 || y != 1 88 | } yield entity 89 | 90 | private def combinations2[T](seq: Seq[T]): Iterator[(T, T)] = 91 | seq.tails flatMap { 92 | case h +: t => t.iterator map ((h, _)) 93 | case Nil => Iterator.empty 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/util/mutable/ComponentsContainer.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.util.mutable 2 | 3 | import scala.collection.Map 4 | import scala.collection.mutable.AnyRefMap 5 | import dev.atedeg.ecscala.given 6 | import dev.atedeg.ecscala.{ Component, ComponentTag, Entity } 7 | 8 | /** 9 | * This trait represents a container of multiple [[scala.collection.immutable.Map]] [Entity, T], with T subtype of 10 | * Component. 11 | */ 12 | private[ecscala] trait ComponentsContainer { 13 | 14 | /** 15 | * @tparam C 16 | * the return map's values' type. 17 | * @return 18 | * a collection of type [[scala.collection.immutable.Map]]. 19 | */ 20 | def apply[C <: Component: ComponentTag]: Option[Map[Entity, C]] 21 | 22 | /** 23 | * @param entityComponentPair 24 | * the (entity, component) pair to add to the container. 25 | * @tparam C 26 | * the type of the [[Component]] to add. 27 | * @return 28 | * a new [[ComponentsContainer]] with the added (entity, component) pair. 29 | */ 30 | def addComponent[C <: Component: ComponentTag](entityComponentPair: (Entity, C)): ComponentsContainer 31 | 32 | /** 33 | * An alias for [[addComponent]]. 34 | */ 35 | def +[C <: Component: ComponentTag](entityComponentPair: (Entity, C)): ComponentsContainer = 36 | addComponent(entityComponentPair) 37 | 38 | /** 39 | * @param entityComponentPair 40 | * the (entity, component) pair to remove from the container. 41 | * @tparam C 42 | * the type of the [[Component]] to remove. 43 | * @return 44 | * a new [[ComponentsContainer]] with the removed (entity, component) pair. 45 | */ 46 | def removeComponent[C <: Component: ComponentTag](entityComponentPair: (Entity, C)): ComponentsContainer 47 | 48 | /** 49 | * An alias for [[removeComponent]]. 50 | */ 51 | def -[C <: Component: ComponentTag](entityComponentPair: (Entity, C)): ComponentsContainer = 52 | removeComponent(entityComponentPair) 53 | 54 | /** 55 | * @param entity 56 | * the [[Entity]] to remove from the container. 57 | * @return 58 | * a [[ComponentsContainer]] with the removed entity and all its components. 59 | */ 60 | def removeEntity(entity: Entity): ComponentsContainer 61 | 62 | /** 63 | * An alias for [[removeEntity]]. 64 | */ 65 | def -(entity: Entity): ComponentsContainer = removeEntity(entity) 66 | } 67 | 68 | private[ecscala] object ComponentsContainer { 69 | def apply(): ComponentsContainer = new ComponentsContainerImpl 70 | 71 | private class ComponentsContainerImpl( 72 | private val componentsMap: AnyRefMap[ComponentTag[? <: Component], AnyRefMap[Entity, ? <: Component]] = 73 | AnyRefMap(), 74 | ) extends ComponentsContainer { 75 | 76 | override def apply[C <: Component](using ct: ComponentTag[C]): Option[Map[Entity, C]] = getContainer[C](using ct) 77 | 78 | override def addComponent[C <: Component](entityComponentPair: (Entity, C))(using ct: ComponentTag[C]) = { 79 | val (entity, component) = entityComponentPair 80 | getContainer[C] match { 81 | case None => componentsMap += ct -> AnyRefMap(entityComponentPair) 82 | case Some(componentMap) => { 83 | val oldComponent = componentMap get entity 84 | oldComponent map (_.entity = None) 85 | componentMap += entityComponentPair 86 | } 87 | } 88 | this 89 | } 90 | 91 | override def removeComponent[C <: Component](entityComponentPair: (Entity, C))(using ct: ComponentTag[C]) = { 92 | getContainer[C] foreach { componentMap => 93 | val (entity, component) = entityComponentPair 94 | val actualComponent = componentMap get entity filter (_ eq component) 95 | actualComponent map { c => 96 | componentMap -= entity 97 | c.entity = None 98 | } 99 | } 100 | this 101 | } 102 | 103 | override def removeEntity(entity: Entity) = { 104 | componentsMap foreach { (ct, componentMap) => 105 | val foundComponent = componentMap get entity 106 | foundComponent foreach { _.entity = None } 107 | componentMap -= entity 108 | } 109 | this 110 | } 111 | 112 | override def toString: String = componentsMap.toString 113 | 114 | private def getContainer[C <: Component](using ct: ComponentTag[C]): Option[AnyRefMap[Entity, C]] = { 115 | // This cast is needed to return a map with the appropriate type and not a generic "Component" type. 116 | // It is always safe to perform such a cast since the ComponentTag holds the type of the retrieved map's components. 117 | componentsMap get ct map (_.asInstanceOf[AnyRefMap[Entity, C]]) filter (!_.isEmpty) 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/dsl/ECScalaDSL.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.dsl 2 | 3 | import dev.atedeg.ecscala.{ 4 | CList, 5 | CListTag, 6 | CNil, 7 | Component, 8 | ComponentTag, 9 | Entity, 10 | IteratingSystem, 11 | System, 12 | View, 13 | World, 14 | } 15 | import dev.atedeg.ecscala.dsl.Syntax 16 | //import dev.atedeg.ecscala.dsl.Words.EntityWord 17 | 18 | /** 19 | * This trait provides a domain specific language (DSL) for expressing the ECScala framework operations using an 20 | * english-like syntax. Here's the things you can do: 21 | * 22 | * '''Create an Entity in a World:''' 23 | * {{{ 24 | * val world = World() 25 | * val entity1 = world hasAn entity 26 | * }}} 27 | * 28 | * '''Remove Entities from a World:''' 29 | * {{{ 30 | * * world -= entity1 31 | * * remove (entity1) from world 32 | * * remove (List(entity1, entity2, entity3)) from world 33 | * }}} 34 | * 35 | * '''Create an Entity in a World with a Component:''' 36 | * {{{ 37 | * val entity1 = world hasAn entity withComponent myComponent 38 | * }}} 39 | * 40 | * '''Create an Entity in a World with multiple Components:''' 41 | * {{{ 42 | * val entity1 = world hasAn entity withComponents { 43 | * myComponent1 &: myComponent2 &: myComponent3 44 | * } 45 | * }}} 46 | * 47 | * '''Add Components to an Entity:''' 48 | * {{{ 49 | * * entity1 += myComponent 50 | * * entity1 withComponent myComponent 51 | * * entity1 withComponents { myComponent1 &: myComponent2 } 52 | * }}} 53 | * 54 | * '''Remove Components from an Entity:''' 55 | * {{{ 56 | * * remove (myComponent) from entity1 57 | * * entity1 -= myComponent 58 | * * remove { myComponent1 &: myComponent2 &: myComponent3 } from entity1 59 | * }}} 60 | * 61 | * '''Add a System to a World:''' 62 | * {{{ 63 | * * world hasA system[MyComponent &: CNil] { (_,_,_) => {}} 64 | * * world hasA system(mySistem) 65 | * * world += mySystem 66 | * }}} 67 | * 68 | * '''Remove a System from a World''' 69 | * {{{ 70 | * * remove (mySystem) from world 71 | * * world -= mySystem 72 | * }}} 73 | * 74 | * '''Get a View from a World:''' 75 | * {{{ 76 | * val view = getView[MyComponent1 &: MyComponent2 &: CNil] from world 77 | * }}} 78 | * 79 | * '''Get a View without certain Components''' 80 | * {{{ 81 | * val view = getView[MyComponent1 &: CNil].excluding[MyComponent2 &: CNil] from world 82 | * }}} 83 | * 84 | * '''Remove all Entities and their Components from a World:''' 85 | * {{{ 86 | * clearAllEntities from world 87 | * }}} 88 | */ 89 | trait ECScalaDSL extends ExtensionMethods with Conversions with Syntax with Words { 90 | 91 | /** 92 | * Keyword that enables the use of the word "entity". 93 | */ 94 | def entity: EntityWord = EntityWord() 95 | 96 | /** 97 | * Keyword that enables the use of the word "system". 98 | */ 99 | def system(system: System)(using world: World): Unit = world.addSystem(system) 100 | 101 | /** 102 | * Keyword that enables the use of the word "system". 103 | */ 104 | def system[L <: CList](system: IteratingSystem[L])(using clt: CListTag[L])(using world: World): Unit = 105 | world.addSystem(system) 106 | 107 | /** 108 | * Keyword that enables the use of the word "getView". 109 | */ 110 | def getView[L <: CList](using clt: CListTag[L]): ViewFromWorld[L] = ViewFromWorld(using clt) 111 | 112 | /** 113 | * Keyword that enables the use of the word "remove" for the removal of a [[Clist]] of [[Component]] from an 114 | * [[Entity]]. 115 | */ 116 | def remove[L <: CList: CListTag](componentsList: L): From[Entity, Unit] = ComponentsFromEntity(componentsList) 117 | 118 | /** 119 | * Keyword that enables the use of the word "remove" for the removal of a [[CList]] of [[Component]] specifing their 120 | * type from an [[Entity]]. 121 | */ 122 | def remove[L <: CList: CListTag]: ComponentsTypeFromEntity[L] = ComponentsTypeFromEntity() 123 | 124 | /** 125 | * Keyword that enables the use of the word "remove" for the removal of a [[Component]] specifing its type from an 126 | * [[Entity]]. 127 | */ 128 | def remove[C <: Component: ComponentTag]: From[Entity, Unit] = ComponentTypeFromEntity() 129 | 130 | /** 131 | * Keyword that enables the use of the word "remove" for the removal of an [[Entity]] from a [[World]]. 132 | */ 133 | def remove(entities: Seq[Entity]): From[World, Unit] = EntitiesFromWorld(entities) 134 | 135 | /** 136 | * Keyword that enables the use of the word "remove" for the removal of a [[System]] from a [[World]]. 137 | */ 138 | def remove(system: System): From[World, Unit] = SystemFromWorld(system) 139 | 140 | /** 141 | * Keyword that enables the use of the word "clearAllEntities" in the dsl. 142 | */ 143 | def clearAllEntities: From[World, Unit] = ClearAllFromWorld() 144 | } 145 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/SystemBuilderTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import dev.atedeg.ecscala.given 6 | import dev.atedeg.ecscala.fixtures.{ Position, SystemBuilderFixture, Velocity } 7 | 8 | class SystemBuilderTest extends AnyWordSpec with Matchers { 9 | def using = afterWord("using") 10 | 11 | "A SystemBuilder" should { 12 | "work with the update with the partial parameter list" in new SystemBuilderFixture { 13 | var updateExecuted = false 14 | world.addSystem(SystemBuilder[Position &: CNil].withUpdate { (_, c, _) => 15 | updateExecuted = true; c 16 | }) 17 | world.update(10) 18 | updateExecuted shouldBe true 19 | } 20 | "work with the update with the complete parameter list" in new SystemBuilderFixture { 21 | var updateExecuted = false 22 | world.addSystem(SystemBuilder[Position &: CNil].withUpdate { (_, c, _, _, _) => 23 | updateExecuted = true; c 24 | }) 25 | world.update(10) 26 | updateExecuted shouldBe true 27 | } 28 | "return the correct system" when using { 29 | "withBefore" in new SystemBuilderFixture { 30 | var beforeExecuted = false 31 | world.addSystem( 32 | SystemBuilder[Position &: CNil].withBefore { (_, _, _) => beforeExecuted = true }.withUpdate { (_, c, _) => 33 | c 34 | }, 35 | ) 36 | world.update(10) 37 | beforeExecuted shouldBe true 38 | } 39 | "withAfter" in new SystemBuilderFixture { 40 | var afterExecuted = false 41 | world.addSystem( 42 | SystemBuilder[Position &: CNil].withAfter { (_, _, _) => afterExecuted = true }.withUpdate { (_, c, _) => c }, 43 | ) 44 | world.update(10) 45 | afterExecuted shouldBe true 46 | } 47 | "withPrecondition" in new SystemBuilderFixture { 48 | var updateExecuted = false 49 | world.addSystem( 50 | SystemBuilder[Position &: CNil] 51 | .withPrecondition(false) 52 | .withUpdate { (_, c, _) => 53 | updateExecuted = true; c 54 | }, 55 | ) 56 | world.update(10) 57 | updateExecuted shouldBe false 58 | } 59 | "withBefore and withAfter" in new SystemBuilderFixture { 60 | var afterExecuted = false 61 | var beforeExecuted = false 62 | world.addSystem( 63 | SystemBuilder[Position &: CNil].withBefore { (_, _, _) => beforeExecuted = true }.withAfter { (_, _, _) => 64 | afterExecuted = true 65 | }.withUpdate { (_, c, _) => c }, 66 | ) 67 | world.update(10) 68 | afterExecuted shouldBe true 69 | beforeExecuted shouldBe true 70 | } 71 | } 72 | } 73 | 74 | "A SystemBuilder" can { 75 | "be converted to an ExcludingSystemBuilder independently from when it is called" in new SystemBuilderFixture { 76 | var updateExecuted = false 77 | world.addSystem( 78 | SystemBuilder[Position &: CNil] 79 | .excluding[Velocity &: CNil] 80 | .withBefore { (_, _, _) => () } 81 | .withAfter { (_, _, _) => () } 82 | .withUpdate { (_, c, _) => 83 | updateExecuted = true; c 84 | }, 85 | ) 86 | world.addSystem( 87 | SystemBuilder[Position &: CNil].withBefore { (_, _, _) => () } 88 | .excluding[Velocity &: CNil] 89 | .withAfter { (_, _, _) => () } 90 | .withUpdate { (_, c, _) => 91 | updateExecuted = true; c 92 | }, 93 | ) 94 | world.addSystem( 95 | SystemBuilder[Position &: CNil].withBefore { (_, _, _) => () }.withAfter { (_, _, _) => () } 96 | .excluding[Velocity &: CNil] 97 | .withUpdate { (_, c, _) => 98 | updateExecuted = true; c 99 | }, 100 | ) 101 | world.update(10) 102 | updateExecuted shouldBe false 103 | } 104 | } 105 | 106 | "An ExcludingSystemBuilder" should { 107 | "work with the update with the partial parameter list" in new SystemBuilderFixture { 108 | var updateExecuted = false 109 | world.addSystem(ExcludingSystemBuilder[Position &: CNil, Velocity &: CNil].withUpdate { (_, c, _) => 110 | updateExecuted = true; c 111 | }) 112 | world.update(10) 113 | updateExecuted shouldBe false 114 | } 115 | "work with the update with the full parameter list" in new SystemBuilderFixture { 116 | var updateExecuted = false 117 | world.addSystem(ExcludingSystemBuilder[Position &: CNil, Velocity &: CNil].withUpdate { (_, c, _, _, _) => 118 | updateExecuted = true; c 119 | }) 120 | world.update(10) 121 | updateExecuted shouldBe false 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/World.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import scala.annotation.targetName 4 | import scala.collection.Map 5 | import dev.atedeg.ecscala.util.mutable.ComponentsContainer 6 | 7 | /** 8 | * A container for [[Entity]], Components and System. 9 | */ 10 | sealed trait World { 11 | 12 | /** 13 | * @return 14 | * the number of [[Entity]] in the [[World]]. 15 | */ 16 | def entitiesCount: Int 17 | 18 | /** 19 | * Create a new [[Entity]] and add it to the [[World]]. 20 | * @return 21 | * the created [[Entity]]. 22 | */ 23 | def createEntity(): Entity 24 | 25 | /** 26 | * Remove a given [[Entity]] from the [[World]]. 27 | * @param entity 28 | * the [[Entity]] to remove. 29 | */ 30 | def removeEntity(entity: Entity): Unit 31 | 32 | /** 33 | * Remove all the entites and their respective components from the [[World]] 34 | */ 35 | def clearEntities(): Unit 36 | 37 | /** 38 | * A [[View]] on this [[World]] that allows to iterate over its entities with components of the type specified in L. 39 | * @tparam L 40 | * [[CList]] with the types of the components. 41 | * @return 42 | * the [[View]]. 43 | */ 44 | def getView[L <: CList: CListTag]: View[L] 45 | 46 | /** 47 | * A [[View]] on this [[World]] that allows to iterate over its entities with components of the type specified in 48 | * LIncluded, that do not have any of the components listed in LExcluded. 49 | * @tparam LIncluded 50 | * [[CList]] with the types of the components that must be present in all entities. 51 | * @tparam LExcluded 52 | * [[CList]] with the types of the components that must not be present in any entity. 53 | * @return 54 | * the [[View]]. 55 | */ 56 | def getView[LIncluded <: CList: CListTag, LExcluded <: CList: CListTag]: ExcludingView[LIncluded, LExcluded] 57 | 58 | /** 59 | * Add a [[System]] to the [[World]]. 60 | * @param system 61 | * the system to add. 62 | */ 63 | def addSystem(system: System): Unit 64 | 65 | /** 66 | * Remove a [[System]] from the [[World]]. 67 | * @param system 68 | * the system to remove. 69 | */ 70 | def removeSystem(system: System): Unit 71 | 72 | /** 73 | * Update the world. 74 | * @param deltaTime 75 | * the time between two updates. 76 | */ 77 | def update(deltaTime: DeltaTime): Unit 78 | 79 | private[ecscala] def getComponents[C <: Component: ComponentTag]: Option[Map[Entity, C]] 80 | 81 | private[ecscala] def addComponent[C <: Component: ComponentTag](entityComponentPair: (Entity, C)): World 82 | 83 | private[ecscala] def removeComponent[C <: Component: ComponentTag](entityComponentPair: (Entity, C)): World 84 | } 85 | 86 | /** 87 | * A factory for the [[World]]. 88 | */ 89 | object World { 90 | def apply(): World = new WorldImpl() 91 | 92 | private class WorldImpl() extends World { 93 | private var entities: Set[Entity] = Set() 94 | private var componentsContainer = ComponentsContainer() 95 | private var systems: List[System] = List() 96 | 97 | override def entitiesCount: Int = entities.size 98 | 99 | override def createEntity(): Entity = { 100 | val entity = Entity(this) 101 | entities += entity 102 | entity 103 | } 104 | 105 | override def removeEntity(entity: Entity): Unit = { 106 | entities -= entity 107 | componentsContainer -= entity 108 | } 109 | 110 | override def clearEntities(): Unit = { 111 | entities foreach { componentsContainer -= _ } 112 | entities = Set() 113 | } 114 | 115 | override def getView[L <: CList](using clt: CListTag[L]): View[L] = View(this)(using clt) 116 | 117 | override def getView[LIncluded <: CList, LExcluded <: CList](using 118 | cltIncl: CListTag[LIncluded], 119 | cltExcl: CListTag[LExcluded], 120 | ): ExcludingView[LIncluded, LExcluded] = View(this)(using cltIncl, cltExcl) 121 | 122 | override def addSystem(system: System): Unit = 123 | systems = systems :+ system 124 | 125 | override def removeSystem(system: System): Unit = 126 | systems = systems filter (_ != system) 127 | 128 | override def update(deltaTime: DeltaTime): Unit = systems foreach (_(deltaTime, this)) 129 | 130 | override def toString: String = componentsContainer.toString 131 | 132 | private[ecscala] override def getComponents[C <: Component: ComponentTag]: Option[Map[Entity, C]] = 133 | componentsContainer[C] 134 | 135 | private[ecscala] override def addComponent[C <: Component: ComponentTag]( 136 | entityComponentPair: (Entity, C), 137 | ): World = { 138 | componentsContainer += entityComponentPair 139 | this 140 | } 141 | 142 | private[ecscala] override def removeComponent[C <: Component: ComponentTag]( 143 | entityComponentPair: (Entity, C), 144 | ): World = { 145 | componentsContainer -= entityComponentPair 146 | this 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/WorldTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import dev.atedeg.ecscala.given 6 | import dev.atedeg.ecscala.fixtures.{ ComponentsFixture, Position, ViewFixture, WorldFixture } 7 | 8 | class WorldTest extends AnyWordSpec with Matchers { 9 | 10 | "A World" when { 11 | "empty" should { 12 | "have size 0" in new WorldFixture { 13 | world.entitiesCount shouldBe 0 14 | } 15 | "have size 1" when { 16 | "an entity is added" in new WorldFixture { 17 | world.createEntity() 18 | world.entitiesCount shouldBe 1 19 | } 20 | } 21 | } 22 | "has 1 entity" should { 23 | "remove all its components" when { 24 | "it is cleared" in new WorldFixture { 25 | val entity = world.createEntity() 26 | val position = Position(1, 1) 27 | entity.setComponent(position) 28 | world.clearEntities() 29 | position.entity shouldBe empty 30 | entity.getComponent[Position] shouldBe empty 31 | } 32 | } 33 | "have size 0" when { 34 | "an entity is removed" in new WorldFixture { 35 | val entity = world.createEntity() 36 | world.removeEntity(entity) 37 | world.entitiesCount shouldBe 0 38 | } 39 | } 40 | "not have a component of a removed entity" in new WorldFixture { 41 | val entity = world.createEntity() 42 | val entity1 = world.createEntity() 43 | entity setComponent Position(1, 2) 44 | entity1 setComponent Position(1, 2) 45 | world.removeEntity(entity) 46 | 47 | world.getComponents[Position] should contain(Map(entity1 -> Position(1, 2))) 48 | } 49 | } 50 | "has 3 entities" should { 51 | "have size 0" when { 52 | "all entities are removed" in new WorldFixture { 53 | val entity = world.createEntity() 54 | val entity1 = world.createEntity() 55 | val entity2 = world.createEntity() 56 | 57 | world.clearEntities() 58 | 59 | world.entitiesCount shouldBe 0 60 | } 61 | } 62 | "not have the components from the removed entities" in new WorldFixture { 63 | val entity = world.createEntity() 64 | val entity1 = world.createEntity() 65 | entity setComponent Position(1, 2) 66 | entity1 setComponent Position(3, 4) 67 | 68 | world.clearEntities() 69 | 70 | world.getComponents[Position] shouldBe empty 71 | } 72 | } 73 | "has entities with components" should { 74 | "return the components" in new WorldFixture with ComponentsFixture { 75 | val entity = world.createEntity() 76 | entity setComponent Position(1, 1) 77 | world.getComponents[Position] should contain(Map(entity -> Position(1, 1))) 78 | } 79 | } 80 | "components are removed from its enities" should { 81 | "not return the components" in new WorldFixture with ComponentsFixture { 82 | val entity = world.createEntity() 83 | val component = Position(1, 1) 84 | entity setComponent component 85 | entity removeComponent component 86 | entity setComponent Position(1, 1) 87 | entity.removeComponent[Position] 88 | 89 | world.getComponents[Position] shouldBe empty 90 | } 91 | } 92 | "update is called" should { 93 | "execute all systems in the same order as they were added" in new ViewFixture { 94 | world.addSystem(IteratingSystem[Position &: CNil]((_, comps, _) => { 95 | val Position(px, py) &: CNil = comps 96 | Position(px * 2, py * 2) &: CNil 97 | })) 98 | 99 | world.addSystem(IteratingSystem[Position &: CNil]((_, comps, _) => { 100 | val Position(x, y) &: CNil = comps 101 | Position(x + 1, y + 1) &: CNil 102 | })) 103 | 104 | world.update(10) 105 | 106 | world.getView[Position &: CNil] should contain theSameElementsAs List( 107 | (entity1, Position(3, 3) &: CNil), 108 | (entity3, Position(3, 3) &: CNil), 109 | (entity4, Position(3, 3) &: CNil), 110 | (entity5, Position(3, 3) &: CNil), 111 | ) 112 | } 113 | } 114 | "a System is removed" should { 115 | "not execute its update" in new WorldFixture { 116 | val entity = world.createEntity() 117 | entity setComponent Position(1, 1) 118 | val system = SystemBuilder[Position &: CNil].withBefore { (_, _, _) => () }.withAfter { (_, _, _) => 119 | () 120 | }.withUpdate { (_, c, _) => 121 | val Position(x, y) &: CNil = c 122 | Position(x + 1, y + 1) &: CNil 123 | } 124 | 125 | world.addSystem(system) 126 | world.removeSystem(system) 127 | world.update(10) 128 | 129 | world.getView[Position &: CNil].toList shouldBe List((entity, Position(1, 1) &: CNil)) 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### SBT ### 2 | .idea 3 | .bsp 4 | 5 | .vscode/ 6 | .metals/ 7 | .bloop/ 8 | .ammonite/ 9 | metals.sbt 10 | 11 | ### SBT ### 12 | # Simple Build Tool 13 | # http://www.scala-sbt.org/release/docs/Getting-Started/Directories.html#configuring-version-control 14 | 15 | dist/* 16 | target/ 17 | lib_managed/ 18 | src_managed/ 19 | project/boot/ 20 | project/plugins/project/ 21 | .history 22 | .cache 23 | .lib/ 24 | 25 | ### Scala ### 26 | *.class 27 | *.log 28 | 29 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 30 | hs_err_pid* 31 | 32 | # doc folder 33 | out/ 34 | 35 | ### LaTeX ### 36 | ## Core latex/pdflatex auxiliary files: 37 | *.aux 38 | *.lof 39 | *.lot 40 | *.fls 41 | *.out 42 | *.toc 43 | *.fmt 44 | *.fot 45 | *.cb 46 | *.cb2 47 | .*.lb 48 | 49 | ## Intermediate documents: 50 | *.dvi 51 | *.xdv 52 | *-converted-to.* 53 | # these rules might exclude image files for figures etc. 54 | # *.ps 55 | # *.eps 56 | *.pdf 57 | 58 | ## Generated if empty string is given at "Please type another file name for output:" 59 | .pdf 60 | 61 | ## Bibliography auxiliary files (bibtex/biblatex/biber): 62 | *.bbl 63 | *.bcf 64 | *.blg 65 | *-blx.aux 66 | *-blx.bib 67 | *.run.xml 68 | 69 | ## Build tool auxiliary files: 70 | *.fdb_latexmk 71 | *.synctex 72 | *.synctex(busy) 73 | *.synctex.gz 74 | *.synctex.gz(busy) 75 | *.pdfsync 76 | 77 | ## Build tool directories for auxiliary files 78 | # latexrun 79 | latex.out/ 80 | 81 | ## Auxiliary and intermediate files from other packages: 82 | # algorithms 83 | *.alg 84 | *.loa 85 | 86 | # achemso 87 | acs-*.bib 88 | 89 | # amsthm 90 | *.thm 91 | 92 | # beamer 93 | *.nav 94 | *.pre 95 | *.snm 96 | *.vrb 97 | 98 | # changes 99 | *.soc 100 | 101 | # comment 102 | *.cut 103 | 104 | # cprotect 105 | *.cpt 106 | 107 | # elsarticle (documentclass of Elsevier journals) 108 | *.spl 109 | 110 | # endnotes 111 | *.ent 112 | 113 | # fixme 114 | *.lox 115 | 116 | # feynmf/feynmp 117 | *.mf 118 | *.mp 119 | *.t[1-9] 120 | *.t[1-9][0-9] 121 | *.tfm 122 | 123 | #(r)(e)ledmac/(r)(e)ledpar 124 | *.end 125 | *.?end 126 | *.[1-9] 127 | *.[1-9][0-9] 128 | *.[1-9][0-9][0-9] 129 | *.[1-9]R 130 | *.[1-9][0-9]R 131 | *.[1-9][0-9][0-9]R 132 | *.eledsec[1-9] 133 | *.eledsec[1-9]R 134 | *.eledsec[1-9][0-9] 135 | *.eledsec[1-9][0-9]R 136 | *.eledsec[1-9][0-9][0-9] 137 | *.eledsec[1-9][0-9][0-9]R 138 | 139 | # glossaries 140 | *.acn 141 | *.acr 142 | *.glg 143 | *.glo 144 | *.gls 145 | *.glsdefs 146 | *.lzo 147 | *.lzs 148 | 149 | # uncomment this for glossaries-extra (will ignore makeindex's style files!) 150 | # *.ist 151 | 152 | # gnuplottex 153 | *-gnuplottex-* 154 | 155 | # gregoriotex 156 | *.gaux 157 | *.glog 158 | *.gtex 159 | 160 | # htlatex 161 | *.4ct 162 | *.4tc 163 | *.idv 164 | *.lg 165 | *.trc 166 | *.xref 167 | 168 | # hyperref 169 | *.brf 170 | 171 | # knitr 172 | *-concordance.tex 173 | # TODO Uncomment the next line if you use knitr and want to ignore its generated tikz files 174 | # *.tikz 175 | *-tikzDictionary 176 | 177 | # listings 178 | *.lol 179 | 180 | # luatexja-ruby 181 | *.ltjruby 182 | 183 | # makeidx 184 | *.idx 185 | *.ilg 186 | *.ind 187 | 188 | # minitoc 189 | *.maf 190 | *.mlf 191 | *.mlt 192 | *.mtc[0-9]* 193 | *.slf[0-9]* 194 | *.slt[0-9]* 195 | *.stc[0-9]* 196 | 197 | # minted 198 | _minted* 199 | *.pyg 200 | 201 | # morewrites 202 | *.mw 203 | 204 | # newpax 205 | *.newpax 206 | 207 | # nomencl 208 | *.nlg 209 | *.nlo 210 | *.nls 211 | 212 | # pax 213 | *.pax 214 | 215 | # pdfpcnotes 216 | *.pdfpc 217 | 218 | # sagetex 219 | *.sagetex.sage 220 | *.sagetex.py 221 | *.sagetex.scmd 222 | 223 | # scrwfile 224 | *.wrt 225 | 226 | # sympy 227 | *.sout 228 | *.sympy 229 | sympy-plots-for-*.tex/ 230 | 231 | # pdfcomment 232 | *.upa 233 | *.upb 234 | 235 | # pythontex 236 | *.pytxcode 237 | pythontex-files-*/ 238 | 239 | # tcolorbox 240 | *.listing 241 | 242 | # thmtools 243 | *.loe 244 | 245 | # TikZ & PGF 246 | *.dpth 247 | *.md5 248 | *.auxlock 249 | 250 | # todonotes 251 | *.tdo 252 | 253 | # vhistory 254 | *.hst 255 | *.ver 256 | 257 | # easy-todo 258 | *.lod 259 | 260 | # xcolor 261 | *.xcp 262 | 263 | # xmpincl 264 | *.xmpi 265 | 266 | # xindy 267 | *.xdy 268 | 269 | # xypic precompiled matrices and outlines 270 | *.xyc 271 | *.xyd 272 | 273 | # endfloat 274 | *.ttt 275 | *.fff 276 | 277 | # Latexian 278 | TSWLatexianTemp* 279 | 280 | ## Editors: 281 | # WinEdt 282 | *.bak 283 | *.sav 284 | 285 | # Texpad 286 | .texpadtmp 287 | 288 | # LyX 289 | *.lyx~ 290 | 291 | # Kile 292 | *.backup 293 | 294 | # gummi 295 | .*.swp 296 | 297 | # KBibTeX 298 | *~[0-9]* 299 | 300 | # TeXnicCenter 301 | *.tps 302 | 303 | # auto folder when using emacs and auctex 304 | ./auto/* 305 | *.el 306 | 307 | # expex forward references with \gathertags 308 | *-tags.tex 309 | 310 | # standalone packages 311 | *.sta 312 | 313 | # Makeindex log files 314 | *.lpz 315 | 316 | # xwatermark package 317 | *.xwm 318 | 319 | # REVTeX puts footnotes in the bibliography by default, unless the nofootinbib 320 | # option is specified. Footnotes are the stored in a file with suffix Notes.bib. 321 | # Uncomment the next line to have this generated file ignored. 322 | #*Notes.bib 323 | 324 | ### LaTeX Patch ### 325 | # LIPIcs / OASIcs 326 | *.vtc 327 | 328 | # glossaries 329 | *.glstex 330 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: [master, develop] 13 | push: 14 | branches: [master, develop] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | jobs: 21 | build: 22 | name: Build and Test 23 | strategy: 24 | matrix: 25 | os: [ubuntu-latest] 26 | scala: [3.0.1] 27 | java: [adopt@1.11, adopt@1.16] 28 | runs-on: ${{ matrix.os }} 29 | steps: 30 | - name: Checkout current branch (full) 31 | uses: actions/checkout@v2 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Setup Java and Scala 36 | uses: olafurpg/setup-scala@v13 37 | with: 38 | java-version: ${{ matrix.java }} 39 | 40 | - name: Cache sbt 41 | uses: actions/cache@v2 42 | with: 43 | path: | 44 | ~/.sbt 45 | ~/.ivy2/cache 46 | ~/.coursier/cache/v1 47 | ~/.cache/coursier/v1 48 | ~/AppData/Local/Coursier/Cache/v1 49 | ~/Library/Caches/Coursier/v1 50 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 51 | 52 | - name: Check that workflows are up to date 53 | run: sbt ++${{ matrix.scala }} githubWorkflowCheck 54 | 55 | - name: Lint check with scalafmt 56 | run: sbt ++${{ matrix.scala }} scalafmtCheckAll 57 | 58 | - name: Tests 59 | run: sbt ++${{ matrix.scala }} test 60 | 61 | - name: Generate JaCoCo report 62 | if: ${{ matrix.java }} == 'adopt@1.11' && ${{ matrix.scala }} == '3.0.1' && github.event_name != 'pull_request' 63 | run: sbt ++${{ matrix.scala }} 'core / jacoco' 64 | 65 | - name: Publish coverage to codecov 66 | if: ${{ matrix.java }} == 'adopt@1.11' && ${{ matrix.scala }} == '3.0.1' && github.event_name != 'pull_request' 67 | uses: codecov/codecov-action@v2 68 | with: 69 | token: ${{ secrets.CODECOV_TOKEN }} 70 | directory: core/target/scala-3.0.1/jacoco/report 71 | fail_ci_if_error: true 72 | 73 | - name: Compress target directories 74 | run: tar cf targets.tar target core/target project/target 75 | 76 | - name: Upload target directories 77 | uses: actions/upload-artifact@v2 78 | with: 79 | name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} 80 | path: targets.tar 81 | 82 | publish: 83 | name: Publish Artifacts 84 | needs: [build] 85 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) 86 | strategy: 87 | matrix: 88 | os: [ubuntu-latest] 89 | scala: [3.0.1] 90 | java: [adopt@1.11] 91 | runs-on: ${{ matrix.os }} 92 | steps: 93 | - name: Checkout current branch (full) 94 | uses: actions/checkout@v2 95 | with: 96 | fetch-depth: 0 97 | 98 | - name: Setup Java and Scala 99 | uses: olafurpg/setup-scala@v13 100 | with: 101 | java-version: ${{ matrix.java }} 102 | 103 | - name: Cache sbt 104 | uses: actions/cache@v2 105 | with: 106 | path: | 107 | ~/.sbt 108 | ~/.ivy2/cache 109 | ~/.coursier/cache/v1 110 | ~/.cache/coursier/v1 111 | ~/AppData/Local/Coursier/Cache/v1 112 | ~/Library/Caches/Coursier/v1 113 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 114 | 115 | - name: Download target directories (3.0.1) 116 | uses: actions/download-artifact@v2 117 | with: 118 | name: target-${{ matrix.os }}-3.0.1-${{ matrix.java }} 119 | 120 | - name: Inflate target directories (3.0.1) 121 | run: | 122 | tar xf targets.tar 123 | rm targets.tar 124 | 125 | - name: Create FatJar for the demo 126 | run: sbt ++${{ matrix.scala }} 'demo / assembly' 127 | 128 | - name: Release to Sonatype 129 | env: 130 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 131 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 132 | CI_CLEAN: sonatypeBundleClean 133 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 134 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 135 | run: sbt ++${{ matrix.scala }} ci-release 136 | 137 | - name: Release to Github Releases 138 | uses: marvinpinto/action-automatic-releases@latest 139 | with: 140 | repo_token: ${{ secrets.GITHUB_TOKEN }} 141 | prerelease: false 142 | title: Release - Version ${{ env.VERSION }} 143 | files: | 144 | core/target/scala-3.0.1/*.jar 145 | core/target/scala-3.0.1/*.pom 146 | demo/target/scala-3.0.1/ECScalaDemo.jar 147 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/util/mutable/ComponentsContainerTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.util.mutable 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import dev.atedeg.ecscala.given 6 | import dev.atedeg.ecscala.fixtures.{ ComponentsFixture, Position, Velocity, WorldFixture } 7 | 8 | class ComponentsContainerTest extends AnyWordSpec with Matchers { 9 | 10 | "A ComponentsContainer" when { 11 | "added an element" which { 12 | "was not present" should { 13 | "return it with apply" in new WorldFixture with ComponentsFixture { 14 | val entity = world.createEntity() 15 | componentsContainer += (entity -> Position(1, 2)) 16 | 17 | componentsContainer[Position] should contain(Map(entity -> Position(1, 2))) 18 | } 19 | } 20 | "was present" should { 21 | "return the updated version" in new WorldFixture with ComponentsFixture { 22 | val entity = world.createEntity() 23 | componentsContainer += (entity -> Position(1, 2)) 24 | componentsContainer += (entity -> Position(2, 3)) 25 | 26 | componentsContainer[Position] should contain(Map(entity -> Position(2, 3))) 27 | } 28 | } 29 | } 30 | "added multiple entities with the same Component type" should { 31 | "return a map of all added entities" in new WorldFixture with ComponentsFixture { 32 | val entities = (0 until 100) map (_ => world.createEntity()) 33 | val entityComponentPairs = entities map (_ -> Position(1, 2)) 34 | entityComponentPairs foreach (componentsContainer += _) 35 | 36 | componentsContainer[Position] should contain(Map.from(entityComponentPairs)) 37 | } 38 | } 39 | "removed a component" should { 40 | "no longer return it with apply" in new WorldFixture with ComponentsFixture { 41 | val entity1 = world.createEntity() 42 | val component1 = Position(1, 2) 43 | val entity2 = world.createEntity() 44 | componentsContainer += (entity1 -> component1) 45 | componentsContainer += (entity2 -> Position(2, 3)) 46 | componentsContainer -= (entity1 -> component1) 47 | 48 | componentsContainer[Position] should contain(Map(entity2 -> Position(2, 3))) 49 | } 50 | } 51 | "removed the last component of a type" should { 52 | "no longer return the type's map with apply" in new WorldFixture with ComponentsFixture { 53 | val entity = world.createEntity() 54 | val component = Position(1, 2) 55 | componentsContainer += (entity -> component) 56 | componentsContainer -= (entity -> component) 57 | 58 | componentsContainer[Position] shouldBe empty 59 | } 60 | } 61 | "trying to remove an entity-component pair that does not exist" should { 62 | "not remove anything" in new WorldFixture with ComponentsFixture { 63 | val entity1 = world.createEntity() 64 | val component = Position(1, 2) 65 | val entity2 = world.createEntity() 66 | componentsContainer += entity1 -> component 67 | componentsContainer -= entity2 -> component 68 | 69 | componentsContainer[Position] should contain(Map(entity1 -> Position(1, 2))) 70 | } 71 | } 72 | "trying to remove a component" which { 73 | "is a case class" should { 74 | "remove it only if the references are the same" in new WorldFixture with ComponentsFixture { 75 | val entity = world.createEntity() 76 | componentsContainer += entity -> Position(1, 2) 77 | componentsContainer -= entity -> Position(1, 2) 78 | 79 | componentsContainer[Position] should contain(Map(entity -> Position(1, 2))) 80 | } 81 | } 82 | } 83 | "trying to remove a component of a type that was never added" should { 84 | "not remove anything" in new WorldFixture with ComponentsFixture { 85 | val entity = world.createEntity() 86 | componentsContainer += entity -> Position(1, 2) 87 | componentsContainer -= entity -> Velocity(1, 2) 88 | 89 | componentsContainer[Position] should contain(Map(entity -> Position(1, 2))) 90 | componentsContainer[Velocity] shouldBe empty 91 | } 92 | } 93 | "removing an entity" which { 94 | "has multiple components" should { 95 | "remove all its components" in new WorldFixture with ComponentsFixture { 96 | val entity = world.createEntity() 97 | componentsContainer += entity -> Position(1, 2) 98 | componentsContainer += entity -> Velocity(2, 3) 99 | componentsContainer -= entity 100 | 101 | componentsContainer[Position] shouldBe empty 102 | componentsContainer[Velocity] shouldBe empty 103 | } 104 | "not remove any other entities" in new WorldFixture with ComponentsFixture { 105 | val entity1 = world.createEntity() 106 | val entity2 = world.createEntity() 107 | componentsContainer += entity1 -> Position(1, 2) 108 | componentsContainer += entity1 -> Velocity(2, 3) 109 | componentsContainer += entity2 -> Position(1, 2) 110 | componentsContainer += entity2 -> Velocity(2, 3) 111 | componentsContainer -= entity1 112 | 113 | componentsContainer[Position] should contain(Map(entity2 -> Position(1, 2))) 114 | componentsContainer[Velocity] should contain(Map(entity2 -> Velocity(2, 3))) 115 | } 116 | } 117 | "was not added" should { 118 | "not remove anything" in new WorldFixture with ComponentsFixture { 119 | val entity1 = world.createEntity() 120 | val entity2 = world.createEntity() 121 | componentsContainer += entity1 -> Position(1, 2) 122 | componentsContainer += entity1 -> Velocity(2, 3) 123 | componentsContainer -= entity2 124 | 125 | componentsContainer[Position] should contain(Map(entity1 -> Position(1, 2))) 126 | componentsContainer[Velocity] should contain(Map(entity1 -> Velocity(2, 3))) 127 | } 128 | } 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/SystemTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import dev.atedeg.ecscala.given 6 | import dev.atedeg.ecscala.fixtures.{ Mass, Position, Velocity, ViewFixture } 7 | 8 | class SystemTest extends AnyWordSpec with Matchers { 9 | def beAbleTo = afterWord("be able to") 10 | 11 | "A System" when { 12 | "with a false precondition" should { 13 | "not execute" in new ViewFixture { 14 | var success = true 15 | world.addSystem(new System { 16 | override def shouldRun: Boolean = false 17 | 18 | override def update(deltaTime: DeltaTime, world: World): Unit = success = false 19 | }) 20 | world.update(10) 21 | success shouldBe true 22 | } 23 | } 24 | "execute when updating" in new ViewFixture { 25 | var success = false 26 | world.addSystem(System((_, _) => success = true)) 27 | world.update(10) 28 | success shouldBe true 29 | } 30 | } 31 | 32 | "An IteratingSystem" when { 33 | "with a false precondition" should { 34 | "not execute" in new ViewFixture { 35 | var success = true 36 | world.addSystem( 37 | System[Position &: CNil] 38 | .withPrecondition(false) 39 | .withBefore { (_, _, _) => success = false } 40 | .withAfter { (_, _, _) => success = false } 41 | .withUpdate { (_, components, _) => 42 | success = false 43 | components 44 | }, 45 | ) 46 | world.update(10) 47 | success shouldBe true 48 | } 49 | } 50 | "executing its update" should beAbleTo { 51 | "update components" in new ViewFixture { 52 | world.addSystem(IteratingSystem[Position &: Velocity &: CNil]((_, comps, _) => { 53 | val Position(x, y) &: Velocity(vx, vy) &: CNil = comps 54 | Position(x + 1, y + 1) &: Velocity(vx + 1, vy + 1) &: CNil 55 | })) 56 | world.update(10) 57 | 58 | world.getView[Position &: Velocity &: CNil] should contain theSameElementsAs List( 59 | (entity1, Position(2, 2) &: Velocity(2, 2)), 60 | (entity3, Position(2, 2) &: Velocity(2, 2)), 61 | (entity4, Position(2, 2) &: Velocity(2, 2)), 62 | ) 63 | } 64 | "remove components" in new ViewFixture { 65 | world.addSystem(IteratingSystem[Position &: Velocity &: CNil]((_, _, _) => Deleted &: Deleted &: CNil)) 66 | world.update(10) 67 | world.getView[Position &: Velocity &: CNil] shouldBe empty 68 | } 69 | "add components" in new ViewFixture { 70 | world.addSystem(IteratingSystem[Position &: Velocity &: CNil]((entity, comps, _) => { 71 | entity setComponent Mass(10) 72 | comps 73 | })) 74 | world.update(10) 75 | 76 | world.getView[Mass &: CNil] should contain theSameElementsAs List( 77 | (entity1, Mass(10) &: CNil), 78 | (entity2, Mass(1) &: CNil), 79 | (entity3, Mass(10) &: CNil), 80 | (entity4, Mass(10) &: CNil), 81 | (entity5, Mass(1) &: CNil), 82 | ) 83 | } 84 | "add entities to its world" in new ViewFixture { 85 | world.addSystem(IteratingSystem[Position &: CNil]((_, comps, _, w, _) => { 86 | w.createEntity() 87 | comps 88 | })) 89 | world.update(10) 90 | world.entitiesCount shouldBe 9 91 | } 92 | "remove entities from its world" in new ViewFixture { 93 | world.addSystem(IteratingSystem[Position &: CNil]((entity, comps, _, w, _) => { 94 | w.removeEntity(entity) 95 | comps 96 | })) 97 | world.update(10) 98 | world.entitiesCount shouldBe 1 99 | } 100 | } 101 | "executing its update" should { 102 | "have the correct delta time" in new ViewFixture { 103 | world.addSystem(IteratingSystem[Position &: CNil]((_, comps, dt) => { 104 | dt shouldBe 10 105 | comps 106 | })) 107 | world.update(10) 108 | } 109 | "execute its before and after handlers in the correct order" in new ViewFixture { 110 | type Comps = Position &: Velocity &: CNil 111 | val testSystem = System[Comps].withBefore { (deltaTime, world, view) => 112 | view foreach (entityComponentsPair => { 113 | val (entity, Position(px, py) &: _) = entityComponentsPair 114 | entity setComponent Position(px * 2, py * 2) 115 | }) 116 | }.withAfter { (deltaTime, world, view) => 117 | view foreach (entityComponentsPair => { 118 | val (entity, Position(px, py) &: _) = entityComponentsPair 119 | entity setComponent Position(px + 1, py + 1) 120 | }) 121 | }.withUpdate { (_, components, _) => components } 122 | 123 | world.addSystem(testSystem) 124 | world.update(10) 125 | 126 | world.getView[Comps] should contain theSameElementsAs List( 127 | (entity1, Position(3, 3) &: Velocity(1, 1)), 128 | (entity3, Position(3, 3) &: Velocity(1, 1)), 129 | (entity4, Position(3, 3) &: Velocity(1, 1)), 130 | ) 131 | } 132 | } 133 | } 134 | 135 | "An ExcludingSystem" when { 136 | "executing its update" should beAbleTo { 137 | "update components" in new ViewFixture { 138 | world.addSystem(ExcludingSystem[Position &: Velocity &: CNil, Mass &: CNil]((_, comps, _, _, _) => { 139 | val Position(x, y) &: Velocity(vx, vy) &: CNil = comps 140 | Position(x + 1, y + 1) &: Velocity(vx + 1, vy + 1) &: CNil 141 | })) 142 | world.update(10) 143 | 144 | world.getView[Position &: Velocity &: CNil, Mass &: CNil] should contain theSameElementsAs List( 145 | (entity1, Position(2, 2) &: Velocity(2, 2)), 146 | (entity4, Position(2, 2) &: Velocity(2, 2)), 147 | ) 148 | } 149 | } 150 | "excludes included components" should { 151 | "not run its update" in new ViewFixture { 152 | var updateExecuted = false 153 | world.addSystem( 154 | System[Position &: CNil].excluding[Position &: CNil].withUpdate { (_, cs, _) => 155 | updateExecuted = true; cs 156 | }, 157 | ) 158 | updateExecuted shouldBe false 159 | } 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/System.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import scala.annotation.tailrec 4 | import dev.atedeg.ecscala.given 5 | 6 | type DeltaTime = Double 7 | 8 | /** 9 | * This trait represents a system, which can update the [[World]] 's state. 10 | */ 11 | trait System { 12 | 13 | private[ecscala] final def apply(deltaTime: DeltaTime, world: World): Unit = { 14 | if (shouldRun) { 15 | update(deltaTime, world) 16 | } 17 | } 18 | 19 | /** 20 | * @return 21 | * whether this [[System]] should be executed or not. 22 | */ 23 | def shouldRun: Boolean = true 24 | 25 | /** 26 | * This method is executed every time the [[World]] is updated. 27 | * @param deltaTime 28 | * the delta time used to update. 29 | * @param world 30 | * the [[World]] in which the [[System]] is being executed. 31 | */ 32 | def update(deltaTime: DeltaTime, world: World): Unit 33 | } 34 | 35 | object System { 36 | 37 | /** 38 | * Create a [[System]] from a lambda that specifies its behaviour. 39 | * @param f 40 | * the behaviour of the [[System]] that takes [[DeltaTime]] and the [[World]] and returns Unit. 41 | * @return 42 | * the created [[System]]. 43 | */ 44 | def apply(f: (DeltaTime, World) => Unit): System = new System { 45 | override def update(deltaTime: DeltaTime, world: World): Unit = f(deltaTime, world) 46 | } 47 | 48 | /** 49 | * Create a [[SystemBuilder]]. 50 | * @tparam L 51 | * a [[CList]] representing the Components to be iterated. 52 | * @return 53 | * the [[SystemBuilder]]. 54 | */ 55 | def apply[L <: CList: CListTag]: SystemBuilder[L] = SystemBuilder[L] 56 | } 57 | 58 | /** 59 | * Represent a way to iterate over entities with specific components (given by the type parameter L) and manupulate 60 | * them. 61 | * @tparam L 62 | * a [[CList]] representing the Components available to the [[IteratingSystem]]. 63 | */ 64 | trait IteratingSystem[L <: CList](using private val clt: CListTag[L]) extends System { 65 | 66 | /** 67 | * This method is executed before each iteration of the [[IteratingSystem]]. 68 | * @param deltaTime 69 | * the delta time used to update. 70 | * @param world 71 | * the [[World]] in which the [[IteratingSystem]] is being executed. 72 | * @param view 73 | * a [[View]] with the Components specified by the [[IteratingSystem]] type. 74 | */ 75 | def before(deltaTime: DeltaTime, world: World, view: View[L]): Unit = {} 76 | 77 | /** 78 | * This method is executed after each iteration of the [[IteratingSystem]]. 79 | * @param deltaTime 80 | * the delta time used to update. 81 | * @param world 82 | * the [[World]] in which the [[IteratingSystem]] is being executed. 83 | * @param view 84 | * a [[View]] with the Components specified by the [[IteratingSystem]] type. 85 | */ 86 | def after(deltaTime: DeltaTime, world: World, view: View[L]): Unit = {} 87 | 88 | /** 89 | * Describes how this [[IteratingSystem]] updates the components (described by the type of the System) of an 90 | * [[Entity]]. 91 | * @param entity 92 | * the [[Entity]] whose components are being updated. 93 | * @param components 94 | * the [[CList]] of Components that are being updated. 95 | * @param deltaTime 96 | * the delta time used to update. 97 | * @param world 98 | * the [[World]] in which this [[IteratingSystem]] is being used. 99 | * @param view 100 | * the [[View]] of all entities with the components described by L. 101 | * @return 102 | * a new [[CList]] with the updated components; it could also contain a special component - Deleted - that is used 103 | * to delete the corresponding component: e.g. If the expected return type is a {{{Position &: Velocity &: CNil}}} 104 | * one could also return {{{Position(1, 2) &: Deleted &: CNil}}} Resulting in the removal of the Velocity Component 105 | * from the given Entity. 106 | */ 107 | def update(entity: Entity, components: L)(deltaTime: DeltaTime, world: World, view: View[L]): Deletable[L] 108 | 109 | override final def update(deltaTime: DeltaTime, world: World): Unit = { 110 | val view = getView(world) 111 | before(deltaTime, world, view) 112 | view foreach { (entity, components) => 113 | val updatedComponents = update(entity, components)(deltaTime, world, view) 114 | updateComponents(updatedComponents)(entity)(using clt) 115 | } 116 | after(deltaTime, world, view) 117 | } 118 | 119 | protected def getView(world: World): View[L] = world.getView(using clt) 120 | 121 | private def updateComponents[L <: CList](components: Deletable[L])(entity: Entity)(using clt: CListTag[L]): Unit = { 122 | components.taggedWith(clt) foreach { taggedComponent => 123 | val (component, ct) = taggedComponent 124 | component match { 125 | case Deleted => entity.removeComponent(using ct) 126 | case _ => entity.setComponent(component)(using ct) 127 | } 128 | } 129 | } 130 | } 131 | 132 | object IteratingSystem { 133 | 134 | def apply[L <: CList: CListTag](f: (Entity, L, DeltaTime) => Deletable[L]): IteratingSystem[L] = 135 | SystemBuilder[L].withUpdate(f) 136 | 137 | def apply[L <: CList: CListTag](f: (Entity, L, DeltaTime, World, View[L]) => Deletable[L]): IteratingSystem[L] = 138 | SystemBuilder[L].withUpdate(f) 139 | } 140 | 141 | /** 142 | * Represent a way to iterate over entities with specific components (given by the type parameter LIncluded) and without 143 | * specific components (given by the type parameter LExcluded) and manipulate them. 144 | * @tparam LIncluded 145 | * a [[CList]] representing the Components available to the [[System]]. 146 | * @tparam LExcluded 147 | * a [[CList]] representing the Components to filter out from the selected entities. 148 | */ 149 | trait ExcludingSystem[LIncluded <: CList, LExcluded <: CList](using 150 | private val cltIncl: CListTag[LIncluded], 151 | private val cltExcl: CListTag[LExcluded], 152 | ) extends IteratingSystem[LIncluded] { 153 | override protected def getView(world: World): View[LIncluded] = world.getView(using cltIncl, cltExcl) 154 | } 155 | 156 | object ExcludingSystem { 157 | 158 | def apply[L <: CList: CListTag, E <: CList: CListTag]( 159 | f: (Entity, L, DeltaTime) => Deletable[L], 160 | ): ExcludingSystem[L, E] = 161 | SystemBuilder[L].excluding[E].withUpdate(f) 162 | 163 | def apply[L <: CList: CListTag, E <: CList: CListTag]( 164 | f: (Entity, L, DeltaTime, World, View[L]) => Deletable[L], 165 | ): ExcludingSystem[L, E] = SystemBuilder[L].excluding[E].withUpdate(f) 166 | def apply[L <: CList: CListTag, E <: CList: CListTag]: ExcludingSystemBuilder[L, E] = ExcludingSystemBuilder[L, E] 167 | } 168 | -------------------------------------------------------------------------------- /demo/src/test/scala/dev/atedeg/ecscalademo/gui/GUITest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscalademo.gui 2 | 3 | import dev.atedeg.ecscalademo.State 4 | import dev.atedeg.ecscalademo.controller.MainViewController 5 | import dev.atedeg.ecscalademo.gui.TestData.* 6 | import javafx.fxml.FXMLLoader 7 | import javafx.scene.Parent 8 | import javafx.scene.control.Button 9 | import javafx.stage.Stage 10 | import org.junit.jupiter.api.Assertions.assertEquals 11 | import org.junit.jupiter.api.extension.ExtendWith 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.params.ParameterizedTest 14 | import org.junit.jupiter.params.provider.{ Arguments, MethodSource } 15 | import org.testfx.api.FxRobot 16 | import org.testfx.assertions.api.Assertions.assertThat 17 | import org.testfx.framework.junit5.{ ApplicationExtension, Start } 18 | import scala.jdk.javaapi.StreamConverters 19 | import scalafx.Includes.* 20 | import scalafx.scene.Scene 21 | 22 | object TestData { 23 | val playPauseButtonId = "#playPauseBtnDelegate" 24 | val addBallButtonId = "#addBallBtnDelegate" 25 | val changeVelocityButtonId = "#changeVelBtnDelegate" 26 | val resetButtonId = "#resetBtnDelegate" 27 | val canvasId = "#canvasDelegate" 28 | 29 | type ButtonName = String 30 | type Disabled = Boolean 31 | type ButtonState = (ButtonName, Disabled) 32 | 33 | private def buttonsState( 34 | playPause: Boolean, 35 | addBall: Boolean, 36 | changeVelocity: Boolean, 37 | reset: Boolean, 38 | ): Seq[ButtonState] = Seq( 39 | (playPauseButtonId, playPause), 40 | (addBallButtonId, addBall), 41 | (changeVelocityButtonId, changeVelocity), 42 | (resetButtonId, reset), 43 | ) 44 | 45 | type EnabledTransitions = Seq[(State, FxRobot => Unit)] 46 | 47 | private val pauseEnabledTransitions: EnabledTransitions = Seq( 48 | State.AddBalls -> reachAddBallState, 49 | State.SelectBall -> reachSelectBallState, 50 | State.Play -> reachPlayState, 51 | State.Pause -> (_.clickOn(resetButtonId)), 52 | ) 53 | 54 | private val playEnabledTransitions: EnabledTransitions = Seq( 55 | State.Pause -> (_.clickOn(playPauseButtonId)), 56 | ) 57 | 58 | private val addBallsEnabledTransitions: EnabledTransitions = Seq( 59 | State.Pause -> (_.clickOn(addBallButtonId)), 60 | State.AddBalls -> (_.clickOn(canvasId)), 61 | State.Pause -> (_.clickOn(resetButtonId)), 62 | ) 63 | 64 | private val selectBallEnabledTransitions: EnabledTransitions = Seq( 65 | State.Play -> (_.clickOn(playPauseButtonId)), 66 | State.SelectBall -> reachSelectBallState, 67 | State.ChangeVelocity -> (_.clickOn(changeVelocityButtonId)), 68 | State.AddBalls -> (_.clickOn(addBallButtonId)), 69 | State.Pause -> (_.clickOn(canvasId)), 70 | State.Pause -> (_.clickOn(resetButtonId)), 71 | ) 72 | 73 | private val changeVelocityEnabledTransitions: EnabledTransitions = Seq( 74 | State.Play -> (_.clickOn(playPauseButtonId)), 75 | State.Pause -> (_.clickOn(resetButtonId)), 76 | State.Pause -> (_.clickOn(changeVelocityButtonId)), 77 | ) 78 | 79 | private def reachPlayState(fxRobot: FxRobot) = fxRobot.clickOn(playPauseButtonId) 80 | 81 | private def reachAddBallState(fxRobot: FxRobot) = fxRobot.clickOn(addBallButtonId) 82 | 83 | private def reachSelectBallState(fxRobot: FxRobot) = { 84 | fxRobot.moveTo(mainScene.lookup(canvasId).get) 85 | fxRobot.moveBy(50, 0) 86 | fxRobot.clickOn() 87 | } 88 | 89 | private def reachChangeVelocityState(fxRobot: FxRobot) = { 90 | reachSelectBallState(fxRobot) 91 | fxRobot.clickOn(changeVelocityButtonId) 92 | } 93 | 94 | type StateDescription = (FxRobot => Unit, EnabledTransitions, Seq[ButtonState]) 95 | 96 | private val stateDescriptions: Map[State, StateDescription] = Map( 97 | State.Pause -> (_ => (), pauseEnabledTransitions, buttonsState(false, false, true, false)), 98 | State.Play -> (reachPlayState, playEnabledTransitions, buttonsState(false, true, true, true)), 99 | State.AddBalls -> (reachAddBallState, addBallsEnabledTransitions, buttonsState(false, false, true, false)), 100 | State.SelectBall -> (reachSelectBallState, selectBallEnabledTransitions, buttonsState(false, false, false, false)), 101 | State.ChangeVelocity -> 102 | (reachChangeVelocityState, changeVelocityEnabledTransitions, buttonsState(false, true, false, false)), 103 | ) 104 | 105 | def buttonsTestArguments = StreamConverters.asJavaSeqStream( 106 | for { 107 | (state, (reachState, _, expectedButtonsConfiguration)) <- stateDescriptions 108 | } yield Arguments.of(state, reachState, expectedButtonsConfiguration), 109 | ) 110 | 111 | def transitionsTestArguments = StreamConverters.asJavaSeqStream( 112 | for { 113 | (state, (reachState, transitions, _)) <- stateDescriptions 114 | (expectedState, transition) <- transitions 115 | } yield Arguments.of(state, reachState, transition, expectedState), 116 | ) 117 | } 118 | 119 | private var mainScene: Scene = _ 120 | private var controller: MainViewController = _ 121 | 122 | @ExtendWith(Array(classOf[ApplicationExtension])) 123 | class GUITest { 124 | 125 | // Necessary in order to test the GUI in the CI headless environemnt 126 | private def setupHeadlessTesting(): Unit = { 127 | System.setProperty("testfx.robot", "glass") 128 | System.setProperty("testfx.headless", "true") 129 | System.setProperty("prism.order", "sw") 130 | System.setProperty("prism.text", "t2k") 131 | System.setProperty("monocle.platform", "Headless") 132 | System.setProperty("glass.platform", "Monocle") 133 | } 134 | setupHeadlessTesting() 135 | 136 | @Start def start(stage: Stage): Unit = { 137 | val loader: FXMLLoader = FXMLLoader() 138 | val root: Parent = loader.load(getClass.getResource("/MainView.fxml").openStream) 139 | controller = loader.getController[MainViewController] 140 | mainScene = new Scene(root) 141 | stage.setScene(mainScene) 142 | stage.setMinHeight(540) 143 | stage.setMinWidth(960) 144 | stage.show() 145 | } 146 | 147 | @ParameterizedTest(name = "The {0} state should respects its button configuration") 148 | @MethodSource(Array("dev.atedeg.ecscalademo.gui.TestData#buttonsTestArguments")) 149 | def checkStatesButtons( 150 | testedState: State, 151 | reachState: FxRobot => Unit, 152 | expectedButtonsConfiguration: Seq[ButtonState], 153 | ): Unit = { 154 | val robot = new FxRobot() 155 | reachState(robot) 156 | robot.checkAllButtons(expectedButtonsConfiguration) 157 | } 158 | 159 | @ParameterizedTest(name = "It should be possible to go from the {0} state to the {3} state") 160 | @MethodSource(Array("dev.atedeg.ecscalademo.gui.TestData#transitionsTestArguments")) 161 | def checkStateTransitions( 162 | testedState: State, 163 | reachState: FxRobot => Unit, 164 | reachExpectedState: FxRobot => Unit, 165 | expectedState: State, 166 | ): Unit = { 167 | val robot = new FxRobot() 168 | reachState(robot) 169 | reachExpectedState(robot) 170 | assertEquals(expectedState, controller.playState.gameState) 171 | } 172 | 173 | @Test 174 | def twoResetsInARowShouldNotThrowAnException(fxRobot: FxRobot): Unit = { 175 | (1 to 2) foreach { _ => 176 | fxRobot.clickOn(playPauseButtonId) 177 | fxRobot.sleep(100) 178 | fxRobot.clickOn(playPauseButtonId) 179 | fxRobot.clickOn(resetButtonId) 180 | } 181 | } 182 | } 183 | 184 | extension (fxRobot: FxRobot) { 185 | 186 | def checkAllButtons(buttonsExpectedConfiguration: Seq[ButtonState]): Unit = 187 | buttonsExpectedConfiguration foreach { fxRobot.findButton(_).checkEnabled(_) } 188 | def findButton(buttonId: String): Button = fxRobot.lookup(buttonId).queryButton() 189 | } 190 | 191 | extension (button: Button) { 192 | 193 | def checkEnabled(shouldBeEnabled: Boolean): Unit = 194 | if shouldBeEnabled then assertThat(button).isDisabled 195 | else assertThat(button).isEnabled 196 | } 197 | -------------------------------------------------------------------------------- /core/src/main/scala/dev/atedeg/ecscala/SystemBuilder.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala 2 | 3 | import dev.atedeg.ecscala.given 4 | 5 | /** 6 | * The generic operations that a SystemBuilder provides. 7 | * @tparam L 8 | * the type of the [[CList]] used by the built [[IteratingSystem]]. 9 | */ 10 | trait SystemBuilderOps[L <: CList: CListTag] { 11 | 12 | /** 13 | * The type of the SystemBuilder produced by the different chained operations. 14 | */ 15 | type BuilderType <: SystemBuilderOps[L] 16 | 17 | /** 18 | * The type of the [[IteratingSystem]] produced when closing the builder. 19 | */ 20 | type SystemType <: IteratingSystem[L] 21 | 22 | /** 23 | * @param before 24 | * the before function used by Systems created by the returned Builder. 25 | * @return 26 | * the new Builder with the specified before function. 27 | */ 28 | def withBefore(before: (DeltaTime, World, View[L]) => Unit): BuilderType 29 | 30 | /** 31 | * @param before 32 | * the after function used by Systems created by the returned Builder. 33 | * @return 34 | * the new Builder with the specified after function. 35 | */ 36 | def withAfter(after: (DeltaTime, World, View[L]) => Unit): BuilderType 37 | 38 | /** 39 | * @param before 40 | * the precondition used by Systems created by the returned Builder. 41 | * @return 42 | * the new Builder with the specified precondition. 43 | */ 44 | def withPrecondition(precondition: => Boolean): BuilderType 45 | 46 | /** 47 | * @param f 48 | * the [[System.update]] function to be used by the created System 49 | * @return 50 | * a System with the given update function 51 | */ 52 | def withUpdate(f: (Entity, L, DeltaTime) => Deletable[L]): SystemType 53 | 54 | /** 55 | * @param f 56 | * the [[System.update]] function to be used by the created System 57 | * @return 58 | * a System with the given update function 59 | */ 60 | def withUpdate(f: (Entity, L, DeltaTime, World, View[L]) => Deletable[L]): SystemType 61 | } 62 | 63 | /** 64 | * A builder used to create [[IteratingSystem]]. 65 | * @tparam L 66 | * the type of the [[CList]] used by the built [[IteratingSystem]]. 67 | */ 68 | trait SystemBuilder[L <: CList: CListTag] extends SystemBuilderOps[L] { 69 | override type BuilderType = SystemBuilder[L] 70 | override type SystemType = IteratingSystem[L] 71 | 72 | /** 73 | * Converts this builder to an [[ExcludingSystemBuilder]] 74 | * @tparam E 75 | * the type of the [[CList]] of components to be excluded. 76 | * @return 77 | * an [[ExcludingSystemBuilder]] from this builder. 78 | */ 79 | def excluding[E <: CList: CListTag]: ExcludingSystemBuilder[L, E] 80 | } 81 | 82 | object SystemBuilder { 83 | 84 | /** 85 | * @tparam L 86 | * the type of the [[CList]] used by the built [[IteratingSystem]]. 87 | * @return 88 | * a new [[SystemBuilder]] 89 | */ 90 | def apply[L <: CList: CListTag]: SystemBuilder[L] = BuilderUtils.SystemBuilderImpl() 91 | } 92 | 93 | /** 94 | * A builder used to create [[ExcludingSystem]]. 95 | * @tparam L 96 | * the type of the [[CList]] used by the built [[IteratingSystem]]. 97 | * @tparam E 98 | * the type of the [[CList]] of components to be excluded. 99 | */ 100 | trait ExcludingSystemBuilder[L <: CList: CListTag, E <: CList: CListTag] extends SystemBuilderOps[L] { 101 | override type BuilderType = ExcludingSystemBuilder[L, E] 102 | override type SystemType = ExcludingSystem[L, E] 103 | } 104 | 105 | object ExcludingSystemBuilder { 106 | 107 | /** 108 | * @tparam L 109 | * the type of the [[CList]] used by the built [[IteratingSystem]]. 110 | * @tparam E 111 | * the type of the [[CList]] of components to be excluded. 112 | * @return 113 | * a new [[ExcludingSystemBuilder]] 114 | */ 115 | def apply[L <: CList: CListTag, E <: CList: CListTag]: ExcludingSystemBuilder[L, E] = 116 | BuilderUtils.ExcludingSystemBuilderImpl() 117 | } 118 | 119 | private object BuilderUtils { 120 | 121 | abstract class BaseSystemBuilder[L <: CList: CListTag]( 122 | before: (DeltaTime, World, View[L]) => Unit = (_, _, _: View[L]) => (), 123 | after: (DeltaTime, World, View[L]) => Unit = (_, _, _: View[L]) => (), 124 | precondition: => Boolean = true, 125 | ) extends SystemBuilderOps[L] { 126 | def systemConstructor(f: (Entity, L, DeltaTime) => Deletable[L]): SystemType 127 | def systemConstructor(f: (Entity, L, DeltaTime, World, View[L]) => Deletable[L]): SystemType 128 | 129 | def builderConstructor( 130 | before: (DeltaTime, World, View[L]) => Unit, 131 | after: (DeltaTime, World, View[L]) => Unit, 132 | precondition: => Boolean, 133 | ): BuilderType 134 | 135 | override def withBefore(newBefore: (DeltaTime, World, View[L]) => Unit) = 136 | builderConstructor(newBefore, after, precondition) 137 | 138 | override def withAfter(newAfter: (DeltaTime, World, View[L]) => Unit) = 139 | builderConstructor(before, newAfter, precondition) 140 | override def withPrecondition(newPrecondition: => Boolean) = builderConstructor(before, after, newPrecondition) 141 | override def withUpdate(f: (Entity, L, DeltaTime) => Deletable[L]) = systemConstructor(f) 142 | override def withUpdate(f: (Entity, L, DeltaTime, World, View[L]) => Deletable[L]) = systemConstructor(f) 143 | } 144 | 145 | class SystemBuilderImpl[L <: CList: CListTag]( 146 | beforeHandler: (DeltaTime, World, View[L]) => Unit = (_, _, _: View[L]) => (), 147 | afterHandler: (DeltaTime, World, View[L]) => Unit = (_, _, _: View[L]) => (), 148 | precondition: => Boolean = true, 149 | ) extends BaseSystemBuilder[L](beforeHandler, afterHandler, precondition) 150 | with SystemBuilder[L] { 151 | 152 | override def systemConstructor(f: (Entity, L, DeltaTime) => Deletable[L]) = new IteratingSystem[L] { 153 | override def update(e: Entity, c: L)(dt: DeltaTime, w: World, v: View[L]) = f(e, c, dt) 154 | override def before(dt: DeltaTime, w: World, v: View[L]): Unit = beforeHandler(dt, w, v) 155 | override def after(dt: DeltaTime, w: World, v: View[L]): Unit = afterHandler(dt, w, v) 156 | override def shouldRun = precondition 157 | } 158 | 159 | override def systemConstructor(f: (Entity, L, DeltaTime, World, View[L]) => Deletable[L]) = new IteratingSystem[L] { 160 | override def update(e: Entity, c: L)(dt: DeltaTime, w: World, v: View[L]) = f(e, c, dt, w, v) 161 | override def before(dt: DeltaTime, w: World, v: View[L]): Unit = beforeHandler(dt, w, v) 162 | override def after(dt: DeltaTime, w: World, v: View[L]): Unit = afterHandler(dt, w, v) 163 | override def shouldRun = precondition 164 | } 165 | 166 | override def builderConstructor( 167 | before: (DeltaTime, World, View[L]) => Unit, 168 | after: (DeltaTime, World, View[L]) => Unit, 169 | precondition: => Boolean, 170 | ) = SystemBuilderImpl(before, after, precondition) 171 | override def excluding[E <: CList: CListTag] = ExcludingSystemBuilderImpl(beforeHandler, afterHandler, precondition) 172 | } 173 | 174 | class ExcludingSystemBuilderImpl[L <: CList: CListTag, E <: CList: CListTag]( 175 | beforeHandler: (DeltaTime, World, View[L]) => Unit = (_, _, _: View[L]) => (), 176 | afterHandler: (DeltaTime, World, View[L]) => Unit = (_, _, _: View[L]) => (), 177 | precondition: => Boolean = true, 178 | ) extends BaseSystemBuilder[L](beforeHandler, afterHandler, precondition) 179 | with ExcludingSystemBuilder[L, E] { 180 | 181 | override def systemConstructor(f: (Entity, L, DeltaTime) => Deletable[L]) = new ExcludingSystem[L, E] { 182 | override def update(e: Entity, c: L)(dt: DeltaTime, w: World, v: View[L]) = f(e, c, dt) 183 | override def before(dt: DeltaTime, w: World, v: View[L]): Unit = beforeHandler(dt, w, v) 184 | override def after(dt: DeltaTime, w: World, v: View[L]): Unit = afterHandler(dt, w, v) 185 | override def shouldRun = precondition 186 | } 187 | 188 | override def systemConstructor(f: (Entity, L, DeltaTime, World, View[L]) => Deletable[L]) = 189 | new ExcludingSystem[L, E] { 190 | override def update(e: Entity, c: L)(dt: DeltaTime, w: World, v: View[L]) = f(e, c, dt, w, v) 191 | override def before(dt: DeltaTime, w: World, v: View[L]): Unit = beforeHandler(dt, w, v) 192 | override def after(dt: DeltaTime, w: World, v: View[L]): Unit = afterHandler(dt, w, v) 193 | override def shouldRun = precondition 194 | } 195 | 196 | override def builderConstructor( 197 | before: (DeltaTime, World, View[L]) => Unit, 198 | after: (DeltaTime, World, View[L]) => Unit, 199 | precondition: => Boolean, 200 | ) = ExcludingSystemBuilderImpl(before, after, precondition) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /core/src/test/scala/dev/atedeg/ecscala/dsl/ECScalaDSLTest.scala: -------------------------------------------------------------------------------- 1 | package dev.atedeg.ecscala.dsl 2 | 3 | import scala.language.implicitConversions 4 | import org.scalatest.matchers.should.Matchers 5 | import org.scalatest.wordspec.AnyWordSpec 6 | import dev.atedeg.ecscala.given 7 | import dev.atedeg.ecscala.{ &:, fixtures, CNil, Component, Entity, IteratingSystem, System, View, World } 8 | import dev.atedeg.ecscala.dsl.ECScalaDSL 9 | import dev.atedeg.ecscala.fixtures.{ 10 | ComponentsFixture, 11 | Gravity, 12 | Mass, 13 | Position, 14 | SystemFixture, 15 | Velocity, 16 | ViewFixture, 17 | WorldFixture, 18 | } 19 | 20 | class ECScalaDSLTest extends AnyWordSpec with Matchers with ECScalaDSL { 21 | 22 | "world hasAn entity withComponents { Component1 &: Component2 }" should { 23 | "work the same way as the world.createEntity() &: entity.setComponent() methods" in new WorldFixture 24 | with ComponentsFixture { 25 | val entity1 = world hasAn entity withComponents { Position(1, 2) &: Velocity(3, 4) &: Gravity(9) } 26 | val entity2 = world hasAn entity withComponent Gravity(24) 27 | 28 | val world2 = World() 29 | val entity3 = world2 hasAn entity withComponents { 30 | Position(1, 2) &: Velocity(3, 4) 31 | } 32 | 33 | world.getComponents[Position] should contain(Map(entity1 -> Position(1, 2))) 34 | world.getComponents[Velocity] should contain(Map(entity1 -> Velocity(3, 4))) 35 | world.getComponents[Gravity] should contain(Map(entity1 -> Gravity(9), entity2 -> Gravity(24))) 36 | world2.getComponents[Position] should contain(Map(entity3 -> Position(1, 2))) 37 | world2.getComponents[Velocity] should contain(Map(entity3 -> Velocity(3, 4))) 38 | } 39 | } 40 | 41 | "myEntity -= myComponent" should { 42 | "work the same way as the entity.removeComponent() method" in new WorldFixture with ComponentsFixture { 43 | val position = Position(1, 2) 44 | val velocity = Velocity(3, 4) 45 | val entity1 = world hasAn entity 46 | 47 | entity1 += position 48 | world.getComponents[Position] should contain(Map(entity1 -> position)) 49 | 50 | entity1 -= position 51 | world.getComponents[Position] shouldBe empty 52 | } 53 | } 54 | 55 | "remove (myComponent) from world" should { 56 | "work the same way as the entity.removeComponent() method" in new WorldFixture with ComponentsFixture { 57 | val position = Position(1, 2) 58 | val velocity = Velocity(3, 4) 59 | val entity1 = world hasAn entity 60 | 61 | entity1 withComponents { position &: velocity } 62 | 63 | remove { position } from entity1 64 | 65 | world.getComponents[Velocity] should contain(Map(entity1 -> velocity)) 66 | } 67 | } 68 | 69 | "remove[Component] from world" should { 70 | "work the same way as the entity.removeComponent() method" in new WorldFixture with ComponentsFixture { 71 | val entity1 = world hasAn entity withComponents { Position(1, 2) &: Velocity(3, 4) } 72 | remove[Position] from entity1 73 | 74 | world.getComponents[Position] shouldBe empty 75 | world.getComponents[Velocity] should contain(Map(entity1 -> Velocity(3, 4))) 76 | } 77 | } 78 | 79 | "remove { myComponent1 &: myComponent2 } from world" should { 80 | "work the same way as multiple entity.removeComponent() method calls" in new WorldFixture with ComponentsFixture { 81 | val position = Position(1, 2) 82 | val velocity = Velocity(3, 4) 83 | val entity1 = world hasAn entity 84 | 85 | entity1 withComponents { position &: velocity } 86 | remove { position &: velocity } from entity1 87 | 88 | world.getComponents[Position] shouldBe empty 89 | world.getComponents[Velocity] shouldBe empty 90 | } 91 | } 92 | 93 | "remove[Component1 &: Component2 &: CNil] from world" should { 94 | "work the same way as multiple entity.removeComponent() method calls" in new WorldFixture with ComponentsFixture { 95 | val entity1 = world hasAn entity withComponents { Position(1, 2) &: Velocity(3, 4) } 96 | remove[Position &: Velocity &: CNil] from entity1 97 | 98 | world.getComponents[Position] shouldBe empty 99 | world.getComponents[Velocity] shouldBe empty 100 | } 101 | } 102 | 103 | "world -= myEntity" should { 104 | "work the same way as the world.removeEntity() method" in new WorldFixture { 105 | val entity1 = world hasAn entity 106 | world -= entity1 107 | world.entitiesCount shouldBe 0 108 | } 109 | } 110 | 111 | "remove (Seq(entity1, entity2)) from world" should { 112 | "work the same way as the world.removeEntity() method" in new WorldFixture { 113 | val entity1 = world hasAn entity 114 | val entity2 = world hasAn entity 115 | 116 | remove { entity1 } from world 117 | world.entitiesCount shouldBe 1 118 | 119 | val entity3 = world hasAn entity 120 | val entity4 = world hasAn entity 121 | 122 | remove { List(entity2, entity3, entity4) } from world 123 | world.entitiesCount shouldBe 0 124 | } 125 | } 126 | 127 | "world hasA system[Component &: CNil] { () => {} }" should { 128 | "work the same way as the world.addSystem() method" in new ViewFixture { 129 | world hasA system[Position &: CNil](IteratingSystem((_, comps, _) => { 130 | val Position(px, py) &: CNil = comps 131 | Position(px * 2, py * 2) &: CNil 132 | })) 133 | 134 | world hasA system[Position &: CNil](IteratingSystem((_, comps, _) => { 135 | val Position(x, y) &: CNil = comps 136 | Position(x + 1, y + 1) &: CNil 137 | })) 138 | 139 | world.update(10) 140 | 141 | world.getView[Position &: CNil] should contain theSameElementsAs List( 142 | (entity1, Position(3, 3) &: CNil), 143 | (entity3, Position(3, 3) &: CNil), 144 | (entity4, Position(3, 3) &: CNil), 145 | (entity5, Position(3, 3) &: CNil), 146 | ) 147 | } 148 | } 149 | 150 | def testAddSystem(world: World): Unit = { 151 | val entity1 = world hasAn entity withComponent Position(1, 1) 152 | world.update(10) 153 | world.getView[Position &: CNil] should contain theSameElementsAs List( 154 | (entity1, Position(4, 4) &: CNil), 155 | ) 156 | } 157 | 158 | "world hasA system(mySystem)" should { 159 | "work the same way as the world.addSystem() method" in new SystemFixture with WorldFixture { 160 | world hasA system(mySystem1) 161 | testAddSystem(world) 162 | } 163 | } 164 | 165 | "world += mySystem" should { 166 | "work the same way as the world.addSystem() method" in new SystemFixture with WorldFixture { 167 | world += mySystem1 168 | testAddSystem(world) 169 | } 170 | } 171 | 172 | def testRemoveSystem(world: World): Unit = { 173 | val entity1 = world hasAn entity withComponent Position(1, 1) 174 | world.update(10) 175 | world.getView[Position &: CNil].toList shouldBe List((entity1, Position(1, 1) &: CNil)) 176 | } 177 | 178 | "remove (mySystem) from world" should { 179 | "work the same way as the world.removeSystem method" in new SystemFixture with WorldFixture { 180 | world hasA system(mySystem2) 181 | remove(mySystem2) from world 182 | testRemoveSystem(world) 183 | } 184 | } 185 | 186 | "world -= mySystem" should { 187 | "work the same way as the world.addSystem() method" in new SystemFixture with WorldFixture { 188 | world hasA system(mySystem2) 189 | world -= mySystem2 190 | testRemoveSystem(world) 191 | } 192 | } 193 | 194 | "getView[Position &: CNil] from world" should { 195 | "work the same way as the world.getView[Position &: CNil] method" in new ViewFixture { 196 | val view = getView[Position &: Velocity &: CNil] from world 197 | 198 | view should contain theSameElementsAs List( 199 | (entity1, Position(1, 1) &: Velocity(1, 1) &: CNil), 200 | (entity3, Position(1, 1) &: Velocity(1, 1) &: CNil), 201 | (entity4, Position(1, 1) &: Velocity(1, 1) &: CNil), 202 | ) 203 | } 204 | } 205 | 206 | "geView[Position &: CNil].excluding[Velocity &: CNil] from world" should { 207 | "work the same way as the world.getView[]" in new ViewFixture { 208 | val view = getView[Position &: Velocity &: CNil].excluding[Mass &: CNil] from world 209 | 210 | view should contain theSameElementsAs List( 211 | (entity1, Position(1, 1) &: Velocity(1, 1) &: CNil), 212 | (entity4, Position(1, 1) &: Velocity(1, 1) &: CNil), 213 | ) 214 | 215 | val view2 = getView[Velocity &: CNil].excluding[Position &: CNil] from world 216 | view2 shouldBe empty 217 | } 218 | } 219 | 220 | "clearAllEntities from world" should { 221 | "work the same way as the world.clearEntities method" in new WorldFixture { 222 | val entity1 = world hasAn entity withComponent Position(1, 2) 223 | val entity2 = world hasAn entity withComponent Position(3, 4) 224 | 225 | clearAllEntities from world 226 | 227 | world.entitiesCount shouldBe 0 228 | world.getComponents[Position] shouldBe empty 229 | } 230 | } 231 | } 232 | --------------------------------------------------------------------------------