Skip to content
Snippets Groups Projects
Commit 71518a13 authored by William Billingsley's avatar William Billingsley
Browse files

Update to 2022

Makes it a little clearer where the boundary between imperative and functional code is. FrameMemory and everything within it is functional.
parent a23f174d
No related branches found
No related tags found
No related merge requests found
Showing with 297 additions and 70 deletions
# Assignment 2: Mixing functional and imperative code
# Assignment 2: Boids
Previously, we've talked about how we can describe a changing system
as a series of immutable states. We're going to do this to implement
a simulation.
In a pure functional language, such as Haskell, the code is a pure (no side-effects) description of a program which is
then run in a runtime environment.
This is going to cause you to write:
As Scala is mixed paradigm, we sometimes find ourselves "pushing mutation to the edges", so that the core parts of your code
can be pure. For instance, "effect types" (types that describe a program that has input and output effects) may have an
`unsafeRunSync` or `unsafeRunAsync` method which is not pure. You then have a pure program in most of the code, with a
single call to an `unsafeRun` method in the `main` method - the mutation has been pushed to the edges of the program.
* Functional code for how you produce the next state from the current
state
* A small number of mutable structures using imperative code for remembering the states
and controlling the simulation. (And painting it.)
We're not going to go quite that far - this is only your second assignment in the unit. However, we are going to try
to separate our functional code from our imperative code somewhat. And we're going to try to make the "important"
code functional (and more testable).
This year, the simulation is *boids*.
We're going to do a simulation where:
* The core logic of the simulation (the current frame, and the "frame memory" storing previously calculated frames) is
written functionally.
* The UI and a SimulationController class are written imperatively.
The simulation is *boids*.
## Boids
......@@ -46,21 +53,10 @@ simulation using a mixture of functional and imperative programming.
## Mixing functional and imperative programming
Your aim is to keep your code as functional as you can, but you're going
to be led into having a few places where there are imperative concepts too.
For example, your simulation is going to keep an action replay memory.
This is going to require you to store past states of the simulation as
you've played them -- something that works well with the *current state*
being an immutable `Seq` of immutable `Boids`.
But the memory itself uses a mutable data structure
(I've picked `mutable.Queue`) so that it can fill over time, and reset
when you click a button
* Everything in `FrameMemory`, `SimulationFrame`, and `Boid` is pure and functional
* `SimulationController` and the UI classes are imperative.
There are also various mutable properties that we're going to add to our
simulation:
There are also various properties that we're going to add to our simulation:
* The wind
* The ability to "startle" the boids by applying a random impulse to
......@@ -86,10 +82,13 @@ Partially implemented, you have:
* `Boid` -- an immutable representation of a Boid. You will need to
work functionally to produce new sequences of `Boid`
* `Simulation` -- a class for storing the simulation states. This
contains a mutable queue for the simulation memory, and some
mutable variables that let you alter what happens on the next
simulation step
* extension methods for `Seq[Boid]` that I think will make your algorithms in `Boid` cleaner to write
* `SimulationFrame`, which holds a `Seq[Boid]` but can also report various statistics on it
* `FrameMemory`, which holds a memory of the last *n* frames of the simulation
* `SimulationController` -- a class the UI buttons and timer can call to control and run the simulation.
* `BoidsApp` -- the runnable program, that creates a window, and adds the
`BoidsPanel` and some buttons. Note that the buttons' event handlers
......@@ -97,7 +96,7 @@ Partially implemented, you have:
## What the components need to do
* The **Action Replay** button must take the simulation back to the start of
* The **Action Replay** button must take the simulation back to the oldest frame in
the memory buffer (typically one second) and let the simulation continue
again from there.
Hint: think about which end you want to insert the frame.
......@@ -133,13 +132,6 @@ Partially implemented, you have:
(average the position vectors). Then for each boid, calculate the square of its distance from the centroid. Return the
mean of that. To test this, hit the "explosion of boids" button. It should drop to nearly zero and then grow.
## Note on the provided code
I've provided this code by writing a solution and then cutting back from it.
But it's possible you might not like some parts of my code. You can delete
and change *any code you like* -- your task is to write the simulation, not
to retain my code.
## Marking
The marking is aimed to be able to be done quickly, with the written feedback being
......
lazy val root = (project in file(".")).
settings(
name := "Boids",
version := "2021.0",
scalaVersion := "3.0.0-RC1"
version := "2022.0",
scalaVersion := "3.1.0"
)
libraryDependencies += "org.scala-lang.modules" %% "scala-swing" % "3.0.0"
libraryDependencies += "org.scalameta" %% "munit" % "0.7.22" % Test
libraryDependencies += "org.scalameta" %% "munit" % "0.7.29" % Test
testFrameworks += new TestFramework("munit.Framework")
sbt.version=1.4.9
sbt.version=1.6.2
// This tells SBT to load the SBT plugin for Scala 3 (codenamed dotty)
// Because Scala 3 is still in "developer preview", its compiler is provided to sbt by a plugin
addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % "0.5.3")
// No plugin needed for Scala 3
\ No newline at end of file
......@@ -95,10 +95,43 @@ object Boid {
val neighBourDist = 50
/** Wrap width of the simulation. ie, for any Boid, 0 <= x < 640 */
def maxX:Int = Simulation.width
def maxX:Int = SimulationController.width
/** Wrap height of the simulation. ie, for any Boid, 0 <= y < 480 */
def maxY:Int = Simulation.height
def maxY:Int = SimulationController.height
/** When the boids are startled, the strength of the vector that is applied to each of them */
val startleStrength:Double = Boid.maxSpeed
/** A function that will "startle" a boid */
def startleFunction(b:Boid):Vec2 = ???
}
/*
* Defining these extension methods might make your work in the Boids algorithms cleaner.
*/
extension (boids:Seq[Boid]) {
/**
* Returns only those boids within d distance of position p
* align, separate, and cohesion all want to consider boids within a certain range.
*/
def closeTo(p:Vec2, d:Double):Seq[Boid] =
???
/**
* Calculates the centroid of a group of boids.
* Cohesion asks a boid to steer towards the centroid of the boids within a certain distance
*/
def centroid:Vec2 =
???
/**
* Calculates the average velocity vector (add them up and divide by the number in the group) of a group of boids
* Align asks a boid to steer so it will align more with its neighbours' average velocity vector
*/
def averageVelocity:Vec2 =
???
}
......@@ -60,17 +60,17 @@ object BoidsApp {
windPanel.add(seWind)
windPanel.setAlignmentX(0)
val windStrength = Simulation.windStrength
nwWind.addActionListener((_) => Simulation.setWindDirection(Vec2.NW))
nWind.addActionListener((_) => Simulation.setWindDirection(Vec2.N))
neWind.addActionListener((_) => Simulation.setWindDirection(Vec2.NE))
wWind.addActionListener((_) => Simulation.setWindDirection(Vec2.W))
stopWind.addActionListener((_) => Simulation.wind=None)
eWind.addActionListener((_) => Simulation.setWindDirection(Vec2.E))
swWind.addActionListener((_) => Simulation.setWindDirection(Vec2.SW))
sWind.addActionListener((_) => Simulation.setWindDirection(Vec2.S))
seWind.addActionListener((_) => Simulation.setWindDirection(Vec2.SE))
val windStrength = SimulationController.windStrength
nwWind.addActionListener((_) => SimulationController.setWindDirection(Vec2.NW))
nWind.addActionListener((_) => SimulationController.setWindDirection(Vec2.N))
neWind.addActionListener((_) => SimulationController.setWindDirection(Vec2.NE))
wWind.addActionListener((_) => SimulationController.setWindDirection(Vec2.W))
stopWind.addActionListener((_) => SimulationController.wind=None)
eWind.addActionListener((_) => SimulationController.setWindDirection(Vec2.E))
swWind.addActionListener((_) => SimulationController.setWindDirection(Vec2.SW))
sWind.addActionListener((_) => SimulationController.setWindDirection(Vec2.S))
seWind.addActionListener((_) => SimulationController.setWindDirection(Vec2.SE))
val wc = new JLabel("Wind controls")
wc.setAlignmentX(0)
......@@ -100,32 +100,31 @@ object BoidsApp {
window.setSize(container.getPreferredSize)
window.setVisible(true)
Simulation.pushState(Simulation.explosionOfBoids(Simulation.numBoids))
replay.addActionListener({ (evt) =>
Simulation.resetQueue()
SimulationController.resetQueue()
})
startle.addActionListener({ (evt) =>
Simulation.oneTimeFunction = Some(Simulation.startleFunction)
SimulationController.oneTimeFunction = Some(Boid.startleFunction)
})
regenesis.addActionListener({ (evt) =>
Simulation.pushState(Simulation.explosionOfBoids(Simulation.numBoids))
SimulationController.pushFrame(SimulationFrame.explosionOfBoids(SimulationController.numBoids))
})
boidsPanel.addMouseListener(new MouseAdapter {
override def mouseClicked(e: MouseEvent):Unit = {
Simulation.pushBoid(Boid(Vec2(e.getX, e.getY), Vec2.randomDir(1)))
SimulationController.pushBoid(Boid(Vec2(e.getX, e.getY), Vec2.randomDir(1)))
}
})
val timer = new Timer(16, (e) => {
boidsPanel.setBoids(Simulation.update())
SimulationController.update()
boidsPanel.setBoids(SimulationController.current.boids)
SwingUtilities.invokeLater(() =>
directionLabel.setText(s"Flock direction: ${Simulation.flockDir} radians")
velocityLabel.setText(s"Flock speed: ${Simulation.flockSpeed} ")
separationLabel.setText(s"Flock separation: ${Simulation.flockSep}")
directionLabel.setText(s"Flock direction: ${SimulationController.current.flockDir} radians")
velocityLabel.setText(s"Flock speed: ${SimulationController.current.flockSpeed} ")
separationLabel.setText(s"Flock separation: ${SimulationController.current.flockSep}")
)
})
timer.start()
......
......@@ -23,9 +23,9 @@ class BoidsPanel extends JPanel {
/** Contains the boids we will be rendering on the next render cycle */
private var boids:Seq[Boid] = Seq.empty
override def getMinimumSize = new Dimension(Simulation.width, Simulation.height)
override def getPreferredSize = new Dimension(Simulation.width, Simulation.height)
override def getMaximumSize = new Dimension(Simulation.width, Simulation.height)
override def getMinimumSize = new Dimension(SimulationController.width, SimulationController.height)
override def getPreferredSize = new Dimension(SimulationController.width, SimulationController.height)
override def getMaximumSize = new Dimension(SimulationController.width, SimulationController.height)
/** Paints our boids */
override def paintComponent(gAwt:Graphics): Unit = {
......
package cosc250.boids
import scala.collection.immutable.Queue
/**
* Holds the replay buffer for our simulation.
*
* @param queue - the queue of frames that is the memory
* @param max - the max number of frames to hold
*/
class FrameMemory(queue:Queue[SimulationFrame], max:Int) {
/** An alternative constructor, so we can say FrameMemory(startFrame, maxFrames) */
def this(startFrame:SimulationFrame, max:Int) = this(Queue(startFrame), max)
def currentFrame:SimulationFrame =
// Remember, items join queues at the back.
???
def oldestFrame:SimulationFrame =
???
def pushFrame(frame:SimulationFrame):FrameMemory =
// Don't forget to dequeue old frames if it's getting too long.
???
}
package cosc250.boids
import scala.collection.mutable
object Simulation {
/**
* We've kept mutation in the simulation just in this top-level object.
*/
object SimulationController {
/** Wrap width of the simulation. ie, for any Boid, 0 <= x < 640 */
val width = 640
......@@ -11,23 +12,14 @@ object Simulation {
val height = 480
/** How many frames of the simulation to hold */
val frameMemory = 60
val frameMemoryLength = 60
/** How manby boids to start with in the simulation */
/** How many boids to start with in the simulation */
val numBoids = 150
/** When the wind is blowing, how strongly it blows */
val windStrength = 0.03
/** When the boids are startled, the strength of the vector that is applied to each of them */
val startleStrength:Double = Boid.maxSpeed
/** A function that will "startle" a boid */
def startleFunction(b:Boid):Vec2 = ???
/** A mutable queue containing the last `frameMemory frames` */
val queue:mutable.Queue[Seq[Boid]] = mutable.Queue.empty[Seq[Boid]]
/** The wind -- an optional acceleration vector */
var wind:Option[Vec2] = None
......@@ -55,21 +47,14 @@ object Simulation {
???
}
/** The current frame */
def current = ???
/** Generates boids in the centre of the simulation, moving at v=1 in a random direction */
def explosionOfBoids(i:Int):Seq[Boid] =
???
/** Pushes a state into the queue */
def pushState(boids:Seq[Boid]):Seq[Boid] = {
???
/**
* A queue containing the last `frameMemory frames`.
* We're using an immutable queue, but we've put it in a var, given that this controller is mutable.
*/
var frameMemory = FrameMemory(SimulationFrame.explosionOfBoids(numBoids), frameMemoryLength)
// Drops a frame from the queue if we've reached the maximum number of frames to remember
if (queue.lengthCompare(frameMemory) > 0) queue.dequeue()
boids
}
/** The current frame */
def current:SimulationFrame = ???
/** Called by a click to the canvas, to say that in the next frame, a boid should be inserted */
def pushBoid(b:Boid):Unit = {
......@@ -81,35 +66,15 @@ object Simulation {
???
}
/** Generate the next frame in the simulation */
def update():Seq[Boid] = {
/** Progress to the next frame in the simulation */
def update():Unit = {
???
}
/** Force the simulation to use this as the next frame */
def pushFrame(frame:SimulationFrame):Unit = {
???
}
/** The current average direction of the flock. Add up all the boids' velocity vectors, and take the theta. */
def flockDir:Double =
println("Warning, you haven't implemented flockDir!")
0d
/** The current average speed of the flock. Take the mean of all the boids' velocity magnitudes. */
def flockSpeed:Double =
println("Warning, you haven't implemented flockSpeed!")
0d
/**
* The variance of the flock's positions, ignoring the fact we wrap around the screen.
* To get this one:
* * Calculate the centroid of the flock (Add all the position vectors, and divide by the number of boids)
* * Calculate the square of the distance of each boid from this centroid, and sum them.
* i.e., sum Math.pow((b.position - centroid).magnitude, 2)
* * Divide this by the number of boids.
*
* We'll probably eyeball the code for this one, given we're going to find it harder to eyeball whether the number
* on the screen looks right!
*/
def flockSep:Double =
println("Warning, you haven't implemented flockSep!")
0d
}
package cosc250.boids
/**
* Represents the state in our simulation.
*
* A simulation frame contains an immutable sequence of Boids. It has methods for reporting various measures about
* the boids in the frame. It has a method for producing a new simulation frame
*/
case class SimulationFrame(boids:Seq[Boid]) {
/** The current average direction of the flock. Add up all the boids' velocity vectors, and take the theta. */
def flockDir:Double =
println("Warning, you haven't implemented flockDir!")
0d
/** The current average speed of the flock. Take the mean of all the boids' velocity magnitudes. */
def flockSpeed:Double =
println("Warning, you haven't implemented flockSpeed!")
0d
/**
* The variance of the flock's positions, ignoring the fact we wrap around the screen.
* To get this one:
* * Calculate the centroid of the flock (Add all the position vectors, and divide by the number of boids)
* * Calculate the square of the distance of each boid from this centroid, and sum them.
* i.e., sum Math.pow((b.position - centroid).magnitude, 2)
* * Divide this by the number of boids.
*
* We'll probably eyeball the code for this one, given we're going to find it harder to eyeball whether the number
* on the screen looks right!
*/
def flockSep:Double =
println("Warning, you haven't implemented flockSep!")
0d
/** This function should calculate the next set of boids assuming there is no wind & no one-time functions applied */
def nextBoids:Seq[Boid] =
???
/**
*
* @param wind - a force applied to every boid. We've called it "wind" but in practice, it'll steer the flock.
* @param oneTimeFunction - a function to apply to every boid (e.g. startle).
* @return
*/
def nextFrame(wind:Option[Vec2] = None, oneTimeFunction:Option[Boid => Vec2] = None):SimulationFrame =
???
}
object SimulationFrame {
/** Generates boids in the centre of the simulation, moving at v=1 in a random direction */
def explosionOfBoids(i:Int):SimulationFrame =
???
}
\ No newline at end of file
package cosc250.boids
/**
* A place for you to write some boid tests.
*
* Boids are an immutable class containing functions. That makes them relatively straightforward to test --
* except that the values within them are doubles, which are hard to compare exactly. Instead, test if they
* are close (i.e. within a certain amount +/- what you're looking for).
*/
class BoidSuite extends munit.FunSuite {
// A place for you to write tests. Some suggested tests to start with have been sketched below
// Let's start with the extension methods closeTo, centroid and averageVelocity on Seq[Boid]...
test("Seq[Boid] should be able to filter just those close to a certain point") {
???
}
test("Seq[Boid] should be able to calculate its centroid") {
???
}
test("Seq[Boid] should be able to calculate its average velocity") {
???
}
}
package cosc250.boids
/**
* SimulationFrames are immutable data classes with functions to methods to produce new immutable values.
* They are eminently testable - although you will probably want to use a small number of boids (e.g. 2 or 3) in
* your tests.
*/
class SimulationFrameSuite extends munit.FunSuite {
// A place for you to write tests
}
package cosc250.boids
/**
*
* This code is provided.
*/
class Vec2Suite extends munit.FunSuite {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment