diff --git a/.github/workflows/scala.yml b/.github/workflows/scala.yml index 8cec972e..4cf65978 100644 --- a/.github/workflows/scala.yml +++ b/.github/workflows/scala.yml @@ -3,18 +3,46 @@ name: Scala CI on: push: +permissions: + contents: write + checks: write + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - cache: 'sbt' - - name: Run tests - run: sbt test + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: 'sbt' + - run: sbt clean + - name: Run tests + run: sbt coverage test + - name: Generate coverage report + run: sbt coverageReport +# - name: Generate documentation +# run: sbt doc + - name: Report + uses: dorny/test-reporter@v1 + if: always() + with: + name: Scala Tests + path: target/test-reports/*.xml + reporter: java-junit + fail-on-error: true + - name: Upload coverage to GitHubPage + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: target/scala-3.2.0/scoverage-report # The folder the action should deploy. + branch: coverage-deploy # The branch the action should deploy to. +# - name: Upload documentation +# uses: actions/upload-artifact@v3 +# with: +# name: documentation +# path: target/scala-3.2.0/api/ + diff --git a/.gitignore b/.gitignore index ed9c5516..4da1d526 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,7 @@ project/plugins/project/ .history .cache .lib/ +.idea/ /.bsp/* # End of https://www.toptal.com/developers/gitignore/api/intellij+all,sbt diff --git a/README.md b/README.md index ea8aa1f2..bc05e74e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ -# PPS-22-mis-com -# PPS-22-mis-com +# Missile Command +- - - +## pps-2022-missile-command +authors: Andrea Brighi, Daniele Di Lillo, Matteo Lazzari +## Introduction +This project is a reimplementation of the classic game Missile Command, developed Scala. +The project is developed using a functional programming paradigm and a TDD approach. \ No newline at end of file diff --git a/build.sbt b/build.sbt index f26aecb2..ea09b2aa 100644 --- a/build.sbt +++ b/build.sbt @@ -1,8 +1,24 @@ -ThisBuild / version := "0.1.0-SNAPSHOT" +ThisBuild / version := "1.0.0" + +ThisBuild / scalaVersion := "3.2.0" + +val scalatest = "org.scalatest" %% "scalatest" % "3.2.12" % Test +val scalaCheck = "org.scalacheck" %% "scalacheck" % "1.17.0" % "test" +val scalactic = "org.scalactic" %% "scalactic" % "3.2.14" -ThisBuild / scalaVersion := "3.1.3" lazy val root = (project in file(".")) .settings( - name := "missile command" + name := "missile command", + libraryDependencies ++= Seq(scalatest, scalaCheck, scalactic) + ++ + Seq( + "io.monix" %% "monix" % "3.4.1", + "dev.optics" %% "monocle-core" % "3.1.0", + "dev.optics" %% "monocle-macro" % "3.1.0" + ), + assembly / mainClass := Some("view.startGame"), + assembly / assemblyJarName := "missile_command.jar", ) + + diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 00000000..c5702c76 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.3") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.0.0") \ No newline at end of file diff --git a/src/main/resources/Base_false_0.png b/src/main/resources/Base_false_0.png new file mode 100644 index 00000000..16132406 Binary files /dev/null and b/src/main/resources/Base_false_0.png differ diff --git a/src/main/resources/Base_false_1.png b/src/main/resources/Base_false_1.png new file mode 100644 index 00000000..fc9e9f06 Binary files /dev/null and b/src/main/resources/Base_false_1.png differ diff --git a/src/main/resources/Base_false_2.png b/src/main/resources/Base_false_2.png new file mode 100644 index 00000000..45557658 Binary files /dev/null and b/src/main/resources/Base_false_2.png differ diff --git a/src/main/resources/Base_false_3.png b/src/main/resources/Base_false_3.png new file mode 100644 index 00000000..d3a51f59 Binary files /dev/null and b/src/main/resources/Base_false_3.png differ diff --git a/src/main/resources/Base_true_0.png b/src/main/resources/Base_true_0.png new file mode 100644 index 00000000..16132406 Binary files /dev/null and b/src/main/resources/Base_true_0.png differ diff --git a/src/main/resources/Base_true_1.png b/src/main/resources/Base_true_1.png new file mode 100644 index 00000000..d8805d86 Binary files /dev/null and b/src/main/resources/Base_true_1.png differ diff --git a/src/main/resources/Base_true_2.png b/src/main/resources/Base_true_2.png new file mode 100644 index 00000000..18d9767a Binary files /dev/null and b/src/main/resources/Base_true_2.png differ diff --git a/src/main/resources/Base_true_3.png b/src/main/resources/Base_true_3.png new file mode 100644 index 00000000..f88f0e50 Binary files /dev/null and b/src/main/resources/Base_true_3.png differ diff --git a/src/main/resources/city_0.png b/src/main/resources/city_0.png new file mode 100644 index 00000000..1da0d63e Binary files /dev/null and b/src/main/resources/city_0.png differ diff --git a/src/main/resources/city_1.png b/src/main/resources/city_1.png new file mode 100644 index 00000000..b2053e06 Binary files /dev/null and b/src/main/resources/city_1.png differ diff --git a/src/main/resources/city_2.png b/src/main/resources/city_2.png new file mode 100644 index 00000000..c5a41e63 Binary files /dev/null and b/src/main/resources/city_2.png differ diff --git a/src/main/resources/city_3.png b/src/main/resources/city_3.png new file mode 100644 index 00000000..30a16e79 Binary files /dev/null and b/src/main/resources/city_3.png differ diff --git a/src/main/resources/enemy_missile.png b/src/main/resources/enemy_missile.png new file mode 100644 index 00000000..0b3ebbbe Binary files /dev/null and b/src/main/resources/enemy_missile.png differ diff --git a/src/main/resources/friendly_missile.png b/src/main/resources/friendly_missile.png new file mode 100644 index 00000000..bdb8b1ae Binary files /dev/null and b/src/main/resources/friendly_missile.png differ diff --git a/src/main/resources/satellite.png b/src/main/resources/satellite.png new file mode 100644 index 00000000..2ea8fe1a Binary files /dev/null and b/src/main/resources/satellite.png differ diff --git a/src/main/scala/controller/Event.scala b/src/main/scala/controller/Event.scala new file mode 100644 index 00000000..0dd1d4f1 --- /dev/null +++ b/src/main/scala/controller/Event.scala @@ -0,0 +1,21 @@ +package controller + +import model.elements2d.Point2D + +/** + * Enum of the possible events in the game. + */ +enum Event: + /** + * Class that represents the event of passing time. + * + * @param time the time passed + */ + case TimePassed(deltaTime: Double) + + /** + * Class that represents the event of launching a friendly missile. + * + * @param position the ending position of the missile + */ + case LaunchMissileTo(position: Point2D) diff --git a/src/main/scala/controller/GameLoop.scala b/src/main/scala/controller/GameLoop.scala new file mode 100644 index 00000000..8400dbec --- /dev/null +++ b/src/main/scala/controller/GameLoop.scala @@ -0,0 +1,57 @@ +package controller + +import monix.eval.Task +import monix.execution.Ack +import monix.execution.Scheduler.Implicits.global +import monix.reactive.subjects.PublishSubject +import monix.reactive.{Observable, Observer, OverflowStrategy} +import org.reactivestreams.Subscriber +import view.gui.UI +import model.World +import controller.update._ + +import scala.concurrent.Future +import scala.concurrent.duration.DurationInt +import scala.language.postfixOps +import scala.util.Random + +/** + * Object that represents the controller of the game. + */ +object GameLoop: + /** + * The observable that will be used to schedule time in the game. + */ + private val time: Observable[Event] = TimeFlow + .tickEach(50 milliseconds) + .map(_.toDouble/1000) + .map(Event.TimePassed.apply) + + /** + * start the game loop. + * + * @param ui the ui that will be used to show the game. + * @return a task that will execute the game. + */ + def start(ui: UI): Task[Unit] = + + given OverflowStrategy[Event] = OverflowStrategy.Default + + val world = World.initialWorld + val controls: Update = Update.combine( + UpdateTime(), + UpdatePosition(), + ActivateSpecialAbility(), + CollisionsDetection(), + LaunchNewMissile() + ) + + val init = Task((world, controls)) + val events = + Observable(time, ui.events.throttleLast(50 milliseconds)).merge + events + .scanEval(init) { case ((world, controls), event) => controls(event, world) } + .doOnNext { case (world, _) => ui.render(world) } + .takeWhile { case (world, _) => world.ground.stillAlive } + .completedL + .doOnFinish(_ => ui.gameOver) diff --git a/src/main/scala/controller/TimeFlow.scala b/src/main/scala/controller/TimeFlow.scala new file mode 100644 index 00000000..fe668942 --- /dev/null +++ b/src/main/scala/controller/TimeFlow.scala @@ -0,0 +1,20 @@ +package controller + +import monix.reactive.Observable +import scala.concurrent.duration.FiniteDuration + +/** + * A object that con be used to create a [[monix.reactive.Observable]] that emits a value every time a given time interval has passed. + */ +object TimeFlow: + /** + * Creates a [[monix.reactive.Observable]] that emits a value every time a given time interval has passed. + * + * @param duration the time interval between two consecutive values + * @return an [[monix.reactive.Observable]] that emits a value every time a given time interval has passed + */ + def tickEach(duration: FiniteDuration): Observable[Long] = + Observable + .fromIterable(LazyList.continually(duration)) + .delayOnNext(duration) + .map(_.toMillis) diff --git a/src/main/scala/controller/update/ActivateSpecialAbility.scala b/src/main/scala/controller/update/ActivateSpecialAbility.scala new file mode 100644 index 00000000..60dd88ec --- /dev/null +++ b/src/main/scala/controller/update/ActivateSpecialAbility.scala @@ -0,0 +1,37 @@ +package controller.update + +import controller.Event +import controller.Event.TimePassed +import controller.update.Update.on +import model.World +import model.explosion.Explosion +import model.behavior.Moveable +import model.collisions.Collisionable +import model.missile.Missile +import monix.eval.Task + +/** + * Object that return an update function for the world to be update with the special abilities of its components + */ +object ActivateSpecialAbility: + /** + * Apply function used to update the world to be update with the special abilities of its components + * + * @return An Update that update the world to be update with the special abilities of its components + */ + def apply(): Update = on[TimePassed] { (_: Event, world: World) => + Task { + def activateSpecialAbility(collisionable: Collisionable): Collisionable = collisionable match + case missile: Missile if missile.destinationReached => missile.explode + case _ => collisionable + + def isTerminated(collisionable: Collisionable): Boolean = collisionable match + case explosion: Explosion => explosion.terminated + case _ => false + + val collisionables = world.collisionables.map(activateSpecialAbility) + val remainedCollisionables = collisionables.filterNot(isTerminated) + val (newMissiles, spawner) = world.spawner.spawn() + world.copy(collisionables = remainedCollisionables ++ newMissiles, spawner = spawner) + } + } diff --git a/src/main/scala/controller/update/CollisionsDetection.scala b/src/main/scala/controller/update/CollisionsDetection.scala new file mode 100644 index 00000000..8f2bad67 --- /dev/null +++ b/src/main/scala/controller/update/CollisionsDetection.scala @@ -0,0 +1,49 @@ +package controller.update + +import controller.Event +import controller.Event.TimePassed +import controller.update.Update.on +import model.{World, calculateNewScore} +import model.collisions.{Collisionable, Damageable, applyDamage, calculateCollisions} +import model.ground.{City, Ground, MissileBattery} +import model.missile.Missile +import monix.eval.Task + +/** + * Object that return an update function for the world to be update with the aftermath of its components collisions + */ +object CollisionsDetection: + /** + * Apply function used to update the world to be update with the aftermath of its components collisions + * + * @return An Update that update the world to be update with the aftermath of its components collisions + */ + def apply(): Update = on[TimePassed] { (_: Event, world: World) => + Task { + def createNewGround(collisionables: List[Collisionable]): (Ground, List[Collisionable]) = + val (collisions, groundElements) = collisionables.partition(_ match + case _: MissileBattery => false + case _: City => false + case _ => true + ) + val (missileBatteries, cities) = groundElements.partition(_ match + case _: MissileBattery => true + case _: City => false + ) + (Ground( + cities.map(_.asInstanceOf[City]), + missileBatteries.map(_.asInstanceOf[MissileBattery]) + ), collisions) + + def isDestroyed(collisionable: Collisionable): Boolean = collisionable match + case damageable: Damageable => damageable.isDestroyed + case _ => false + + val collisionables = world.collisionables ++ world.ground.cities ++ world.ground.turrets + val collisionableAfterCollisions = applyDamage(calculateCollisions(collisionables)) + val newScore = calculateNewScore(collisionableAfterCollisions, world.score) + val (newGround, newCollisionables) = createNewGround(collisionableAfterCollisions.keys.toList) + val newNotDestroyedCollisionables = newCollisionables.filterNot(isDestroyed) + world.copy(collisionables = newNotDestroyedCollisionables, score = newScore, ground = newGround) + } + } \ No newline at end of file diff --git a/src/main/scala/controller/update/LaunchNewMissile.scala b/src/main/scala/controller/update/LaunchNewMissile.scala new file mode 100644 index 00000000..4189551a --- /dev/null +++ b/src/main/scala/controller/update/LaunchNewMissile.scala @@ -0,0 +1,28 @@ +package controller.update + +import controller.Event +import controller.Event.LaunchMissileTo +import controller.update.Update.on +import model.World +import model.behavior.Moveable +import model.collisions.Collisionable +import monix.eval.Task + +/** + * Object that return an update function for the world to be updated when the user launch a missile. + */ +object LaunchNewMissile: + + /** + * Apply function used to update the world to be updated when the user launch a missile. + * + * @return an update function for the world to be updated when the user launch a missile. + */ + def apply(): Update = on[LaunchMissileTo] { (event: LaunchMissileTo, world: World) => + Task { + val (ground, missile) = world.ground.shootMissile(event.position) + missile match + case Some(missile) => world.copy(collisionables = world.collisionables :+ missile, ground = ground) + case None => world.copy(collisionables = world.collisionables, ground = ground) + } + } diff --git a/src/main/scala/controller/update/Update.scala b/src/main/scala/controller/update/Update.scala new file mode 100644 index 00000000..e1fb97d4 --- /dev/null +++ b/src/main/scala/controller/update/Update.scala @@ -0,0 +1,77 @@ +package controller.update + +import monix.eval.Task +import monix.reactive.Observable + +import scala.annotation.tailrec +import scala.reflect.ClassTag +import controller.Event +import model.World + +/** + * A trait for function from pair (Event, World) to [[Task]] World, Update. + * Used to update the world. + */ +trait Update extends ((Event, World) => Task[(World, Update)]) : + /** + * Allows to update the world to be computed after this one. + * + * @param control the update to be computed after this one. + * @return a new update with the world and the sequence of the two update. + */ + def andThen(control: Update): Update = (event: Event, world: World) => + this (event, world) + .flatMap { case (world, left) => control(event, world).map { case (world, right) => (left, right, world) } } + .map { case (left, right, world) => (world, Update.combineTwo(left, right)) } + +/** + * Companion object for [[Update]]. + */ +object Update: + /*extension (function: (Event, World) => (World, Update)) + def lift: Update = + (event: Event, agar: World) => Task(function(event, agar))*/ + + /*def same(function: (Event, World) => World): Update = (event: Event, world: World) => + Task(function(event, world), same(function))*/ + + /** + * Function that allow the instruction to be executed only if the [[Event]] E happened. + * + * @param control the update to be executed. + * @param ev the [[Event]] that must happen. + * @tparam E the type of the [[Event]]. + * @return the update that will be executed only if the [[Event]] E happened. + */ + def on[E <: Event](control: (E, World) => Task[World])(using ev: ClassTag[E]): Update = + lazy val result: Update = (event: Event, world: World) => + event match + case event: E => (control(event, world).map(world => (world, result))) + case _ => Task((world, result)) + result + + /* + val empty: Update = (_: Event, world: World) => Task((world, empty)) + */ + + /** + * Function that allow two [[Update]] to be executed in sequence. + * + * @param engineA the first [[Update]] to be executed. + * @param engineB the second [[Update]] to be executed. + * @return The [[Update]] that will execute the two [[Update]] in sequence. + */ + def combineTwo(engineA: Update, engineB: Update): Update = (event: Event, world: World) => + for + updateA <- engineA.apply(event, world) + (newWorld, newEngineA) = updateA + updateB <- engineB(event, newWorld) + (lastWorld, newEngineB) = updateB + yield (lastWorld, combineTwo(newEngineA, newEngineB)) + + /** + * Function that allow a sequence of [[Update]] to be executed in sequence. + * @param engines the sequence of [[Update]] to be executed. + * @return The [[Update]] that will execute the sequence of [[Update]] in sequence. + */ + def combine(engines: Update*): Update = engines.reduce(_.andThen(_)) diff --git a/src/main/scala/controller/update/UpdatePosition.scala b/src/main/scala/controller/update/UpdatePosition.scala new file mode 100644 index 00000000..54ca9174 --- /dev/null +++ b/src/main/scala/controller/update/UpdatePosition.scala @@ -0,0 +1,29 @@ +package controller.update + +import controller.Event +import controller.Event.TimePassed +import controller.update.Update.on +import model.World +import model.behavior.Moveable +import model.collisions.Collisionable +import monix.eval.Task + +/** + * Object that return an update function for the world to update the position its components + */ +object UpdatePosition: + /** + * Apply function used to update the position of the game components + * + * @return An Update that update the position of the game components + */ + def apply(): Update = on[TimePassed] { (_: Event, world: World) => + Task { + def updateMovable(collisionable: Collisionable): Collisionable = collisionable match + case moveable: Moveable => moveable.move().asInstanceOf[Collisionable] + case _ => collisionable + + val collisionables = world.collisionables.map(updateMovable) + world.copy(collisionables = collisionables) + } + } \ No newline at end of file diff --git a/src/main/scala/controller/update/UpdateTime.scala b/src/main/scala/controller/update/UpdateTime.scala new file mode 100644 index 00000000..f44d4ce0 --- /dev/null +++ b/src/main/scala/controller/update/UpdateTime.scala @@ -0,0 +1,33 @@ +package controller.update + +import controller.Event +import monix.eval.Task +import controller.Event.* +import model.* +import controller.update.Update.* +import model.behavior.Timeable +import model.collisions.Collisionable +import model.ground.Ground + + +/** + * Object that return an update function for the world to update the time of its components + */ +object UpdateTime: + /** + * Apply function used to update the time of the game components + * + * @return An Update that update the time of the game components + */ + def apply(): Update = on[TimePassed] { (event: TimePassed, world: World) => + Task { + def updateTimeble(collisionable: Collisionable): Collisionable = collisionable match + case timeable: Timeable => timeable.timeElapsed(event.deltaTime).asInstanceOf[Collisionable] + case _ => collisionable + + val collisionables = world.collisionables.map(updateTimeble) + val spawner = world.spawner.timeElapsed(event.deltaTime) + val ground = Ground(world.ground.cities, world.ground.turrets.map(_.timeElapsed(event.deltaTime))) + world.copy(collisionables = collisionables, spawner = spawner, ground = ground) + } + } diff --git a/src/main/scala/model/World.scala b/src/main/scala/model/World.scala new file mode 100644 index 00000000..f983eb7b --- /dev/null +++ b/src/main/scala/model/World.scala @@ -0,0 +1,37 @@ +package model + +import model.collisions.Collisionable +import model.ground.Ground +import model.spawner.Spawner + +/** + * Object with the values of the world and its initial state. + */ +object World: + + /** + * The width of the world. + */ + val width = 400 + + /** + * The height of the world. + */ + val height = 300 + + /** + * The initial state of the world. + * + * @return the initial state of the world + */ + def initialWorld: World = World(Ground(), Spawner(5, width, height), List.empty[Collisionable], 0) + +/** + * Class that represents the world. + * + * @param ground the ground of the player in the world. + * @param spawner the spawner of the enemies in the world. + * @param collisionables the collisionables in the world. + * @param score the current score of the player. + */ +case class World(ground: Ground, spawner: Spawner, collisionables: List[Collisionable], score: ScorePoint) diff --git a/src/main/scala/model/behavior/Moveable.scala b/src/main/scala/model/behavior/Moveable.scala new file mode 100644 index 00000000..b2e969d7 --- /dev/null +++ b/src/main/scala/model/behavior/Moveable.scala @@ -0,0 +1,12 @@ +package model.behavior + +import model.elements2d.Point2D +import model.DeltaTime + +trait Moveable extends Timeable : + override def timeElapsed(dt: DeltaTime): Moveable + def move(): Moveable + def destinationReached: Boolean + def position: Point2D + def destination: Point2D + diff --git a/src/main/scala/model/behavior/Timeable.scala b/src/main/scala/model/behavior/Timeable.scala new file mode 100644 index 00000000..8b5d59e2 --- /dev/null +++ b/src/main/scala/model/behavior/Timeable.scala @@ -0,0 +1,7 @@ +package model.behavior + +import model.DeltaTime + +trait Timeable: + + def timeElapsed(dt: DeltaTime): Timeable diff --git a/src/main/scala/model/collisions/Collisionable.scala b/src/main/scala/model/collisions/Collisionable.scala new file mode 100644 index 00000000..6a21a750 --- /dev/null +++ b/src/main/scala/model/collisions/Collisionable.scala @@ -0,0 +1,44 @@ +package model.collisions + +import model.elements2d.Point2D +import model.collisions.* +import model.collisions.hitbox.{HitBoxEmpty, HitBoxIntersection} + +/** + * Enum representing the different affiliation of an object in game. + */ +enum Affiliation: + case Friendly, Enemy, Neutral + +/** + * Trait representing an object that can collide with other objects. + * + */ +trait Collisionable: + + /** + * Return the hit box of the object. + * + * @return the hit box of the object. + */ + protected def hitBox: HitBox + + /** + * Return true if the object is colliding with the given object. + * + * @param other the other object. + * @param distance the distance between two points in the hit box area. + * @return true if the object is colliding with the given object. + */ + def isCollidingWith(other: Collisionable)(using distance: Distance): Boolean = + HitBoxIntersection(hitBox, other.hitBox) != HitBoxEmpty + + /** + * Return the affiliation of the object. + * + * @return the affiliation of the object. + */ + def affiliation: Affiliation + + + diff --git a/src/main/scala/model/collisions/Damageable.scala b/src/main/scala/model/collisions/Damageable.scala new file mode 100644 index 00000000..da03bd7d --- /dev/null +++ b/src/main/scala/model/collisions/Damageable.scala @@ -0,0 +1,35 @@ +package model.collisions + +/** + * Trait that represents an object that can be damaged by a collision. + */ +trait Damageable extends Collisionable : + + /** + * The current health of the object. + * + * @return the current health of the object. + */ + def currentLife: LifePoint + + /** + * The initial health of the object. + * + * @return the initial health of the object. + */ + def initialLife: LifePoint + + /** + * The object takes damage. + * + * @param damage the damage that the object received. + * @return the object with the new health. + */ + def takeDamage(damage: LifePoint): Damageable + + /** + * returns true if the object is destroyed. + * + * @return true if the object is destroyed. + */ + def isDestroyed: Boolean = currentLife <= lifePointDeath diff --git a/src/main/scala/model/collisions/Damager.scala b/src/main/scala/model/collisions/Damager.scala new file mode 100644 index 00000000..24707974 --- /dev/null +++ b/src/main/scala/model/collisions/Damager.scala @@ -0,0 +1,13 @@ +package model.collisions + +/** + * A Trait that represents an that can inflict damage to other objects. + */ +trait Damager extends Collisionable : + + /** + * The damage inflicted by this object. + * + * @return the damage inflicted by this object. + */ + def damageInflicted: LifePoint \ No newline at end of file diff --git a/src/main/scala/model/collisions/HitBox.scala b/src/main/scala/model/collisions/HitBox.scala new file mode 100644 index 00000000..ba4a2057 --- /dev/null +++ b/src/main/scala/model/collisions/HitBox.scala @@ -0,0 +1,66 @@ +package model.collisions + +import model.elements2d.Point2D +import org.scalactic.Equality +import math.BigDecimal.double2bigDecimal + +/** + * Trait that represents an hit box of an object. + * An hit box is a shape that represents the area of an object. + * + */ +trait HitBox: + /** + * Returns the grater x value of the hit box. + * + * @return the grater x value of the hit box if present else an Option empty. + */ + def xMax: Option[Double] + + /** + * Returns the greater y value of the hit box. + * + * @return the grater y value of the hit box if present else an Option empty . + */ + def yMax: Option[Double] + + /** + * Returns the lower x value of the hit box. + * + * @return the lower x value of the hit box if present else an Option empty. + */ + def xMin: Option[Double] + + /** + * Returns the lower y value of the hit box. + * + * @return the lower y value of the hit box if present else an Option empty. + */ + def yMin: Option[Double] + + /** + * Returns an iterator of points in the hit box area with distance. + * + * @param distance the distance between two points in the area. + * @return an iterator of points in the hit box area with distance. + */ + def area(using distance: Distance): Iterator[Point2D] = + if (xMax.isEmpty || yMax.isEmpty || xMin.isEmpty || yMin.isEmpty) Iterator.empty + else + (for + x <- xMin.get to xMax.get by distance + y <- yMin.get to yMax.get by distance + point = Point2D(x.doubleValue, y.doubleValue) + if contains(point) + yield + point + ).iterator + + /** + * Returns true if the point is contained in the hit box. + * + * @param point the point to check + * @param equality the given equality + * @return true if the point is contained in the hit box. + */ + def contains(point: Point2D)(using equality: Equality[Double]): Boolean diff --git a/src/main/scala/model/collisions/hitbox/HitBoxAggregation.scala b/src/main/scala/model/collisions/hitbox/HitBoxAggregation.scala new file mode 100644 index 00000000..b1626027 --- /dev/null +++ b/src/main/scala/model/collisions/hitbox/HitBoxAggregation.scala @@ -0,0 +1,49 @@ +package model.collisions.hitbox + +import model.collisions.HitBox +import model.elements2d.Point2D +import model.collisions.hitbox._ +import model.collisions._ + +/** + * Trait that allows to generalize the operations for an hit box created by other hit boxes + */ +trait HitBoxAggregation extends HitBox : + + /** + * The list of hit boxes that compose this hit box + */ + protected val hitBoxes: Seq[HitBox] + + /** + * function that allow to return the value of the max side of the hit box + */ + protected val functionForMin: (Double, Double) => Double + + /** + * function that allow to return the value of the min side of the hit box + */ + protected val functionForMax: (Double, Double) => Double + + private def hitBoxesInterval(getValue: HitBox => Option[Double], confrontFunction: (Double, Double) => Double): Option[Double] = + hitBoxes.foldLeft(getValue(hitBoxes.head))((value, hitBox) => optionalConfront(value, getValue(hitBox))(confrontFunction)) + + /** + * Return the value of the confrontation between the two optional values. + * The function calculate the value of the confrontation between the two double value using confrontFunction + * + * @param l the option on left side of the confront + * @param r the the option on right side of the confront + * @param confrontFunction the function that allow to confront the two sides on double type + * @return the result of the confront + */ + protected def optionalConfront(l: Option[Double], r: Option[Double])(confrontFunction: (Double, Double) => Double): Option[Double] + + override lazy val xMax: Option[Double] = hitBoxesInterval(_.xMax, functionForMax) + + override lazy val yMax: Option[Double] = hitBoxesInterval(_.yMax, functionForMax) + + override lazy val xMin: Option[Double] = hitBoxesInterval(_.xMin, functionForMin) + + override lazy val yMin: Option[Double] = hitBoxesInterval(_.yMin, functionForMin) + diff --git a/src/main/scala/model/collisions/hitbox/HitBoxCircular.scala b/src/main/scala/model/collisions/hitbox/HitBoxCircular.scala new file mode 100644 index 00000000..71316791 --- /dev/null +++ b/src/main/scala/model/collisions/hitbox/HitBoxCircular.scala @@ -0,0 +1,37 @@ +package model.collisions.hitbox + +import model.collisions.* +import model.elements2d.Point2D +import org.scalactic.Equality +import utilities.MathUtilities.* + +/** + * Factory for a new hit box that has the shape of a circle. + */ +object HitBoxCircular: + + /** + * Returns a new hit box that has the shape of a circle. + * + * @param center the center of the circle + * @param radius the radius of the circle + * @return a new hit box that has the shape of a circle + */ + def apply(center: Point2D, radius: Double): HitBox = + if (radius <= 0) + HitBoxEmpty + else + HitBoxCircular(center, radius) + + private case class HitBoxCircular(center: Point2D, radius: Double) extends HitBoxSymmetric : + + protected def max(values: Iterable[Double]): Option[Double] = Option(values.head + radius) + + protected def min(values: Iterable[Double]): Option[Double] = Option(values.head - radius) + + protected def x: Iterable[Double] = Iterable(center.x) + + protected def y: Iterable[Double] = Iterable(center.y) + + override def contains(point: Point2D)(using equality: Equality[Double]): Boolean = + (point <-> center) <== radius diff --git a/src/main/scala/model/collisions/hitbox/HitBoxEmpty.scala b/src/main/scala/model/collisions/hitbox/HitBoxEmpty.scala new file mode 100644 index 00000000..76b5f23a --- /dev/null +++ b/src/main/scala/model/collisions/hitbox/HitBoxEmpty.scala @@ -0,0 +1,52 @@ +package model.collisions.hitbox + +import model.collisions.{HitBox, Distance} +import model.elements2d.Point2D +import org.scalactic.Equality + +/** + * Object that represents a hit box that is empty. + */ +case object HitBoxEmpty extends HitBox : + + /** + * The hit box is empty so it doesn't have an interval. + * + * @return always option empty + */ + override val xMax: Option[Double] = Option.empty + + /** + * The hit box is empty so it doesn't have an interval. + * + * @return always option empty + */ + override val yMax: Option[Double] = Option.empty + + /** + * The hit box is empty so it doesn't have an interval. + * + * @return always option empty + */ + override val xMin: Option[Double] = Option.empty + + /** + * The hit box is empty so it doesn't have an interval. + * + * @return always option empty + */ + override val yMin: Option[Double] = Option.empty + + /** + * The hit box is empty so it doesn't have any point. + * + * @return always Iterator empty + */ + override def area(using step: Distance = 0): Iterator[Point2D] = Iterator.empty + + /** + * The hit box is empty so it doesn't contains any point. + * + * @return always false + */ + override def contains(point: Point2D)(using equality: Equality[Double] = null): Boolean = false diff --git a/src/main/scala/model/collisions/hitbox/HitBoxIntersection.scala b/src/main/scala/model/collisions/hitbox/HitBoxIntersection.scala new file mode 100644 index 00000000..f495e023 --- /dev/null +++ b/src/main/scala/model/collisions/hitbox/HitBoxIntersection.scala @@ -0,0 +1,51 @@ +package model.collisions.hitbox + +import model.collisions.HitBox +import model.elements2d.Point2D +import model.collisions.hitbox.* +import model.collisions.* +import org.scalactic.Equality + +/** + * Factory for hit box that is the intersection of other hit boxes. + */ +object HitBoxIntersection: + + /** + * Returns the intersection of the given hit boxes, if there isn't an intersection return the hit box empty. + * + * @param hitBoxes the hit boxes to intersect + * @return the intersection of the given hit boxes + */ + def apply(hitBoxes: HitBox*)(using Distance): HitBox = + val intersection = hitBoxes match + case Seq() => HitBoxEmpty + case Seq(hitBox) => hitBox + case _ => HitBoxIntersection(hitBoxes) + intersection match + case HitBoxEmpty => HitBoxEmpty + case _ if intersection.xMax.isEmpty || intersection.yMax.isEmpty || intersection.xMin.isEmpty || intersection.yMin.isEmpty => HitBoxEmpty + case _ if intersection.xMax.get < intersection.xMin.get => HitBoxEmpty + case _ if intersection.yMax.get < intersection.yMin.get => HitBoxEmpty + case _ if intersection.area.isEmpty => HitBoxEmpty + case _ => intersection + + private case class HitBoxIntersection(hitBoxes: Seq[HitBox]) extends HitBoxAggregation : + + protected def optionalConfront(l: Option[Double], r: Option[Double])(confrontFunction: (Double, Double) => Double): Option[Double] = + (l, r) match + case (Some(valueL), Some(valueR)) => Some(confrontFunction(valueL, valueR)) + case _ => None + + protected val functionForMax: (Double, Double) => Double = math.min + protected val functionForMin: (Double, Double) => Double = math.max + + /*override val xMax: Option[Double] = hitBoxes.foldLeft(hitBoxes.head.xMax)((xMax, hitBox) => optionalConfront(xMax, hitBox.xMax)(_.min(_))) + + override val yMax: Option[Double] = hitBoxes.foldLeft(hitBoxes.head.yMax)((yMax, hitBox) => optionalConfront(yMax, hitBox.yMax)(_.min(_))) + + override val xMin: Option[Double] = hitBoxes.foldLeft(hitBoxes.head.xMin)((xMin, hitBox) => optionalConfront(xMin, hitBox.xMin)(_.max(_))) + + override val yMin: Option[Double] = hitBoxes.foldLeft(hitBoxes.head.yMin)((yMin, hitBox) => optionalConfront(yMin, hitBox.yMin)(_.max(_)))*/ + + override def contains(point: Point2D)(using equality: Equality[Double]): Boolean = hitBoxes.forall(_.contains(point)) diff --git a/src/main/scala/model/collisions/hitbox/HitBoxPoint.scala b/src/main/scala/model/collisions/hitbox/HitBoxPoint.scala new file mode 100644 index 00000000..576ff728 --- /dev/null +++ b/src/main/scala/model/collisions/hitbox/HitBoxPoint.scala @@ -0,0 +1,35 @@ +package model.collisions.hitbox + +import model.collisions.* +import model.collisions.hitbox.HitBoxSymmetric +import model.elements2d.Point2D +import model.elements2d.Point2D.GivenEquality.given +import org.scalactic.Equality +import org.scalactic.TripleEquals.* + +/** + * Factory for an hit box that is a point. + */ +object HitBoxPoint: + + /** + * Returns a new hit box that contains only a point. + * + * @param point the point in the hit box + * @return a new hit box that contains only a point + */ + def apply(point: Point2D): HitBox = HitBoxPoint(point) + + private case class HitBoxPoint(point: Point2D) extends HitBoxSymmetric : + + protected def max(values: Iterable[Double]): Option[Double] = Option(values.head) + + protected def min(values: Iterable[Double]): Option[Double] = max(values) + + protected def x: Iterable[Double] = Iterable(point.x) + + protected def y: Iterable[Double] = Iterable(point.y) + + override def area(using step: Distance = 0): Iterator[Point2D] = Iterator(point) + + override def contains(point: Point2D)(using equality: Equality[Double]): Boolean = point === this.point \ No newline at end of file diff --git a/src/main/scala/model/collisions/hitbox/HitBoxRectangular.scala b/src/main/scala/model/collisions/hitbox/HitBoxRectangular.scala new file mode 100644 index 00000000..c769277d --- /dev/null +++ b/src/main/scala/model/collisions/hitbox/HitBoxRectangular.scala @@ -0,0 +1,62 @@ +package model.collisions.hitbox + +import model.collisions.* +import model.collisions.hitbox.HitBoxSymmetric +import model.elements2d.{Angle, Point2D, Vector2D} +import org.scalactic.{Equality, TripleEquals} +import utilities.MathUtilities.* + +/** + * Factory for an hit box that is a rectangle. + */ +object HitBoxRectangular: + + /** + * Returns a new hit box that is a rectangle. + * + * @param center the center of the rectangle. + * @param base the base of the rectangle. + * @param height the height of the rectangle. + * @param rotation the rotation of the rectangle. + * @return a new hit box that is a rectangle. + */ + def apply(center: Point2D, base: Double, height: Double, rotation: Angle): HitBox = + if (base <= 0 || height <= 0 || rotation == null) + HitBoxEmpty + else + HitBoxRectangular(center, base, height, rotation) + + private case class HitBoxRectangular(center: Point2D, base: Double, height: Double, rotation: Angle) extends HitBoxSymmetric : + private val baseVector = Vector2D(base / 2, rotation) + private val heightVector = Vector2D(height / 2, Angle.Degree(rotation.degree + 90)) + private val vertices: List[Point2D] = List( + center --> (baseVector + heightVector), + center --> (-baseVector + heightVector), + center --> (-baseVector + -heightVector), + center --> (baseVector + -heightVector) + ) + + /*override val xMax: Option[Double] = Option(vertices.map(_.x).max) + + override val yMax: Option[Double] = Option(vertices.map(_.y).max) + + override val xMin: Option[Double] = Option(vertices.map(_.x).min) + + override val yMin: Option[Double] = Option(vertices.map(_.y).min)*/ + protected val x: Iterable[Double] = + vertices.map(_.x) + protected def y: Iterable[Double] = + vertices.map(_.y) + + protected def max(values: Iterable[Double]): Option[Double] = Option(values.max) + + protected def min(values: Iterable[Double]): Option[Double] = Option(values.min) + + override def contains(point: Point2D)(using equality: Equality[Double]): Boolean = + def lineEquation(p: Point2D, p1: Point2D, p2: Point2D): Double = + (p2.y - p1.y) * (p.x - p1.x) - (p2.x - p1.x) * (p.y - p1.y) + + (lineEquation(point, vertices(1), vertices(0)) >== 0.0) && + (lineEquation(point, vertices(2), vertices(1)) >== 0.0) && + (lineEquation(point, vertices(3), vertices(2)) >== 0.0) && + (lineEquation(point, vertices(0), vertices(3)) >== 0.0) diff --git a/src/main/scala/model/collisions/hitbox/HitBoxSymmetric.scala b/src/main/scala/model/collisions/hitbox/HitBoxSymmetric.scala new file mode 100644 index 00000000..3a384770 --- /dev/null +++ b/src/main/scala/model/collisions/hitbox/HitBoxSymmetric.scala @@ -0,0 +1,46 @@ +package model.collisions.hitbox + +import model.collisions.HitBox + +/** + * Trait that allows to define a symmetric operation for both x and y + */ +trait HitBoxSymmetric extends HitBox : + + /** + * Return all the x coordinates that are needed to calculate the hit box interval + * + * @return all the x coordinates that are needed to calculate the hit box interval + */ + protected def x: Iterable[Double] + + /** + * Return all the y coordinates that are needed to calculate the hit box interval + * + * @return all the y coordinates that are needed to calculate the hit box interval + */ + protected def y: Iterable[Double] + + /** + * Return the calculated max side of hit box from the values + * + * @param values the values to calculate the max side of hit box + * @return An Option with the max side of hit box + */ + protected def max(values: Iterable[Double]): Option[Double] + + /** + * Return the calculated min side of hit box from the values + * + * @param values the values to calculate the min side of hit box + * @return An Option with the min side of hit box + */ + protected def min(values: Iterable[Double]): Option[Double] + + override lazy val xMax: Option[Double] = max(x) + + override lazy val yMax: Option[Double] = max(y) + + override lazy val xMin: Option[Double] = min(x) + + override lazy val yMin: Option[Double] = min(y) diff --git a/src/main/scala/model/collisions/hitbox/HitBoxUnion.scala b/src/main/scala/model/collisions/hitbox/HitBoxUnion.scala new file mode 100644 index 00000000..dce5f4a0 --- /dev/null +++ b/src/main/scala/model/collisions/hitbox/HitBoxUnion.scala @@ -0,0 +1,39 @@ +package model.collisions.hitbox + +import model.collisions.HitBox +import model.elements2d.Point2D +import model.collisions.hitbox.* +import model.collisions.* +import org.scalactic.Equality + +/** + * Factory for hit box that is the union of other hit boxes. + */ +object HitBoxUnion: + + /** + * Creates a hit box that is the union of the given hit boxes. + * + * @param hitBoxes the hit boxes to be united. + * @return the hit box that is the union of the given hit boxes. + */ + def apply(hitBoxes: HitBox*): HitBox = hitBoxes match + case Seq() => HitBoxEmpty + case Seq(hitBox) => hitBox + case _ => HitBoxUnion(hitBoxes) + + private case class HitBoxUnion(hitBoxes: Seq[HitBox]) extends HitBoxAggregation : + protected def optionalConfront(l: Option[Double], r: Option[Double])(confrontFunction: (Double, Double) => Double): Option[Double] = + (l, r) match + case (Some(l), Some(r)) => Some(confrontFunction(l, r)) + case (Some(l), None) => Some(l) + case (None, Some(r)) => Some(r) + case _ => None + + protected val functionForMax: (Double, Double) => Double = math.max + + protected val functionForMin: (Double, Double) => Double = math.min + + override def contains(point: Point2D)(using equality: Equality[Double]): Boolean = hitBoxes.exists(_.contains(point)) + + override def area(using step: Distance): Iterator[Point2D] = hitBoxes.flatMap(_.area).distinct.iterator diff --git a/src/main/scala/model/collisions/package.scala b/src/main/scala/model/collisions/package.scala new file mode 100644 index 00000000..0c9e9026 --- /dev/null +++ b/src/main/scala/model/collisions/package.scala @@ -0,0 +1,99 @@ +package model + +import model.collisions.Damageable + + +/** + * Trait, class, object and constant for the representation of element that collide. + */ +package object collisions: + + import model.collisions._ + + /** + * Alias for life measure + */ + type LifePoint = Int + + /** + * The life constant for destroy an object + */ + val lifePointDeath = 0 + + /** + * Alias for the distance between points in the hit box area + */ + type Distance = Double + + /** + * Apply the damage to the damageable object as key in the map. + * + * @param collisionResults a map that has as keys the damageable objects and as values a list with the damaeger that collides with them + * @return a map that has as keys the damageable objects update with the new damage and as values a list with the damaeger that collides with them + */ + def applyDamage(collisionResults: Map[Collisionable, List[Collisionable]]): Map[Collisionable, List[Collisionable]] = + def getDamage(collisionable: Collisionable): LifePoint = + collisionable match + case damager: Damager => damager.damageInflicted + case _ => 0 + + collisionResults.map( + (collisionable, damagers) => + collisionable match + case damageable: Damageable => (damageable.takeDamage(damagers.map(getDamage _).sum), damagers) + case _ => (collisionable, damagers) + ) + + + /** + * Check the collision between multiple collidables, + * return a map that has as keys the damageable objects and as values list with the damaeger that collides with them. + * The methods only check the collision between the damager and damageable that aren' on the same side . + * + * @param collisionables the collidables to check + * @return a map that has as keys the damageable objects and as values a list with the damaeger that collides with them + */ + def calculateCollisions(collisionables: List[Collisionable]): Map[Collisionable, List[Collisionable]] = + given Distance = 0.1 + + def isDestroyed(collisionable: Collisionable): Boolean = + collisionable match + case damageable: Damageable => damageable.isDestroyed + case _ => false + + + def areOnTheSameSide(collisionable1: Collisionable, collisionable2: Collisionable): Boolean = + collisionable1.affiliation == collisionable2.affiliation + + def isADamager(collisionable: Collisionable): Boolean = + collisionable match + case _: Damager => true + case _ => false + + def isADamageable(collisionable: Collisionable): Boolean = + collisionable match + case _: Damageable => true + case _ => false + val notNullCollisionables = collisionables.filterNot(_ == null) + val realCollision = + (for + damager <- notNullCollisionables + if !isDestroyed(damager) + if isADamager(damager) + damageable <- notNullCollisionables + if !isDestroyed(damageable) + if isADamageable(damageable) + if !areOnTheSameSide(damager, damageable) + if damager.isCollidingWith(damageable) + yield + (damageable, damager)) + .foldLeft(Map[Collisionable, List[Collisionable]]() + .withDefaultValue(List.empty))((res, v) => { + val key = v._1 + res + (key -> (res(key) :+ v._2)) + }) + (for + collisionable <- notNullCollisionables + yield + (collisionable, if realCollision.contains(collisionable) then realCollision(collisionable) else List.empty)) + .toMap \ No newline at end of file diff --git a/src/main/scala/model/elements2d/Angle.scala b/src/main/scala/model/elements2d/Angle.scala new file mode 100644 index 00000000..aa59881f --- /dev/null +++ b/src/main/scala/model/elements2d/Angle.scala @@ -0,0 +1,67 @@ +package model.elements2d + + +object Angle: + private val straightAngleRadiant = math.Pi + private val straightAngleDegree = 180 + + private def identity(value: Double): Double = value + + private def periodicWithNegative(value: Double, straightAngle: Double): Double = -straightAngle + math.abs(value % straightAngle) + + private def periodicCircular(value: Double, completeAngle: Double): Double = value % completeAngle + +/** + * Represents an angle that could be measured in either degree or radiant. + * + */ +enum Angle: + + import Angle._ + + /** + * Creates an angle from a given value in degree. + * + * @param value the value of the angle in degree + */ + case Degree(value: Double) + + /** + * Creates an angle from a given value in radiant. + * + * @param value the value of the angle in radiant + */ + case Radian(value: Double) + + /** + * Returns the value of the angle in radiant. + * + * @return the value of the angle in radiant + */ + def radiant: Double = + def degreeToRadian(value: Double): Double = value * straightAngleRadiant / straightAngleDegree + + conversion(straightAngleRadiant)(degreeToRadian)(identity) + + /** + * Returns the value of the angle in degree. + * + * @return the value of the angle in degree + */ + def degree: Double = + def radiantToDegree(value: Double): Double = value * straightAngleDegree / straightAngleRadiant + + conversion(straightAngleDegree)(identity)(radiantToDegree) + + private def conversion(straightAngleOfConversion: Double)(fromDegree: Double => Double)(fromRadian: Double => Double): Double = + def conversionPeriodicWithNegative(value: Double)(straightAngleOfConverted: Double)(conversionFunction: Double => Double): Double = + val completeAngleOfConverted = 2 * straightAngleOfConverted + val valuePeriodicCircular = periodicCircular(value, completeAngleOfConverted) + value match + case _ if valuePeriodicCircular == -straightAngleOfConverted => straightAngleOfConversion + case _ if valuePeriodicCircular > straightAngleOfConverted || valuePeriodicCircular < -straightAngleOfConverted => periodicWithNegative(conversionFunction(value), straightAngleOfConversion) + case _ => conversionFunction(valuePeriodicCircular) + + this match + case Degree(value) => conversionPeriodicWithNegative(value)(straightAngleDegree)(fromDegree) + case Radian(value) => conversionPeriodicWithNegative(value)(straightAngleRadiant)(fromRadian) diff --git a/src/main/scala/model/elements2d/Point2D.scala b/src/main/scala/model/elements2d/Point2D.scala new file mode 100644 index 00000000..56a42b93 --- /dev/null +++ b/src/main/scala/model/elements2d/Point2D.scala @@ -0,0 +1,116 @@ +package model.elements2d + +import scala.annotation.targetName + +/** + * The representation a point in a 2 dimensional space. + */ +trait Point2D: + + /** + * The x coordinate of the point. + * + * @return the x coordinate of the point. + */ + def x: Double + + /** + * The y coordinate of the point. + * + * @return the y coordinate of the point. + */ + def y: Double + + /** + * Returns a new point translated by the given vector. + * + * @param vector the vector that coordinate the point has to bes translated + * @return returns a new point translated by the given vector + */ + @targetName("translate") + def -->(vector: Vector2D): Point2D = Point2D(x + vector.x, y + vector.y) + + /** + * Returns the distance between this point and the given point. + * + * @param other the point to which the distance is calculated + * @return the distance between this point and the given point + */ + @targetName("distance") + def <->(other: Point2D): Double = (this <--> other).magnitude + + /** + * Returns the vector between this point and the given point. + * + * @param other the point to which the vector is calculated + * @return the vector between this point and the given point + */ + @targetName("distance") + def <-->(other: Point2D): Vector2D = Vector2D(other.x - x, other.y - y) + + /** + * Returns a new point that is the result of the scale of this point of a given vector. + * + * @param vector the vector that coordinate the point has to be scaled + * @return a new point that is the result of the scale of this point of a given vector + */ + @targetName("scale") + def *(vector: Vector2D): Point2D = Point2D(x * vector.x, y * vector.y) + + /** + * Returns a new point that is the result of the scale of this point of a scalar. + * + * @param scale the scalar that coordinate the point has to be scaled + * @return a new point that is the result of the scale of this point of a given scalar + */ + @targetName("scale") + def *(scale: Double): Point2D = Point2D(x * scale, y * scale) + +/** + * The companion object of the Point2D trait. + */ +object Point2D: + + import org.scalactic.{Equality, TolerantNumerics} + import org.scalactic.TripleEquals.convertToEqualizer + + /** + * Object that provides the equality for the Point2D trait. + */ + object GivenEquality: + + /** + * The given for the equality of two Double. + * The tolerance is 0.01. + * + * @return the given for the equality of two Double with tolerance 0.01 + */ + given Equality[Double] = TolerantNumerics.tolerantDoubleEquality(0.01) + + /** + * The given for the equality of two Point2D. + * The tolerance is used for the x and y coordinates. + * + * @param equality the given equality for the Double + * @return the given for the equality of two Point2D + */ + given Point2DEquality(using equality: Equality[Double]): Equality[Point2D] with + def areEqual(point: Point2D, other: Any): Boolean = + other match + case otherPoint: Point2D => point.x === otherPoint.x && point.y === otherPoint.y + case _ => false + + + /** + * Creates a new point with the given coordinates. + * + * @param xCoordinate the x coordinate of the point + * @param yCoordinate the y coordinate of the point + * @return a new point with the given coordinates + */ + def apply(xCoordinate: Double, yCoordinate: Double): Point2D = Point2DImpl(xCoordinate, yCoordinate) + + private case class Point2DImpl(xCoordinate: Double, yCoordinate: Double) extends Point2D : + override def x: Double = xCoordinate + + override def y: Double = yCoordinate \ No newline at end of file diff --git a/src/main/scala/model/elements2d/Vector2D.scala b/src/main/scala/model/elements2d/Vector2D.scala new file mode 100644 index 00000000..5a95a60b --- /dev/null +++ b/src/main/scala/model/elements2d/Vector2D.scala @@ -0,0 +1,177 @@ +package model.elements2d + +import scala.annotation.targetName + +/** + * The representation of a vector in 2 dimensional space. + */ +enum Vector2D: + + /** + * Creates a new vector with the given coordinates. + * + * @param xComponent the x coordinate + * @param yComponent the y coordinate + */ + @targetName("vector2D") + case <>(xComponent: Double, yComponent: Double) + + /** + * Return the Zero vector (additive identity). + */ + case Zero + + /** + * The x component of the vector. + * + * @return the x component of the vector + */ + def x: Double = this match + case <>(x, _) => x + case Zero => 0 + + /** + * The y component of the vector. + * + * @return the y component of the vector + */ + def y: Double = this match + case <>(_, y) => y + case Zero => 0 + + + /** + * The magnitude of the vector. + * + * @return the magnitude of the vector + */ + def magnitude: Double = this match + case <>(x, y) => math.sqrt(x * x + y * y) + case Zero => 0 + + /** + * The direction of the vector. + * + * @return an option with the angle of vector's direction if the vector is not zero, otherwise a empty option + */ + def direction: Option[Angle] = this match + case <>(x, y) => Option(Angle.Radian(math.atan2(y, x))) + case Zero => Option.empty + + /** + * Calculates the sum of this vector and another vector. + * + * @param other the other vector + * @return a new vector that is the sum of the two vectors if sum of the two vectors is the vector Zero returns the vector Zero + */ + @targetName("plus") + def +(other: Vector2D): Vector2D = <>(x + other.x, y + other.y) match + case <>(0, 0) => Zero + case vector => vector + + /** + * Calculates the difference of this vector and another vector. + * + * @param other the other vector + * @return a new vector that is the difference of the two vectors if difference of the two vectors is the vector Zero returns the vector Zero + */ + @targetName("minus") + def -(other: Vector2D): Vector2D = <>(x - other.x, y - other.y) match + case <>(0, 0) => Zero + case vector => vector + + /** + * Calculates the product of this vector and a scalar. + * + * @param scalar the scalar value to multiply the vector with + * @return a new vector that is the product of the vector and the scalar + */ + @targetName("multiplyByScalar") + def *(scalar: Double): Vector2D = <>(x * scalar, y * scalar) match + case <>(0, 0) => Zero + case vector => vector + + /** + * Calculates the quotient of this vector and a scalar. + * + * @param scalar the scalar value to divide the vector with + * @return a new vector that is the quotient of the vector and the scalar + */ + @targetName("divideByScalar") + def /(scalar: Double): Vector2D = this * (1 / scalar) + + /** + * Calculates the normalized vector (magnitude =1) of the current vector. + * + * @return a new vector that is the normalized vector of the current vector or Zero + */ + def normalize: Vector2D = this match + case Zero => Zero + case vector => vector / magnitude + + /** + * Calculates the opposite vector of the current vector. + * + * @return a new vector that is the opposite vector of the current vector + */ + @targetName("opposite") + def unary_- = this * -1 + +/** + * Companion object for the Vector2D class. + */ +object Vector2D: + + /** + * Object that contains the givens for the Vector2D class equality (===). + */ + object GivenEquality: + + import org.scalactic.{Equality, TolerantNumerics} + import org.scalactic.TripleEquals.convertToEqualizer + + /** + * The given for the equality of two Double. + * The tolerance is 0.01. + * + * @return the given for the equality of two Double with tolerance 0.01 + */ + given Equality[Double] = TolerantNumerics.tolerantDoubleEquality(0.01) + + /** + * The given for the equality of two Vector2D. + * The tolerance is used for the x and y components. + * + * @param equality the given equality for the Double + * @return the given for the equality of two Vector2D + */ + given Vector2DEquality(using equality: Equality[Double]): Equality[Vector2D] with + def areEqual(vector: Vector2D, other: Any): Boolean = + other match + case otherVector: Vector2D => vector.x === otherVector.x && vector.y === otherVector.y + case _ => false + + + /** + * Creates a new vector with the given x and y, if the x and y are 0 return the vector Zero. + * + * @param x the x component of the vector + * @param y the y component of the vector + * @return a new vector with the given x and y, if the x and y are 0 return the vector Zero + */ + def apply(x: Double, y: Double): Vector2D = (x, y) match + case (0, 0) => Zero + case _ => <>(x, y) + + /** + * Creates a new vector with the given magnitude and direction, if the magnitude is 0 return the vector Zero. + * + * @param magnitude the magnitude of the vector + * @param direction the angle of vector's direction + * @return a new vector with the given magnitude and direction, if the magnitude is 0 or the direction is null return the vector Zero + */ + def apply(magnitude: Double, direction: Angle): Vector2D = magnitude match + case 0 => Zero + case _ if direction == null => Zero + case _ => <>(math.cos(direction.radiant), math.sin(direction.radiant)) * magnitude + diff --git a/src/main/scala/model/elements2d/package.scala b/src/main/scala/model/elements2d/package.scala new file mode 100644 index 00000000..c3e73892 --- /dev/null +++ b/src/main/scala/model/elements2d/package.scala @@ -0,0 +1,6 @@ +package model + +/** + * Trait, class, object and enum for the representation of basic element in a 2 dimensional space. + */ +package object elements2d diff --git a/src/main/scala/model/explosion/Explosion.scala b/src/main/scala/model/explosion/Explosion.scala new file mode 100644 index 00000000..a05902d3 --- /dev/null +++ b/src/main/scala/model/explosion/Explosion.scala @@ -0,0 +1,62 @@ +package model.explosion + +import model.DeltaTime +import model.behavior.Timeable +import model.collisions.hitbox.HitBoxCircular +import model.collisions.{Affiliation, Collisionable, Damager, HitBox, LifePoint} +import model.elements2d.Point2D + +trait Explosion extends Damager, Timeable: + + def position: Point2D + + def radius: Double + + def terminated: Boolean + + override def timeElapsed(dt: DeltaTime): Explosion + +object Explosion: + + def apply(damageToInflict: LifePoint, hitboxRadius: Double, myPosition: Point2D, dt: DeltaTime = 0)(using maxTime: MaxTime, parentAffiliation: Affiliation): Explosion = new Explosion { + + (damageToInflict, hitboxRadius) match + case (n, m) if n == 0 || m == 0 => throw new IllegalArgumentException() + case _ => () + + override def position: Point2D = myPosition + + override def radius: Double = hitboxRadius match + case n if n >= 0 => hitboxRadius + case n if n < 0 => Math.abs(hitboxRadius) + + override def timeElapsed(_dt: DeltaTime): Explosion = apply(damageToInflict, hitboxRadius, myPosition, dt + _dt) + + override def terminated: Boolean = dt >= maxTime + + /** + * The damage inflicted by this object. + * + * @return the damage inflicted by this object. + */ + override def damageInflicted: LifePoint = damageToInflict match + case n if n > 0 => damageToInflict + case n if n < 0 => Math.abs(damageToInflict) + + /** + * Return the hit box of the object. + * + * @return the hit box of the object. + */ + override protected def hitBox: HitBox = HitBoxCircular(position, hitboxRadius) + + /** + * Return the affiliation of the object. + * + * @return the affiliation of the object. + */ + override def affiliation: Affiliation = parentAffiliation + } + + + diff --git a/src/main/scala/model/explosion/package.scala b/src/main/scala/model/explosion/package.scala new file mode 100644 index 00000000..eae9e48b --- /dev/null +++ b/src/main/scala/model/explosion/package.scala @@ -0,0 +1,8 @@ +package model +import model.DeltaTime + +package object explosion: + + type MaxTime = DeltaTime + + val standardRadius: Int = 20 \ No newline at end of file diff --git a/src/main/scala/model/ground/City.scala b/src/main/scala/model/ground/City.scala new file mode 100644 index 00000000..d2e04980 --- /dev/null +++ b/src/main/scala/model/ground/City.scala @@ -0,0 +1,43 @@ +package model.ground + +import model.collisions.hitbox.HitBoxRectangular +import model.collisions.{Affiliation, Collisionable, Damageable, HitBox, LifePoint} +import model.elements2d.* +import utilities._ + +case class City(val position: Point2D, val life: LifePoint = cityInitialLife) extends Damageable: + private val collider: HitBox = HitBoxRectangular(Point2D(position.x + cityBaseSize/2, position.y + cityHeightSize/2), + cityBaseSize, + cityHeightSize, + Angle.Degree(0)) + + /** + * @return string containing all the informations about the city + */ + override def toString: String = "City --> Position: x:" + position.x + " y:" + position.y + "; Life: " + life + "\n" + + /** + * @return the initial health of the object. + */ + override def initialLife: LifePoint = cityInitialLife + + /** + * @return the current health of the object. + */ + override def currentLife: LifePoint = life + + /** + * @param damage the damage that the object received. + * @return the object with the new health. + */ + override def takeDamage(damage: LifePoint): City = City(position, life - damage) + + /** + * @return the affiliation of the object. + */ + override def affiliation: Affiliation = Affiliation.Friendly + + /** + * @return the hit box of the object. + */ + override def hitBox: HitBox = collider \ No newline at end of file diff --git a/src/main/scala/model/ground/Ground.scala b/src/main/scala/model/ground/Ground.scala new file mode 100644 index 00000000..03b15bbe --- /dev/null +++ b/src/main/scala/model/ground/Ground.scala @@ -0,0 +1,113 @@ +package model.ground + +import model.DeltaTime +import model.collisions.{Damageable, LifePoint} +import model.elements2d.Point2D +import model.ground.City +import model.missile.Missile +import view.ViewConstants +import utilities._ + + + +object Ground: + def apply(cities: List[City], turrets: List[MissileBattery]): Ground = new Ground(cities, turrets) + def apply(): Ground = + val cities = + for y <- List.range(0, 2) //generate all cities in 2 waves. + x <- List.range(0, 3) //1° wave it generate all left side cities. 2° waves all the right side cities + yield City(Point2D(missileBatteryBaseSize + 2* turretSpacer + + (cityBaseSize + citySpacer) * x + + (3 * cityBaseSize + 2 * citySpacer + 2 * turretSpacer + missileBatteryBaseSize) * y, + ViewConstants.GUI_height - cityHeightSize)) + val turrets = + for x <- List.range(0, 3) + yield MissileBattery(Point2D(turretSpacer + (missileBatteryBaseSize + 2 * turretSpacer + 3 * cityBaseSize + 2 * citySpacer) * x + ,ViewConstants.GUI_height - missileBatteryHeightSize)) + Ground(cities, turrets) + +case class Ground(cities: List[City], turrets: List[MissileBattery]): + + /*** + * @return Return all the cities that are still alive + */ + def citiesAlive = + for city <- cities if city.currentLife > 0 + yield city + + /*** + * @return Return the number of cities that are still alive + */ + def numberOfCitiesAlive: Int = citiesAlive.length + + /*** + * @return Return all the missile batteries that are still alive + */ + def missileBatteryAlive = + for battery <- turrets if battery.currentLife > 0 + yield battery + + /*** + * @return Return the number of missile batteries that are still alive + */ + def numberOfMissileBatteryAlive: Int = missileBatteryAlive.length + + /*** + * Method used for check if the player is still alive + * @return True if there are at least 1 city alive + */ + def stillAlive: Boolean = numberOfCitiesAlive > 0 + + /*** + * Method used for shooting the nearest turret to a determinate point. + * @param endPoint Point where the missile should explode + * @return Tuple containing the new updated ground and the missile shooted. Missile is null if all turrets were reloading + */ + def shootMissile(endPoint: Point2D): Tuple2[Ground, Option[Missile]] = + def calculatePositions: List[Tuple2[Double, MissileBattery]] = + for battery <- missileBatteryAlive if battery.isReadyForShoot + yield (battery.bottomLeft_Position <-> endPoint, battery) + + val batteriesReadyForShoot = calculatePositions + if(batteriesReadyForShoot.length > 0) then //only if there at least 1 turret ready for shoot, it will procede with shooting + val missilesBatteryInformations = batteriesReadyForShoot.minBy(_._1)._2.shootRocket(endPoint).get + val newTurrets = turrets.map( t => if (t == batteriesReadyForShoot.minBy(_._1)._2) missilesBatteryInformations._1 else t) + (Ground(cities, newTurrets) , Some(missilesBatteryInformations._2)) + else + (Ground(cities, turrets), None) + + /*** + * Method used for deal damage to a determinate structure. + * @param structure Object of the structure that have to take damage. + * @param damageDealed Damage that the structure have to take. + * @return A new ground containing all the informations updated. + */ + def dealDamage(structure: Damageable, damageDealed: LifePoint, ground: Ground = this): Ground = structure match + case c: City => val newCities = ground.cities.map(o => if (o == c) o.takeDamage(damageDealed) else o) + Ground(newCities, ground.turrets) + case m: MissileBattery => val newTurrets = ground.turrets.map(o => if (o == m) o.takeDamage(damageDealed) else o) + Ground(ground.cities, newTurrets) + + /*** + * Method used for deal damage to a determinate set of structures + * @param structures List of structures that have to take damage. + * @param damageDealed Damage that the structures have to take. + * @return A new ground containing all the informations updated. + */ + def dealDamage(structures: List[Damageable], damageDealed: LifePoint): Ground = + var newGround = this + //structures.foreach( structure => newGround = dealDamage(structure, damageDealed, newGround)) + structures.map( s => newGround = dealDamage(s, damageDealed, newGround) ) + newGround + + /*** + * Method used for deal different damages to a determinate set of structures + * @param structures List of structures that have to take damage. + * @param damages List of damages that the structures have to take. + * @return A new ground containing all the informations updated. + */ + def dealDamage(structures: List[Damageable], damages: List[LifePoint]): Ground = + var newGround = this + for ((structure, damage) <- (structures zip damages)) + newGround = dealDamage(structure, damage, newGround) + newGround \ No newline at end of file diff --git a/src/main/scala/model/ground/MissileBattery.scala b/src/main/scala/model/ground/MissileBattery.scala new file mode 100644 index 00000000..473528a6 --- /dev/null +++ b/src/main/scala/model/ground/MissileBattery.scala @@ -0,0 +1,80 @@ +package model.ground + +import model.DeltaTime +import model.behavior.Timeable +import model.collisions.hitbox.HitBoxRectangular +import model.collisions.{Affiliation, Collisionable, Damageable, HitBox, LifePoint} +import model.elements2d.* +import model.missile.{Missile, hitboxHeight, velocity} +import utilities._ + +import java.time +import java.time.LocalDateTime + + +case class MissileBattery(val bottomLeft_Position: Point2D, + val life: LifePoint = missileBatteryInitialLife, + dt: DeltaTime = 0) extends Damageable, Timeable: + + + private val collider: HitBox = HitBoxRectangular(Point2D(bottomLeft_Position.x + missileBatteryBaseSize/2, bottomLeft_Position.y + missileBatteryHeightSize/2), + missileBatteryBaseSize, + missileBatteryHeightSize, + Angle.Degree(0)) //collider of the object + + /** + * @return true: If the turret is reloading and still not able to shoot + */ + def isReadyForShoot: Boolean = dt >= reloadingTime + + /** + * @return a tuple of MissileBattery and Missile if turret is ready for shoot, None if turret is reloading + */ + def shootRocket(endingPoint: Point2D): Option[Tuple2[MissileBattery, Missile]] = + if this.isReadyForShoot then + Some((this.copy(dt = 0), + Missile(missileHealth, + missileFriendlyDamage, + velocity, + Point2D(bottomLeft_Position.x + missileBatteryBaseSize, + bottomLeft_Position.y + 0.8 * missileBatteryHeightSize), + endingPoint))) //If not reloading, allow shoot + else + None + + /** + * @return a string containing all the valuable informations + */ + override def toString: String = "Missile battery --> Position: x:" + bottomLeft_Position.x + " y:" + bottomLeft_Position.y + "; Ready for shoot: " + isReadyForShoot + "; Life: " + life + "; Internal digital time: " + dt + "\n" + + /** + * @return the affiliation of the object. + */ + override def affiliation: Affiliation = Affiliation.Friendly + + /** + * @return the hit box of the object. + */ + override def hitBox: HitBox = collider + + /** + * @return the initial health of the object. + */ + override def initialLife: LifePoint = missileBatteryInitialLife + + /** + * @return the current health of the object. + */ + override def currentLife: LifePoint = life + + /** + * @param damage the damage that the object received. + * @return the object with the new health. + */ + override def takeDamage(damage: LifePoint): MissileBattery = this.copy(life = life - damage) + + /** + * @param actualdt Digital time passed since last update + * @return A MissileBattery with the new digital time + */ + override def timeElapsed(actualdt: DeltaTime): MissileBattery = this.copy(dt = dt + actualdt) \ No newline at end of file diff --git a/src/main/scala/model/missile/Missile.scala b/src/main/scala/model/missile/Missile.scala new file mode 100644 index 00000000..afec17ec --- /dev/null +++ b/src/main/scala/model/missile/Missile.scala @@ -0,0 +1,92 @@ +package model.missile + +import model.behavior.* +import model.collisions.{Affiliation, Collisionable, Damageable, HitBox, LifePoint, lifePointDeath} +import model.elements2d.{Angle, Point2D, Vector2D} +import model.explosion.{Explosion, MaxTime, standardRadius} +import model.DeltaTime + +import scala.util.Random + +given maxTime: MaxTime = 8 + +given Conversion[(Point2D, Point2D), Vector2D] with + override def apply(x: (Point2D, Point2D)): Vector2D = (x._2 <--> x._1).normalize + +trait Missile extends Damageable, Moveable: + + def damage: LifePoint + + def position: Point2D + + def velocity: Double + + def direction: Vector2D = (position, destination) + + override def move(): Missile + + def explode: Explosion + + val moveStrategy: MissileMovement = Missile.BasicMove(this)(_) + +trait Scorable(val points: Int) extends Damageable + +case class MissileImpl( + lifePoint: LifePoint, + override val position: Point2D, + override val destination: Point2D, + dt: DeltaTime = 0, + override val affiliation: Affiliation = Affiliation.Friendly, + override val damage: LifePoint = damage, + override val velocity: Double = velocity + ) extends Missile with MissileDamageable(lifePoint, position, destination): + + override def takeDamage(damageToInflict: LifePoint): Missile = damageToInflict match + case n if (currentLife - n) <= 0 => newMissile(life = lifePointDeath) + case n if (n > 0) => newMissile(life = currentLife - n) + case _ => this + + override def move(): Missile = this match + case m if m.isDestroyed => newMissile(life = lifePointDeath) + case _ => newMissile(pos = moveStrategy(dt), _dt = 0) + + override def timeElapsed(dt: DeltaTime): Missile = newMissile(_dt = dt + this.dt) + + override def destinationReached: Boolean = position == destination + + override def explode: Explosion = + given expAffiliation: Affiliation = affiliation + Explosion(damage, hitboxRadius = standardRadius, position) + + private def newMissile( + life: LifePoint = lifePoint, + pos: Point2D = position, + _dt: DeltaTime = dt + ) = affiliation match + case Affiliation.Friendly => this.copy(lifePoint = life, position = pos, dt = _dt) + case Affiliation.Enemy => + val score = this.asInstanceOf[Scorable].points + new MissileImpl(life, pos, destination, _dt, affiliation, damage, velocity) with Scorable(score) + case _ => throw IllegalStateException() + +object Missile: + + def enemyMissile(lifePoint: LifePoint = initialLife, + _damage: LifePoint = damage, + _velocity: Double = velocity, + position: Point2D, + finalPosition: Point2D, + score: Int = 1): Missile = new MissileImpl(lifePoint, position, finalPosition, 0, affiliation = Affiliation.Enemy, damage = _damage, velocity = _velocity) with Scorable(1) + + def BasicMove(missile: Missile)(dt: DeltaTime): Point2D = + val distanceToMove = missile.velocity * dt + val distanceToFinalPosition = missile.position <-> missile.destination + if distanceToMove >= distanceToFinalPosition + then missile.destination + else missile.position --> (missile.direction * distanceToMove * (-1)) + + def apply(lifePoint: LifePoint = initialLife, + _damage: LifePoint = damage, + _velocity: Double = velocity, + position: Point2D, + finalPosition: Point2D) : Missile = MissileImpl(lifePoint, position, finalPosition, dt = 0, damage = _damage, velocity = _velocity) diff --git a/src/main/scala/model/missile/MissileDamageable.scala b/src/main/scala/model/missile/MissileDamageable.scala new file mode 100644 index 00000000..82240804 --- /dev/null +++ b/src/main/scala/model/missile/MissileDamageable.scala @@ -0,0 +1,17 @@ +package model.missile + +import model.collisions.hitbox.HitBoxRectangular +import model.collisions.{Affiliation, Collisionable, Damageable, Distance, HitBox, LifePoint} +import model.elements2d.{Angle, Point2D} +import model.missile + +trait MissileDamageable(lifePoint: LifePoint, position: Point2D, finalPosition: Point2D) extends Damageable: + + override protected def hitBox: HitBox = basicHitBox(position, angle) + + override def initialLife: LifePoint = missile.initialLife + + override def currentLife: LifePoint = lifePoint + + def angle: Option[Angle] = (finalPosition <--> position).direction + diff --git a/src/main/scala/model/missile/package.scala b/src/main/scala/model/missile/package.scala new file mode 100644 index 00000000..0a8f29b3 --- /dev/null +++ b/src/main/scala/model/missile/package.scala @@ -0,0 +1,49 @@ +package model + +import collisions.{Affiliation, HitBox, LifePoint, hitbox} +import model.collisions.hitbox.HitBoxRectangular +import model.elements2d.{Angle, Point2D} +import model.missile.Missile +import model.collisions.Affiliation.* +import model.behavior.* + +import scala.util.Random + +package object missile: + + sealed trait MissileType + case class BasicMissile(affiliation: Affiliation) extends MissileType + case object RandomMissile extends MissileType + case object ZigZagMissile extends MissileType + + val initialLife: LifePoint = 1 + val velocity: Double = 100 + val damage: Int = 1 + val hitboxBase: Double = 10.0 + val hitboxHeight: Double = 30.0 + val maxHeight: Int = 100 + val maxWidth: Int = 50 + val angle: Angle = Angle.Degree(90) + + val basicHitBox: (Point2D, Option[Angle]) => HitBox = (point_, angle_) => HitBoxRectangular(point_, hitboxBase, hitboxHeight, angle_.getOrElse(angle)) + + type MissileMovement = (DeltaTime) => Point2D + + given affiliation: Affiliation = Affiliation.Enemy + //generating a random positioned missile of type T + def GenerateRandomMissile(missileType: MissileType, finalDestination: Point2D)(using random: Random): Option[Missile] = missileType match + case BasicMissile(affiliation) => + for + x <- Option.apply(Random.nextInt(maxWidth)) //length del campo (vedere monadi) + y <- Option.apply(maxHeight) //height del campo + yield Missile(initialLife, damage, velocity, Point2D(x, y), finalDestination) + + case RandomMissile => + for + vel <- Option.apply(Random.nextDouble() * velocity) + x <- Option.apply(Random.nextInt(maxWidth)) //length del campo (vedere monadi) + y <- Option.apply(maxHeight) + damage <- Option.apply(damage) + yield Missile(initialLife, damage, velocity, Point2D(x, y), Point2D(10,10)) + + case _ => Option(Missile(initialLife, damage, velocity, Point2D(0, 0), finalDestination)) \ No newline at end of file diff --git a/src/main/scala/model/missile/zigzag/ZigZagMissile.scala b/src/main/scala/model/missile/zigzag/ZigZagMissile.scala new file mode 100644 index 00000000..56e4466a --- /dev/null +++ b/src/main/scala/model/missile/zigzag/ZigZagMissile.scala @@ -0,0 +1,26 @@ +package model.missile.zigzag +import model.collisions.Affiliation +import model.elements2d.Point2D +import model.missile.* + +import scala.util.Random + +object ZigZagMissile: + + trait ZigZagMissile: + missile: Missile => + def zigzag(): Boolean = true + + override def move(): Missile = + println("OVERRIDE") + this + + def apply() = new MissileImpl(5, Point2D(0,0), Point2D(5,5)) with ZigZagMissile + +@main def test() = + import ZigZagMissile.* + given random: Random() + val zigzagMissile = apply() + zigzagMissile.zigzag() + zigzagMissile.move() + diff --git a/src/main/scala/model/package.scala b/src/main/scala/model/package.scala new file mode 100644 index 00000000..0289323a --- /dev/null +++ b/src/main/scala/model/package.scala @@ -0,0 +1,35 @@ +import model.collisions.{Affiliation, Collisionable, Damageable} + +package object model: + + /** + * Type alias for the abstraction of virtual time + */ + type DeltaTime = Double + + /** + * Type alias for the point that are given in the game when an object is destroyed. + */ + type ScorePoint = Int + + /** + * Trait that represents an object that give points when destroyed. + */ + trait Scorable(val points: ScorePoint) extends Damageable + + /** + * Function that calculate the new score of the player. + * It only add the points of the destroyed object if the one of the object that inflict damage to the damageable + * is owned by the player. + * + * @param collisionables a map that has as keys the damageable objects and as values a list with the damaeger that collides with them. + * @param actualScore the actual score of the player. + * @return the new score of the player. + */ + def calculateNewScore(collisionables: Map[Collisionable, List[Collisionable]], actualScore: ScorePoint): ScorePoint = + def getScorePoint(collisionable: Collisionable, damagers: List[Collisionable]): ScorePoint = + collisionable match + case scorable: Scorable if scorable.isDestroyed && damagers.map(_.affiliation).contains(Affiliation.Friendly) => scorable.points + case _ => 0 + + collisionables.map((damageable, damagers) => getScorePoint(damageable, damagers)).sum + actualScore diff --git a/src/main/scala/model/spawner/Spawner.scala b/src/main/scala/model/spawner/Spawner.scala new file mode 100644 index 00000000..655d22bf --- /dev/null +++ b/src/main/scala/model/spawner/Spawner.scala @@ -0,0 +1,47 @@ +package model.spawner + +import model.behavior.* +import model.collisions.Affiliation +import model.elements2d.Point2D +import model.missile.{Missile, damage, initialLife, velocity} +import model.missile.Missile +import model.{DeltaTime, World} +import view.ViewConstants + +import scala.util.Random + +extension(v: Double) + def map(f: Double => Double) = f(v) + +trait Spawner(using Random) extends Timeable: + + def spawn(): (List[Missile], Spawner) + + override def timeElapsed(dt: DeltaTime): Spawner + +object Spawner: + + def apply(interval: DeltaTime, maxWidth: Double, maxHeight: Double, timeFromStart: DeltaTime = 0, dt: DeltaTime = 0): Spawner = new Spawner(using Random) { + + override def spawn(): (List[Missile], Spawner) = + dt match + case n if n >= interval => + val step: Int = (n / interval).toInt + val randomX_start = (0 until step map { i => (i, (Random.nextDouble() * maxWidth * (ViewConstants.GUI_width / World.width))) }).toList + val randomX_end = (0 until step map { i => (i, (Random.nextDouble() * maxWidth * (ViewConstants.GUI_width / World.width))) }).toList + val generator: List[Missile] = + for + (i, x_start) <- randomX_start + (j, x_end) <- randomX_end + if i == j + yield Missile.enemyMissile(initialLife, damage, _velocity = velocity, Point2D(x_start, 0), + Point2D(x_end, ViewConstants.GUI_height)) //TODO coordinate campo + (generator, Spawner(interval, maxWidth, maxHeight, timeFromStart)) + case _ => (List(), this) + + override def timeElapsed(_dt: DeltaTime): Spawner = (timeFromStart + dt) match + case v if v < threshold => Spawner.apply(interval, maxWidth, maxHeight, v, dt + _dt) + case v if v >= threshold => Spawner.apply(interval, maxWidth, maxHeight, v, dt + _dt) + + + } diff --git a/src/main/scala/model/spawner/package.scala b/src/main/scala/model/spawner/package.scala new file mode 100644 index 00000000..feb94b97 --- /dev/null +++ b/src/main/scala/model/spawner/package.scala @@ -0,0 +1,5 @@ +package model + +package object spawner: + + val threshold: Int = 100 diff --git a/src/main/scala/utilities/MathUtilities.scala b/src/main/scala/utilities/MathUtilities.scala new file mode 100644 index 00000000..573aae47 --- /dev/null +++ b/src/main/scala/utilities/MathUtilities.scala @@ -0,0 +1,83 @@ +package utilities + +import org.scalactic.TripleEqualsSupport.Spread +import org.scalactic.{Equality, TolerantNumerics} +import math.Numeric.Implicits.infixNumericOps +import scala.annotation.targetName +import org.scalactic.TripleEquals.Equalizer +import scala.math.pow + +/** + * The Object that provides the extension methods `>==`, `<==` and `**`. + * It also provides the given `Equality` that is used to have a tolerance with the double. + */ +object MathUtilities: + + /** + * Object that contains the givens for the Double for class equality (===). + */ + object DoubleEquality: + /** + * The given for the equality of two Double. + * The tolerance is 0.01. + * + * @return the given for the equality of two Double with tolerance 0.01 + */ + given Equality[Double] = TolerantNumerics.tolerantDoubleEquality(0.01) + + + extension[T: Numeric] (value: T) + + /** + * Returns the value of the specified number raised to the power of exponent. + * + * @tparam exponent the exponent + * @return the value of the specified number raised to the power of exponent + */ + @targetName("pow") + def **(exponent: T): Double = pow(value.toDouble, exponent.toDouble) + + + extension (leftSide: Equalizer[Double]) + + /** + * Returns true if the left side is less than or equal the right side in the given tolerance. + * + * @param rightSide the right side + * @param equality the given equality + * @return true if the left side is less than or equal the right side in the given tolerance + */ + @targetName("lessEqual") + def <==(rightSide: Any)(using equality: Equality[Double]): Boolean = rightSide match + case double: Double => leftSide === double || leftSide.leftSide < double + case _ => false + + /** + * Returns true if the left side is greater than or equal the right side in the given tolerance. + * + * @param rightSide the right side + * @param equality the given equality + * @return true if the left side is greater than or equal the right side in the given tolerance + */ + @targetName("greaterEqual") + def >==(rightSide: Any)(using equality: Equality[Double]): Boolean = rightSide match + case double: Double => leftSide === double || leftSide.leftSide > double + case _ => false + + /** + * Returns true if the left side is less than the right side in the tolerance. + * + * @param spread the right side and the tolerance + * @return true if the left side is less than or equal the right side in the tolerance + */ + @targetName("lessEqual") + def <==(spread: Spread[Double]): Boolean = leftSide === spread || leftSide.leftSide < spread.pivot + + /** + * Returns true if the left side is greater than the right side in the tolerance. + * + * @param spread the right side and the tolerance + * @return true if the left side is greater than or equal the right side in the tolerance + */ + @targetName("greaterEqual") + def >==(spread: Spread[Double]): Boolean = leftSide === spread || leftSide.leftSide > spread.pivot diff --git a/src/main/scala/utilities/package.scala b/src/main/scala/utilities/package.scala new file mode 100644 index 00000000..90b254aa --- /dev/null +++ b/src/main/scala/utilities/package.scala @@ -0,0 +1,20 @@ +import model.collisions.LifePoint +import view.ViewConstants + +package object utilities: + //missile battery informations + val missileBatteryBaseSize: Int = 100 //base size + val missileBatteryHeightSize: Int = (missileBatteryBaseSize/ViewConstants.batteryMissileBaseHeightRatio).toInt //height size + val reloadingTime: Int = 3 //reloading time of the turret (in seconds!) + val missileBatteryInitialLife: LifePoint = 3 //initial life + //city informations + val cityBaseSize: Int = 100 //base size + val cityHeightSize: Int = (cityBaseSize/ViewConstants.cityBaseHeightRatio).toInt //height size + val cityInitialLife: LifePoint = 3 //initial life + //missile informations + val missileHealth: LifePoint = 1 + val missileFriendlyDamage: LifePoint = 1 + //Structure spawning parameters + val citySpacer: Int = 20 //spacer between cities + val turretSpacer: Int = 75 //spacer between a city and a turret + diff --git a/src/main/scala/view/CollisionableVisualizer.scala b/src/main/scala/view/CollisionableVisualizer.scala new file mode 100644 index 00000000..bf5c435b --- /dev/null +++ b/src/main/scala/view/CollisionableVisualizer.scala @@ -0,0 +1,39 @@ +package view + +import java.awt.{Color, Image, Toolkit} +import model.collisions.{Affiliation, Collisionable} +import model.elements2d.{Angle, Point2D} +import model.explosion.Explosion +import model.missile.{Missile, MissileDamageable, MissileImpl, hitboxBase, hitboxHeight} + +import java.awt.image.BufferedImage +import javax.imageio.ImageIO +import java.io.File + +case class CollisionableElement(image: BufferedImage, baseWidth: Int, baseHeight: Int, + position: Point2D, angle: Angle = Angle.Degree(0)) + +object CollisionableVisualizer: + + def printElements(collisionables: List[Collisionable])(using conversion: Conversion[Double, Int]): List[CollisionableElement] = + + val conversion: Collisionable => CollisionableElement = (c: Collisionable) => c match + case m: Missile with MissileDamageable => + var optImage: Option[BufferedImage] = Option.empty + try { + m.affiliation match + case Affiliation.Enemy => optImage = Option(ImageIO.read(getClass.getResource("/enemy_missile.png"))) + case _ => optImage = Option(ImageIO.read(getClass.getResource("/friendly_missile.png"))) + } + CollisionableElement(optImage.getOrElse(null), hitboxBase, hitboxHeight, m.position, m.angle.getOrElse(Angle.Degree(0))) + case e: Explosion => + val diameter = e.radius * 2 + val bi = new BufferedImage(diameter,diameter, BufferedImage.TYPE_INT_ARGB) + val g2d = bi.createGraphics() + g2d.setColor(Color.RED) + g2d.drawOval(0, 0, diameter, diameter) + g2d.dispose() + CollisionableElement(bi, diameter, diameter, e.position) + + collisionables.map(conversion) + diff --git a/src/main/scala/view/Main.scala b/src/main/scala/view/Main.scala new file mode 100644 index 00000000..570fae15 --- /dev/null +++ b/src/main/scala/view/Main.scala @@ -0,0 +1,12 @@ +package view + +import controller.GameLoop +import model.World +import view.gui.GUI +import monix.execution.Scheduler.Implicits.global + +object Main: + def main(args: Array[String]): Unit = + val swingUI = new GUI(ViewConstants.GUI_width, ViewConstants.GUI_height + 39) //39 is the number of pixels in the bar + val loop = GameLoop.start(swingUI) + loop.runAsyncAndForget \ No newline at end of file diff --git a/src/main/scala/view/ViewConstants.scala b/src/main/scala/view/ViewConstants.scala new file mode 100644 index 00000000..94696fc0 --- /dev/null +++ b/src/main/scala/view/ViewConstants.scala @@ -0,0 +1,8 @@ +package view + +object ViewConstants: + val cityBaseHeightRatio: Double = 1.972789 //580 base x 294 height + val batteryMissileBaseHeightRatio: Double = 1.523012 + + val GUI_width = 1400 + val GUI_height = 800 diff --git a/src/main/scala/view/Visualizer.scala b/src/main/scala/view/Visualizer.scala new file mode 100644 index 00000000..39c2eb4b --- /dev/null +++ b/src/main/scala/view/Visualizer.scala @@ -0,0 +1,51 @@ +package view + +import java.awt.{Image, Toolkit} +import model.collisions.{Collisionable, Damageable} +import model.elements2d.Point2D +import model.ground.{City, Ground, MissileBattery} +import model.missile.{Missile, hitboxBase, hitboxHeight} +import utilities._ + + + +import javax.imageio.ImageIO + +object Visualizer: + val resourceFolderPath: String = (System.getProperty("user.dir").toString + "\\src\\main\\resources\\") + + /*** + * Method used for preparing the ImageView of a city. + * @param structure City to be used. + * @return ImageView of the city passed. + */ + def prepareCityImage(structure: City): Tuple4[Image, Point2D, Int, Int] = + ( + ImageIO.read(getClass.getResource("/city_" + structure.currentLife + ".png")), + structure.position, + cityBaseSize, + cityHeightSize + ) + + /*** + * Method used for preparing the ImageView of a missile turret. + * @param structure Missile turret to be used. + * @return ImageView of the missile turret passed + */ + def prepareBatteryMissileImage(structure: MissileBattery): Tuple4[Image, Point2D, Int, Int] = + ( + ImageIO.read(getClass.getResource("/Base_" + structure.isReadyForShoot + "_" + structure.currentLife + ".png")), + structure.bottomLeft_Position, + missileBatteryBaseSize, + missileBatteryHeightSize + ) + + /*** + * Method used for preparing a list of ImageView of a given ground. + * @param ground Ground to be used. + * @return List of ImageView of all the structures in the ground. + */ + def printGround(ground: Ground): List[Tuple4[Image, Point2D, Int, Int]] = + (for structure <- ground.cities yield prepareCityImage(structure)) + ++ + (for structure <- ground.turrets yield prepareBatteryMissileImage(structure)) \ No newline at end of file diff --git a/src/main/scala/view/audio/AudioPlayer.scala b/src/main/scala/view/audio/AudioPlayer.scala new file mode 100644 index 00000000..09cb27ec --- /dev/null +++ b/src/main/scala/view/audio/AudioPlayer.scala @@ -0,0 +1,21 @@ +package view.audio + +import java.net.{MalformedURLException, URL} +import javax.sound.sampled.{AudioSystem, UnsupportedAudioFileException} + +object AudioPlayer: + def playAudio(URL_audio: String): Unit = + try + val url = new URL(URL_audio) + val audioIn = AudioSystem.getAudioInputStream(url) + val clip = AudioSystem.getClip + clip.open(audioIn) + clip.start + catch + case ex1: MalformedURLException => println("URL not found") + case ex2: UnsupportedAudioFileException => println("URL found, but not an audio") + case ex3: Throwable => + + + //Add audio here + val explosionSmall = "https://www.shockwave-sound.com/sound-effects/explosion-sounds/Arcade%20Explo%20A.wav" \ No newline at end of file diff --git a/src/main/scala/view/gui/EndGamePane.scala b/src/main/scala/view/gui/EndGamePane.scala new file mode 100644 index 00000000..c615a4e4 --- /dev/null +++ b/src/main/scala/view/gui/EndGamePane.scala @@ -0,0 +1,24 @@ +package view.gui + +import view.Visualizer + +import java.awt.{Color, Component, Graphics, FlowLayout} +import javax.swing.{JLabel, JPanel} +import javax.swing.JButton + +import java.awt.BorderLayout +import java.awt.Dimension + +class EndGamePane(width: Int, height: Int) extends JPanel{ + + this.setSize(width, height) + + override def paintComponent(graphics: Graphics): Unit = + super.paintComponent(graphics) + graphics.clearRect(0, 0, width, height) + graphics.setColor(Color.GRAY) + graphics.fillRect((width / 2) - 50, (height / 2) - 50, 100, 100) + graphics.setColor(Color.WHITE) + graphics.drawString("FINE GIOCO", width/2 - 30, height/2) + +} diff --git a/src/main/scala/view/gui/GUI.scala b/src/main/scala/view/gui/GUI.scala new file mode 100644 index 00000000..ee33b86b --- /dev/null +++ b/src/main/scala/view/gui/GUI.scala @@ -0,0 +1,71 @@ +package view.gui + +import controller.{Event, GameLoop} +import monix.eval.Task +import monix.reactive.Observable +import monix.reactive.subjects.PublishSubject +import org.w3c.dom.events.MouseEvent +import view.gui.UI +import model.elements2d.Point2D +import controller.Event +import model.World +import view.Main +import view.gui.WorldPane +import java.awt.event.MouseMotionListener +import java.awt.{BorderLayout, Color, Dimension, FlowLayout, Graphics, event} +import java.awt.event.ActionEvent +import java.awt.event.ActionListener +import javax.swing.* + +class GUI(width: Int, height: Int) extends UI: + private val frame = JFrame("Game") + + frame.setSize(width, height) + frame.setVisible(true) + frame.setLocationRelativeTo(null) + frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE) + + def render(world: World): Task[Unit] = Task { + SwingUtilities.invokeAndWait { () => + if (frame.getContentPane.getComponentCount != 0) + frame.getContentPane.remove(0) + frame.getContentPane.add(WorldPane(world, width, height)) + frame.getContentPane.repaint() + } + } + + def customRender(world: World) = + SwingUtilities.invokeAndWait { () => + if (frame.getContentPane.getComponentCount != 0) + frame.getContentPane.remove(0) + frame.getContentPane.add(WorldPane(world, width, height)) + frame.getContentPane.repaint() + } + + + override def events: Observable[Event] = frame.getContentPane + .mouseObservable() + .map((x, y) => Event.LaunchMissileTo(Point2D(x, y)) ) + + override def gameOver: Task[Unit] = Task { + SwingUtilities.invokeAndWait { () => + if (frame.getContentPane.getComponentCount != 0) + frame.getContentPane.remove(0) + val panel = new JPanel() + panel.setSize(width, height) + panel.setLayout(new FlowLayout()) + val button = new JButton("RIGIOCA") + val gui = this + button.addActionListener(new ActionListener() { + override def actionPerformed(e: ActionEvent): Unit = { + frame.dispose() + Main.main(null) + } + }) + panel.add(button) + + frame.getContentPane.add(panel) + frame.revalidate() + frame.repaint() + } + } \ No newline at end of file diff --git a/src/main/scala/view/gui/MouseHandler.scala b/src/main/scala/view/gui/MouseHandler.scala new file mode 100644 index 00000000..fc8c19c7 --- /dev/null +++ b/src/main/scala/view/gui/MouseHandler.scala @@ -0,0 +1,19 @@ +package view.gui + +import java.awt.Component +import monix.execution.cancelables.SingleAssignCancelable +import monix.reactive.{Observable, OverflowStrategy} +import monix.reactive.subjects.PublishSubject + +import java.awt.event.{MouseAdapter, MouseEvent, MouseMotionListener} + +extension (component: Component) + def mouseObservable(): Observable[(Int, Int)] = + Observable.create(OverflowStrategy.Unbounded) { subject => + component.addMouseListener(new MouseAdapter: + override def mouseClicked(e: MouseEvent): Unit = + println("CLICKED (" + e.getX + ", " + e.getY + ")") + subject.onNext((e.getX, e.getY)) + ) + SingleAssignCancelable() + } diff --git a/src/main/scala/view/gui/UI.scala b/src/main/scala/view/gui/UI.scala new file mode 100644 index 00000000..08bab134 --- /dev/null +++ b/src/main/scala/view/gui/UI.scala @@ -0,0 +1,32 @@ +package view.gui + +import controller.Event +import model.World +import monix.eval.Task +import monix.reactive.Observable + +/** + * Trait that represents a view of the game. + */ +trait UI: + /** + * The event source produced by this UI. + * + * @return an observable of events + */ + def events: Observable[Event] + + /** + * Render the world representation. + * + * @param world the world to render + * @return a task that will render the world + */ + def render(world: World): Task[Unit] + + /** + * Render the end of the game. + * + * @return a task that will render the end of the game + */ + def gameOver: Task[Unit] diff --git a/src/main/scala/view/gui/WorldPane.scala b/src/main/scala/view/gui/WorldPane.scala new file mode 100644 index 00000000..eecaffbd --- /dev/null +++ b/src/main/scala/view/gui/WorldPane.scala @@ -0,0 +1,52 @@ +package view.gui + +import model.World +import view.{CollisionableVisualizer, Visualizer} + +import java.awt.event.MouseMotionListener +import java.awt.* +import java.awt.geom.AffineTransform +import java.awt.image.{AffineTransformOp, BufferedImage} +import javax.swing.{JButton, JPanel} +import model.elements2d.Angle + +import java.io.File +import javax.imageio.ImageIO + +given Conversion[Double, Int] with + override def apply(x: Double): Int = x.toInt + +private class WorldPane(val world: World, width: Int, height: Int) extends JPanel: + this.setSize(width, height) + val resourceFolderPath: String = (System.getProperty("user.dir").toString + "\\src\\main\\resources\\") + + override def paintComponent(graphics: Graphics): Unit = + super.paintComponent(graphics) + val g2d: Graphics2D = graphics.asInstanceOf[Graphics2D] + + graphics.clearRect(0, 0, width, height) + Visualizer.printGround(world.ground).map( + imageData => graphics.drawImage(imageData._1, imageData._2.x, imageData._2.y, + imageData._3, + imageData._4, this) + ) + + CollisionableVisualizer.printElements(world.collisionables) foreach { i => + g2d.translate(i.position.x, i.position.y) + g2d.rotate(i.angle.radiant - Angle.Degree(90).radiant) + + graphics.drawImage(i.image, 0 - (i.baseWidth / 2), 0 - (i.baseHeight / 2), i.baseWidth, i.baseHeight, null) + + g2d.rotate(-1 * (i.angle.radiant - Angle.Degree(90).radiant)) + g2d.translate(-i.position.x, -i.position.y) + } + + +// world.all.foreach { entity => +// val (x, y) = ((entity.position.x * width).toInt, (entity.position.y * height).toInt) +// val (widthCircle, heightCircle) = ((entity.diameter * width).toInt, (entity.diameter * height).toInt) +// graphics.setColor(Color.getHSBColor(entity.size.toFloat, 1, 0.5)) +// graphics.fillOval(x - widthCircle / 2, y - heightCircle / 2, widthCircle, heightCircle) +// graphics.setColor(Color.BLACK) +// graphics.drawOval(x - widthCircle / 2, y - heightCircle / 2, widthCircle, heightCircle) +// } \ No newline at end of file diff --git a/src/main/scala/view/package.scala b/src/main/scala/view/package.scala new file mode 100644 index 00000000..f9f66495 --- /dev/null +++ b/src/main/scala/view/package.scala @@ -0,0 +1,3 @@ +package object view: + + val viewMapper: (Double) => Double = _ * (ViewConstants.GUI_width / model.World.width) diff --git a/src/test/scala/model/ScoreTest.scala b/src/test/scala/model/ScoreTest.scala new file mode 100644 index 00000000..6114ad1b --- /dev/null +++ b/src/test/scala/model/ScoreTest.scala @@ -0,0 +1,168 @@ +package model + +import model.collisions.{Affiliation, Damageable, DamageableTest, Damager, DamagerTest, Distance, applyDamage, calculateCollisions} +import model.elements2d.Point2D +import org.scalatest.GivenWhenThen +import org.scalatest.featurespec.AnyFeatureSpec + +class ScoreTest extends AnyFeatureSpec with GivenWhenThen : + info("When a Scorable object has been destroyed, it increases the score of the player") + info("If the scorable isn't destroyed, the score doesn't change") + + given distance: Distance = 0.1 + + Feature("Don't add score") { + Scenario("The objects don't collide") { + Given("Two objects that don't collide") + val score = 0 + val initialLife = 1 + val damageable = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Neutral) + val damager = DamagerTest(Point2D(10, 10), Affiliation.Friendly) + assert(!damageable.isCollidingWith(damager)) + + When("Calculate the score") + val update = applyDamage(calculateCollisions(List(damager, damageable))) + val newScore = calculateNewScore(update, score) + + Then("The score doesn't change") + + assert(newScore == score) + for + element <- update + yield + element._1 match + case damageable: Damageable => + assert(!damageable.isDestroyed) + case _ => assert(element._2.isEmpty) + } + + Scenario("The objects collide but the damageable is not destroyed") { + Given("Two objects that collide") + val score = 0 + val initialLife = 3 + val damageable = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Neutral) + val damager = DamagerTest(Point2D(0, 0), Affiliation.Friendly) + assert(damageable.isCollidingWith(damager)) + + When("Calculate the score") + val update = applyDamage(calculateCollisions(List(damager, damageable))) + val newScore = calculateNewScore(update, score) + + Then("The score doesn't change") + + assert(newScore == score) + for + element <- update + yield + element._1 match + case damageable: Damageable => + assert(!damageable.isDestroyed) + case _ => assert(element._2.isEmpty) + } + + Scenario("The objects collide but the damager is not owed to the player") { + Given("Two objects that collide") + val score = 0 + val initialLife = 1 + val damageable = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Neutral) + val damager = DamagerTest(Point2D(0, 0), Affiliation.Enemy) + assert(damageable.isCollidingWith(damager)) + + When("Calculate the score") + val update = applyDamage(calculateCollisions(List(damager, damageable))) + val newScore = calculateNewScore(update, score) + + Then("The score doesn't change") + + assert(newScore == score) + for + element <- update + yield + element._1 match + case damageable: Damageable => + assert(damageable.isDestroyed) + case _ => assert(element._2.isEmpty) + } + } + + Feature("Add score") { + Scenario("The objects collide and the damageable is destroyed and the score should be increase") { + Given("Two objects that collide") + val score = 0 + val initialLife = 1 + val damageable = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Neutral) + val damager = DamagerTest(Point2D(0, 0), Affiliation.Friendly) + assert(damageable.isCollidingWith(damager)) + + When("Calculate the score") + val update = applyDamage(calculateCollisions(List(damager, damageable))) + val newScore = calculateNewScore(update, score) + + Then("The score changes") + + assert(newScore == 1) + for + element <- update + yield + element._1 match + case damageable: Damageable => + assert(damageable.isDestroyed) + case _ => assert(element._2.isEmpty) + } + + Scenario("Multiple objects are destroyed") { + Given("Two objects that collide") + val score = 0 + val initialLife = 1 + val damageable1 = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Neutral) + val damageable2 = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Neutral) + val damager = DamagerTest(Point2D(0, 0), Affiliation.Friendly) + assert(damageable1.isCollidingWith(damager)) + assert(damageable2.isCollidingWith(damager)) + + When("Calculate the score") + val update = applyDamage(calculateCollisions(List(damager, damageable1, damageable2))) + val newScore = calculateNewScore(update, score) + + Then("The score changes") + + assert(update.size == 3) + assert(newScore == 2) + for + element <- update + yield + element._1 match + case damageable: Damageable => + assert(damageable.isDestroyed) + case _ => assert(element._2.isEmpty) + } + + Scenario("The objects collide and the damageable is destroyed and the score should be increase, starting score is not 0") { + Given("Two objects that collide") + val score = 3 + val initialLife = 1 + val damageable = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Neutral) + val damager = DamagerTest(Point2D(0, 0), Affiliation.Friendly) + assert(damageable.isCollidingWith(damager)) + + When("Calculate the score") + val update = applyDamage(calculateCollisions(List(damager, damageable))) + val newScore = calculateNewScore(update, score) + + Then("The score changes") + + assert(newScore == 4) + for + element <- update + yield + element._1 match + case damageable: Damageable => + assert(damageable.isDestroyed) + case _ => assert(element._2.isEmpty) + } + } + + + + + diff --git a/src/test/scala/model/collisions/CollisonsTest.scala b/src/test/scala/model/collisions/CollisonsTest.scala new file mode 100644 index 00000000..c207fa2b --- /dev/null +++ b/src/test/scala/model/collisions/CollisonsTest.scala @@ -0,0 +1,201 @@ +package model.collisions + +import model.elements2d.Point2D +import org.scalatest.GivenWhenThen +import org.scalatest.featurespec.AnyFeatureSpec +import model.collisions._ + +class CollisonsTest extends AnyFeatureSpec with GivenWhenThen : + info("When a Damageable object collides with an Damager object, it should be damaged, if they aren't on the same affiliation") + info("If they are on the same affiliation, nothing should happen") + info("If the Damageable object is already dead, nothing should happen") + info("If the Damageable object has not enough life, it should be destroyed") + + given distance: Distance = 0.1 + + Feature("Don't inflict damage") { + Scenario("The objects don't collide") { + Given("Two objects that don't collide") + val initialLife = 3 + val damageable = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Friendly) + val damager = DamagerTest(Point2D(10, 10), Affiliation.Enemy) + assert(!damageable.isCollidingWith(damager)) + + When("Calculate collision and apply damage") + val update = applyDamage(calculateCollisions(List(damageable, damager))) + + Then("The map should have 2 elements both empty") + assert(update.size == 2) + for + element <- update + yield + assert(element._2.isEmpty) + } + + Scenario("The object collide but they are of the same type") { + Given("Two objects that collide but are of the same type") + val initialLife = 3 + val damageable1 = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Friendly) + val damageable2 = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Enemy) + assert(damageable1.isCollidingWith(damageable2)) + + When("Calculate collision and apply damage") + val update = calculateCollisions(List(damageable1, damageable2)) + + Then("The map should have 2 elements both empty") + assert(update.size == 2) + for + element <- update + yield + assert(element._2.isEmpty) + } + + Scenario("The object collide but they are of the same side") { + Given("Two objects that collide but are of the same side") + val initialLife = 3 + val damageable = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Friendly) + val damager = DamagerTest(Point2D(0, 0), Affiliation.Friendly) + assert(damageable.isCollidingWith(damager)) + + When("Calculate collision") + val update = applyDamage(calculateCollisions(List(damageable, damager))) + + Then("The map should have 2 elements both empty") + assert(update.size == 2) + for + element <- update + yield + assert(element._2.isEmpty) + } + + Scenario("There are no objects") { + Given("No objects") + + val collisionables = List.empty[Collisionable] + + When("Calculate collision") + val update = applyDamage(calculateCollisions(collisionables)) + + Then("The map should be empty") + assert(update.isEmpty) + } + + Scenario("The object is null"){ + Given("An object null") + + val collisionables = List[Collisionable](null) + + + When("Calculate collision") + val update = applyDamage(calculateCollisions(collisionables)) + + + Then("The map should be empty") + assert(update.isEmpty) + } + } + + Feature("Inflict damage") { + Scenario("The objects collide") { + Given("Two objects that collide") + val initialLife = 3 + val damageable = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Friendly) + val damager = DamagerTest(Point2D(0, 0), Affiliation.Enemy) + assert(damageable.isCollidingWith(damager)) + + When("Check collision and apply damages") + val update = applyDamage(calculateCollisions(List(damageable, damager))) + + Then("The map should have 2 elements one not empty") + assert(update.size == 2) + for + element <- update + yield + element._1 match + case damageable: Damageable => + assert(element._2.size == 1) + assert(element._2.contains(damager)) + assert(!damageable.isDestroyed) + assert(damageable.currentLife == initialLife - damager.damageInflicted) + case _ => assert(element._2.isEmpty) + } + + Scenario("The objects collide and the damageable should be destroyed") { + Given("Two objects that collide of different type and side") + val initialLife = 1 + val damageable = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Friendly) + val damager = DamagerTest(Point2D(0, 0), Affiliation.Enemy) + assert(damageable.isCollidingWith(damager)) + + When("Check collision and apply damages") + val update = applyDamage(calculateCollisions(List(damageable, damager))) + + Then("The map should have 2 elements, the damageable should be destroyed and the damager should be empty") + assert(update.size == 2) + for + element <- update + yield + element._1 match + case damageable: Damageable => + assert(element._2.size == 1) + assert(element._2.contains(damager)) + assert(damageable.isDestroyed) + assert(damageable.currentLife == initialLife - damager.damageInflicted) + case _ => assert(element._2.isEmpty) + } + + Scenario("The objects collide one is both damageable and damager") { + Given("Two objects that collide of different type and side") + val initialLife = 3 + val both = DamagerDamageableTest(Point2D(0, 0), initialLife, Affiliation.Friendly) + val damager = DamagerTest(Point2D(0, 0), Affiliation.Enemy) + assert(both.isCollidingWith(damager)) + + When("Check collision and apply damages") + val update = applyDamage(calculateCollisions(List(both, damager))) + + Then("The map should have 2 elements, the damageable should have damage and the damager should be empty") + assert(update.size == 2) + for + element <- update + yield + element._1 match + case damageable: Damageable => + assert(element._2.size == 1) + assert(element._2.contains(damager)) + assert(!damageable.isDestroyed) + assert(damageable.currentLife == initialLife - damager.damageInflicted) + case _ => assert(element._2.isEmpty) + } + + Scenario("Multiple objects collide") { + Given("Three objects that collide of different type and side") + val initialLife = 3 + val damageable = DamageableTest(Point2D(0, 0), initialLife, Affiliation.Friendly) + val both = DamagerDamageableTest(Point2D(0, 0), initialLife, Affiliation.Enemy) + val damager = DamagerTest(Point2D(0, 0), Affiliation.Neutral) + assert(both.isCollidingWith(damager)) + assert(both.isCollidingWith(damageable)) + assert(damageable.isCollidingWith(damager)) + + When("Check collision and apply damages") + val update = applyDamage(calculateCollisions(List(both, damager, damageable))) + + Then("The damageable objects should be damaged 2 times and the object that is both damageable and damager should be damaged 1 time") + assert(update.size == 3) + for + element <- update + yield + element._1 match + case damageable: Damageable => + damageable match + case damager: Damager => + assert(element._2.size == 1) + assert(damageable.currentLife == initialLife - 1) + case _ => + assert(element._2.size == 2) + assert(damageable.currentLife == initialLife - 2) + assert(!damageable.isDestroyed) + case _ => assert(element._2.isEmpty) + } + } \ No newline at end of file diff --git a/src/test/scala/model/collisions/DamageableTest.scala b/src/test/scala/model/collisions/DamageableTest.scala new file mode 100644 index 00000000..13626fff --- /dev/null +++ b/src/test/scala/model/collisions/DamageableTest.scala @@ -0,0 +1,24 @@ +package model.collisions + +import model.Scorable +import model.collisions.hitbox.HitBoxPoint +import model.elements2d.Point2D +import model.collisions._ + +object DamageableTest: + + def apply(position: Point2D, life: LifePoint, affiliation: Affiliation): Damageable = new DamageableTest(position, life, life, affiliation) with Scorable(1) + +class DamageableTest(_position: Point2D, _currentLife: LifePoint, _initialLife: LifePoint, _affiliation: Affiliation) extends Damageable : + + def affiliation: Affiliation = _affiliation + def currentLife: LifePoint = _currentLife + def initialLife: LifePoint = _initialLife + + protected def hitBox: HitBox = HitBoxPoint(_position) + + def takeDamage(damage: LifePoint): Damageable = + this match + case _: Scorable => new DamageableTest(_position, currentLife - damage, initialLife, affiliation) with Scorable(1) + case _ => new DamageableTest(_position, currentLife - damage, initialLife, affiliation) + diff --git a/src/test/scala/model/collisions/DamagerDamageableTest.scala b/src/test/scala/model/collisions/DamagerDamageableTest.scala new file mode 100644 index 00000000..57d4715f --- /dev/null +++ b/src/test/scala/model/collisions/DamagerDamageableTest.scala @@ -0,0 +1,17 @@ +package model.collisions + +import model.collisions.hitbox.HitBoxPoint +import model.elements2d.Point2D + +object DamagerDamageableTest: + def apply(position: Point2D, currentLife: LifePoint, affiliation: Affiliation): Collisionable = + case class DamagerDamageableTest(position: Point2D, currentLife: LifePoint, affiliation: Affiliation) extends Damageable, Damager : + + protected def hitBox: HitBox = HitBoxPoint(position) + + def initialLife: LifePoint = 3 + + def takeDamage(damage: LifePoint): Damageable = this.copy(position, currentLife - damage) + + def damageInflicted: LifePoint = 1 + DamagerDamageableTest(position, currentLife, affiliation) diff --git a/src/test/scala/model/collisions/DamagerTest.scala b/src/test/scala/model/collisions/DamagerTest.scala new file mode 100644 index 00000000..9abb1f1f --- /dev/null +++ b/src/test/scala/model/collisions/DamagerTest.scala @@ -0,0 +1,15 @@ +package model.collisions + +import model.collisions.hitbox.HitBoxPoint +import model.elements2d.Point2D + +object DamagerTest: + + def apply(position: Point2D, affiliation: Affiliation): Damager = DamagerTest(position, affiliation) + + private case class DamagerTest(position: Point2D, affiliation: Affiliation) extends Damager : + + protected def hitBox: HitBox = HitBoxPoint(position) + + def damageInflicted: LifePoint = 1 + diff --git a/src/test/scala/model/collisions/hitbox/HitBoxCircularTest.scala b/src/test/scala/model/collisions/hitbox/HitBoxCircularTest.scala new file mode 100644 index 00000000..b74a2203 --- /dev/null +++ b/src/test/scala/model/collisions/hitbox/HitBoxCircularTest.scala @@ -0,0 +1,80 @@ +package model.collisions.hitbox + +import model.collisions.hitbox.HitBoxCircular +import model.collisions.Distance +import model.elements2d.Point2D +import model.elements2d.Point2D.GivenEquality.given +import org.scalactic.{Equality, TolerantNumerics} +import org.scalatest.funspec.AnyFunSpec +import utilities.MathUtilities.* + +import scala.math.BigDecimal.double2bigDecimal + +object HitBoxCircularTest: + private val tolerance: Double = 0.1 + + private given equality: Equality[Double] = TolerantNumerics.tolerantDoubleEquality(tolerance) + + private given distance: Distance = 1.0 + + private val xCenter = 1.0 + private val yCenter = 2.0 + private val radius = 1.0 + private val center = Point2D(xCenter, yCenter) + private val hitBox = HitBoxCircular(center, radius) + +class HitBoxCircularTest extends AnyFunSpec : + + import HitBoxCircularTest.{*, given} + + describe("An hit box") { + + describe("with a circular shape") { + + it("should be usable multiple times") { + for _ <- hitBox.area + yield () + assert(hitBox.area.hasNext) + } + + it("should have an interval from center - radius to center + radius") { + assert(hitBox.xMax.get === (center.x + radius)) + assert(hitBox.xMin.get === (center.x - radius)) + assert(hitBox.yMax.get === (center.y + radius)) + assert(hitBox.yMin.get === (center.y - radius)) + } + + it("should be empty if the radius is 0") { + val hitBox = HitBoxCircular(center, 0) + assert(hitBox.area.isEmpty) + } + + it("should be empty if the radius is negative") { + val hitBox = HitBoxCircular(center, -1) + assert(hitBox.area.isEmpty) + } + + it("should have the points in the circle with radius inside") { + for + x <- xCenter - radius to xCenter + radius by distance + y <- yCenter - radius to yCenter + radius by distance + if ((x.doubleValue - center.x) ** 2.0) + ((y.doubleValue - center.y) ** 2.0) <= radius ** 2.0 + yield + assert(hitBox.contains(Point2D(x.doubleValue, y.doubleValue))) + } + + it("shouldn't have the points outside the circle radius") { + for + x <- xCenter - radius - 1 to xCenter + radius + 1 by distance + y <- yCenter - radius - 1 to yCenter + radius + 1 by distance + if ((x.doubleValue - center.x) ** 2.0) + ((y.doubleValue - center.y) ** 2.0) > radius ** 2.0 + yield + assert(!hitBox.contains(Point2D(x.doubleValue, y.doubleValue))) + } + + it("shouldn't have the points outside the circle radius explicit test") { + val point = Point2D(xCenter + radius, yCenter + radius) + assert(!hitBox.contains(point)) + } + } + } diff --git a/src/test/scala/model/collisions/hitbox/HitBoxEmptyTest.scala b/src/test/scala/model/collisions/hitbox/HitBoxEmptyTest.scala new file mode 100644 index 00000000..a1276e50 --- /dev/null +++ b/src/test/scala/model/collisions/hitbox/HitBoxEmptyTest.scala @@ -0,0 +1,34 @@ +package model.collisions.hitbox + +import model.collisions.hitbox.HitBoxEmpty +import model.elements2d.Point2D +import org.scalatest.funspec.AnyFunSpec + +class HitBoxEmptyTest extends AnyFunSpec : + + private val hitBoxEmpty = HitBoxEmpty + + describe("An hit box") { + + describe("that is empty") { + + it("should have an empty iterator of points") { + assert(!hitBoxEmpty.area.hasNext) + } + + it("should not contain any point") { + assert(!hitBoxEmpty.contains(Point2D(0, 0))) + } + + it("should have a size of 0") { + assert(hitBoxEmpty.area.isEmpty) + } + + it("shouldn't have a space of points ( no x and y interval)") { + assert(hitBoxEmpty.xMax == Option.empty) + assert(hitBoxEmpty.xMin == Option.empty) + assert(hitBoxEmpty.yMax == Option.empty) + assert(hitBoxEmpty.yMin == Option.empty) + } + } + } diff --git a/src/test/scala/model/collisions/hitbox/HitBoxIntersectionTest.scala b/src/test/scala/model/collisions/hitbox/HitBoxIntersectionTest.scala new file mode 100644 index 00000000..d597233b --- /dev/null +++ b/src/test/scala/model/collisions/hitbox/HitBoxIntersectionTest.scala @@ -0,0 +1,238 @@ +package model.collisions.hitbox + +import model.collisions.hitbox.* +import model.collisions.Distance +import model.elements2d.{Angle, Point2D} +import org.scalactic.{Equality, TolerantNumerics} +import org.scalatest.funspec.AnyFunSpec + +import scala.math.BigDecimal.double2bigDecimal + +object HitBoxIntersectionTest: + private val tolerance: Double = 0.1 + + private given distance: Distance = 1.0 + + private given equality: Equality[Double] = TolerantNumerics.tolerantDoubleEquality(tolerance) + +class HitBoxIntersectionTest extends AnyFunSpec : + + import HitBoxIntersectionTest.{*, given} + + describe("An hit box") { + + describe("that is the intersection of other hit boxes") { + + it("should be able to be create from multiple hit box") { + val hitBox = HitBoxIntersection( + HitBoxPoint(Point2D(0, 0)), + HitBoxPoint(Point2D(1, 1)), + HitBoxEmpty + ) + assert(hitBox != null) + } + + describe("that result to be empty ") { + + it("should be able to be create from 0 hit box") { + val hitBox = HitBoxIntersection() + assert(hitBox != null) + } + + it("should be an empty hit box if one of the hit box is empty and the other is a point") { + val hitBox = HitBoxIntersection( + HitBoxPoint(Point2D(0, 0)), + HitBoxEmpty + ) + assert(hitBox == HitBoxEmpty) + } + + it("should be an empty hit box if one of the hit box is empty and the other is a rectangle") { + val hitBox = HitBoxIntersection( + HitBoxRectangular(Point2D(0, 0), 2, 3, Angle.Degree(0)), + HitBoxEmpty + ) + assert(hitBox == HitBoxEmpty) + } + + it("should be an empty hit box if one of the hit box is empty and the other is a circle") { + val hitBox = HitBoxIntersection( + HitBoxCircular(Point2D(0, 0), 2), + HitBoxEmpty + ) + assert(hitBox == HitBoxEmpty) + } + + it("should be an empty hit box if one of the hit box is empty and the others are not") { + val hitBox = HitBoxIntersection( + HitBoxPoint(Point2D(0, 0)), + HitBoxRectangular(Point2D(2, 3), 2, 3, Angle.Degree(0)), + HitBoxCircular(Point2D(-1, -1), 2), + HitBoxEmpty + ) + assert(hitBox == HitBoxEmpty) + } + + it("should be an empty hit box if there is no intersection between the hit boxes (x)") { + val hitBox = HitBoxIntersection( + HitBoxPoint(Point2D(0, 0)), + HitBoxPoint(Point2D(1, 0)) + ) + assert(hitBox == HitBoxEmpty) + } + + it("should be an empty hit box if there is no intersection between the hit boxes (y)") { + val hitBox = HitBoxIntersection( + HitBoxPoint(Point2D(0, 0)), + HitBoxPoint(Point2D(0, 1)) + ) + assert(hitBox == HitBoxEmpty) + } + + it("should be an empty hit box if there is no intersection between the hit boxes same interval") { + val intervalInterceptPoint = Point2D(1, 1) + val circle = HitBoxCircular(Point2D(-1, -1), 2) + val rectangle = HitBoxRectangular(Point2D(2, 2), 2, 2, Angle.Degree(0)) + val hitBox = HitBoxIntersection( + rectangle, + circle + ) + assert(rectangle.contains(intervalInterceptPoint)) + assert(!circle.contains(intervalInterceptPoint)) + assert(hitBox == HitBoxEmpty) + } + + } + + describe("that result to be a point") { + + it("should be able to be create from 1 hit box") { + val point = Point2D(0, 0) + val hitBox = HitBoxIntersection( + HitBoxPoint(point) + ) + assert(hitBox.contains(point)) + } + + it("should be able to be create from multiple hit boxes, one is a point") { + val point = Point2D(0, 0) + val hitBox = HitBoxIntersection( + HitBoxPoint(point), + HitBoxCircular(point, 1) + ) + assert(hitBox.contains(point)) + assert(hitBox.area.size == 1) + } + + it("should be able to be create from multiple hit boxes, no one is a point") { + val pointRectangle = Point2D(2, 0) + val pointCircle = Point2D(0, 0) + val hitBox = HitBoxIntersection( + HitBoxRectangular(pointRectangle, 2, 3, Angle.Degree(0)), + HitBoxCircular(pointCircle, 1) + ) + assert(hitBox.contains(Point2D(1, 0))) + assert(hitBox.area.size == 1) + } + } + + describe("that result to be one of given shape") { + it("should be able to be create from multiple hit boxes, the result is a rectangle") { + val point = Point2D(0, 0) + val rectangularHitBox = HitBoxRectangular(point, 2, 2, Angle.Degree(0)) + val hitBox = HitBoxIntersection( + HitBoxCircular(point, 4), + rectangularHitBox + ) + assert(rectangularHitBox.area.forall(hitBox.contains)) + + } + + it("should be able to be create from multiple hit boxes, the result is a circle") { + val point = Point2D(0, 0) + val circularHitBox = HitBoxCircular(point, 1) + val hitBox = HitBoxIntersection( + circularHitBox, + HitBoxRectangular(point, 2, 2, Angle.Degree(0)) + ) + assert(hitBox.area.forall(circularHitBox.contains)) + } + + it("should be able to be create from multiple hit boxes, the result is a circle (check interval)") { + val point = Point2D(0, 0) + val hitBox = HitBoxIntersection( + HitBoxCircular(point, 1), + HitBoxRectangular(point, 2, 2, Angle.Degree(0)) + ) + assert(hitBox.xMax.get === 1.0) + assert(hitBox.xMin.get === -1.0) + assert(hitBox.yMax.get === 1.0) + assert(hitBox.yMin.get === -1.0) + } + } + + describe("that result to be a complex shape") { + + val pointRectangle = Point2D(2, 0) + val pointCircle = Point2D(2, 2) + val circleHitBox = HitBoxCircular(pointCircle, 1) + val rectangularHitBox = HitBoxRectangular(pointRectangle, 2, 4, Angle.Degree(0)) + + it("should be able to be create from multiple hit boxes, no one is a point") { + val hitBox = HitBoxIntersection( + circleHitBox, + rectangularHitBox + ) + assert(hitBox != HitBoxEmpty) + } + + it("should have intersection interval ") { + val hitBox = HitBoxIntersection( + circleHitBox, + rectangularHitBox + ) + assert(hitBox.xMax.get === 3.0) + assert(hitBox.xMin.get === 1.0) + assert(hitBox.yMax.get === 2.0) + assert(hitBox.yMin.get === 1.0) + } + + it("shouldn't contains all the points of the hit boxes") { + val hitBox = HitBoxIntersection( + circleHitBox, + rectangularHitBox + ) + assert(!circleHitBox.area.forall(hitBox.contains)) + assert(!rectangularHitBox.area.forall(hitBox.contains)) + } + + it("should contains the common points of the hit boxes") { + val hitBox = HitBoxIntersection( + circleHitBox, + rectangularHitBox + ) + for + x <- hitBox.xMin.get to hitBox.xMax.get by 0.1 + y <- hitBox.yMin.get to hitBox.yMax.get by 0.1 + point = Point2D(x.doubleValue, y.doubleValue) + if circleHitBox.contains(point) && rectangularHitBox.contains(point) + yield + assert(hitBox.contains(point)) + } + + it("shouldn't contains points that aren't in common between the hit boxes") { + val hitBox = HitBoxIntersection( + circleHitBox, + rectangularHitBox + ) + for + x <- hitBox.xMin.get to hitBox.xMax.get by 0.1 + y <- hitBox.yMin.get to hitBox.yMax.get by 0.1 + point = Point2D(x.doubleValue, y.doubleValue) + if !circleHitBox.contains(point) || !rectangularHitBox.contains(point) + yield + assert(!hitBox.contains(point)) + } + } + } + } diff --git a/src/test/scala/model/collisions/hitbox/HitBoxPointTest.scala b/src/test/scala/model/collisions/hitbox/HitBoxPointTest.scala new file mode 100644 index 00000000..6c2edcba --- /dev/null +++ b/src/test/scala/model/collisions/hitbox/HitBoxPointTest.scala @@ -0,0 +1,71 @@ +package model.collisions.hitbox + +import model.collisions.hitbox.* +import model.collisions.Distance +import model.elements2d.Point2D +import model.elements2d.Point2D.GivenEquality.given +import org.scalactic.{Equality, TolerantNumerics} +import org.scalatest.funspec.AnyFunSpec +import utilities.MathUtilities.* +import utilities.MathUtilities.DoubleEquality.given + +import scala.math.BigDecimal.double2bigDecimal + +object HitBoxPointTest: + private given distance: Distance = 1.0 + + private val pointX = 1.0 + private val pointY = 2.0 + private val hitBox = HitBoxPoint(Point2D(pointX, pointY)) + +class HitBoxPointTest extends AnyFunSpec : + + import HitBoxPointTest.{*, given} + + describe("An hit box") { + describe("that is a point") { + + it("should only have an iterator with one point") { + val hitBoxIterator = hitBox.area + assert(hitBoxIterator.hasNext) + assert(hitBoxIterator.next() === Point2D(pointX, pointY)) + assert(!hitBoxIterator.hasNext) + } + + it("should be usable multiple times") { + for _ <- hitBox.area + yield () + assert(hitBox.area.hasNext) + } + + it("should contain the point") { + assert(hitBox.contains(Point2D(pointX, pointY))) + } + + it("should have the point as interval") { + assert(hitBox.xMax == Option(pointX)) + assert(hitBox.xMin == Option(pointX)) + assert(hitBox.yMax == Option(pointY)) + assert(hitBox.yMin == Option(pointY)) + } + + it("shouldn't have other points inside") { + for + x <- -1.0 to 3.0 by distance + y <- 0.0 to 4.0 by distance + if (x !== pointX) && (y !== pointY) + yield + assert(!hitBox.contains(Point2D(x.doubleValue, y.doubleValue))) + } + + it("should have the points in the double tolerance") { + val tolerance: Double = 0.1 + + given equality: Equality[Double] = TolerantNumerics.tolerantDoubleEquality(tolerance) + + val otherPoint = Point2D(pointX + tolerance / 2, pointY + tolerance / 2) + assert(Point2D(pointX, pointY) === otherPoint) + assert(hitBox.contains(Point2D(pointX, pointY))) + } + } + } \ No newline at end of file diff --git a/src/test/scala/model/collisions/hitbox/HitBoxRectangularTest.scala b/src/test/scala/model/collisions/hitbox/HitBoxRectangularTest.scala new file mode 100644 index 00000000..0fc317f0 --- /dev/null +++ b/src/test/scala/model/collisions/hitbox/HitBoxRectangularTest.scala @@ -0,0 +1,145 @@ +package model.collisions.hitbox + +import model.collisions.hitbox.{HitBoxEmpty, HitBoxRectangular} +import model.collisions.Distance +import model.elements2d.Point2D.GivenEquality.given +import model.elements2d.{Angle, Point2D, Vector2D} +import org.scalactic.{Equality, TolerantNumerics} +import org.scalatest.funspec.AnyFunSpec +import utilities.MathUtilities.* + +import scala.math.BigDecimal.double2bigDecimal + +object HitBoxRectangularTest: + private given distance: Distance = 1.0 + + private val tolerance: Double = 0.1 + + private given equality: Equality[Double] = TolerantNumerics.tolerantDoubleEquality(tolerance) + + private val xCenter = 1.0 + private val yCenter = 2.0 + private val base = 2.0 + private val height = 1.0 + private val angle = 0.0 + private val center = Point2D(xCenter, yCenter) + +class HitBoxRectangularTest extends AnyFunSpec : + + import HitBoxRectangularTest.{*, given} + + describe("An hit box") { + + describe("with a rectangular shape") { + val hitBox = HitBoxRectangular(center, base, height, Angle.Degree(angle)) + + it("should be usable multiple times") { + for _ <- hitBox.area + yield () + assert(hitBox.area.hasNext) + } + + it("should be empty if the base is negative") { + val hitBox = HitBoxRectangular(center, -base, height, Angle.Degree(angle)) + assert(hitBox == HitBoxEmpty) + } + + it("should be empty if the height is negative") { + val hitBox = HitBoxRectangular(center, base, -height, Angle.Degree(angle)) + assert(hitBox == HitBoxEmpty) + } + + it("should be empty it the angle is null") { + val hitBox = HitBoxRectangular(center, base, height, null) + assert(hitBox == HitBoxEmpty) + } + + describe(" without rotation") { + + it("should have an interval from center - base / 2 to center + base / 2 and center - height / 2 to center + height / 2") { + assert(hitBox.xMax.get === (center.x + base / 2)) + assert(hitBox.xMin.get === (center.x - base / 2)) + assert(hitBox.yMax.get === (center.y + height / 2)) + assert(hitBox.yMin.get === (center.y - height / 2)) + } + + it("should have the points in the rectangle with the given base and height with the given center inside") { + for + x <- xCenter - base / 2 to xCenter + base / 2 by distance + y <- yCenter - height / 2 to yCenter + height / 2 by distance + yield + assert(hitBox.contains(Point2D(x.doubleValue, y.doubleValue))) + } + + it("shouldn't have the points outside the rectangle with the given base and height with the given center") { + for + x <- xCenter - base / 2 - 1 to xCenter + base / 2 + 1 by distance + y <- yCenter - height / 2 - 1 to yCenter + height / 2 + 1 by distance + if x < xCenter - base / 2 || x > xCenter + base / 2 || y < yCenter - height / 2 || y > yCenter + height / 2 + yield + assert(!hitBox.contains(Point2D(x.doubleValue, y.doubleValue))) + } + } + + describe("with 90° rotation") { + val angle = 90.0 + val hitBoxRotated = HitBoxRectangular(center, base, height, Angle.Degree(angle)) + + it("should have an interval from center - base / 2 to center + base / 2 and center - height / 2 to center + height / 2") { + assert(hitBoxRotated.xMax.get === (center.x + height / 2)) + assert(hitBoxRotated.xMin.get === (center.x - height / 2)) + assert(hitBoxRotated.yMax.get === (center.y + base / 2)) + assert(hitBoxRotated.yMin.get === (center.y - base / 2)) + } + + it("should have the points in the rectangle with the given base and height with the given center inside") { + for + x <- xCenter - height / 2 to xCenter + height / 2 by distance + y <- yCenter - base / 2 to yCenter + base / 2 by distance + yield + assert(hitBoxRotated.contains(Point2D(x.doubleValue, y.doubleValue))) + } + + it("shouldn't have the points outside the rectangle with the given base and height with the given center") { + for + x <- xCenter - height / 2 - 1 to xCenter + height / 2 + 1 by distance + y <- yCenter - base / 2 - 1 to yCenter + base / 2 + 1 by distance + if x < xCenter - height / 2 || x > xCenter + height / 2 || y < yCenter - base / 2 || y > yCenter + base / 2 + yield + assert(!hitBoxRotated.contains(Point2D(x.doubleValue, y.doubleValue))) + } + } + + describe("with 45° rotation") { + val angle = 45.0 + val side = base + val hitBox = HitBoxRectangular(center, side, side, Angle.Degree(angle)) + + it("should have an interval based on its diagonal") { + val diagonal = side * math.sqrt(2.0) + assert(hitBox.xMax.get === (center.x + diagonal / 2)) + assert(hitBox.xMin.get === (center.x - diagonal / 2)) + assert(hitBox.yMax.get === (center.y + diagonal / 2)) + assert(hitBox.yMin.get === (center.y - diagonal / 2)) + } + + it("should have the points in the rectangle with the given area the given center inside") { + for + x <- hitBox.xMin.get to hitBox.xMax.get by distance + y <- hitBox.yMin.get to hitBox.yMax.get by distance + if (math.abs(x.doubleValue - center.x) + math.abs(y.doubleValue - center.y)) <== (side * math.sqrt(2.0) / 2) + yield + assert(hitBox.contains(Point2D(x.doubleValue, y.doubleValue))) + } + + it("shouldn't have the points outside the rectangle with the given area the given center") { + for + x <- hitBox.xMin.get - 1 to hitBox.xMax.get + 1 by distance + y <- hitBox.yMin.get - 1 to hitBox.yMax.get + 1 by distance + if (math.abs(x.doubleValue - center.x) + math.abs(y.doubleValue - center.y)) >== (side * math.sqrt(2.0) / 2) + yield + assert(!hitBox.contains(Point2D(x.doubleValue, y.doubleValue))) + } + } + } + } diff --git a/src/test/scala/model/collisions/hitbox/HitBoxUnionTest.scala b/src/test/scala/model/collisions/hitbox/HitBoxUnionTest.scala new file mode 100644 index 00000000..6eeec66c --- /dev/null +++ b/src/test/scala/model/collisions/hitbox/HitBoxUnionTest.scala @@ -0,0 +1,156 @@ +package model.collisions.hitbox + +import model.collisions.hitbox.* +import model.collisions.Distance +import model.elements2d.Point2D +import model.elements2d.Point2D.GivenEquality.given +import org.scalactic.{Equality, TolerantNumerics} +import org.scalatest.funspec.AnyFunSpec + +import scala.math.BigDecimal.double2bigDecimal + +object HitBoxUnionTest: + private val tolerance: Double = 0.1 + + private given distance: Distance = 1.0 + + private given equality: Equality[Double] = TolerantNumerics.tolerantDoubleEquality(tolerance) + +class HitBoxUnionTest extends AnyFunSpec : + + import HitBoxUnionTest.{*, given} + + describe("An hit box") { + + describe("that is the union of other hit boxes") { + + it("should be empty if there are no hit boxes") { + val hitBox = HitBoxUnion() + assert(hitBox == HitBoxEmpty) + } + + it("should be the same hit box if there is only one hit box") { + val hitBox = HitBoxUnion(HitBoxCircular(Point2D(0, 0), 1)) + assert(hitBox == HitBoxCircular(Point2D(0, 0), 1)) + } + + it("should be able to be create from multiple hit box check not null") { + val hitBox = HitBoxUnion( + HitBoxPoint(Point2D(0, 0)), + HitBoxPoint(Point2D(1, 1)) + ) + assert(hitBox != null) + } + + it("should be able to be create from multiple hit box check content") { + val point1 = Point2D(0, 0) + val point2 = Point2D(1, 1) + val hitBox = HitBoxUnion( + HitBoxPoint(point1), + HitBoxPoint(point2) + ) + assert(hitBox.contains(point1)) + assert(hitBox.contains(point2)) + assert(!hitBox.contains(Point2D(2, 2))) + } + + it("should be the first hit box if the other is empty (check inside)") { + val point = Point2D(0, 0) + val circularHitBox = HitBoxCircular(point, 1) + val hitBox = HitBoxUnion( + circularHitBox, + HitBoxEmpty + ) + assert(circularHitBox.area.forall(hitBox.contains)) + } + + it("should be the first hit box if the other is empty (check interval)") { + val point = Point2D(0, 0) + val circularHitBox = HitBoxCircular(point, 1) + val hitBox = HitBoxUnion( + HitBoxEmpty, + circularHitBox + ) + assert(hitBox.xMax == circularHitBox.xMax) + assert(hitBox.xMin == circularHitBox.xMin) + assert(hitBox.yMax == circularHitBox.yMax) + assert(hitBox.yMin == circularHitBox.yMin) + } + + it("should be the first hit box if the other is empty (check outside)") { + val point = Point2D(0, 0) + val circularHitBox = HitBoxCircular(point, 1) + val hitBox = HitBoxUnion( + circularHitBox, + HitBoxEmpty + ) + for + x <- -1 to 1 by 0.1 + y <- -1 to 1 by 0.1 + point = Point2D(x.toDouble, y.toDouble) + if !circularHitBox.contains(point) + yield + assert(!hitBox.contains(point)) + } + + it("should be the union of the given hit boxes (check interval)") { + val point1 = Point2D(0, 0) + val point2 = Point2D(-2, 2) + val pointHitBox = HitBoxPoint(point2) + val circularHitBox = HitBoxCircular(point1, 1) + val hitBox = HitBoxUnion( + pointHitBox, + circularHitBox + ) + assert(hitBox.xMax == circularHitBox.xMax) + assert(hitBox.xMin == pointHitBox.xMin) + assert(hitBox.yMax == pointHitBox.yMax) + assert(hitBox.yMin == circularHitBox.yMin) + } + + it("should be the union of the given hit boxes (check inside)") { + val point1 = Point2D(0, 0) + val point2 = Point2D(-2, 2) + val pointHitBox = HitBoxPoint(point2) + val circularHitBox = HitBoxCircular(point1, 1) + val hitBox = HitBoxUnion( + pointHitBox, + circularHitBox + ) + assert(circularHitBox.area.forall(hitBox.contains)) + assert(hitBox.contains(point2)) + } + + it("should be the union of the given hit boxes (check outside)") { + val point1 = Point2D(0, 0) + val point2 = Point2D(-2, 2) + val pointHitBox = HitBoxPoint(point2) + val circularHitBox = HitBoxCircular(point1, 1) + val hitBox = HitBoxUnion( + pointHitBox, + circularHitBox + ) + for + x <- -2 to 1 by 0.1 + y <- -1 to 2 by 0.1 + point = Point2D(x.toDouble, y.toDouble) + if !circularHitBox.contains(point) && (point !== point2) + yield + assert(!hitBox.contains(point)) + } + + it("should have the points in the hit box returned only once") { + val point1 = Point2D(0, 0) + val point2 = Point2D(-2, 2) + val hitBox = HitBoxUnion( + HitBoxPoint(point1), + HitBoxPoint(point2), + HitBoxPoint(point1) + ) + val iterator = hitBox.area + assert(iterator.next() === point1) + assert(iterator.next() === point2) + assert(!iterator.hasNext) + } + } + } \ No newline at end of file diff --git a/src/test/scala/model/elements2d/AngleTest.scala b/src/test/scala/model/elements2d/AngleTest.scala new file mode 100644 index 00000000..edcf5752 --- /dev/null +++ b/src/test/scala/model/elements2d/AngleTest.scala @@ -0,0 +1,65 @@ +package model.elements2d + +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import model.elements2d._ +import org.scalactic.Tolerance.convertNumericToPlusOrMinusWrapper + +object AngleTest: + private val degreeTestAngle = 45 + private val radiantTestAngle = math.Pi / 4 + private val degreeStraightAngle = 180 + private val radiantStraightAngle = math.Pi + +class AngleTest extends AnyFunSpec : + + import AngleTest._ + + describe("An Angle") { + describe("in degree") { + it("should be created from a value in degree") { + val angle = Angle.Degree(degreeTestAngle) + assert(angle.degree == degreeTestAngle) + } + it("should be converted to radian") { + val angle = Angle.Degree(degreeTestAngle) + assert(angle.radiant == radiantTestAngle) + } + it("should be negative after 180 degree") { + val angle = Angle.Degree(degreeStraightAngle + degreeTestAngle) + assert(angle.degree == -degreeStraightAngle + degreeTestAngle) + assert(angle.radiant == -radiantStraightAngle + radiantTestAngle) + } + it("should be 180° at -180°") { + val angle = Angle.Degree(-degreeStraightAngle) + assert(angle.degree == degreeStraightAngle) + } + it("should be pi at -180°") { + val angle = Angle.Degree(-degreeStraightAngle) + assert(angle.radiant === radiantStraightAngle) + } + } + describe("in radian") { + it("should be created from a value in radian") { + val angle = Angle.Radian(radiantTestAngle) + assert(angle.radiant === radiantTestAngle) + } + it("should be converted to degree") { + val angle = Angle.Radian(radiantTestAngle) + assert(angle.degree == degreeTestAngle) + } + it("should be negative after pi degree") { + val angle = Angle.Radian(-radiantStraightAngle - radiantTestAngle) + assert(angle.degree == -degreeStraightAngle + degreeTestAngle) + assert(angle.radiant == -radiantStraightAngle + radiantTestAngle) + } + it("should be 180° at -pi") { + val angle = Angle.Radian(-radiantStraightAngle) + assert(angle.degree == degreeStraightAngle) + } + it("should be pi at -pi") { + val angle = Angle.Radian(-radiantStraightAngle) + assert(angle.radiant === radiantStraightAngle) + } + } + } diff --git a/src/test/scala/model/elements2d/AngleTestProperties.scala b/src/test/scala/model/elements2d/AngleTestProperties.scala new file mode 100644 index 00000000..fbf2f5f1 --- /dev/null +++ b/src/test/scala/model/elements2d/AngleTestProperties.scala @@ -0,0 +1,33 @@ +package model.elements2d + +import org.scalacheck.{Gen, Properties} +import org.scalacheck.Prop.forAll + +object AngleTestProperties: + private val degreeStraightAngle = 180.0 + private val radiantStraightAngle = math.Pi + private val degreeLimitAngle = 4 * degreeStraightAngle + private val radiantLimitAngle = 4 * radiantStraightAngle + private val angleRadiantGen = Gen.choose(-radiantLimitAngle, radiantLimitAngle).map(Angle.Radian.apply) + private val angleDegreeGen = Gen.choose(-degreeLimitAngle, degreeLimitAngle).map(Angle.Degree.apply) + + +class AngleTestProperties extends Properties("Angle") : + + import AngleTestProperties._ + + property("Angle in degree should be in interval ]-180°, 180°] (Degree)") = forAll(angleDegreeGen) { angle => + angle.degree > -degreeStraightAngle && angle.degree <= degreeStraightAngle + } + + property("Angle in degree should be in interval ]-180°, 180°] (Radiant)") = forAll(angleRadiantGen) { angle => + angle.degree > -degreeStraightAngle && angle.degree <= degreeStraightAngle + } + + property("Angle in radiant should be in interval ]-pi, pi] (Degree)") = forAll(angleDegreeGen) { angle => + angle.radiant > -radiantStraightAngle && angle.radiant <= radiantStraightAngle + } + + property("Angle in radiant should be in interval ]-pi, pi] (Radiant)") = forAll(angleRadiantGen) { angle => + angle.radiant > -radiantStraightAngle && angle.radiant <= radiantStraightAngle + } diff --git a/src/test/scala/model/elements2d/Point2DTest.scala b/src/test/scala/model/elements2d/Point2DTest.scala new file mode 100644 index 00000000..3c87393b --- /dev/null +++ b/src/test/scala/model/elements2d/Point2DTest.scala @@ -0,0 +1,77 @@ +package model.elements2d + +import model.elements2d.Point2DTest.{xTest, yTest} +import org.scalactic.{Equality, TolerantNumerics} +import org.scalactic.TripleEquals.convertToEqualizer +import org.scalatest.funspec.AnyFunSpec +import Point2D.GivenEquality.given + + +object Point2DTest: + + private val xTest = 3.0 + private val yTest = 4.0 + +class Point2DTest extends AnyFunSpec : + + import Point2DTest._ + + describe("A Point2D") { + it("should be created with a x and y coordinate") { + val point = Point2D(xTest, yTest) + assert(point.x === xTest) + assert(point.y === yTest) + } + + it("should be able to be translated") { + val point = Point2D(xTest, yTest) + val vector = Vector2D(1, 1) + val expectedPoint = Point2D(xTest + 1, yTest + 1) + val translatedPoint = point --> vector + assert(translatedPoint === expectedPoint) + } + + it("shouldn't be able to be translated with a zero vector") { + val point = Point2D(xTest, yTest) + val translatedPoint = point --> Vector2D.Zero + assert(translatedPoint === point) + } + + it("should be able to scale") { + val point = Point2D(xTest, yTest) + val scale = Vector2D(2, 2) + val expectedPoint = Point2D(xTest * 2, yTest * 2) + val scaledPoint = point * scale + assert(scaledPoint === expectedPoint) + } + + it("should be able to scale with a single value") { + val point = Point2D(xTest, yTest) + val scale = 2 + val expectedPoint = Point2D(xTest * 2, yTest * 2) + val scaledPoint = point * scale + assert(scaledPoint === expectedPoint) + } + + it("should be able to calculate the distance between two points as vector") { + val point1 = Point2D(0, 0) + val point2 = Point2D(xTest, yTest) + val expectedDistance = Vector2D(xTest, yTest) + val distance = point1 <--> point2 + assert(distance === expectedDistance) + } + + it("should be able to calculate the distance between two points as double") { + val point1 = Point2D(0, 0) + val point2 = Point2D(xTest, yTest) + val expectedDistance = 5.0 + val distance = point1 <-> point2 + assert(distance === expectedDistance) + } + + it("shouldn't be equal to a vector2D") { + val point = Point2D(xTest, yTest) + val vector = Vector2D(xTest, yTest) + assert(point !== vector) + } + } diff --git a/src/test/scala/model/elements2d/Vector2DTest.scala b/src/test/scala/model/elements2d/Vector2DTest.scala new file mode 100644 index 00000000..e7521517 --- /dev/null +++ b/src/test/scala/model/elements2d/Vector2DTest.scala @@ -0,0 +1,242 @@ +package model.elements2d + +import org.scalatest.funspec.AnyFunSpec +import org.scalactic.Equality +import Vector2D.GivenEquality.given + +object Vector2DTest: + + private val xTest = 3.0 + private val yTest = 4.0 + private val magnitudeTest = 5.0 + private val angleDegreeTest = 53.13 + private val angleStraightDegree = 180.0 + + +class Vector2DTest extends AnyFunSpec : + + import Vector2DTest._ + import Vector2D._ + + describe("A Vector 2D") { + describe("when is a vector") { + it("should be created from a x and y coordinate and have their values") { + val vector2D = Vector2D(xTest, yTest) + assert(vector2D.x === xTest) + assert(vector2D.y === yTest) + } + + it("should be created from a magnitude and a direction and have their values 1° quadrant") { + val vector2D = Vector2D(magnitudeTest, Angle.Degree(angleDegreeTest)) + assert(vector2D.x === xTest) + assert(vector2D.y === yTest) + } + + it("should be created from a magnitude and a direction and have their values 2° quadrant") { + val vector2D = Vector2D(magnitudeTest, Angle.Degree(angleStraightDegree - angleDegreeTest)) + assert(vector2D.x === -xTest) + assert(vector2D.y === yTest) + } + + it("should be created from a magnitude and a direction and have their values 3° quadrant") { + val vector2D = Vector2D(magnitudeTest, Angle.Degree(-angleStraightDegree + angleDegreeTest)) + assert(vector2D.x === -xTest) + assert(vector2D.y === -yTest) + } + + it("should be created from a magnitude and a direction and have their values 4° quadrant") { + val vector2D = Vector2D(magnitudeTest, Angle.Degree(-angleDegreeTest)) + assert(vector2D.x === xTest) + assert(vector2D.y === -yTest) + } + + it("shouldn't be created from a null direction") { + val vector2D = Vector2D(magnitudeTest, null) + assert(vector2D == Vector2D.Zero) + } + + it("should have a magnitude") { + val vector2D = Vector2D(xTest, yTest) + assert(vector2D.magnitude === magnitudeTest) + } + + it("should have a direction 1° quadrant") { + val vector2D = Vector2D(xTest, yTest) + assert(vector2D.direction.get.degree === angleDegreeTest) + } + + it("should have a direction 2° quadrant") { + val vector2D = Vector2D(-xTest, yTest) + assert(vector2D.direction.get.degree === angleStraightDegree - angleDegreeTest) + } + + it("should have a direction 3° quadrant") { + val vector2D = Vector2D(-xTest, -yTest) + assert(vector2D.direction.get.degree === -angleStraightDegree + angleDegreeTest) + } + + it("should have a direction 4° quadrant") { + val vector2D = Vector2D(xTest, -yTest) + assert(vector2D.direction.get.degree === -angleDegreeTest) + } + + it("should be able to add another vector") { + val vector2D = Vector2D(xTest, yTest) + val otherVector2D = Vector2D(2, 1) + val expectedVector = Vector2D(xTest + 2, yTest + 1) + val vector2DSum = vector2D + otherVector2D + assert(vector2DSum === expectedVector) + } + + it("should be able to subtract another vector") { + val vector2D = Vector2D(4, 3) + val otherVector2D = Vector2D(2, 1) + val expectedVector = Vector2D(2, 2) + val vector2DSum = vector2D - otherVector2D + assert(vector2DSum === expectedVector) + } + + it("should be able to multiply by a scalar") { + val vector2D = Vector2D(4, 3) + val expectedVector = Vector2D(8, 6) + val vector2DProduct = vector2D * 2 + assert(vector2DProduct === expectedVector) + } + + it("should be able to divide by a scalar") { + val vector2D = Vector2D(4, 8) + val expectedVector = Vector2D(2, 4) + val vector2DProduct = vector2D / 2 + assert(vector2DProduct === expectedVector) + } + + it("should be able to calculate his normalized vector") { + val rad2 = math.sqrt(2) / 2 + val vector2D = Vector2D(4, 4) + val normalizedVector2D = vector2D.normalize + assert(normalizedVector2D === Vector2D(rad2, rad2)) + } + + it("opposite should have the same magnitude but opposite direction") { + val vector2D = Vector2D(xTest, yTest) + val expectedOpposite = Vector2D(-xTest, -yTest) + val oppositeVector2D = -vector2D + val oppositeAngle = Angle.Degree(vector2D.direction.get.degree + 180) + assert(oppositeVector2D === expectedOpposite) + assert(oppositeVector2D.magnitude === vector2D.magnitude) + assert(oppositeVector2D.direction.get.degree === oppositeAngle.degree) + } + + it("opposite should have the same magnitude but opposite direction angle 180°") { + val vector2D = Vector2D(-xTest, 0) + val expectedOpposite = Vector2D(xTest, 0) + val oppositeVector2D = -vector2D + assert(oppositeVector2D === expectedOpposite) + assert(oppositeVector2D.magnitude === vector2D.magnitude) + assert(oppositeVector2D.direction.get.degree === 0.0) + } + + it("opposite should have the same magnitude but opposite direction angle 0°") { + val vector2D = Vector2D(xTest, 0) + val expectedOpposite = Vector2D(-xTest, 0) + val oppositeVector2D = -vector2D + assert(oppositeVector2D === expectedOpposite) + assert(oppositeVector2D.magnitude === vector2D.magnitude) + assert(oppositeVector2D.direction.get.degree === 180.0) + } + + it("must return Zero vector if subtracting itself") { + val vector2D = Vector2D(xTest, yTest) + val vector2DSub = vector2D - vector2D + assert(vector2DSub === Vector2D.Zero) + } + + it("must return Zero vector if added its opposite") { + val vector2D = Vector2D(xTest, yTest) + val vector2DSum = vector2D + (-vector2D) + assert(vector2DSum === Vector2D.Zero) + } + } + + describe("when is a zero vector") { + it("should have magnitude equal to zero") { + val vector2D = Vector2D.Zero + assert(vector2D.magnitude == 0) + } + + it("shouldn't have direction, because it's undefined") { + val vector2D = Vector2D.Zero + assert(vector2D.direction.isEmpty) + } + + it("should have x y coordinate both equal to 0") { + val vector2D = Vector2D.Zero + assert(vector2D.x === 0.0) + assert(vector2D.y === 0.0) + } + + it("should be created from a x and y coordinate with both values equal to 0") { + val vector2D = Vector2D(0, 0) + assert(vector2D == Vector2D.Zero) + } + + it("should be created from a magnitude that is 0") { + val vector2D = Vector2D(0, Angle.Degree(angleDegreeTest)) + assert(vector2D == Vector2D.Zero) + } + + it("shouldn't be created from a angle 0° and a magnitude != 0") { + val vector2D = Vector2D(magnitudeTest, Angle.Degree(0)) + assert(vector2D != Vector2D.Zero) + } + + it("should be created from a angle with value null") { + val vector2D = Vector2D(magnitudeTest, null) + assert(vector2D == Vector2D.Zero) + } + + it("should be able to add another vector and return the other vector") { + val vector2D = Vector2D.Zero + val otherVector2D = Vector2D(magnitudeTest, Angle.Degree(angleDegreeTest)) + val vector2DSum = vector2D + otherVector2D + assert(vector2DSum === Vector2D(magnitudeTest, Angle.Degree(angleDegreeTest))) + } + + it("should be able to subtract another vector and return the opposite of the other vector") { + val straightAngleDegree = 180 + val vector2D = Vector2D.Zero + val otherVector2D = Vector2D(magnitudeTest, Angle.Degree(angleDegreeTest)) + val vector2DSum = vector2D - otherVector2D + assert(vector2DSum === Vector2D(magnitudeTest, Angle.Degree(-straightAngleDegree + angleDegreeTest))) + } + + it("should be able to multiply by a scalar") { + val vector2D = Vector2D.Zero + val vector2DProduct = vector2D * 2 + assert(vector2DProduct === Vector2D.Zero) + } + + it("should be able to divide by a scalar") { + val vector2D = Vector2D.Zero + val vector2DProduct = vector2D / 2 + assert(vector2DProduct === Vector2D.Zero) + } + + it("should be able to calculate his normalized vector (Zero)") { + val vector2D = Vector2D.Zero + val normalizedVector2D = vector2D.normalize + assert(normalizedVector2D === Vector2D.Zero) + } + + it("opposite should be Zero") { + val vector2D = Vector2D.Zero + assert(-vector2D === Vector2D.Zero) + } + } + + it("shouldn't be equal to a Point2D") { + val point = Point2D(xTest, yTest) + val vector = Vector2D(xTest, yTest) + assert(vector !== point) + } + } diff --git a/src/test/scala/model/elements2d/Vector2DTestProperties.scala b/src/test/scala/model/elements2d/Vector2DTestProperties.scala new file mode 100644 index 00000000..f712352b --- /dev/null +++ b/src/test/scala/model/elements2d/Vector2DTestProperties.scala @@ -0,0 +1,29 @@ +package model.elements2d + +import org.scalacheck.{Prop, Properties, Gen} +import org.scalacheck.Prop.{forAll, exists} + +object Vector2DTestProperties: + private val vector2DGen: Gen[Vector2D] = for + x <- Gen.choose(Double.MinValue, Double.MaxValue) + y <- Gen.choose(Double.MinValue, Double.MaxValue) + yield + Vector2D(x, y) + + private val vectors2DGen: Gen[(Vector2D, Vector2D)] = for + v1 <- vector2DGen + v2 <- vector2DGen + yield + (v1, v2) + +class Vector2DTestProperties extends Properties("Vector") : + + import Vector2DTestProperties._ + + property("Sum is commutative") = forAll(vectors2DGen) { (v1: Vector2D, v2: Vector2D) => + v1 + v2 == v2 + v1 + } + property("Sum has vector zero as identity") = forAll(vector2DGen) { (v: Vector2D) => + v + Vector2D.Zero == v && Vector2D.Zero + v == v + } + diff --git a/src/test/scala/model/explosion/ExplosionTest.scala b/src/test/scala/model/explosion/ExplosionTest.scala new file mode 100644 index 00000000..e9a5d8b6 --- /dev/null +++ b/src/test/scala/model/explosion/ExplosionTest.scala @@ -0,0 +1,92 @@ +package model.explosion + +import model.collisions.{Affiliation, Collisionable, HitBox} +import model.collisions.hitbox.HitBoxPoint +import model.elements2d.Point2D +import model.collisions.Distance +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import model.missile.* + +object ExplosionTest: + val damage = 10 + val radius = 5 + val position = Point2D(0, 0) + +class ExplosionTest extends AnyFunSpec : + import ExplosionTest.* + import model.missile.MissileTest.TestMissile + import model.explosion.MaxTime + given maxTime: MaxTime = 10 + + describe("An explosion") { + describe("circular") { + describe("when created") { + it("should have damage specified in input") { + given affiliation: Affiliation = Affiliation.Friendly + val explosion = Explosion(damage, radius, position) + assert(explosion.damageInflicted == damage) + } + it("should have valid damage value even if a negative one is specified") { + given affiliation: Affiliation = Affiliation.Friendly + val explosion = Explosion(-damage, radius, position) + assert(explosion.damageInflicted == damage) + } + it("should have valid radius value even if a negative one is specified") { + given affiliation: Affiliation = Affiliation.Friendly + val explosion = Explosion(damage, -radius, position) + assert(explosion.radius == radius) + } + it("should throw IllegalArgumentException when damage is assigned to 0") { + assertThrows[IllegalArgumentException] { + given affiliation: Affiliation = Affiliation.Friendly + Explosion(0, radius, position) + } + } + it("should throw IllegalArgumentException when radius is assigned to 0") { + assertThrows[IllegalArgumentException] { + given affiliation: Affiliation = Affiliation.Friendly + Explosion(damage, 0, position) + } + } + } + describe("when a point is inside its action radius") { + it("should recognize a collision") { + given distance : Distance = 1 + given affiliation: Affiliation = Affiliation.Friendly + val explosion = Explosion(damage, radius, position) + val startPosition = Point2D(3,3) + val point = new Collisionable { + + override protected def hitBox: HitBox = HitBoxPoint(startPosition) + + override def affiliation: Affiliation = Affiliation.Friendly + } + + assert(explosion.isCollidingWith(point)) + + } + } + describe("when exploding") { + it("should be into exploding state until the max time") { + given affiliation: Affiliation = Affiliation.Friendly + val explosion = Explosion(damage, radius, position) + val newExplosion = explosion.timeElapsed(14) + assert(newExplosion.terminated, true) + } + } + } + describe("A missile") { + describe("when exploding") { + it("should generate an explosion with its own affiliation type") { + val missile = Missile(0, + damage, + velocity, + Point2D(0, 0), Point2D(0, 0)) + val explosion = missile.explode + assert(missile.affiliation == Affiliation.Friendly) + assert(explosion.affiliation == Affiliation.Friendly) + } + } + } + } \ No newline at end of file diff --git a/src/test/scala/model/ground/CityTest.scala b/src/test/scala/model/ground/CityTest.scala new file mode 100644 index 00000000..3f5a4467 --- /dev/null +++ b/src/test/scala/model/ground/CityTest.scala @@ -0,0 +1,38 @@ +package model.ground + +import model.collisions.Affiliation +import model.elements2d.Point2D +import org.scalatest.funspec.AnyFunSpec +import utilities._ + + +class CityTest extends AnyFunSpec { + private val xTest = 3.0 + private val yTest = 4.0 + val point = Point2D(xTest, yTest) + val city = City(point) + + describe("A city") { + it("should create a new city in given position and with correct life") { + assert(city.position.x === xTest) + assert(city.position.y === yTest) + assert(city.initialLife === cityInitialLife) + assert(city.currentLife === cityInitialLife) + } + it("should be friendly and have 3HP") { + assert(city.affiliation === Affiliation.Friendly) + assert(city.currentLife === 3) + } + it("should take damage and get destroyed if reach 0HP") { + assert(city.currentLife === 3) + val city2 = city.takeDamage(3) + assert(city2.currentLife === 0) + assert(city2.isDestroyed) + } + it("should keep the same position after taking damage") { + assert(city.currentLife === 3) + val city2 = city.takeDamage(1).asInstanceOf[City] + assert(city2.position === city.position) + } + } +} diff --git a/src/test/scala/model/ground/GroundTest.scala b/src/test/scala/model/ground/GroundTest.scala new file mode 100644 index 00000000..1b58b480 --- /dev/null +++ b/src/test/scala/model/ground/GroundTest.scala @@ -0,0 +1,127 @@ +package model.ground + +import model.DeltaTime +import model.elements2d.Point2D +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funspec.AnyFunSpec + +class GroundTest extends AnyFunSpec with BeforeAndAfterAll : + var ground = Ground() + val shootPoint = Point2D(0, 10) + + //before each test, it will regenerate the ground as new + override def beforeAll(): Unit = { + super.beforeAll() + ground = Ground() + } + + private def elapseTimeToBatteries(groundPassed: Ground, time: DeltaTime) : Ground = + Ground(ground.cities, groundPassed.turrets.map(b => b.timeElapsed(time))) + + describe("The ground") { + it("should be generate the required number of cities and batteries") { + assert(ground.numberOfCitiesAlive === 6) + println(ground.turrets) + assert(ground.numberOfMissileBatteryAlive === 3) + } + it("should remove a turret if damaged") { + var missileTurret = ground.missileBatteryAlive.map( i => if (i == ground.missileBatteryAlive.last) i.takeDamage(3) else i) + ground = Ground(ground.cities, missileTurret) + assert(ground.numberOfMissileBatteryAlive === 2) + } + + it("should say the player is still alive if more than 1 city is alive") { + assert(ground.stillAlive) + } + + it("should create a new ground after shoot with missile and reloading turret") { + var resultContainer = ground.shootMissile(shootPoint) + ground = resultContainer._1 + assert(resultContainer._2.isEmpty) //true. Turrets are reloading, so all of them can't shoot the missile + + ground = elapseTimeToBatteries(ground, 3000) + + resultContainer = ground.shootMissile(shootPoint); + ground = resultContainer._1 + assert(resultContainer._2.nonEmpty) //true. 1 turret shoot the missile + assert(ground.turrets(0).isReadyForShoot == false) //false. 1° turret is reloading because it shooted + assert(ground.turrets(1).isReadyForShoot) //true. 2° and 3° turret didn't shoot + assert(ground.turrets(2).isReadyForShoot) //true. 2° and 3° turret didn't shoot + } + + it("should handle multiple shoots") { + ground = elapseTimeToBatteries(ground, 3000) + ground = ground.shootMissile(shootPoint)._1 //first missile shooted + ground = ground.shootMissile(shootPoint)._1 //2° missile shooted + assert(ground._2.nonEmpty) //true. I shooted twice, the missile is shooted from middle battery + assert(ground.turrets(0).isReadyForShoot == false) //false. Turret is reloading + assert(ground.turrets(1).isReadyForShoot == false) //false. Turret is reloading + assert(ground.turrets(2).isReadyForShoot) //true. Turret is ready for shoot + } + + it("should destroy a city if hitted") { + val cities = ground.cities.map(c => if ( c == ground.cities.last ) c.takeDamage(3) else c ) + ground = Ground(cities, ground.turrets) + assert(ground.cities.last.currentLife === 0) + assert(ground.numberOfCitiesAlive === 5) + assert(ground.stillAlive) + } + + it("should destroy all the cities") { + val cities = ground.cities.map( c => c.takeDamage(3)) + ground = Ground(cities, ground.turrets) + assert(!ground.stillAlive) + } + + it("should destroy all the cities and try to shoot") { + val turrets = ground.turrets.map( t => t.takeDamage(3)) + ground = Ground(ground.cities, turrets) + ground = elapseTimeToBatteries(ground, 3000) + var ground2 = ground.shootMissile(shootPoint) + assert(ground2._2.isEmpty) + } + + describe("Test new way of dealing damage") { + it("should deal damage to a specified city") { + //use last city as an example + ground = Ground() + ground = ground.dealDamage(ground.cities.last, 3) + assert(ground.numberOfCitiesAlive === 5) + } + it("should deal damage to a specified turret") { + //use last turret as an example + ground = Ground() + ground = ground.dealDamage(ground.turrets.last, 3) + assert(ground.numberOfMissileBatteryAlive === 2) + } + it("should deal damage to multiple structures (same type)") { + ground = Ground() + val structures = List(ground.turrets(0), ground.turrets(1), ground.turrets(2)) + ground = ground.dealDamage(structures, 3) + assert(ground.numberOfMissileBatteryAlive === 0) + } + it("should deal damage to multiple structures (different types)") { + ground = Ground() + val structures = List(ground.turrets(0), ground.turrets(2), ground.cities(5), ground.cities(2)) + ground = ground.dealDamage(structures, 3) + assert(ground.numberOfMissileBatteryAlive === 1) + assert(ground.numberOfCitiesAlive === 4) + } + it("should deal multiple damage to multiple structures (same type)") { + ground = Ground() + val structures = List(ground.turrets(0), ground.turrets(1), ground.turrets(2)) + val damages = List(3, 3, 1) + ground = ground.dealDamage(structures, damages) + assert(ground.numberOfMissileBatteryAlive === 1) + } + it("should deal multiple damage to multiple structures (different type)") { + ground = Ground() + val structures = List(ground.turrets(0), ground.turrets(1), ground.turrets(2), + ground.cities(5), ground.cities(2), ground.cities(4)) + val damages = List(3, 3, 1, 1, 3, 2) + ground = ground.dealDamage(structures, damages) + assert(ground.numberOfMissileBatteryAlive === 1) + assert(ground.numberOfCitiesAlive === 5) + } + } + } diff --git a/src/test/scala/model/ground/MissileBatteryTest.scala b/src/test/scala/model/ground/MissileBatteryTest.scala new file mode 100644 index 00000000..43eb55b5 --- /dev/null +++ b/src/test/scala/model/ground/MissileBatteryTest.scala @@ -0,0 +1,66 @@ +package model.ground + +import model.collisions.Affiliation +import model.elements2d.Point2D +import model.{DeltaTime, ground} +import model.ground.MissileBattery +import model.missile.Missile +import org.scalactic.{Equality, TolerantNumerics} +import org.scalatest.funspec.AnyFunSpec +import utilities._ + + +import java.time + +class MissileBatteryTest extends AnyFunSpec{ + + val xTest = 3.0 + val yTest = 4.0 + val point = Point2D(xTest, yTest) + + describe("A missile battery") { + it("should create a new turret in given position and with specified life") { + val batteryTurret = MissileBattery(point) + assert(batteryTurret.bottomLeft_Position.x === xTest) + assert(batteryTurret.bottomLeft_Position.y === yTest) + assert(batteryTurret.initialLife === missileBatteryInitialLife) + assert(batteryTurret.currentLife === missileBatteryInitialLife) + } + + it("should fail if you shoot twice in a row without waiting") { + var batteryTurret = ground.MissileBattery(point) + assert(batteryTurret.isReadyForShoot == false) //false. When turret got created, the reload will start + batteryTurret = batteryTurret.timeElapsed(1) + assert(batteryTurret.isReadyForShoot == false) //false. Turret is still reloading + batteryTurret = batteryTurret.timeElapsed(2) + assert(batteryTurret.isReadyForShoot) //true. Turret finished reloading + } + it("should be a friendly unit") { + val batteryTurret = ground.MissileBattery(point) + assert(batteryTurret.affiliation === Affiliation.Friendly) + } + + it("should fail if you shoot right after creating a turret") { + val batteryTurret = ground.MissileBattery(point) + assert(batteryTurret.shootRocket(point).isEmpty) + } + it("should pass if you shoot after waiting for the reload time") { + var batteryTurret = ground.MissileBattery(point) + batteryTurret = batteryTurret.timeElapsed(3) + val values = batteryTurret.shootRocket(Point2D(10.0, 10.0)) + assert(values.nonEmpty) + + val tupled: Tuple2[MissileBattery, Missile] = values.get + assert(!tupled._1.isReadyForShoot) //true. The turret shot, so is reloading + assert(tupled._2.affiliation === Affiliation.Friendly) + assert(tupled._2.destination === Point2D(10.0, 10.0)) + } + + it("should get damaged and destroied if it have 0HP") { + val batteryTurret = ground.MissileBattery(point) + val batteryTurret2 = batteryTurret.takeDamage(3) + assert(batteryTurret2.currentLife === 0) + assert(batteryTurret2.isDestroyed) + } + } +} \ No newline at end of file diff --git a/src/test/scala/model/missile/MissileTest.scala b/src/test/scala/model/missile/MissileTest.scala new file mode 100644 index 00000000..efd83cb9 --- /dev/null +++ b/src/test/scala/model/missile/MissileTest.scala @@ -0,0 +1,67 @@ +package model.missile +import model.collisions.Affiliation +import model.elements2d.Point2D +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +import scala.util.Random + +object MissileTest: + + //retta passante per due punti + private val damage = 1 + private val velocity = 10 + private val startPosition = Point2D(0,0) + private val finalPosition = Point2D(10,10) + private val dt = 0.1 + + //given affiliation: Affiliation = Affiliation.Enemy + val TestMissile : () => Missile = () => Missile(initialLife, damage, velocity, startPosition, finalPosition) + + +class MissileTest extends AnyFunSpec : + + import MissileTest._ + + describe("A missile") { + describe("with enemy role") { + it("should have enemy affiliation") { + val missile = Missile.enemyMissile(initialLife, damage, velocity, startPosition, finalPosition) + assert(missile.affiliation == Affiliation.Enemy) + } + } + it("should be scorable") { + val missile = Missile.enemyMissile(initialLife, damage, velocity, startPosition, finalPosition) + val mMissile = missile.takeDamage(2) + assert(mMissile.isInstanceOf[Scorable]) + } + describe("with friendly role") { + it("should have friendly affiliation") { + given Random() + val missile = GenerateRandomMissile(BasicMissile(Affiliation.Friendly), finalPosition) + assert(missile.get.affiliation == Affiliation.Friendly) + } + } + it("should calculate its own direction based on start position and final position") { + val missile = TestMissile() + assert(missile.direction == (finalPosition <--> startPosition).normalize) + } + it("should move along its direction") { + val missile = TestMissile() + val movedMissile = missile.timeElapsed(dt).move() + assert(movedMissile.position == (missile.position --> (missile.direction * missile.velocity * dt)) * (-1)) + } + it("should decrease its lifepoints when damaged") { + val missile = TestMissile() + val damagedMissile = missile.takeDamage(damage) + assert(damagedMissile.currentLife == initialLife - damage) + } + it("should be destroyed if its lifepoints are lower than 0") { + val missile = TestMissile() + val damagedMissile = missile.takeDamage(initialLife) + assert(damagedMissile.isDestroyed) + } + } + + + diff --git a/src/test/scala/model/spawner/SpawnerTest.scala b/src/test/scala/model/spawner/SpawnerTest.scala new file mode 100644 index 00000000..84caa474 --- /dev/null +++ b/src/test/scala/model/spawner/SpawnerTest.scala @@ -0,0 +1,54 @@ +package model.spawner +import model.collisions.Affiliation +import model.elements2d.Point2D +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +object SpawnerTest: + val maxHeight = 500 + val maxWidth = 300 + +class SpawnerTest extends AnyFunSpec : + import SpawnerTest._ + + describe("A spawner of enemy missiles") { + describe("with an interval of a missile per second") { + it("should generate enemy missiles") { + val old_spawner = Spawner(1, maxWidth, maxHeight) + val spawner = old_spawner.timeElapsed(1) + val missiles = spawner.spawn()._1 + if(!missiles.isEmpty) + assert(missiles(0).affiliation == Affiliation.Enemy) + } + it("should generate empty list if no enough time is passed") { + val old_spawner = Spawner(1, maxWidth, maxHeight) + val spawner = old_spawner.timeElapsed(0.33) + val missiles = spawner.spawn()._1 + assert(missiles.size == 0) + } + it("should generate a missile after 1 second of virtual time") { + val old_spawner = Spawner(1, maxWidth, maxHeight) + val spawner = old_spawner.timeElapsed(1) + val missiles = spawner.spawn()._1 + assert(missiles.size == 1) + assert(missiles(0).affiliation == Affiliation.Enemy) + } + it("should generate n missiles after n seconds of virtual time") { + val timePassed = 10 + val old_spawner = Spawner(1, maxWidth, maxHeight) + val spawner = old_spawner.timeElapsed(timePassed) + val missiles = spawner.spawn()._1 + assert(missiles.size == timePassed) + } + } + describe("with an interval of a missile every 0.33 second") { + it("should generate missiles after 1 second of virtual time") { + val timePassed = 1 + val old_spawner = Spawner(0.33, maxWidth, maxHeight) + val spawner = old_spawner.timeElapsed(timePassed) + val missiles = spawner.spawn()._1 + assert(missiles.size == 3) + } + } + } + diff --git a/src/test/scala/utilities/MathUtilitiesTest.scala b/src/test/scala/utilities/MathUtilitiesTest.scala new file mode 100644 index 00000000..11b70a92 --- /dev/null +++ b/src/test/scala/utilities/MathUtilitiesTest.scala @@ -0,0 +1,176 @@ +package utilities + +import org.scalatest.funspec.AnyFunSpec +import org.scalactic.TripleEquals.* +import MathUtilities.* +import MathUtilities.DoubleEquality.given +import org.scalactic.Tolerance.convertNumericToPlusOrMinusWrapper +import org.scalactic.{Equality, TolerantNumerics} + +class MathUtilitiesTest extends AnyFunSpec : + + private val value = 1.0 + + describe("The operator <==") { + + describe("with the given tolerance") { + + val tolerance = 0.01 + val difference = 0.1 + + given Equality[Double] = TolerantNumerics.tolerantDoubleEquality(tolerance) + + it("should return true if the first number is less than or equal to the second number with a tolerance of 0.01 (default)") { + val comparedValue = value + difference + assert(value <== comparedValue) + assert(value !== comparedValue) + } + + it("should return true if the first number is less than or equal to the second number with a tolerance of 0.01 (in tolerance)") { + val comparedValue = value + tolerance + assert(value <== comparedValue) + assert(value === comparedValue) + } + + it("should return false if the first number is greater than the second number but out side of a tolerance of 0.01") { + val comparedValue = value - difference + assert(!(value <== comparedValue)) + assert(value !== comparedValue) + } + + it("should return true if the first number is greater than the second number but in a tolerance of 0.01") { + val comparedValue = value - tolerance + assert(value <== comparedValue) + assert(value === comparedValue) + } + } + + describe("with the explicit tolerance") { + + val tolerance = 0.2 + val difference = 0.1 + + it("should return true if the first number is less than or equal to the second number with a tolerance of 0.2 (default)") { + val comparedValue = value + difference + tolerance + assert(value <== comparedValue +- tolerance) + assert(value !== comparedValue) + } + + it("should return true if the first number is less than or equal to the second number with a tolerance of 0.2 (in tolerance)") { + val comparedValue = value + tolerance + assert(value <== comparedValue +- tolerance) + assert(value === comparedValue +- tolerance) + } + + it("should return false if the first number is greater than the second number but out side of a tolerance of 0.2") { + val comparedValue = value - tolerance - difference + assert(!(value <== comparedValue +- tolerance)) + assert(value !== comparedValue +- tolerance) + } + + it("should return true if the first number is greater than the second number but in a tolerance of 0.2") { + val comparedValue = value - tolerance + assert(value <== comparedValue +- tolerance) + assert(value === comparedValue +- tolerance) + } + + it("should return false if the second element is not a double") { + val comparedValue = "test" + assert(!(value <== comparedValue)) + } + } + } + + describe("The operator >==") { + + describe("with the given tolerance") { + + val tolerance = 0.01 + val difference = 0.1 + + given Equality[Double] = TolerantNumerics.tolerantDoubleEquality(tolerance) + + it("should return true if the first number is less than or equal to the second number with a tolerance of 0.01 (default)") { + val comparedValue = value + difference + assert(comparedValue >== value) + assert(value !== comparedValue) + } + + it("should return true if the first number is less than or equal to the second number with a tolerance of 0.01 (in tolerance)") { + val comparedValue = value + tolerance + assert(comparedValue >== value) + assert(value === comparedValue) + } + + it("should return false if the first number is greater than the second number but out side of a tolerance of 0.01") { + val comparedValue = value - difference + assert(!(comparedValue >== value)) + assert(value !== comparedValue) + } + + it("should return true if the first number is greater than the second number but in a tolerance of 0.01") { + val comparedValue = value - tolerance + assert(comparedValue >== value) + assert(value === comparedValue) + } + + it("should return false if the second element is not a double") { + val comparedValue = "test" + assert(!(value >== comparedValue)) + } + } + + describe("with the explicit tolerance") { + + val tolerance = 0.2 + val difference = 0.1 + + it("should return true if the first number is less than or equal to the second number with a tolerance of 0.2 (default)") { + val comparedValue = value + tolerance + difference + assert(comparedValue >== value +- tolerance) + assert(value !== comparedValue +- tolerance) + } + + it("should return true if the first number is less than or equal to the second number with a tolerance of 0.2 (in tolerance)") { + val comparedValue = value + tolerance + assert(comparedValue >== value +- tolerance) + assert(value === comparedValue +- tolerance) + } + + it("should return false if the first number is greater than the second number but out side of a tolerance of 0.2") { + val comparedValue = value - tolerance - difference + assert(!(comparedValue >== value +- tolerance)) + assert(value !== comparedValue +- tolerance) + } + + it("should return true if the first number is greater than the second number but in a tolerance of 0.2") { + val comparedValue = value - tolerance + assert(comparedValue >== value +- tolerance) + assert(value === comparedValue +- tolerance) + } + } + } + + describe("The operator **") { + + val base = 2.0 + + it("should return the square of a number") { + val expected = 4.0 + val exponent = 2.0 + assert(expected === base ** exponent) + } + + it("should return the cube of a number") { + val expected = 8.0 + val exponent = 3.0 + assert(expected === base ** exponent) + } + + it("should return the square root of a number") { + val expected = math.sqrt(base) + val exponent = 0.5 + assert(expected === base ** exponent) + } + } + diff --git a/src/test/scala/view/CollisionableVisualizerTest.scala b/src/test/scala/view/CollisionableVisualizerTest.scala new file mode 100644 index 00000000..a96eced6 --- /dev/null +++ b/src/test/scala/view/CollisionableVisualizerTest.scala @@ -0,0 +1,22 @@ +package view + +import model.spawner.Spawner +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class CollisionableVisualizerTest extends AnyFunSpec : + + given Conversion[Double, Int] with + override def apply(x: Double): Int = x.toInt + + describe("A collisionable visualizer") { + describe("that convert collisionable elements into graphic ones") { + it("should return a view element of all missiles") { + val spawner = Spawner(1, 10, 10) + val newSpawner = spawner.timeElapsed(10) + val missiles = newSpawner.spawn()._1 + val visualElements = CollisionableVisualizer.printElements(missiles) + assert(visualElements.size == 10) + } + } + } \ No newline at end of file diff --git a/src/test/scala/view/VisualizerTest.scala b/src/test/scala/view/VisualizerTest.scala new file mode 100644 index 00000000..78244aa2 --- /dev/null +++ b/src/test/scala/view/VisualizerTest.scala @@ -0,0 +1,31 @@ +package view + +import model.ground.Ground +import org.scalatest.funspec.AnyFunSpec + +class VisualizerTest extends AnyFunSpec { + var ground = Ground() + val resourceFolderPath: String = (System.getProperty("user.dir").toString + "\\src\\main\\resources\\") + //val jfxPanel: JFXPanel = new JFXPanel + +// describe("The visualizer") { +// ignore ("should generate the required amount of new items when called") { +// assert(Visualizer.printGround(ground).length === 9) +// } +// ignore ("should generate the correct image") { +// assert(Visualizer.printGround(ground)(0).getImage.getUrl === resourceFolderPath + "\\city_3.png") +// assert(Visualizer.printGround(ground)(3).getImage.getUrl === resourceFolderPath + "\\city_3.png") +// assert(Visualizer.printGround(ground)(8).getImage.getUrl === resourceFolderPath + "\\Base_false_3.png") +// } +// ignore ("should change the printable image in case of damages") { +// assert(Visualizer.printGround(ground)(0).getImage.getUrl === resourceFolderPath + "\\city_3.png") +// ground = ground.dealDamage(ground.cities(0), 2) +// assert(Visualizer.printGround(ground)(0).getImage.getUrl === resourceFolderPath + "\\city_1.png") +// } +// ignore ("should change the printable image in case of full reload of the turret") { +// assert(Visualizer.printGround(ground)(8).getImage.getUrl === resourceFolderPath + "\\Base_false_3.png") +// Thread.sleep(3000) +// assert(Visualizer.printGround(ground)(8).getImage.getUrl === resourceFolderPath + "\\Base_true_3.png") +// } +// } +} diff --git a/src/test/scala/view/audio/AudioPlayerTest.scala b/src/test/scala/view/audio/AudioPlayerTest.scala new file mode 100644 index 00000000..774101da --- /dev/null +++ b/src/test/scala/view/audio/AudioPlayerTest.scala @@ -0,0 +1,15 @@ +package view.audio + +import org.scalatest.funspec.AnyFunSpec + + +class AudioPlayerTest extends AnyFunSpec { + describe("What the audioplayer should do") { + ignore ("should play a specified audio") { + AudioPlayer.playAudio(AudioPlayer.explosionSmall) + } + ignore ("should play even if resource is not available") { + AudioPlayer.playAudio("broken URL") + } + } +}