diff --git a/src/main/scala/ocelot/desktop/node/ComputerAwareNode.scala b/src/main/scala/ocelot/desktop/node/ComputerAwareNode.scala index 671a10c..0068aa6 100644 --- a/src/main/scala/ocelot/desktop/node/ComputerAwareNode.scala +++ b/src/main/scala/ocelot/desktop/node/ComputerAwareNode.scala @@ -1,9 +1,7 @@ package ocelot.desktop.node -import ocelot.desktop.OcelotDesktop -import ocelot.desktop.{Settings => DesktopSettings} +import ocelot.desktop.{ColorScheme, OcelotDesktop, Settings => DesktopSettings} import ocelot.desktop.audio._ -import ocelot.desktop.geometry.Vector2D import ocelot.desktop.graphics.{Graphics, IconSource} import ocelot.desktop.inventory.SyncedInventory import ocelot.desktop.node.ComputerAwareNode._ @@ -11,14 +9,13 @@ import ocelot.desktop.node.Node.Size import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.event.BrainEvent import ocelot.desktop.ui.event.handlers.DiskActivityHandler -import ocelot.desktop.ui.widget.ComputerErrorMessageLabel +import ocelot.desktop.ui.particle.Particle import ocelot.desktop.util.Messages import totoro.ocelot.brain.Settings import totoro.ocelot.brain.entity.traits.{Entity, Environment, WorkspaceAware} import totoro.ocelot.brain.event._ import java.util.Calendar -import scala.collection.mutable.ArrayBuffer abstract class ComputerAwareNode(entity: Entity with Environment with WorkspaceAware) extends EntityNode(entity) @@ -27,13 +24,6 @@ abstract class ComputerAwareNode(entity: Entity with Environment with WorkspaceA with OcelotLogParticleNode with ShiftClickNode { - // access should be synchronized because messages are added in the update thread - private val messages = ArrayBuffer.empty[(Float, ComputerErrorMessageLabel)] - - private def addErrorMessage(message: ComputerErrorMessageLabel): Unit = messages.synchronized { - messages += ((0f, message)) - } - private lazy val soundCardSounds: (SoundStream, SoundSource) = Audio.newStream(SoundCategory.Records) private def soundCardStream: SoundStream = soundCardSounds._1 private def soundCardSource: SoundSource = soundCardSounds._2 @@ -44,13 +34,11 @@ abstract class ComputerAwareNode(entity: Entity with Environment with WorkspaceA case Some(message) => logger.info(s"[EVENT] Machine crash (address = ${event.address})! Message code ${event.message}: $message") message - case None => logger.info(s"[EVENT] Machine crash (address = ${event.address})! Message: ${event.message}") event.message } - - addErrorMessage(new ComputerErrorMessageLabel(this, message)) + UiHandler.root.workspaceView.particleSystem.add(new ErrorMessageParticle(message)) case BrainEvent(event: BeepEvent) if !Audio.isDisabled => BeepGenerator.newBeep(".", event.frequency, event.duration).play() @@ -70,28 +58,6 @@ abstract class ComputerAwareNode(entity: Entity with Environment with WorkspaceA }) } - override def update(): Unit = { - messages.synchronized { - messages.mapInPlace { case (t, message) => (t + ErrorMessageMoveSpeed * UiHandler.dt, message) } - messages.filterInPlace(_._1 <= 1f) - } - - super.update() - } - - private def drawMessageParticles(g: Graphics): Unit = messages.synchronized { - for ((time, message) <- messages.reverseIterator) { - message.position = message.initialPosition + Vector2D(0, -MaxErrorMessageDistance * time) - message.alpha = 1 - time - message.draw(g) - } - } - - override def drawParticles(g: Graphics): Unit = { - super.drawParticles(g) - drawMessageParticles(g) - } - protected def drawOverlay(g: Graphics): Unit = HolidayIcon match { case Some(icon) if DesktopSettings.get.enableFestiveDecorations => val holidayOverlaySize = Size * 1.6f @@ -110,9 +76,22 @@ abstract class ComputerAwareNode(entity: Entity with Environment with WorkspaceA override def draw(g: Graphics): Unit = { super.draw(g) - drawOverlay(g) } + + private class ErrorMessageParticle(message: String) extends Particle { + private val offsetX = size.width / 2 - message.length * 4 + private val offsetY = -8 + override def update(dt: Float): Unit = { + time += ErrorMessageMoveSpeed * dt + } + override def draw(g: Graphics): Unit = { + g.setSmallFont() + g.foreground = ColorScheme("ErrorMessage").withAlpha(1 - (2 * time - 1).min(1).max(0)) + g.text(position.x + offsetX, position.y + offsetY - MaxErrorMessageDistance * time, message) + g.setNormalFont() + } + } } object ComputerAwareNode { diff --git a/src/main/scala/ocelot/desktop/node/Node.scala b/src/main/scala/ocelot/desktop/node/Node.scala index 30ebd49..c922c2a 100644 --- a/src/main/scala/ocelot/desktop/node/Node.scala +++ b/src/main/scala/ocelot/desktop/node/Node.scala @@ -297,8 +297,6 @@ abstract class Node extends Widget with MouseHandler with HoverHandler with Pers drawPortLegend(g) } - def drawParticles(g: Graphics): Unit = {} - def drawPorts(g: Graphics): Unit = { for ((port, rects) <- portsBounds) { val color = port.getColor diff --git a/src/main/scala/ocelot/desktop/node/OcelotLogParticleNode.scala b/src/main/scala/ocelot/desktop/node/OcelotLogParticleNode.scala index e490219..7e3c2b1 100644 --- a/src/main/scala/ocelot/desktop/node/OcelotLogParticleNode.scala +++ b/src/main/scala/ocelot/desktop/node/OcelotLogParticleNode.scala @@ -8,62 +8,41 @@ import ocelot.desktop.graphics.Graphics import ocelot.desktop.node.OcelotLogParticleNode._ import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.event.BrainEvent +import ocelot.desktop.ui.particle.Particle -import scala.collection.mutable import scala.util.Random trait OcelotLogParticleNode extends Node { - private case class LogParticle( - var time: Float = -LogParticleGrow, - angle: Float = Random.between(0f, 2 * math.Pi.toFloat * LogParticleMaxAngle), - ) - - // access should be synchronized because log particles are added in the update thread - private val logParticles = mutable.ArrayDeque.empty[LogParticle] - - private def addLogParticle(): Unit = logParticles.synchronized { - if (logParticles.length < MaxLogParticles) { - logParticles += LogParticle() - } - } - eventHandlers += { case BrainEvent(OcelotInterface.LogEvent.CardToUser(_, _)) => - addLogParticle() + val system = UiHandler.root.workspaceView.particleSystem + if (system.count[LogParticle](Some(this)) < MaxLogParticles) { + system.add(new LogParticle) + } } - override def update(): Unit = { - super.update() - - logParticles.synchronized { - logParticles.foreach(particle => particle.time += LogParticleMoveSpeed * UiHandler.dt) - logParticles.filterInPlace(_.time <= 1f) + private class LogParticle extends Particle(-LogParticleGrow, origin = Some(this)) { + private val angle: Float = Random.between(0f, 2 * math.Pi.toFloat * LogParticleMaxAngle) + override def update(dt: Float): Unit = { + time += LogParticleMoveSpeed * dt } - } - - private def drawLogParticles(g: Graphics): Unit = logParticles.synchronized { - for (particle <- logParticles) { - val size = (1 + particle.time / LogParticleGrow).clamp() * LogParticleSize - val offset = particle.time.clamp() * LogParticleMoveDistance - val alpha = 1 - particle.time.clamp() + override def draw(g: Graphics): Unit = { + val size = (1 + time / LogParticleGrow).clamp() * LogParticleSize + val offset = time.clamp() * LogParticleMoveDistance + val alpha = 1 - time.clamp() val r1 = (bounds.w max bounds.h) / math.sqrt(2) + offset + LogParticlePadding val r2 = r1 + size for (i <- 0 until LogParticleCount) { - val angle = particle.angle + (2 * math.Pi).toFloat * i / LogParticleCount - val v = Vector2D.unit(angle) + val a = angle + (2 * math.Pi).toFloat * i / LogParticleCount + val v = Vector2D.unit(a) val p1 = v * r1 + bounds.center val p2 = v * r2 + bounds.center g.line(p1, p2, 1f, ColorScheme("LogParticle").mapA(_ => alpha)) } } } - - override def drawParticles(g: Graphics): Unit = { - super.drawParticles(g) - drawLogParticles(g) - } } object OcelotLogParticleNode { diff --git a/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNodeBase.scala b/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNodeBase.scala index ea4f2ff..8a9cb4a 100644 --- a/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNodeBase.scala +++ b/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNodeBase.scala @@ -7,11 +7,10 @@ import ocelot.desktop.graphics.Graphics import ocelot.desktop.node.{EntityNode, LabeledEntityNode} import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.event.BrainEvent +import ocelot.desktop.ui.particle.Particle import totoro.ocelot.brain.entity.traits.{Entity, Environment} import totoro.ocelot.brain.event.NoteBlockTriggerEvent -import scala.collection.mutable - abstract class NoteBlockNodeBase(entity: Entity with Environment) extends EntityNode(entity) with LabeledEntityNode { eventHandlers += { case BrainEvent(event: NoteBlockTriggerEvent) => @@ -22,31 +21,24 @@ abstract class NoteBlockNodeBase(entity: Entity with Environment) extends Entity volume = event.volume.toFloat.min(1f).max(0f), ).play() - addParticle(event.pitch) - } - - private val particles = mutable.ArrayBuffer[(Float, Int)]() - - private def addParticle(pitch: Int): Unit = { - synchronized { - particles += ((0f, pitch)) - } - } - - override def drawParticles(g: Graphics): Unit = synchronized { - for ((time, pitch) <- particles.reverseIterator) { - val col = ColorScheme("Note" + pitch.min(24).max(0)).withAlpha(1 - (2 * time - 1).min(1).max(0)) - g.sprite("particles/Note", position + Vector2D(pitch / 24f * 40f + 5, height / 2 - 10 - 100 * time), - Size2D(14, 20), col) - } - particles.mapInPlace { case (t, p) => (t + 1.2f * UiHandler.dt, p) } - particles.filterInPlace(_._1 <= 1f) + UiHandler.root.workspaceView.particleSystem.add(new NoteParticle(event.pitch)) } override def update(): Unit = { super.update() - - if (isHovered || isMoving) + if (isHovered || isMoving) { root.get.statusBar.addMouseEntry("icons/LMB", "Play sample") + } + } + + private class NoteParticle(pitch: Int) extends Particle { + override def update(dt: Float): Unit = { + time += 1.2f * UiHandler.dt + } + override def draw(g: Graphics): Unit = { + val col = ColorScheme("Note" + pitch.min(24).max(0)).withAlpha(1 - (2 * time - 1).min(1).max(0)) + g.sprite("particles/Note", position + Vector2D(pitch / 24f * 40f + 5, height / 2 - 10 - 100 * time), + Size2D(14, 20), col) + } } } diff --git a/src/main/scala/ocelot/desktop/ui/particle/Particle.scala b/src/main/scala/ocelot/desktop/ui/particle/Particle.scala new file mode 100644 index 0000000..1f91984 --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/particle/Particle.scala @@ -0,0 +1,25 @@ +package ocelot.desktop.ui.particle + +import ocelot.desktop.graphics.Graphics +import ocelot.desktop.ui.widget.Widget + +/** + * Everything a single particle needs to live and thrive. + * @param time current lifetime of the particle + * @param ttl when the `time` will reach this value - the particle is going to be discarded + * @param origin optional, marks the widget that "spawned" this particle + */ + +case class Particle( + var time: Float = 0.0f, + var ttl: Float = 1.0f, + origin: Option[Widget] = None, +) { + def expired: Boolean = time >= ttl + + def update(dt: Float): Unit = { + time += dt + } + + def draw(g: Graphics): Unit = {} +} diff --git a/src/main/scala/ocelot/desktop/ui/particle/ParticleSystem.scala b/src/main/scala/ocelot/desktop/ui/particle/ParticleSystem.scala new file mode 100644 index 0000000..ee6370d --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/particle/ParticleSystem.scala @@ -0,0 +1,32 @@ +package ocelot.desktop.ui.particle + +import ocelot.desktop.graphics.Graphics +import ocelot.desktop.ui.widget.Widget + +import scala.collection.mutable.ListBuffer + +class ParticleSystem { + private val particles: ListBuffer[Particle] = ListBuffer.empty + + def add(particle: Particle): Unit = particles.addOne(particle) + + def count[A](origin: Option[Widget]): Int = particles.count(p => p.isInstanceOf[A] && p.origin == origin) + + /** + * Calls `update` method on all particles, advances time. + * Removes expired particles. + * @param dt delta time + */ + def update(dt: Float): Unit = { + particles.foreach(_.update(dt)) + particles.filterInPlace(!_.expired) + } + + def draw(g: Graphics): Unit = { + particles.foreach(_.draw(g)) + } + + def clear(): Unit = { + particles.clear() + } +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/ComputerErrorMessageLabel.scala b/src/main/scala/ocelot/desktop/ui/widget/ComputerErrorMessageLabel.scala deleted file mode 100644 index afa21ca..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/ComputerErrorMessageLabel.scala +++ /dev/null @@ -1,23 +0,0 @@ -package ocelot.desktop.ui.widget - -import ocelot.desktop.ColorScheme -import ocelot.desktop.color.Color -import ocelot.desktop.geometry.Vector2D -import ocelot.desktop.node.Node - -class ComputerErrorMessageLabel(node: Node, override val text: String) extends Label { - override def isSmall: Boolean = true - - var alpha: Float = 1f - - override def color: Color = ColorScheme("ErrorMessage").toRGBANorm.mapA(_ * alpha) - - val initialPosition: Vector2D = { - val position = node.position - val size = node.size - - position + Vector2D(size.width / 2 - minimumSize.width / 2, -minimumSize.height) - } - - position = initialPosition -} diff --git a/src/main/scala/ocelot/desktop/ui/widget/WorkspaceView.scala b/src/main/scala/ocelot/desktop/ui/widget/WorkspaceView.scala index 2750958..e70266f 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/WorkspaceView.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/WorkspaceView.scala @@ -6,10 +6,12 @@ import ocelot.desktop.graphics.{Graphics, IconSource} import ocelot.desktop.node.Node.Size import ocelot.desktop.node.nodes.{ComputerNode, ScreenNode} import ocelot.desktop.node.{EntityNode, Node, NodePort} +import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.event._ import ocelot.desktop.ui.event.handlers.{HoverHandler, MouseHandler} import ocelot.desktop.ui.event.sources.KeyEvents import ocelot.desktop.ui.layout.{CopyLayout, Layout} +import ocelot.desktop.ui.particle.ParticleSystem import ocelot.desktop.ui.widget.WorkspaceView.NodeLoadException import ocelot.desktop.ui.widget.window.{NodeSelector, ProfilerWindow, WindowPool} import ocelot.desktop.util.Keybind.{Center, Profiler} @@ -34,6 +36,7 @@ class WorkspaceView extends Widget with Persistable with MouseHandler with Hover @volatile var nodes: immutable.Seq[Node] = immutable.ArraySeq[Node]() + var particleSystem = new ParticleSystem var windowPool = new WindowPool var nodeSelector = new NodeSelector var profilerWindow = new ProfilerWindow @@ -65,6 +68,7 @@ class WorkspaceView extends Widget with Persistable with MouseHandler with Hover def reset(): Unit = { nodes.foreach(_.dispose()) nodes = nodes.empty + particleSystem.clear() windowPool.deleteAllWindows() nodeSelector = new NodeSelector profilerWindow = new ProfilerWindow @@ -458,7 +462,7 @@ class WorkspaceView extends Widget with Persistable with MouseHandler with Hover } } - def newConnectionTarget: Option[(Node, NodePort)] = newConnection flatMap { case (node, _, endpoint) => + private def newConnectionTarget: Option[(Node, NodePort)] = newConnection flatMap { case (node, _, endpoint) => val validTargets = { nodes.iterator .filter(_ != node) @@ -607,7 +611,6 @@ class WorkspaceView extends Widget with Persistable with MouseHandler with Hover nodes.foreach(_.draw(g)) nodes.foreach(_.drawLight(g)) nodes.foreach(_.drawLabel(g)) - nodes.foreach(_.drawParticles(g)) portsAlpha.update() portsAlpha.goto(if (newConnection.isDefined) 1 else 0) @@ -637,12 +640,15 @@ class WorkspaceView extends Widget with Persistable with MouseHandler with Hover drawSelectorConnection(g, Rect2D(newNodePos.x + cameraOffset.x, newNodePos.y + cameraOffset.y, 64, 64), nodeSelector.bounds, color = color) + particleSystem.draw(g) + drawChildren(g) } override def update(): Unit = { super.update() nodes.foreach(_.update()) + particleSystem.update(UiHandler.dt) if (isHovered) { root.get.statusBar.addKeyEntry(Settings.get.keymap.name(Center), "Reset camera")