Refactor and unify particle system

This commit is contained in:
UnicornFreedom 2025-08-03 01:34:22 +02:00
parent b248c4beeb
commit c510cbb834
No known key found for this signature in database
GPG Key ID: B4ED0DB6B940024F
8 changed files with 112 additions and 124 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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()
}
override def update(): Unit = {
super.update()
logParticles.synchronized {
logParticles.foreach(particle => particle.time += LogParticleMoveSpeed * UiHandler.dt)
logParticles.filterInPlace(_.time <= 1f)
val system = UiHandler.root.workspaceView.particleSystem
if (system.count[LogParticle](Some(this)) < MaxLogParticles) {
system.add(new LogParticle)
}
}
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()
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
}
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 {

View File

@ -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)
}
}
}

View File

@ -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 = {}
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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")