Skip to content

Commit

Permalink
feat: add back and forth game skeleton
Browse files Browse the repository at this point in the history
  • Loading branch information
tassiluca committed Jan 29, 2024
1 parent 55b2582 commit cb31ce4
Show file tree
Hide file tree
Showing 20 changed files with 444 additions and 33 deletions.
11 changes: 11 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .idea/scala_compiler.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions back-and-forth/src/main/scala/io/github/tassiLuca/Launcher.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.github.tassiLuca

import gears.async.default.given
import gears.async.Async

import concurrent.duration.DurationInt
import io.github.tassiLuca.boundary.impl.{SwingUI, Timer}
import io.github.tassiLuca.core.{Controller, Space2D}

import scala.language.postfixOps

@main def main(): Unit = Async.blocking:
val view = SwingUI(500, 800)
val timer = Timer(1 seconds)
view.asRunnable.run
timer.asRunnable.run
val controller = Controller
.reactive(
boundaries = Set(timer),
updatableBoundaries = Set(view),
reaction = (_, s) => s,
)(Space2D((450, 700)))
.run
controller.await
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.github.tassiLuca.boundary

import gears.async.{Async, Task}
import io.github.tassiLuca.core.Event

trait Boundary:
def src: Async.Source[Event]
def asRunnable: Task[Unit]

trait UpdatableBoundary[M] extends Boundary:
def update(model: M): Unit
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.github.tassiLuca.boundary

import gears.async.{Async, Listener}
import io.github.tassiLuca.core.Event

object BoundarySource extends Async.OriginalSource[Event]:
private[boundary] var listeners = Set[Listener[Event]]()

override def poll(k: Listener[Event]): Boolean = false

override def dropListener(k: Listener[Event]): Unit = synchronized:
listeners = listeners - k

override protected def addListener(k: Listener[Event]): Unit = synchronized:
listeners = listeners + k

def notifyListeners(e: Event): Unit = synchronized:
listeners.foreach(_.completeNow(e, this))
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package io.github.tassiLuca.boundary.impl

import gears.async.{Async, Task}
import io.github.tassiLuca.boundary.{BoundarySource, UpdatableBoundary}
import io.github.tassiLuca.core.Event.{ChangeDirection, Freeze}
import io.github.tassiLuca.core.{Event, RectangularEntities, Space2D}

import java.awt.event.{KeyAdapter, KeyEvent}
import java.awt.{BorderLayout, Color, Graphics}
import javax.swing.border.LineBorder
import javax.swing.{JFrame, JPanel, SwingUtilities, WindowConstants}

class SwingUI(width: Int, height: Int) extends UpdatableBoundary[Space2D & RectangularEntities]:
private val boundarySource = BoundarySource
private val frame = JFrame("Popping Bubbles game")

frame.addKeyListener(
new KeyAdapter:
override def keyPressed(e: KeyEvent): Unit = e.getKeyCode match
case 38 | 40 => boundarySource.notifyListeners(ChangeDirection)
case 32 => boundarySource.notifyListeners(Freeze)
case _ => (),
)

override inline final def src: Async.Source[Event] = boundarySource

override def asRunnable: Task[Unit] = Task {
frame.setSize(width, height)
frame.setVisible(true)
frame.setLocationRelativeTo(null)
frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE)
}

override def update(space: Space2D & RectangularEntities): Unit = SwingUtilities.invokeAndWait { () =>
if frame.getContentPane.getComponentCount != 0 then frame.getContentPane.remove(0)
frame.getContentPane.add(WorldPane(space, space.world.shape.width, space.world.shape.height), BorderLayout.CENTER)
frame.getContentPane.repaint()
}

private class WorldPane(space: Space2D & RectangularEntities, width: Int, height: Int) extends JPanel:
setSize(width, height)
setBorder(LineBorder(Color.DARK_GRAY, 2))

