From 4209a05c62416ab364d1447a6daf3ab3473b689d Mon Sep 17 00:00:00 2001 From: smok1e Date: Tue, 8 Jul 2025 04:44:06 +0300 Subject: [PATCH] Added explosion animation --- sprites/particles/Smoke.png | Bin 0 -> 1962 bytes .../sounds/minecraft/countdown_beep.ogg | Bin 0 -> 6186 bytes .../ocelot/desktop/audio/SoundBuffers.scala | 2 + .../ocelot/desktop/audio/SoundSource.scala | 4 +- .../ocelot/desktop/graphics/Graphics.scala | 59 +++++++---- .../ocelot/desktop/graphics/IconSource.scala | 6 ++ .../desktop/node/ComputerAwareNode.scala | 8 +- .../desktop/node/SmokeParticleNode.scala | 98 ++++++++++++++++++ .../ocelot/desktop/util/Spritesheet.scala | 2 +- 9 files changed, 151 insertions(+), 28 deletions(-) create mode 100644 sprites/particles/Smoke.png create mode 100644 src/main/resources/ocelot/desktop/sounds/minecraft/countdown_beep.ogg create mode 100644 src/main/scala/ocelot/desktop/node/SmokeParticleNode.scala diff --git a/sprites/particles/Smoke.png b/sprites/particles/Smoke.png new file mode 100644 index 0000000000000000000000000000000000000000..97b7cba43b5902176deee069301135513e04c268 GIT binary patch literal 1962 zcmcIl+iM(E9G*o@FQ$#wQnezCQ;NNvxzEndkZqFgMzg_*Az7OEQ1I+IXLg6|&KYMW z*-d;2DwZmOks>HQD75%ij8LVCV4>}!QdF#>AYMQVLjM78XZB{TNel&dnVBe?V#qgYRX?&DS)s{mvvD#l349Y0&t!$6#2`QIjPp4f z65bLOfMi+aO-sZjs9~rb;Z<4HBt?}J4akNqr)}Nh+dqMs3$bfYkB#w&n0g)z1L}x=phUbVM@9P-D zsJk|p4XaI0F_K7?)EF#c!j!J5ph9CBRp>f?bU$~!3G7@*U`)dzrPWTR#yc#$YKUq6 zz?A1>T8|DUirNcE9>ydqu&fzCHi4We%Cc>kwqhKXHCvWjP=^%)hB5p%Shtn5t^E^B zvFFZj2I@F=!H;6-BT~p`1tv=LJZzgPa$(vaAOlqem{><3Ltq+Yuws~+Z0Uy8(#6*2 zDVj~fm#lBqEvATZbR%S&vSB8Fpt>ps$kkm?G7T3fNKp(mVw5PqbqPHoXE!uj8 zFwzZ@--1Qdjq5NZLuD4}|12(PJ@FBo+gLg@kdCm4fSO5bz_fG)K$wO=cTL?iU4@i% z)N%cPk=_m~szAR?*vTmgx9`m6s(8IT+_JB3Cl6~oBPHi$tHPvPi?8|Zrn8kLvN^>L z>}1$&73`(kY>3a|6tY(G$@0{LtPQOc@lAE)l1h7<+%Qz_7i*mJ&WjE=F*uy4t(`dQCjU8 z+;a8$8>#Bf(V3{SwD*xMXOCZStkc|!t4^2+#iQT#Eq&iB{(k@U4?a9^p4|Pmb$Zv2 zUn^&<{HqV|8c2P+av;^mBg!7W#4J literal 0 HcmV?d00001 diff --git a/src/main/resources/ocelot/desktop/sounds/minecraft/countdown_beep.ogg b/src/main/resources/ocelot/desktop/sounds/minecraft/countdown_beep.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d0105c9b0754ad0d4438f186208853ae1e2b327e GIT binary patch literal 6186 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 literal 0 HcmV?d00001 diff --git a/src/main/scala/ocelot/desktop/audio/SoundBuffers.scala b/src/main/scala/ocelot/desktop/audio/SoundBuffers.scala index cfeee21..0a62f33 100644 --- a/src/main/scala/ocelot/desktop/audio/SoundBuffers.scala +++ b/src/main/scala/ocelot/desktop/audio/SoundBuffers.scala @@ -44,6 +44,8 @@ object SoundBuffers extends Resource { lazy val MinecraftClickRelease: SoundBuffer = load("/ocelot/desktop/sounds/minecraft/click_release.ogg") lazy val MinecraftExplosion: SoundBuffer = load("/ocelot/desktop/sounds/minecraft/explosion.ogg") + lazy val SelfDestructingCardCountdownBeep: SoundBuffer = load("/ocelot/desktop/sounds/minecraft/countdown_beep.ogg") + lazy val NoteBlock: Map[String, SoundBuffer] = List( "banjo", "basedrum", "bass", "bell", "bit", "chime", "cow_bell", "didgeridoo", "flute", "guitar", "harp", "hat", "iron_xylophone", "pling", "snare", "xylophone", diff --git a/src/main/scala/ocelot/desktop/audio/SoundSource.scala b/src/main/scala/ocelot/desktop/audio/SoundSource.scala index 5598fec..0081f61 100644 --- a/src/main/scala/ocelot/desktop/audio/SoundSource.scala +++ b/src/main/scala/ocelot/desktop/audio/SoundSource.scala @@ -133,6 +133,6 @@ object SoundSource { lazy val MachineFloppyEject: SoundSource = SoundSource.fromBuffer(SoundBuffers.MachineFloppyEject, SoundCategory.Environment) - lazy val SelfDestructingCardBeep: SoundSource = - BeepGenerator.newBeep(".", 1000, 100) + lazy val SelfDestructingCardCountdownBeep: SoundSource = + SoundSource.fromBuffer(SoundBuffers.SelfDestructingCardCountdownBeep, SoundCategory.Environment) } diff --git a/src/main/scala/ocelot/desktop/graphics/Graphics.scala b/src/main/scala/ocelot/desktop/graphics/Graphics.scala index c17f164..390bb43 100644 --- a/src/main/scala/ocelot/desktop/graphics/Graphics.scala +++ b/src/main/scala/ocelot/desktop/graphics/Graphics.scala @@ -345,7 +345,40 @@ class Graphics(private var width: Int, private var height: Int, private var scal animation: Option[Animation] = None): Unit = { sprite = name foreground = color - _rect(x, y, width, height, fixUV = true, animation) + + val spriteRect = animation match { + case Some(animation) => + val duration = animation.frames.map(_._2).sum + var timeOffset = 0f + var curFrame = 0 + + breakable { + for ((idx, dur) <- animation.frames) { + timeOffset += dur + curFrame = idx + if (timeOffset >= time % duration) break + } + } + + val size = animation.frameSize match { + case Some(size) => Size2D(this.spriteRect.w, this.spriteRect.w * size.height / size.width) + case None => Size2D(this.spriteRect.w, this.spriteRect.w) + } + Some(this.spriteRect.copy(y = this.spriteRect.y + curFrame * size.height, h = size.height)) + + case None => None + } + + _rect(x, y, width, height, fixUV = true, spriteRect) + } + + def sprite(name: String, x: Float, y: Float, width: Float, height: Float, + color: Color, + spriteRect: Rect2D): Unit = { + sprite = name + foreground = color + + _rect(x, y, width, height, fixUV = true, Some(spriteRect)) } def rect(r: Rect2D, color: Color): Unit = { @@ -368,28 +401,8 @@ class Graphics(private var width: Int, private var height: Int, private var scal private def _rect(x: Float, y: Float, width: Float, height: Float, fixUV: Boolean = true, - animation: Option[Animation] = None): Unit = { - val spriteRect = animation match { - case None => this.spriteRect - case Some(animation) => - val duration = animation.frames.map(_._2).sum - var timeOffset = 0f - var curFrame = 0 - - breakable { - for ((idx, dur) <- animation.frames) { - timeOffset += dur - curFrame = idx - if (timeOffset >= time % duration) break - } - } - - val size = animation.frameSize match { - case Some(size) => Size2D(this.spriteRect.w, this.spriteRect.w * size.height / size.width) - case None => Size2D(this.spriteRect.w, this.spriteRect.w) - } - this.spriteRect.copy(y = this.spriteRect.y + curFrame * size.height, h = size.height) - } + spriteRectOptional: Option[Rect2D] = None): Unit = { + val spriteRect = spriteRectOptional.getOrElse(this.spriteRect) val uvTransform = Transform2D.translate(spriteRect.x, spriteRect.y) >> (if (fixUV) diff --git a/src/main/scala/ocelot/desktop/graphics/IconSource.scala b/src/main/scala/ocelot/desktop/graphics/IconSource.scala index c9c9d1e..116b13f 100644 --- a/src/main/scala/ocelot/desktop/graphics/IconSource.scala +++ b/src/main/scala/ocelot/desktop/graphics/IconSource.scala @@ -367,4 +367,10 @@ object IconSource { val InnerBorderB: IconSource = IconSource(s"$prefix/InnerBorderB") } + + // ----------------------- Particles ----------------------- + + object Particles { + val Smoke: IconSource = IconSource("particles/Smoke") + } } diff --git a/src/main/scala/ocelot/desktop/node/ComputerAwareNode.scala b/src/main/scala/ocelot/desktop/node/ComputerAwareNode.scala index 1e1264c..4a3ddc6 100644 --- a/src/main/scala/ocelot/desktop/node/ComputerAwareNode.scala +++ b/src/main/scala/ocelot/desktop/node/ComputerAwareNode.scala @@ -24,6 +24,7 @@ abstract class ComputerAwareNode(entity: Entity with Environment with WorkspaceA with SyncedInventory with DiskActivityHandler with OcelotLogParticleNode + with SmokeParticleNode with ShiftClickNode with PositionalSoundSourcesNode { @@ -32,7 +33,10 @@ abstract class ComputerAwareNode(entity: Entity with Environment with WorkspaceA private def soundCardSource: SoundSource = soundCardSounds._2 // PositionalSoundSourcesNode - override def soundSources: Seq[SoundSource] = Seq(SoundSource.MinecraftExplosion) + override def soundSources: Seq[SoundSource] = Seq( + SoundSource.MinecraftExplosion, + SoundSource.SelfDestructingCardCountdownBeep + ) eventHandlers += { case BrainEvent(event: MachineCrashEvent) => @@ -69,7 +73,7 @@ abstract class ComputerAwareNode(entity: Entity with Environment with WorkspaceA slot.get match { case Some(card: SelfDestructingCard) => if (card.time > 0 && card.time % 20 == 0) { - SoundSource.SelfDestructingCardBeep.play() + SoundSource.SelfDestructingCardCountdownBeep.play() } case _ => } diff --git a/src/main/scala/ocelot/desktop/node/SmokeParticleNode.scala b/src/main/scala/ocelot/desktop/node/SmokeParticleNode.scala new file mode 100644 index 0000000..2898972 --- /dev/null +++ b/src/main/scala/ocelot/desktop/node/SmokeParticleNode.scala @@ -0,0 +1,98 @@ +package ocelot.desktop.node + +import ocelot.desktop.color.{Color, RGBAColorNorm} +import ocelot.desktop.geometry.{Rect2D, Size2D, Vector2D} +import ocelot.desktop.graphics.{Graphics, IconSource} +import ocelot.desktop.ui.UiHandler +import ocelot.desktop.ui.event.BrainEvent +import ocelot.desktop.util.Spritesheet +import totoro.ocelot.brain.event.SelfDestructingCardBoomEvent + +import scala.collection.mutable +import scala.util.Random + +trait SmokeParticleNode extends Node { + private case class SmokeParticle( + var velocity: Vector2D = Vector2D(randomParticleVelocityComponent, randomParticleVelocityComponent), + var offset: Vector2D = Vector2D(0, 0), + var color: RGBAColorNorm = randomParticleColor, + var time: Float = 0, + var duration: Float = randomParticleDuration + ) + + private def randomParticleVelocityComponent: Float = Random.between( + -SmokeParticleNode.SmokeParticleVelocityRange, + SmokeParticleNode.SmokeParticleVelocityRange, + ) + + private def randomParticleColor: RGBAColorNorm = { + val channel = Random.between(0.7f, 1f) + RGBAColorNorm(channel, channel, channel) + } + + private def randomParticleDuration: Float = Random.between( + SmokeParticleNode.SmokeParticleAnimationDuration._1, + SmokeParticleNode.SmokeParticleAnimationDuration._2 + ) + + private val smokeParticles = mutable.ArrayDeque.empty[SmokeParticle] + + eventHandlers += { + case BrainEvent(_: SelfDestructingCardBoomEvent) => + val count = Random.between( + SmokeParticleNode.SmokeParticleCount._1, + SmokeParticleNode.SmokeParticleCount._2 + ) + + for (_ <- 1 to count) { + smokeParticles += SmokeParticle() + } + } + + override def update(): Unit = { + super.update() + + smokeParticles.foreach(particle => { + particle.time += UiHandler.dt + particle.offset += (particle.velocity + SmokeParticleNode.SmokeParticleVolatilizationSpeed) * UiHandler.dt + particle.velocity *= SmokeParticleNode.SmokeParticleVelocityDamping + }) + + smokeParticles.filterInPlace(particle => particle.time <= particle.duration) + } + + override def drawParticles(g: Graphics): Unit = { + super.drawParticles(g) + + for (particle <- smokeParticles) { + val spriteRect = Spritesheet.sprites(IconSource.Particles.Smoke.path) + + val animationFrameCount = spriteRect.h / spriteRect.w + val animationFrame = (particle.time / particle.duration * animationFrameCount).toInt + + val particlePosition = bounds.center + particle.offset + + g.sprite( + IconSource.Particles.Smoke.path, + particlePosition.x, + particlePosition.y, + SmokeParticleNode.SmokeParticleSize.width, + SmokeParticleNode.SmokeParticleSize.height, + particle.color, + spriteRect.copy( + y = spriteRect.y + animationFrame * spriteRect.w, + h = spriteRect.w + ) + ) + } + } +} + +private object SmokeParticleNode { + private final val SmokeParticleSize: Size2D = Size2D(32, 32) + private final val SmokeParticleVelocityRange: Float = 300 + private final val SmokeParticleVolatilizationSpeed: Vector2D = Vector2D(0, -50) + private final val SmokeParticleVelocityDamping: Float = .99f + private final val SmokeParticleCount: (Int, Int) = (7, 10) + private final val SmokeParticleAnimationDuration: (Float, Float) = (1f, 4f) +} \ No newline at end of file diff --git a/src/main/scala/ocelot/desktop/util/Spritesheet.scala b/src/main/scala/ocelot/desktop/util/Spritesheet.scala index 4a65d26..4015fb3 100644 --- a/src/main/scala/ocelot/desktop/util/Spritesheet.scala +++ b/src/main/scala/ocelot/desktop/util/Spritesheet.scala @@ -1,6 +1,6 @@ package ocelot.desktop.util -import ocelot.desktop.geometry.{Rect2D, Size2D} +import ocelot.desktop.geometry.{Rect2D, Size2D, Vector2D} import ocelot.desktop.graphics.{IconSource, Texture} import javax.imageio.ImageIO