override def paintComponent(g: Graphics): Unit =
val (px, py) = space.world.player.position
g.fillRect(px, py, space.world.player.shape.width, space.world.player.shape.height)
space.world.obstacles.foreach(o =>
val (px, py) = o.position
g.drawRect(px, py, o.shape.width, o.shape.height),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.github.tassiLuca.boundary.impl

import gears.async.{Async, Task}
import gears.async.default.given
import io.github.tassiLuca.boundary.Boundary
import io.github.tassiLuca.core.Event

import scala.concurrent.duration.Duration

class Timer(period: Duration) extends Boundary:
private val timer = gears.async.Timer(period)

override def src: Async.Source[Event] =
timer.src.transformValuesWith(_ => Event.Tick)

override def asRunnable: Task[Unit] = Task(timer.run())
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.github.tassiLuca.core

import gears.async.TaskSchedule.RepeatUntilFailure
import gears.async.{Async, Future, ReadableChannel, SendableChannel, Task, UnboundedChannel}
import io.github.tassiLuca.boundary.{Boundary, UpdatableBoundary}

import java.time.LocalTime
import scala.annotation.tailrec

trait Controller[Event, Model]:
def reactive(
boundaries: Set[Boundary],
updatableBoundaries: Set[UpdatableBoundary[Model]],
reaction: Reaction[Event, Model],
)(initial: Space2D & RectangularEntities): Task[Unit]

object Controller extends Controller[Event, Space2D & RectangularEntities]:
override def reactive(
boundaries: Set[Boundary],
updatableBoundaries: Set[UpdatableBoundary[Space2D & RectangularEntities]],
reaction: Reaction[Event, Space2D & RectangularEntities],
)(initial: Space2D & RectangularEntities): Task[Unit] = Task:
val channel = UnboundedChannel[Event]() // may go out of memory!
val producers = (boundaries ++ updatableBoundaries).map(_ produceOn channel)
Future { consumeFrom(updatableBoundaries, channel, initial, reaction) }
producers.map(_.run).toSeq.awaitAll // important!

extension (b: Boundary)
private def produceOn(channel: SendableChannel[Event]): Task[Unit] = Task {
channel.send(b.src.awaitResult)
}.schedule(RepeatUntilFailure())

@tailrec
private def consumeFrom(
updatableBoundaries: Set[UpdatableBoundary[Space2D & RectangularEntities]],
channel: ReadableChannel[Event],
space2D: Space2D & RectangularEntities,
reaction: Reaction[Event, Space2D & RectangularEntities],
)(using Async): Unit =
val event = channel.read().toOption.get // may fail due to closed channel
println(s"Consuming $event @ ${LocalTime.now()}")
val newSpace = reaction(event, space2D)
updatableBoundaries.foreach(_.update(newSpace))
consumeFrom(updatableBoundaries, channel, space2D, reaction)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.github.tassiLuca.core

enum Event:
case Tick
case ChangeDirection
case Freeze
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.github.tassiLuca.core

import scala.annotation.targetName

type Reaction[Event, Model] = (Event, Model) => Model

extension [A, B, C](f: (A, B) => C)
@targetName("chainWith")
def >>[D](g: (A, C) => D): (A, B) => D = (a, b) => g(a, f(a, b))
53 changes: 53 additions & 0 deletions back-and-forth/src/main/scala/io/github/tassiLuca/core/Space.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.github.tassiLuca.core

import io.github.tassiLuca.utils.*

/** The N-d space on which the game world is absorbed. */
trait Space:

type Position
type Speed
type Shape

def world: World
def world_=(world: World): Unit

sealed trait Entity:
val position: Position
val speed: Speed
val shape: Shape

class Player(override val position: Position, override val speed: Speed, override val shape: Shape) extends Entity
class Obstacle(override val position: Position, override val speed: Speed, override val shape: Shape) extends Entity

case class World(player: Player, obstacles: Set[Obstacle], shape: Shape)

extension (e: Entity) def isCollidingWith(e2: Entity): Boolean

/** A 2 dimensional space. */
trait Space2D extends Space:
override type Speed = Vector2D
override type Position = Point2D

/** A mixin specifying rectangular shapes along with the definition of collision among them. */
trait RectangularEntities:
self: Space2D =>

case class RectangularShape(width: Int, height: Int)

override type Shape = RectangularShape

extension (e: Entity)
override def isCollidingWith(e2: Entity): Boolean =
(e.position._1 <= e2.position._1 + e2.shape.width) && (e.position._1 + e.shape.width >= e2.position._1) &&
(e.position._2 <= e2.position._2 + e2.shape.height) && (e.position._2 + e.shape.height >= e2.position._2)

object Space2D:
def apply(size: Point2D): Space2D with RectangularEntities = new Space2D with RectangularEntities:
var _world: World = World(
player = Player(size / 2, (0, 1.0), RectangularShape(size._1 / 10, size._2 / 10)),
obstacles = Set.empty,
shape = RectangularShape(size._1, size._2),
)
override def world: World = _world
override def world_=(world: World): Unit = _world = world
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.github.tassiLuca.utils

import scala.annotation.targetName

type Point2D = (Int, Int)

type Vector2D = (Double, Double)

extension (p: Point2D)

@targetName("divideBy")
def /(i: Int): Point2D = (p._1 / i, p._2 / i)

@targetName("subtract")
def -(i: Int): Point2D = (p._1 - i, p._2 - i)

extension (v: Vector2D) def module: Double = Math.sqrt(v._1 * v._1 + v._2 * v._2)
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.github.tassiLuca.boundary.impl

import gears.async.default.given
import gears.async.{Async, Listener}
import io.github.tassiLuca.core.Event
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

import concurrent.duration.DurationInt
import scala.language.postfixOps

class TimerTest extends AnyFlatSpec with Matchers:

"Timer when run" should "generate Tick events observable waiting for them" in {
val timer = Timer(1 seconds)
val before = System.currentTimeMillis()
Async.blocking:
timer.asRunnable.run
timer.src.awaitResult shouldBe Event.Tick
val now = System.currentTimeMillis()
(now - before) should (be > 1_000L)
}

"Timer when run" should "generate Tick events observable attaching a listener" in {
Async.blocking:
val timerPeriod = 1 seconds
val timer = Timer(timerPeriod)
var event: Event = null
timer.src.onComplete(Listener((e, _) => event = e))
timer.asRunnable.run
Thread.sleep(timerPeriod.toMillis + 100)
event shouldBe Event.Tick
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.github.tassiLuca.core

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class SpaceTest extends AnyFlatSpec with Matchers:

"Creating a 2D space" should "initialize a world of the given size with just a player and no obstacles" in {
val size = (100, 150)
val space = Space2D(size)
space.world.player.position should be < size
space.world.obstacles.size shouldBe 0
space.world.shape.width shouldBe size._1
space.world.shape.height shouldBe size._2
}

"It" should "be possible to update the world" in {}

"Checking collision between two 2D rectangular shapes" should "work" in {
val space = Space2D((10, 10))
space.world.player.position shouldBe (5, 5)
space.world.player.shape shouldBe space.RectangularShape(1, 1)
val collidingObstacles = Set(
space.Obstacle((4, 3), (0, 0), space.RectangularShape(1, 2)),
space.Obstacle((5, 6), (0, 0), space.RectangularShape(1, 2)),
space.Obstacle((6, 5), (0, 0), space.RectangularShape(2, 1)),
)
val nonCollidingObstacles = Set(
space.Obstacle((0, 0), (0, 0), space.RectangularShape(1, 2)),
space.Obstacle((7, 6), (0, 0), space.RectangularShape(3, 3)),
)
space.world = space.World(space.world.player, collidingObstacles ++ nonCollidingObstacles, space.world.shape)
collidingObstacles.foreach(o => space.isCollidingWith(space.world.player)(o) shouldBe true)
nonCollidingObstacles.foreach(o => space.isCollidingWith(space.world.player)(o) shouldBe false)
}
Loading

0 comments on commit cb31ce4

Please sign in to comment.