diff --git a/ScalaGameEngine/src/main/scala/sge/audio/Exports.scala b/ScalaGameEngine/src/main/scala/sge/audio/Exports.scala new file mode 100644 index 00000000..5da70a52 --- /dev/null +++ b/ScalaGameEngine/src/main/scala/sge/audio/Exports.scala @@ -0,0 +1,4 @@ +package sge.audio + +export SoundIO.* +export sge.audio.AudioClipId.* \ No newline at end of file diff --git a/ScalaGameEngine/src/main/scala/sge/audio/SoundIO.scala b/ScalaGameEngine/src/main/scala/sge/audio/SoundIO.scala new file mode 100644 index 00000000..b858d44a --- /dev/null +++ b/ScalaGameEngine/src/main/scala/sge/audio/SoundIO.scala @@ -0,0 +1,266 @@ +package sge.audio + +import sge.core.* + +import javax.sound.sampled.* +import java.io.{BufferedInputStream, InputStream} +import java.util.concurrent.atomic.AtomicLong +import scala.collection.concurrent.TrieMap + +/** An extension of IO that provides audio playback capabilities. + */ +trait SoundIO extends IO: + /** The master volume for all audio clips (0.0 to 1.0) */ + def masterVolume: Double + + /** Sets the master volume that affects all audio clips. + * + * @param volume the new master volume (0.0 to 1.0) + */ + def masterVolume_=(volume: Double): Unit + + /** Plays an audio clip from the given resource path. + * @param path + * the path to the audio file in the resources folder + * @param loop + * whether the audio should loop continuously + * @param volume + * the volume level (0.0 to 1.0) + * @return + * an AudioClipId that can be used to control the playback + */ + def play(path: String, loop: Boolean = false, volume: Double = 1.0): AudioClipId + + /** Stops the playback of a specific audio clip. + * @param clipId + * the identifier of the audio clip to stop + */ + def stop(clipId: AudioClipId): Unit + + /** Pauses the playback of a specific audio clip. + * @param clipId + * the identifier of the audio clip to pause + */ + def pause(clipId: AudioClipId): Unit + + /** Resumes the playback of a paused audio clip. + * @param clipId + * the identifier of the audio clip to resume + */ + def resume(clipId: AudioClipId): Unit + + /** Sets the volume for a specific audio clip. + * @param clipId + * the identifier of the audio clip + * @param volume + * the new volume level (0.0 to 1.0) + */ + def setVolume(clipId: AudioClipId, volume: Double): Unit + + /** Checks if an audio clip is currently playing. + * @param clipId + * the identifier of the audio clip + * @return + * true if the clip is playing, false otherwise + */ + def isPlaying(clipId: AudioClipId): Boolean + + /** Stops all currently playing audio clips. + */ + def stopAll(): Unit + +/** Represents a unique identifier for an audio clip being played. + */ +opaque type AudioClipId = Long + +object AudioClipId: + /** Creates a new AudioClipId from a Long value. + * @param id the underlying ID value + * @return the AudioClipId + */ + def apply(id: Long): AudioClipId = id + + extension (clipId: AudioClipId) + /** Gets the underlying Long value of the AudioClipId. + * @return the ID as a Long + */ + def value: Long = clipId + + /** Represents an invalid or non-existent audio clip. + */ + val Invalid: AudioClipId = -1L + + +/** Companion object for SoundIO with factory methods and implementation. + */ +object SoundIO: + + /** Creates a new SoundIO instance. + * @param masterVolume the initial master volume (0.0 to 1.0) + * @return a new SoundIO instance + */ + def apply(masterVolume: Double = 1.0): SoundIO = + new SoundIOImpl(masterVolume) + + /** Private implementation of SoundIO. + */ + private class SoundIOImpl( + private var _masterVolume: Double + ) extends SoundIO: + + require(_masterVolume >= 0.0 && _masterVolume <= 1.0, "Master volume must be between 0.0 and 1.0") + + // Thread-safe collections for managing clips + private val activeClips: TrieMap[AudioClipId, ClipInfo] = TrieMap.empty + private val clipIdGenerator: AtomicLong = AtomicLong(0) + + // Cached audio data for performance + private val audioCache: TrieMap[String, Array[Byte]] = TrieMap.empty + + override def masterVolume: Double = _masterVolume + override def masterVolume_=(volume: Double): Unit = + require(volume >= 0.0 && volume <= 1.0, "Master volume must be between 0.0 and 1.0") + _masterVolume = volume + // Update volume for all active clips + activeClips.values.foreach(info => applyVolume(info.clip, info.volume)) + + override def play(path: String, loop: Boolean = false, volume: Double = 1.0): AudioClipId = + require(volume >= 0.0 && volume <= 1.0, "Volume must be between 0.0 and 1.0") + + try + val clip = createClip(path) + val clipId = AudioClipId(clipIdGenerator.incrementAndGet()) + + applyVolume(clip, volume) + + if loop then + clip.loop(Clip.LOOP_CONTINUOUSLY) + else + clip.start() + + // Add listener to clean up when clip finishes + clip.addLineListener(event => + if event.getType == LineEvent.Type.STOP && !loop then + cleanupClip(clipId) + ) + + activeClips.put(clipId, ClipInfo(clip, volume, loop)) + clipId + catch + case e: Exception => + System.err.println(s"Error playing audio: ${e.getMessage}") + AudioClipId.Invalid + + override def stop(clipId: AudioClipId): Unit = + activeClips.get(clipId).foreach { info => + info.clip.stop() + info.clip.close() + activeClips.remove(clipId) + } + + override def pause(clipId: AudioClipId): Unit = + activeClips.get(clipId).foreach { info => + if info.clip.isRunning then + info.clip.stop() + } + + override def resume(clipId: AudioClipId): Unit = + activeClips.get(clipId).foreach { info => + if !info.clip.isRunning then + if info.looping then + info.clip.loop(Clip.LOOP_CONTINUOUSLY) + else + info.clip.start() + } + + override def setVolume(clipId: AudioClipId, volume: Double): Unit = + require(volume >= 0.0 && volume <= 1.0, "Volume must be between 0.0 and 1.0") + activeClips.get(clipId).foreach { info => + activeClips.put(clipId, info.copy(volume = volume)) + applyVolume(info.clip, volume) + } + + override def isPlaying(clipId: AudioClipId): Boolean = + activeClips.get(clipId).exists(_.clip.isRunning) + + override def stopAll(): Unit = + activeClips.keys.foreach(stop) + + override def onEngineStop(): Unit = + stopAll() + audioCache.clear() + + private def createClip(path: String): Clip = + // Try to get cached audio data or load it + val audioData = audioCache.getOrElseUpdate(path, loadAudioData(path)) + + val inputStream = AudioSystem.getAudioInputStream( + new BufferedInputStream(new java.io.ByteArrayInputStream(audioData)) + ) + val clip = AudioSystem.getClip() + clip.open(inputStream) + clip + + private def loadAudioData(path: String): Array[Byte] = + val resourceStream: InputStream = getClass.getResourceAsStream(s"/$path") + if resourceStream == null then + throw IllegalArgumentException(s"Audio resource not found: $path") + + val buffered = new BufferedInputStream(resourceStream) + val data = buffered.readAllBytes() + buffered.close() + data + + private def applyVolume(clip: Clip, clipVolume: Double): Unit = + if clip.isControlSupported(FloatControl.Type.MASTER_GAIN) then + val gainControl = clip.getControl(FloatControl.Type.MASTER_GAIN).asInstanceOf[FloatControl] + val effectiveVolume = clipVolume * _masterVolume + // Convert linear volume (0.0 - 1.0) to decibels + val dB = if effectiveVolume > 0 then + 20.0f * Math.log10(effectiveVolume).toFloat + else + gainControl.getMinimum + + // Clamp to valid range + val clampedDb = Math.max(gainControl.getMinimum, Math.min(gainControl.getMaximum, dB)) + gainControl.setValue(clampedDb) + + private def cleanupClip(clipId: AudioClipId): Unit = + activeClips.get(clipId).foreach { info => + info.clip.close() + activeClips.remove(clipId) + } + + /** Internal case class to track clip information */ + private case class ClipInfo( + clip: Clip, + volume: Double, + looping: Boolean + ) + + /** Builder for SoundIO instances. + * @param masterVolume the master volume (0.0 to 1.0) + */ + case class SoundIOBuilder( + masterVolume: Double = 1.0 + ) + + /** Build a SoundIO with a new master volume. + * @param volume the new master volume + * @return a new builder + */ + def withMasterVolume(volume: Double): SoundIOBuilder = + SoundIOBuilder(masterVolume = volume) + + extension (builder: SoundIOBuilder) + /** Create a new SoundIO from this builder configuration. + * @return a SoundIO implementation + */ + def build(): SoundIO = SoundIO(builder.masterVolume) + + /** Build a SoundIO with a new master volume. + * @param volume the new master volume + * @return a new builder + */ + def withMasterVolume(volume: Double): SoundIOBuilder = + builder.copy(masterVolume = volume) diff --git a/ScalaGameEngine/src/main/scala/sge/audio/behaviours/AudioBehaviours.scala b/ScalaGameEngine/src/main/scala/sge/audio/behaviours/AudioBehaviours.scala new file mode 100644 index 00000000..4af67f6c --- /dev/null +++ b/ScalaGameEngine/src/main/scala/sge/audio/behaviours/AudioBehaviours.scala @@ -0,0 +1,182 @@ +package sge.audio.behaviours + +import sge.core.* +import sge.audio.* + +/** Base behaviour for playing audio. It provides the basic mechanism to + * interact with SoundIO for audio playback. Similar to how Renderer + * interacts with SwingIO for graphics. + */ +trait AudioPlayer extends Behaviour: + /** The current audio clip ID being played by this behaviour. + * Returns AudioClipId.Invalid if no clip is playing. + */ + protected var currentClipId: AudioClipId = AudioClipId.Invalid + + /** Plays the given audio path through the engine's SoundIO. + * @param engine the game engine + * @param path the path to the audio file + * @param loop whether the audio should loop + * @param volume the volume level (0.0 to 1.0) + */ + protected def playAudio(engine: Engine, path: String, loop: Boolean = false, volume: Double = 1.0): Unit = + val soundIO = engine.io.asInstanceOf[SoundIO] + currentClipId = soundIO.play(path, loop, volume) + + /** Stops the currently playing audio clip. + * @param engine the game engine + */ + protected def stopAudio(engine: Engine): Unit = + if currentClipId != AudioClipId.Invalid then + val soundIO = engine.io.asInstanceOf[SoundIO] + soundIO.stop(currentClipId) + currentClipId = AudioClipId.Invalid + + /** Pauses the currently playing audio clip. + * @param engine the game engine + */ + protected def pauseAudio(engine: Engine): Unit = + if currentClipId != AudioClipId.Invalid then + val soundIO = engine.io.asInstanceOf[SoundIO] + soundIO.pause(currentClipId) + + /** Resumes the currently paused audio clip. + * @param engine the game engine + */ + protected def resumeAudio(engine: Engine): Unit = + if currentClipId != AudioClipId.Invalid then + val soundIO = engine.io.asInstanceOf[SoundIO] + soundIO.resume(currentClipId) + + /** Sets the volume for the currently playing audio clip. + * @param engine the game engine + * @param volume the new volume level (0.0 to 1.0) + */ + protected def setAudioVolume(engine: Engine, volume: Double): Unit = + if currentClipId != AudioClipId.Invalid then + val soundIO = engine.io.asInstanceOf[SoundIO] + soundIO.setVolume(currentClipId, volume) + + /** Checks if the current audio clip is playing. + * @param engine the game engine + * @return true if audio is playing, false otherwise + */ + protected def isAudioPlaying(engine: Engine): Boolean = + if currentClipId != AudioClipId.Invalid then + val soundIO = engine.io.asInstanceOf[SoundIO] + soundIO.isPlaying(currentClipId) + else + false + +/** A behaviour that plays a sound effect once when enabled. + * Useful for one-shot sounds like explosions, pickups, etc. + * + * @param audioPath the path to the audio file in resources + * @param volume the volume level (0.0 to 1.0) + */ +trait SoundEffect( + audioPath: String, + volume: Double = 1.0 +) extends AudioPlayer: + + override def onStart: Engine => Unit = + engine => + super.onStart(engine) + playAudio(engine, audioPath, loop = false, volume) + + override def onDeinit: Engine => Unit = + engine => + super.onDeinit(engine) + stopAudio(engine) + +/** A behaviour that plays background music in a loop. + * The music starts when the object is enabled and stops when disabled. + * + * @param musicPath the path to the audio file in resources + * @param volume the volume level (0.0 to 1.0) + */ +trait BackgroundMusic( + musicPath: String, + volume: Double = 1.0 +) extends AudioPlayer: + + private var musicVolume: Double = volume + + /** Gets the current music volume. */ + def getMusicVolume: Double = musicVolume + + /** Sets the music volume. + * @param newVolume the new volume level (0.0 to 1.0) + * @param engine optional engine reference for live update + */ + def setMusicVolume(newVolume: Double)(using engine: Option[Engine] = None): Unit = + musicVolume = newVolume + engine.foreach(e => setAudioVolume(e, newVolume)) + + override def onStart: Engine => Unit = + engine => + super.onStart(engine) + playAudio(engine, musicPath, loop = true, musicVolume) + + override def onEnabled: Engine => Unit = + engine => + super.onEnabled(engine) + if currentClipId != AudioClipId.Invalid then + resumeAudio(engine) + + override def onDisabled: Engine => Unit = + engine => + super.onDisabled(engine) + pauseAudio(engine) + + override def onDeinit: Engine => Unit = + engine => + super.onDeinit(engine) + stopAudio(engine) + +/** A behaviour that allows playing sounds on demand via the playSound method. + * Useful when you need to trigger sounds from game logic. + */ +trait SoundEmitter extends AudioPlayer: + + private var lastEngine: Option[Engine] = None + + override def onUpdate: Engine => Unit = + engine => + super.onUpdate(engine) + lastEngine = Some(engine) + + /** Plays a sound effect once. + * @param path the path to the audio file + * @param volume the volume level (0.0 to 1.0) + */ + def playSound(path: String, volume: Double = 1.0): Unit = + lastEngine.foreach { engine => + val soundIO = engine.io.asInstanceOf[SoundIO] + soundIO.play(path, loop = false, volume) + } + + /** Plays a looping sound. + * @param path the path to the audio file + * @param volume the volume level (0.0 to 1.0) + * @return the AudioClipId of the playing clip + */ + def playLoopingSound(path: String, volume: Double = 1.0): AudioClipId = + lastEngine.map { engine => + val soundIO = engine.io.asInstanceOf[SoundIO] + soundIO.play(path, loop = true, volume) + }.getOrElse(AudioClipId.Invalid) + + /** Stops a specific sound by its clip ID. + * @param clipId the ID of the clip to stop + */ + def stopSound(clipId: AudioClipId): Unit = + lastEngine.foreach { engine => + val soundIO = engine.io.asInstanceOf[SoundIO] + soundIO.stop(clipId) + } + + override def onDeinit: Engine => Unit = + engine => + super.onDeinit(engine) + stopAudio(engine) diff --git a/ScalaGameEngine/src/main/scala/sge/crossdomain/SwingWithSoundIO.scala b/ScalaGameEngine/src/main/scala/sge/crossdomain/SwingWithSoundIO.scala new file mode 100644 index 00000000..685a7ff8 --- /dev/null +++ b/ScalaGameEngine/src/main/scala/sge/crossdomain/SwingWithSoundIO.scala @@ -0,0 +1,184 @@ +package sge.crossdomain + +import sge.audio.* +import sge.core.* +import sge.swing.SwingIO + +import java.awt.{Color, Graphics2D} + +/** A combined IO that includes both graphics (SwingIO) and audio (SoundIO) capabilities. + * This trait allows behaviours to access both rendering and sound functionalities + * through a single IO instance. + */ +trait SwingWithSoundIO extends SwingIO with SoundIO + +/** Companion object for creating combined SwingIO + SoundIO instances. + */ +object SwingWithSoundIO: + + /** Creates a new SwingWithSoundIO that combines graphics and audio IO. + * @param swingIO the SwingIO instance for graphics + * @param soundIO the SoundIO instance for audio + * @return a combined IO instance + */ + def apply(swingIO: SwingIO, soundIO: SoundIO): SwingWithSoundIO = + new SwingWithSoundIOImpl(swingIO, soundIO) + + /** Creates a new SwingWithSoundIO using builders. + * @param title the window title + * @param size the window size in pixels + * @param pixelsPerUnit the pixels per game unit ratio + * @param center the center position in game coordinates + * @param background the background color + * @param frameIconPath the path to the frame icon + * @param masterVolume the master volume for audio (0.0 to 1.0) + * @return a combined IO instance + */ + def apply( + title: String, + size: (Int, Int), + pixelsPerUnit: Int = 100, + center: Vector2D = (0, 0), + background: Color = Color.white, + frameIconPath: String = "icon.png", + masterVolume: Double = 1.0 + ): SwingWithSoundIO = + new SwingWithSoundIOImpl( + SwingIO(title, size, pixelsPerUnit, center, background, frameIconPath), + SoundIO(masterVolume) + ) + + /** Private implementation combining SwingIO and SoundIO functionality. + */ + private class SwingWithSoundIOImpl( + private val graphics: SwingIO, + private val audio: SoundIO + ) extends SwingWithSoundIO: + + // Delegate SwingIO methods to graphics + export graphics.{ + title, + size, + pixelsPerUnit, + pixelsPerUnit_=, + center, + center_=, + backgroundColor, + frameIconPath, + draw, + show, + inputButtonWasPressed, + scenePointerPosition + } + + // Delegate SoundIO methods to audio + export audio.{ + play, + stop, + pause, + resume, + setVolume, + isPlaying, + stopAll, + masterVolume, + masterVolume_= + } + + // Override IO lifecycle methods to call both + override def onFrameEnd: Engine => Unit = + engine => + graphics.onFrameEnd(engine) + audio.onFrameEnd(engine) + + override def onEngineStop(): Unit = + graphics.onEngineStop() + audio.onEngineStop() + + /** Builder for SwingWithSoundIO instances. + */ + case class SwingWithSoundIOBuilder( + title: String = "Title", + size: (Int, Int) = (0, 0), + pixelsPerUnit: Int = 100, + center: Vector2D = (0, 0), + background: Color = Color.white, + frameIconPath: String = "icon.png", + masterVolume: Double = 1.0 + ) + + /** Build a SwingWithSoundIO with a new title. + * @param title the new title + * @return a new builder + */ + def withTitle(title: String): SwingWithSoundIOBuilder = + SwingWithSoundIOBuilder(title = title) + + /** Build a SwingWithSoundIO with a new size. + * @param size the new size + * @return a new builder + */ + def withSize(size: (Int, Int)): SwingWithSoundIOBuilder = + SwingWithSoundIOBuilder(size = size) + + extension (builder: SwingWithSoundIOBuilder) + /** Create a new SwingWithSoundIO from this builder configuration. + * @return a SwingWithSoundIO implementation + */ + def build(): SwingWithSoundIO = SwingWithSoundIO( + builder.title, + builder.size, + builder.pixelsPerUnit, + builder.center, + builder.background, + builder.frameIconPath, + builder.masterVolume + ) + + /** Build with a new title. + * @param title the new title + * @return a new builder + */ + def withTitle(title: String): SwingWithSoundIOBuilder = + builder.copy(title = title) + + /** Build with a new size. + * @param size the new size + * @return a new builder + */ + def withSize(size: (Int, Int)): SwingWithSoundIOBuilder = + builder.copy(size = size) + + /** Build with a new pixels/unit ratio. + * @param pixelsPerUnit the new pixels/unit ratio + * @return a new builder + */ + def withPixelsPerUnitRatio(pixelsPerUnit: Int): SwingWithSoundIOBuilder = + builder.copy(pixelsPerUnit = pixelsPerUnit) + + /** Build with a new center position. + * @param center the new position + * @return a new builder + */ + def withCenter(center: Vector2D): SwingWithSoundIOBuilder = + builder.copy(center = center) + + /** Build with a new background color. + * @param color the new background color + * @return a new builder + */ + def withBackgroundColor(color: Color): SwingWithSoundIOBuilder = + builder.copy(background = color) + + /** Build with a new frame icon path. + * @param frameIconPath the new frame icon path + * @return a new builder + */ + def withFrameIconPath(frameIconPath: String): SwingWithSoundIOBuilder = + builder.copy(frameIconPath = frameIconPath) + + /** Build with a new master volume. + * @param volume the new master volume (0.0 to 1.0) + * @return a new builder + */ + def withMasterVolume(volume: Double): SwingWithSoundIOBuilder = + builder.copy(masterVolume = volume) diff --git a/ScalaGameEngine/src/main/scala/sge/swing/Exports.scala b/ScalaGameEngine/src/main/scala/sge/swing/Exports.scala index c8884fea..61ec361c 100644 --- a/ScalaGameEngine/src/main/scala/sge/swing/Exports.scala +++ b/ScalaGameEngine/src/main/scala/sge/swing/Exports.scala @@ -13,4 +13,8 @@ export behaviours.ingame.RectRenderer export behaviours.ingame.OvalRenderer export behaviours.ingame.TextRenderer export behaviours.ingame.ImageRenderer +export behaviours.ingame.AnimatedImageRenderer +export behaviours.ingame.ControlledAnimationRenderer export behaviours.overlay.UITextRenderer +export output.Animations +export output.Animations.* \ No newline at end of file diff --git a/ScalaGameEngine/src/main/scala/sge/swing/behaviours/ingame/Renderers.scala b/ScalaGameEngine/src/main/scala/sge/swing/behaviours/ingame/Renderers.scala index f91a0ee3..0180f8a2 100644 --- a/ScalaGameEngine/src/main/scala/sge/swing/behaviours/ingame/Renderers.scala +++ b/ScalaGameEngine/src/main/scala/sge/swing/behaviours/ingame/Renderers.scala @@ -9,7 +9,9 @@ import GameElements.* import Shapes.* import Text.* import Images.* +import Animations.* import java.awt.{Graphics2D, Color} +import sge.swing.output.Animations.AnimationController.AnimationControllerBuilder /** Behaviour for rendering a generic swing game element on a SwingIO */ @@ -198,3 +200,82 @@ trait TextRenderer( this.renderOffset = offset this.renderRotation = rotation this.renderingPriority = priority + +/** Behaviour for rendering an animated image on a SwingIO. The animation is + * automatically updated every frame based on deltaTime. Sizes must be > 0, and + * images must be located in a resource folder. + */ +trait AnimatedImageRenderer( + _animation: Animation, + width: Double, + height: Double, + offset: Vector2D = (0, 0), + rotation: Angle = 0.degrees, + priority: Int = 0 +) extends GameElementRenderer: + protected val element: AnimatedImage = + Animations.animatedImage(_animation, width, height) + + export element.{ + elementWidth => animationWidth, + elementWidth_= => animationWidth_=, + elementHeight => animationHeight, + elementHeight_= => animationHeight_=, + animation + } + + export animation.{ + frames, + loop, + totalDuration + } + + this.renderOffset = offset + this.renderRotation = rotation + this.renderingPriority = priority + + override def onUpdate: Engine => Unit = + engine => + super.onUpdate(engine) + element.animation.update(engine.deltaTimeSeconds) + +/** Behaviour for rendering animations with multiple animation states. Uses an + * AnimationController to manage and switch between different animations. + * Useful for characters with idle, walk, run animations, etc. + */ +trait ControlledAnimationRenderer( + controller: AnimationControllerBuilder, + width: Double, + height: Double, + offset: Vector2D = (0, 0), + rotation: Angle = 0.degrees, + priority: Int = 0 +) extends GameElementRenderer: + protected val element: AnimationController = controller.withSize(width, height).build() + + export element.{ + elementWidth => animationWidth, + elementWidth_= => animationWidth_=, + elementHeight => animationHeight, + elementHeight_= => animationHeight_=, + currentAnimation, + currentAnimationName, + reset, + hasAnimation + } + + this.renderOffset = offset + this.renderRotation = rotation + this.renderingPriority = priority + + override def onUpdate: Engine => Unit = + engine => + super.onUpdate(engine) + element.update(engine.deltaTimeSeconds) + + /** Switches to a different animation by name. + * @param name + * the name of the animation to play + */ + def playAnimation(name: String): Unit = + element.play(name) diff --git a/ScalaGameEngine/src/main/scala/sge/swing/output/Animations.scala b/ScalaGameEngine/src/main/scala/sge/swing/output/Animations.scala new file mode 100644 index 00000000..6da3ad1e --- /dev/null +++ b/ScalaGameEngine/src/main/scala/sge/swing/output/Animations.scala @@ -0,0 +1,283 @@ +package sge.swing.output + +import java.awt.{Graphics2D, Image as AWTImage} +import Images.{ImageLoader, ImageResizer} +import GameElements.* + +/** Utility object for animations */ +object Animations: + + /** Represents a single frame of an animation. + * @param imagePath + * the path to the image file in resources + * @param duration + * the duration of this frame in seconds + */ + case class AnimationFrame(imagePath: String, duration: Double): + require(duration > 0, "Frame duration must be positive") + val image: ImageResizer = ImageResizer(imagePath) + + /** Represents an animation as a sequence of frames. + * @param frames + * the sequence of animation frames + * @param loop + * whether the animation should loop when it reaches the end + */ + class Animation( + val frames: Seq[AnimationFrame], + val loop: Boolean = true + ): + require(frames.nonEmpty, "Animation must have at least one frame") + + private var _currentFrameIndex: Int = 0 + private var _elapsedTime: Double = 0.0 + private var _finished: Boolean = false + + /** The index of the current frame being displayed. */ + def currentFrameIndex: Int = _currentFrameIndex + + /** The current frame being displayed. */ + def currentFrame: AnimationFrame = frames(_currentFrameIndex) + + /** Whether the animation has finished playing (only relevant for non-looping animations). */ + def isFinished: Boolean = _finished + + /** The total duration of the animation in seconds. */ + def totalDuration: Double = frames.map(_.duration).sum + + /** Updates the animation state based on elapsed time. + * @param deltaTimeSeconds + * the time elapsed since the last update in seconds + */ + def update(deltaTimeSeconds: Double): Unit = + if _finished then return + + _elapsedTime += deltaTimeSeconds + + while _elapsedTime >= currentFrame.duration do + _elapsedTime -= currentFrame.duration + + if _currentFrameIndex < frames.length - 1 then + _currentFrameIndex += 1 + else if loop then + _currentFrameIndex = 0 + else + _finished = true + _elapsedTime = 0 + + /** Resets the animation to its initial state. */ + def reset(): Unit = + _currentFrameIndex = 0 + _elapsedTime = 0.0 + _finished = false + + /** Jumps to a specific frame index. + * @param index + * the frame index to jump to + */ + def goToFrame(index: Int): Unit = + require(index >= 0 && index < frames.length, s"Frame index must be between 0 and ${frames.length - 1}") + _currentFrameIndex = index + _elapsedTime = 0.0 + _finished = false + + /** Creates a copy of this animation with independent state. */ + def copy(): Animation = + val newAnim = new Animation(frames, loop) + newAnim._currentFrameIndex = this._currentFrameIndex + newAnim._elapsedTime = this._elapsedTime + newAnim._finished = this._finished + newAnim + + object Animation: + /** Creates an animation from a list of image paths with uniform frame duration. + * @param imagePaths + * the paths to the frame images + * @param frameDuration + * the duration of each frame in seconds + * @param loop + * whether the animation should loop (default true) + * @return + * a new Animation + */ + def uniform(imagePaths: Seq[String], frameDuration: Double, loop: Boolean = true): Animation = + new Animation(imagePaths.map(path => AnimationFrame(path, frameDuration)), loop) + + /** Creates an animation from frame image paths with a naming pattern. + * @param basePath + * the base path without extension (e.g., "sprites/walk") + * @param extension + * the file extension (e.g., "png") + * @param frameCount + * the number of frames + * @param frameDuration + * the duration of each frame in seconds + * @param loop + * whether the animation should loop (default true) + * @param startIndex + * the starting index for frame numbering (default 0) + * @return + * a new Animation + * + * This will look for files like: sprites/walk_0.png, sprites/walk_1.png, etc. + */ + def fromPattern( + basePath: String, + extension: String, + frameCount: Int, + frameDuration: Double, + loop: Boolean = true, + startIndex: Int = 0 + ): Animation = + val paths = (startIndex until (startIndex + frameCount)) + .map(i => s"${basePath}_$i.$extension") + uniform(paths, frameDuration, loop) + + /** A GameElement that displays an animated image. + */ + trait AnimatedImage extends GameElement: + /** The animation being played. */ + val animation: Animation + + override def drawElement: Graphics2D => (Int, Int, Int, Int) => Unit = + g2d => + (posX, posY, w, h) => + val img = animation.currentFrame.image.resize(w, h) + g2d.drawImage(img, posX, posY, null) + + private class SimpleAnimatedImage( + override val animation: Animation, + width: Double, + height: Double + ) extends BaseGameElement(width, height) + with AnimatedImage + + /** Creates an AnimatedImage from an Animation. + * @param animation + * the animation to display + * @param width + * the width in game units + * @param height + * the height in game units + * @return + * a new AnimatedImage + */ + def animatedImage( + animation: Animation, + width: Double, + height: Double + ): AnimatedImage = + SimpleAnimatedImage(animation, width, height) + + /** Manages multiple named animations with easy switching. + * Useful for characters with different states (idle, walk, run, etc.) + */ + class AnimationController( + private val animations: Map[String, Animation], + private val defaultAnimation: String, + width: Double, + height: Double + ) extends BaseGameElement(width, height): + require(animations.nonEmpty, "Must have at least one animation") + require(animations.contains(defaultAnimation), s"Default animation '$defaultAnimation' not found") + + private var _currentAnimationName: String = defaultAnimation + private var _currentAnimation: Animation = animations(defaultAnimation).copy() + + /** The name of the currently playing animation. */ + def currentAnimationName: String = _currentAnimationName + + /** The currently playing animation. */ + def currentAnimation: Animation = _currentAnimation + + /** Updates the current animation. + * @param deltaTimeSeconds + * the time elapsed since the last update + */ + def update(deltaTimeSeconds: Double): Unit = + _currentAnimation.update(deltaTimeSeconds) + + /** Switches to a different animation. + * @param name + * the name of the animation to switch to + */ + def play(name: String): Unit = + if !animations.contains(name) then + throw IllegalArgumentException(s"Animation '$name' not found") + + if name != _currentAnimationName then + _currentAnimationName = name + _currentAnimation = animations(name).copy() + + /** Resets the current animation to its beginning. */ + def reset(): Unit = + _currentAnimation.reset() + + /** Checks if a specific animation exists. + * @param name + * the animation name + * @return + * true if the animation exists + */ + def hasAnimation(name: String): Boolean = + animations.contains(name) + + override def drawElement: Graphics2D => (Int, Int, Int, Int) => Unit = + g2d => (x, y, w, h) => + val img = this.currentAnimation.currentFrame.image.resize(w, h) + g2d.drawImage(img, x, y, null) + + object AnimationController: + /** Builder for creating an AnimationController. + */ + class AnimationControllerBuilder: + private var animations: Map[String, Animation] = Map.empty + private var defaultAnim: Option[String] = None + private var width: Double = 1.0 + private var height: Double = 1.0 + + /** Adds an animation to the controller. + * @param name + * the name for this animation + * @param animation + * the animation + * @return + * this builder + */ + def addAnimation(name: String, animation: Animation): AnimationControllerBuilder = + animations = animations + (name -> animation) + this + + /** Sets the default animation. + * @param name + * the name of the default animation + * @return + * this builder + */ + def withDefault(name: String): AnimationControllerBuilder = + defaultAnim = Some(name) + this + + /** + * Sets the size of the animation controller. + * + * @param width + * @param height + * @return + */ + private[swing] def withSize(width: Double, height: Double): AnimationControllerBuilder = + this.width = width + this.height = height + this + + /** Builds the AnimationController. + * @return + * a new AnimationController + */ + private[swing] def build(): AnimationController = + new AnimationController(animations, defaultAnim.getOrElse( + throw IllegalStateException("Default animation must be set") + ), width, height) + + /** Creates a new builder for AnimationController. */ + def builder(): AnimationControllerBuilder = new AnimationControllerBuilder() diff --git a/ScalaGameEngine/src/test/resources/audio_example.wav b/ScalaGameEngine/src/test/resources/audio_example.wav new file mode 100644 index 00000000..3179e0ee Binary files /dev/null and b/ScalaGameEngine/src/test/resources/audio_example.wav differ diff --git a/ScalaGameEngine/src/test/resources/epic-crocodile_0.png b/ScalaGameEngine/src/test/resources/epic-crocodile_0.png new file mode 100644 index 00000000..80c04094 Binary files /dev/null and b/ScalaGameEngine/src/test/resources/epic-crocodile_0.png differ diff --git a/ScalaGameEngine/src/test/resources/epic-crocodile_1.png b/ScalaGameEngine/src/test/resources/epic-crocodile_1.png new file mode 100644 index 00000000..80c04094 Binary files /dev/null and b/ScalaGameEngine/src/test/resources/epic-crocodile_1.png differ diff --git a/ScalaGameEngine/src/test/resources/epic-crocodile_2.png b/ScalaGameEngine/src/test/resources/epic-crocodile_2.png new file mode 100644 index 00000000..80c04094 Binary files /dev/null and b/ScalaGameEngine/src/test/resources/epic-crocodile_2.png differ diff --git a/ScalaGameEngine/src/test/scala/sge/audio/SoundIOTest.scala.scala b/ScalaGameEngine/src/test/scala/sge/audio/SoundIOTest.scala.scala new file mode 100644 index 00000000..7fa25a0b --- /dev/null +++ b/ScalaGameEngine/src/test/scala/sge/audio/SoundIOTest.scala.scala @@ -0,0 +1,28 @@ +package sge.audio + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers.* +import sge.core.IO + +class SoundIOTest extends AnyFlatSpec: + "SoundIO" should "be an IO class" in: + SoundIO() shouldBe a[IO] + + it should "always have a master volume between 0.0 and 1.0" in: + an[IllegalArgumentException] should be thrownBy { + val soundIO = SoundIO(1.1) + } + + an[IllegalArgumentException] should be thrownBy { + val soundIO = SoundIO(-0.1) + } + + it should "be customizable" in: + val soundIO = SoundIO.withMasterVolume(0.5).build() + + soundIO.masterVolume shouldBe 0.5 + + it should "allow changing the master volume at runtime" in: + val soundIO = SoundIO(0.7) + soundIO.masterVolume = 0.3 + soundIO.masterVolume shouldBe 0.3 \ No newline at end of file diff --git a/ScalaGameEngine/src/test/scala/sge/crossdomain/SwingWithSoundIOTest.scala b/ScalaGameEngine/src/test/scala/sge/crossdomain/SwingWithSoundIOTest.scala new file mode 100644 index 00000000..473af0c1 --- /dev/null +++ b/ScalaGameEngine/src/test/scala/sge/crossdomain/SwingWithSoundIOTest.scala @@ -0,0 +1,57 @@ +package sge.crossdomain + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers.* +import sge.core.IO +import sge.swing.SwingIO +import sge.audio.SoundIO +import java.awt.Color + +class SwingWithSoundIOTest extends AnyFlatSpec: + "SwingWithSoundIO" should "be an union of SwingIO and SoundIO" in: + val io = SwingWithSoundIO(SwingIO("title", (800, 600)), SoundIO(0.5)) + io.title shouldBe "title" + io.size shouldBe (800, 600) + io.masterVolume shouldBe 0.5 + + io shouldBe a[IO] + io shouldBe a[SwingIO] + io shouldBe a[SoundIO] + + it should "be created with parameters of SwingIO and SoundIO" in: + val io = SwingWithSoundIO( + title = "Game Window", + size = (1024, 768), + pixelsPerUnit = 100, + center = (0, 0), + background = Color.black, + frameIconPath = "icon.png", + masterVolume = 0.75 + ) + + io.title shouldBe "Game Window" + io.size shouldBe (1024, 768) + io.pixelsPerUnit shouldBe 100 + io.center shouldBe (0, 0) + io.backgroundColor shouldBe Color.black + io.masterVolume shouldBe 0.75 + + io shouldBe a[IO] + io shouldBe a[SwingIO] + io shouldBe a[SoundIO] + + it should "be created as factory" in: + val io = SwingWithSoundIO + .withTitle("title") + .withSize(800, 600) + .withBackgroundColor(Color.red) + .withMasterVolume(0.3) + .build() + + io.title shouldBe "title" + io.size shouldBe (800, 600) + io.backgroundColor shouldBe Color.red + io.masterVolume shouldBe 0.3 + io shouldBe a[IO] + io shouldBe a[SwingIO] + io shouldBe a[SoundIO] diff --git a/ScalaGameEngine/src/test/scala/sge/swing/behaviours/RendererTestUtilities.scala b/ScalaGameEngine/src/test/scala/sge/swing/behaviours/RendererTestUtilities.scala index d876118b..c3a49323 100644 --- a/ScalaGameEngine/src/test/scala/sge/swing/behaviours/RendererTestUtilities.scala +++ b/ScalaGameEngine/src/test/scala/sge/swing/behaviours/RendererTestUtilities.scala @@ -153,3 +153,29 @@ object RendererTestUtilities: frame.draw(topLeft.renderer(frame)) frame.draw(topRight.renderer(frame)) frame.show() + + def animationRenderer( + frameNames: Seq[String], + framesDuration: Double, + width: Int, + height: Int, + loop: Boolean = true + ) = new Behaviour + with AnimatedImageRenderer( + Animation.uniform(frameNames, framesDuration, loop), + width, + height + ) + with Positionable + + def animationControllerRenderer( + builder: AnimationController.AnimationControllerBuilder, + width: Int, + height: Int + ) = new Behaviour + with Positionable + with ControlledAnimationRenderer( + builder, + width, + height + ) diff --git a/ScalaGameEngine/src/test/scala/sge/swing/behaviours/ingame/AnimatedImageRendererTests.scala b/ScalaGameEngine/src/test/scala/sge/swing/behaviours/ingame/AnimatedImageRendererTests.scala new file mode 100644 index 00000000..4807ab5c --- /dev/null +++ b/ScalaGameEngine/src/test/scala/sge/swing/behaviours/ingame/AnimatedImageRendererTests.scala @@ -0,0 +1,58 @@ +package sge.swing.behaviours.ingame + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers.* +import sge.swing.output.Animations.Animation +import sge.core.* +import sge.core.behaviours.dimension2d.Positionable +import sge.swing.output.Animations +import sge.swing.behaviours.RendererTestUtilities.animationRenderer + +class AnimatedImageRendererTests extends AnyFlatSpec: + "AnimatedImageRenderer" should "be created" in: + val renderer = + animationRenderer(Seq("epic-crocodile.png"), 0.01, 10, 10, false) + + renderer.animationHeight shouldBe 10 + renderer.animationWidth shouldBe 10 + renderer.animation shouldBe a[Animation] + renderer.animation.frames shouldBe Seq( + Animations.AnimationFrame("epic-crocodile.png", 0.01) + ) + renderer.frames shouldBe renderer.animation.frames + renderer.animation.loop shouldBe false + renderer.loop shouldBe renderer.animation.loop + renderer.animation.totalDuration shouldBe renderer.frames + .map(_.duration) + .sum + renderer.totalDuration shouldBe renderer.animation.totalDuration + + all( + renderer.animation.frames.map(_.duration shouldBe 0.01) + ) + + it should "not be initialized with negative size" in: + an[IllegalArgumentException] shouldBe thrownBy: + animationRenderer(Seq("epic-crocodile.png"), 0.01, 0, 10) + + an[IllegalArgumentException] shouldBe thrownBy: + animationRenderer(Seq("epic-crocodile.png"), 0.01, 10, -10) + + it should "be able to change its size" in: + val renderer = + animationRenderer(Seq("epic-crocodile.png"), 0.01, 10, 10, false) + renderer.animationHeight = 20 + renderer.animationHeight shouldBe 20 + + renderer.animationWidth = 20 + renderer.animationWidth shouldBe 20 + + it should "not be possible to change size to negative values" in: + val renderer = + animationRenderer(Seq("epic-crocodile.png"), 0.01, 10, 10, false) + + an[IllegalArgumentException] shouldBe thrownBy: + renderer.animationHeight = -20 + + an[IllegalArgumentException] shouldBe thrownBy: + renderer.animationWidth = -10 \ No newline at end of file diff --git a/ScalaGameEngine/src/test/scala/sge/swing/behaviours/ingame/ControlledAnimationRendererTests.scala b/ScalaGameEngine/src/test/scala/sge/swing/behaviours/ingame/ControlledAnimationRendererTests.scala new file mode 100644 index 00000000..7eab7c4e --- /dev/null +++ b/ScalaGameEngine/src/test/scala/sge/swing/behaviours/ingame/ControlledAnimationRendererTests.scala @@ -0,0 +1,77 @@ +package sge.swing.behaviours.ingame + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers.* +import sge.swing.output.Animations.Animation +import sge.core.* +import sge.core.behaviours.dimension2d.Positionable +import sge.swing.output.Animations +import sge.swing.output.Animations.AnimationController +import sge.swing.behaviours.RendererTestUtilities.animationControllerRenderer + +class ControlledAnimationRendererTests extends AnyFlatSpec: + val builder = AnimationController + .builder() + .addAnimation( + "default", + Animation.uniform(Seq("epic-crocodile.png"), 0.01) + ) + .addAnimation( + "second", + Animation.uniform(Seq("epic-crocodile.png"), 0.5) + ) + .withDefault("default") + + "ControlledAnimationRenderer" should "be created" in: + val renderer = animationControllerRenderer( + builder, + 10, + 10 + ) + + renderer.animationHeight shouldBe 10 + renderer.animationWidth shouldBe 10 + renderer.currentAnimationName shouldBe "default" + renderer.hasAnimation("default") shouldBe true + renderer.hasAnimation("second") shouldBe true + renderer.hasAnimation("other") shouldBe false + + it should "not be initialized with negative size" in: + an[IllegalArgumentException] shouldBe thrownBy: + animationControllerRenderer(builder, 0, 10) + + an[IllegalArgumentException] shouldBe thrownBy: + animationControllerRenderer(builder, 10, -10) + + it should "be able to change its size" in: + val renderer = + animationControllerRenderer(builder, 10, 10) + renderer.animationHeight = 20 + renderer.animationHeight shouldBe 20 + + renderer.animationWidth = 20 + renderer.animationWidth shouldBe 20 + + it should "not be possible to change size to negative values" in: + val renderer = + animationControllerRenderer(builder, 10, 10) + + an[IllegalArgumentException] shouldBe thrownBy: + renderer.animationHeight = -20 + + an[IllegalArgumentException] shouldBe thrownBy: + renderer.animationWidth = -10 + + it should "change current animation" in: + val renderer = + animationControllerRenderer(builder, 10, 10) + + renderer.playAnimation("second") + renderer.currentAnimationName shouldBe "second" + + it should "not change to an animation that does not exists" in: + val renderer = + animationControllerRenderer(builder, 10, 10) + + an[IllegalArgumentException] shouldBe thrownBy: + renderer.playAnimation("other") diff --git a/ScalaGameEngine/src/test/scala/sge/swing/output/AnimationsTest.scala b/ScalaGameEngine/src/test/scala/sge/swing/output/AnimationsTest.scala new file mode 100644 index 00000000..da217318 --- /dev/null +++ b/ScalaGameEngine/src/test/scala/sge/swing/output/AnimationsTest.scala @@ -0,0 +1,182 @@ +package sge.swing.output + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers.* +import sge.swing.output.Animations.AnimationFrame +import sge.swing.output.Images.ImageResizer +import sge.swing.output.Animations.Animation +import org.scalatest.BeforeAndAfterEach +import sge.swing.output.GameElements.BaseGameElement +import sge.swing.output.Animations.AnimationController +import sge.swing.output.Animations.AnimationController.AnimationControllerBuilder +import sge.swing.output.GameElements.GameElement + +class AnimationsTest extends AnyFlatSpec with BeforeAndAfterEach: + val animationFrame1 = AnimationFrame("epic-crocodile.png", 0.1) + val animationFrame2 = AnimationFrame("epic-crocodile_0.png", 0.2) + var animation = Animation(Seq(animationFrame1, animationFrame2)) + var controller = AnimationController(Map("default" -> animation), "default", 10, 10) + + override protected def beforeEach(): Unit = + animation = Animation(Seq(animationFrame1, animationFrame2)) + controller = AnimationController(Map("default" -> animation, "second" -> Animation(Seq(animationFrame2))), "default", 10, 10) + + "AnimationFrame" should "have initial values" in: + animationFrame1.imagePath shouldBe "epic-crocodile.png" + animationFrame1.duration shouldBe 0.1 + animationFrame1.image shouldBe ImageResizer("epic-crocodile.png") + + it should "have a positive duration" in: + an[IllegalArgumentException] shouldBe thrownBy: + AnimationFrame("epic-crocodile.png", 0) + + "Animation" should "have initial values" in: + animation.frames shouldBe Seq(animationFrame1, animationFrame2) + animation.loop shouldBe true + animation.currentFrame shouldBe animationFrame1 + animation.currentFrameIndex shouldBe 0 + animation.isFinished shouldBe false + animation.totalDuration shouldBe animationFrame1.duration + animationFrame2.duration + + it should "go to another frame" in: + animation.goToFrame(1) + animation.currentFrame shouldBe animationFrame2 + animation.currentFrameIndex shouldBe 1 + animation.isFinished shouldBe false + + it should "update state within a loop" in: + animation.update(0.05) + animation.currentFrame shouldBe animationFrame1 + animation.isFinished shouldBe false + animation.currentFrameIndex shouldBe 0 + + animation.update(0.05) + animation.isFinished shouldBe false + animation.currentFrame shouldBe animationFrame2 + animation.currentFrameIndex shouldBe 1 + + animation.update(0.2) + animation.currentFrame shouldBe animationFrame1 + animation.isFinished shouldBe false + animation.currentFrameIndex shouldBe 0 + + it should "finish if not a loop animation" in: + val animation = Animation(Seq(animationFrame1, animationFrame2), false) + animation.update(animation.totalDuration) + animation.isFinished shouldBe true + animation.currentFrame shouldBe animationFrame2 + animation.currentFrameIndex shouldBe 1 + + it should "be restarted" in: + val animation = Animation(Seq(animationFrame1, animationFrame2), false) + animation.update(animation.totalDuration) + animation.reset() + + animation.isFinished shouldBe false + animation.currentFrame shouldBe animationFrame1 + animation.currentFrameIndex shouldBe 0 + + it should "be possible to create a copy of the animation" in: + val animation2 = animation.copy() + animation2.frames shouldBe animation.frames + animation2.loop shouldBe animation.loop + animation2.currentFrame shouldBe animation.currentFrame + animation2.currentFrameIndex shouldBe animation.currentFrameIndex + animation2.isFinished shouldBe animation.isFinished + animation2 shouldNot be(animation) + + it should "be possible to create an animation from a uniform sequence of frames" in: + val animation = + Animation.uniform(Seq("epic-crocodile.png", "epic-crocodile_0.png"), 0.1, false) + + val animation2 = + Animation.uniform(Seq("epic-crocodile.png"), 0.1) + + animation.frames shouldBe Seq(AnimationFrame("epic-crocodile.png", 0.1), AnimationFrame("epic-crocodile_0.png", 0.1)) + animation.currentFrame shouldBe AnimationFrame("epic-crocodile.png", 0.1) + animation.loop shouldBe false + + animation2.loop shouldBe true + + it should "be possible to create an animation from a pattern of frames" in: + val animation = + Animation.fromPattern("epic-crocodile", "png", 2, 0.1, false) + + val animation2 = + Animation.fromPattern("epic-crocodile", "png", 2, 0.1, startIndex = 1) + + animation.loop shouldBe false + animation.currentFrameIndex shouldBe 0 + animation.currentFrame shouldBe AnimationFrame("epic-crocodile_0.png", 0.1) + animation.frames shouldBe Seq(AnimationFrame("epic-crocodile_0.png", 0.1), AnimationFrame("epic-crocodile_1.png", 0.1)) + + animation2.loop shouldBe true + animation2.currentFrameIndex shouldBe 0 + animation2.currentFrame shouldBe AnimationFrame("epic-crocodile_1.png", 0.1) + animation2.frames shouldBe Seq(AnimationFrame("epic-crocodile_1.png", 0.1), AnimationFrame("epic-crocodile_2.png", 0.1)) + + "AnimatedImage" should "be created" in: + val image = Animations.animatedImage(animation, 1, 1) + image.animation shouldBe animation + image.elementWidth shouldBe 1 + image.elementHeight shouldBe 1 + image shouldBe an[BaseGameElement] + + "AnimationController" should "be created" in: + val controller = AnimationController(Map("default" -> animation), "default", 10, 10) + controller.currentAnimation.frames shouldBe animation.frames + controller.currentAnimation.loop shouldBe animation.loop + controller.currentAnimationName shouldBe "default" + controller.hasAnimation("default") shouldBe true + controller.hasAnimation("other") shouldBe false + controller.elementWidth shouldBe 10 + controller.elementHeight shouldBe 10 + controller shouldBe a[BaseGameElement] + + it should "switch between frames" in: + controller.currentAnimationName shouldBe "default" + controller.play("second") + controller.currentAnimation.frames shouldBe Seq(animationFrame2) + controller.currentAnimationName shouldBe "second" + + it should "not be possible to play an animation not present" in: + an[IllegalArgumentException] shouldBe thrownBy: + controller.play("other") + + it should "update the current animation" in: + controller.update(0.02) + controller.currentAnimation.currentFrame shouldBe animationFrame1 + controller.update(0.08) + controller.currentAnimation.currentFrame shouldBe animationFrame2 + + it should "reset the current animation" in: + controller.update(0.1) + controller.currentAnimation.currentFrame shouldBe animationFrame2 + controller.reset() + controller.currentAnimation.currentFrame shouldBe animationFrame1 + + it should "not change animation if trying to change with the same animation" in: + controller.update(0.1) + controller.play("default") + controller.currentAnimation.currentFrame shouldBe animationFrame2 + + it should "have animations" in: + an[IllegalArgumentException] shouldBe thrownBy: + AnimationController(Map(), "default", 10, 10) + + it should "have the default animation in the animations" in: + an[IllegalArgumentException] shouldBe thrownBy: + AnimationController(Map("other" -> animation), "default", 10, 10) + + it should "be created with a factory" in: + val controllerFactory = AnimationController.builder().withSize(10, 10).addAnimation("default", animation).withDefault("default") + val controller = controllerFactory.build() + + controller.currentAnimationName shouldBe "default" + controller.currentAnimation.frames shouldBe animation.frames + controller.elementHeight shouldBe 10 + controller.elementWidth shouldBe 10 + + "AnimationControllerFactory" should "have a default" in: + an[IllegalStateException] shouldBe thrownBy: + AnimationController.builder().build() diff --git a/StealthGame/src/main/resources/Background.wav b/StealthGame/src/main/resources/Background.wav new file mode 100644 index 00000000..3179e0ee Binary files /dev/null and b/StealthGame/src/main/resources/Background.wav differ diff --git a/StealthGame/src/main/resources/sprites/Back_0.png b/StealthGame/src/main/resources/sprites/Back_0.png new file mode 100644 index 00000000..6a870ea0 Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Back_0.png differ diff --git a/StealthGame/src/main/resources/sprites/Back_1.png b/StealthGame/src/main/resources/sprites/Back_1.png new file mode 100644 index 00000000..7d73551c Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Back_1.png differ diff --git a/StealthGame/src/main/resources/sprites/Back_2.png b/StealthGame/src/main/resources/sprites/Back_2.png new file mode 100644 index 00000000..6a870ea0 Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Back_2.png differ diff --git a/StealthGame/src/main/resources/sprites/Back_3.png b/StealthGame/src/main/resources/sprites/Back_3.png new file mode 100644 index 00000000..da6b3843 Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Back_3.png differ diff --git a/StealthGame/src/main/resources/sprites/Front_0.png b/StealthGame/src/main/resources/sprites/Front_0.png new file mode 100644 index 00000000..a42ff12f Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Front_0.png differ diff --git a/StealthGame/src/main/resources/sprites/Front_1.png b/StealthGame/src/main/resources/sprites/Front_1.png new file mode 100644 index 00000000..1d3bac98 Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Front_1.png differ diff --git a/StealthGame/src/main/resources/sprites/Front_2.png b/StealthGame/src/main/resources/sprites/Front_2.png new file mode 100644 index 00000000..a42ff12f Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Front_2.png differ diff --git a/StealthGame/src/main/resources/sprites/Front_3.png b/StealthGame/src/main/resources/sprites/Front_3.png new file mode 100644 index 00000000..409a66ab Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Front_3.png differ diff --git a/StealthGame/src/main/resources/sprites/Left_0.png b/StealthGame/src/main/resources/sprites/Left_0.png new file mode 100644 index 00000000..8afe371d Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Left_0.png differ diff --git a/StealthGame/src/main/resources/sprites/Left_1.png b/StealthGame/src/main/resources/sprites/Left_1.png new file mode 100644 index 00000000..03b2975f Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Left_1.png differ diff --git a/StealthGame/src/main/resources/sprites/Left_2.png b/StealthGame/src/main/resources/sprites/Left_2.png new file mode 100644 index 00000000..8afe371d Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Left_2.png differ diff --git a/StealthGame/src/main/resources/sprites/Left_3.png b/StealthGame/src/main/resources/sprites/Left_3.png new file mode 100644 index 00000000..f0fc995c Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Left_3.png differ diff --git a/StealthGame/src/main/resources/sprites/Right_0.png b/StealthGame/src/main/resources/sprites/Right_0.png new file mode 100644 index 00000000..2605a6f6 Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Right_0.png differ diff --git a/StealthGame/src/main/resources/sprites/Right_1.png b/StealthGame/src/main/resources/sprites/Right_1.png new file mode 100644 index 00000000..2a5ab31e Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Right_1.png differ diff --git a/StealthGame/src/main/resources/sprites/Right_2.png b/StealthGame/src/main/resources/sprites/Right_2.png new file mode 100644 index 00000000..2605a6f6 Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Right_2.png differ diff --git a/StealthGame/src/main/resources/sprites/Right_3.png b/StealthGame/src/main/resources/sprites/Right_3.png new file mode 100644 index 00000000..3c42150d Binary files /dev/null and b/StealthGame/src/main/resources/sprites/Right_3.png differ diff --git a/StealthGame/src/main/scala/StealthGame.scala b/StealthGame/src/main/scala/StealthGame.scala index 8aba85fa..8f4767be 100644 --- a/StealthGame/src/main/scala/StealthGame.scala +++ b/StealthGame/src/main/scala/StealthGame.scala @@ -1,10 +1,9 @@ import sge.core.{Engine, Storage} import sge.swing.SwingIO -import config.Config.{SCREEN_HEIGHT, SCREEN_WIDTH, PIXEL_UNIT_RATIO} +import config.Config.{PIXEL_UNIT_RATIO, SCREEN_HEIGHT, SCREEN_WIDTH} import scenes.StartingMenu -import scenes.LoseGame -import scenes.WinGame -import scenes.DifficultyMenu +import sge.audio.SoundIO +import sge.crossdomain.SwingWithSoundIO @main def main = val io = SwingIO @@ -13,4 +12,8 @@ import scenes.DifficultyMenu .withPixelsPerUnitRatio(PIXEL_UNIT_RATIO) .build() - Engine(io, Storage(), fpsLimit = 60).run(StartingMenu) + val audio = SoundIO.withMasterVolume(1).build() + + Engine(SwingWithSoundIO(io, audio), Storage(), fpsLimit = 60).run( + StartingMenu + ) diff --git a/StealthGame/src/main/scala/model/behaviours/Character.scala b/StealthGame/src/main/scala/model/behaviours/Character.scala index d27c2af2..5f058f43 100644 --- a/StealthGame/src/main/scala/model/behaviours/Character.scala +++ b/StealthGame/src/main/scala/model/behaviours/Character.scala @@ -8,6 +8,8 @@ import model.logic.* import Action.* import Direction.* import model.behaviours.CharacterCollisions.collidesWithWalls +import AnimationController.AnimationControllerBuilder + import java.awt.Color import config.Config.CHARACTERS_WIDTH import config.Config.CHARACTERS_HEIGHT @@ -16,14 +18,14 @@ private abstract class Character( width: Double = CHARACTERS_WIDTH, height: Double = CHARACTERS_HEIGHT, speed: Vector2D, - imagePath: String, + animationControllerBuilder: AnimationControllerBuilder, initialPosition: Vector2D = (0, 0) )( scaleWidth: Double = 1, scaleHeight: Double = 1 ) extends Behaviour with Positionable(initialPosition) - with ImageRenderer(imagePath, width, height) + with ControlledAnimationRenderer(animationControllerBuilder, width, height) with RectCollider(width, height) with Scalable(scaleWidth, scaleHeight) with Velocity: @@ -46,7 +48,8 @@ private abstract class Character( case RIGHT => (speed.x, 0) if collidesWithWalls(engine, this) - then velocity = velocity * -1 + then + velocity = velocity * -1 else velocity = action match case IDLE => (0, 0) diff --git a/StealthGame/src/main/scala/model/behaviours/enemies/Enemy.scala b/StealthGame/src/main/scala/model/behaviours/enemies/Enemy.scala index d008cb98..1c09e00f 100644 --- a/StealthGame/src/main/scala/model/behaviours/enemies/Enemy.scala +++ b/StealthGame/src/main/scala/model/behaviours/enemies/Enemy.scala @@ -10,6 +10,7 @@ import model.logic.MovementStateImpl.* import scala.compiletime.ops.boolean import model.behaviours.CharacterCollisions.collidesWithWalls import config.Config.* +import AnimationController.AnimationControllerBuilder /** It rappresents a general Enemy with a Visual Range. * @@ -29,7 +30,7 @@ import config.Config.* * @param scaleHeight */ class Enemy( - imagePath: String, + animationControllerBuilder: AnimationControllerBuilder, initialDirection: Direction, position: Vector2D = (0, 0), speed: Vector2D = (PATROL_SPEED, PATROL_SPEED), @@ -39,7 +40,7 @@ class Enemy( visualRangeSize: Double = height * 2, scaleWidth: Double = 1, scaleHeight: Double = 1 -) extends Character(width, height, speed, imagePath, position)( +) extends Character(width, height, speed, animationControllerBuilder, position)( scaleWidth, scaleHeight ) @@ -100,7 +101,7 @@ class Enemy( visualRange.positionOffset = vector * horizzontalOffset def verticalOffset = - (imageHeight / 2 + visualRange.shapeHeight / 2) + (animationHeight / 2 + visualRange.shapeHeight / 2) def horizzontalOffset = - (imageWidth / 2 + visualRange.shapeWidth / 2) + (animationWidth / 2 + visualRange.shapeWidth / 2) diff --git a/StealthGame/src/main/scala/model/behaviours/player/Player.scala b/StealthGame/src/main/scala/model/behaviours/player/Player.scala index 6c9cfe89..58f30250 100644 --- a/StealthGame/src/main/scala/model/behaviours/player/Player.scala +++ b/StealthGame/src/main/scala/model/behaviours/player/Player.scala @@ -41,7 +41,22 @@ class Player( sprint: Double = 1.5 ) extends Character( speed = speed, - imagePath = "ninja.png", + animationControllerBuilder = AnimationController + .builder() + .withDefault("idle_front") + .addAnimation("idle_front", Animation.uniform(Seq("sprites/Front_0.png"), 0.1, false)) + .addAnimation("idle_back", Animation.uniform(Seq("sprites/Back_0.png"), 0.1, false)) + .addAnimation("idle_left", Animation.uniform(Seq("sprites/Left_0.png"), 0.1, false)) + .addAnimation("idle_right", Animation.uniform(Seq("sprites/Right_0.png"), 0.1, false)) + .addAnimation("move_front", Animation.fromPattern("sprites/Front", "png", 4, 0.15)) + .addAnimation("move_back", Animation.fromPattern("sprites/Back", "png", 4, 0.15)) + .addAnimation("move_left", Animation.fromPattern("sprites/Left", "png", 4, 0.15)) + .addAnimation("move_right", Animation.fromPattern("sprites/Right", "png", 4, 0.15)) + .addAnimation("sprint_front", Animation.fromPattern("sprites/Front", "png", 4, 0.08)) + .addAnimation("sprint_back", Animation.fromPattern("sprites/Back", "png", 4, 0.08)) + .addAnimation("sprint_left", Animation.fromPattern("sprites/Left", "png", 4, 0.08)) + .addAnimation("sprint_right", Animation.fromPattern("sprites/Right", "png", 4, 0.08)) + , initialPosition = initialPosition )(scaleWidth, scaleHeight) with InputHandler: @@ -58,10 +73,31 @@ class Player( super.onInit(engine) _lifes = engine.storage.get[Int]("Lifes") + override def onEarlyUpdate: Engine => Unit = engine => + action match + case Action.IDLE => direction match + case Direction.TOP => this.playAnimation("idle_back") + case Direction.LEFT => this.playAnimation("idle_left") + case Direction.BOTTOM => this.playAnimation("idle_front") + case Direction.RIGHT => this.playAnimation("idle_right") + case Action.MOVE => direction match + case Direction.TOP => this.playAnimation("move_back") + case Direction.LEFT => this.playAnimation("move_left") + case Direction.BOTTOM => this.playAnimation("move_front") + case Direction.RIGHT => this.playAnimation("move_right") + case Action.SPRINT => direction match + case Direction.TOP => this.playAnimation("sprint_back") + case Direction.LEFT => this.playAnimation("sprint_left") + case Direction.BOTTOM => this.playAnimation("sprint_front") + case Direction.RIGHT => this.playAnimation("sprint_right") + + super.onEarlyUpdate(engine) + override def onLateUpdate: Engine => Unit = engine => collidesWithWalls(engine, this) collidesWithEnemies(engine, this, currentScene) collidesWithStairs(engine, this, nextScene) + super.onLateUpdate(engine) def lifes_=(l: Int) = diff --git a/StealthGame/src/main/scala/scenes/levels/Level.scala b/StealthGame/src/main/scala/scenes/levels/Level.scala index 7836d486..2c872d84 100644 --- a/StealthGame/src/main/scala/scenes/levels/Level.scala +++ b/StealthGame/src/main/scala/scenes/levels/Level.scala @@ -10,6 +10,7 @@ import sge.core.* import behaviours.dimension2d.{Positionable, Scalable} import sge.swing.* import model.behaviours.{TopBound, BottomBound, RightBound, LeftBound} +import sge.audio.behaviours.BackgroundMusic /** Scene containing the items that will be on every level, like the Player, the * UI of the lifes, the stair and the bounds of the map @@ -31,7 +32,8 @@ object Level extends Scene: sprint = PLAYER_SPRINT ), LifesBehaviour(), - Stairs(STAIRS_WIDTH, STAIRS_HEIGHT, "stairs.png", stairsPosition)() + Stairs(STAIRS_WIDTH, STAIRS_HEIGHT, "stairs.png", stairsPosition)(), + new Behaviour with BackgroundMusic("Background.wav") ) ++ this() override def apply(): Iterable[Behaviour] = Seq( diff --git a/StealthGame/src/main/scala/scenes/levels/LevelOne.scala b/StealthGame/src/main/scala/scenes/levels/LevelOne.scala index f230d199..76e07b71 100644 --- a/StealthGame/src/main/scala/scenes/levels/LevelOne.scala +++ b/StealthGame/src/main/scala/scenes/levels/LevelOne.scala @@ -68,14 +68,19 @@ object LevelOne extends Scene: val rightEnemyX: Double = SCENE_RIGHT_EDGE - CHARACTERS_WIDTH * 3 val rightEnemyY: Double = topHorizzontalWallY - CHARACTERS_WIDTH + def enemyAnimationController = AnimationController + .builder() + .withDefault("patrol") + .addAnimation("patrol", Animation.uniform(Seq("patrol.png"), 1)) + def apply() = Seq( new Enemy( - "patrol.png", + enemyAnimationController, Direction.TOP, (bottomEnemyX, bottomEnemyY) )() with StopThenTurnRightOnCollidePattern(1), new Enemy( - "patrol.png", + enemyAnimationController, Direction.LEFT, (rightEnemyX, rightEnemyY) )() with TurningLeftPattern(2) diff --git a/StealthGame/src/main/scala/scenes/levels/LevelThree.scala b/StealthGame/src/main/scala/scenes/levels/LevelThree.scala index d66bf142..7c6eac8e 100644 --- a/StealthGame/src/main/scala/scenes/levels/LevelThree.scala +++ b/StealthGame/src/main/scala/scenes/levels/LevelThree.scala @@ -7,6 +7,7 @@ import model.behaviours.* import enemies.* import patterns.* import scenes.WinGame +import sge.swing.* object LevelThree extends Scene: override def apply(): Iterable[Behaviour] = Level( @@ -41,14 +42,25 @@ object Enemies: val rightEnemyX = rightWallX - wallsWidth - CHARACTERS_WIDTH / 2 + def enemyAnimationController = AnimationController + .builder() + .withDefault("patrol") + .addAnimation("patrol", Animation.uniform(Seq("patrol.png"), 1)) + def apply() = Seq( new Enemy( - "patrol.png", + enemyAnimationController, Direction.LEFT, (movingEnemyX, movingEnemyY) )() with MovingPattern with TurnLeftOnCollidePattern, - new Enemy("patrol.png", Direction.TOP, (rightEnemyX, wallsY))() - with TurningLeftPattern(4), - new Enemy("patrol.png", Direction.TOP, (-rightEnemyX, wallsY))() - with TurningRightPattern(4) + new Enemy( + enemyAnimationController, + Direction.TOP, + (rightEnemyX, wallsY) + )() with TurningLeftPattern(4), + new Enemy( + enemyAnimationController, + Direction.TOP, + (-rightEnemyX, wallsY) + )() with TurningRightPattern(4) ) diff --git a/StealthGame/src/main/scala/scenes/levels/LevelTwo.scala b/StealthGame/src/main/scala/scenes/levels/LevelTwo.scala index aea54b0e..4d2e1425 100644 --- a/StealthGame/src/main/scala/scenes/levels/LevelTwo.scala +++ b/StealthGame/src/main/scala/scenes/levels/LevelTwo.scala @@ -7,6 +7,7 @@ import model.behaviours.* import enemies.* import patterns.* import model.logic.Direction +import sge.swing.* object LevelTwo extends Scene: override def apply(): Iterable[Behaviour] = @@ -25,20 +26,25 @@ object LevelTwo extends Scene: ) private object Enemies: + def enemyAnimationController = AnimationController + .builder() + .withDefault("patrol") + .addAnimation("patrol", Animation.uniform(Seq("patrol.png"), 1)) + val bottomLeftEnemyPosition = ( -SCENE_RIGHT_EDGE + CHARACTERS_WIDTH, -SCENE_TOP_EDGE + CHARACTERS_HEIGHT ) val topRightEnemyPosition = bottomLeftEnemyPosition * -1 def apply() = Seq( - new Enemy("patrol.png", Direction.RIGHT)() with TurningRightPattern(5), + new Enemy(enemyAnimationController, Direction.RIGHT)() with TurningRightPattern(5), new Enemy( - "patrol.png", + enemyAnimationController, Direction.RIGHT, position = bottomLeftEnemyPosition )() with MovingPattern with TurnLeftOnCollidePattern, new Enemy( - "patrol.png", + enemyAnimationController, Direction.LEFT, position = topRightEnemyPosition )() with MovingPattern with TurnLeftOnCollidePattern diff --git a/StealthGame/src/test/scala/mocks/MockSwingIO.scala b/StealthGame/src/test/scala/mocks/MockSwingIO.scala index 2aa3356a..c8809ad5 100644 --- a/StealthGame/src/test/scala/mocks/MockSwingIO.scala +++ b/StealthGame/src/test/scala/mocks/MockSwingIO.scala @@ -3,8 +3,29 @@ package mocks import sge.swing.* import sge.core.* import java.awt.{Color, Graphics2D} +import sge.crossdomain.SwingWithSoundIO +import sge.audio.AudioClipId + +class MockSwingIO extends SwingWithSoundIO: + + override def play(path: String, loop: Boolean, volume: Double): AudioClipId = AudioClipId.Invalid + + override def masterVolume_=(volume: Double): Unit = () + + override def masterVolume: Double = 0.0 + + override def stopAll(): Unit = () + + override def isPlaying(clipId: AudioClipId): Boolean = false + + override def setVolume(clipId: AudioClipId, volume: Double): Unit = () + + override def stop(clipId: AudioClipId): Unit = () + + override def pause(clipId: AudioClipId): Unit = () + + override def resume(clipId: AudioClipId): Unit = () -class MockSwingIO extends SwingIO: override def onFrameEnd: Engine => Unit = _ => () override def scenePointerPosition(): Vector2D = (0, 0) diff --git a/StealthGame/src/test/scala/model/behaviours/enemies/EnemiesVisualRangeTests.scala b/StealthGame/src/test/scala/model/behaviours/enemies/EnemiesVisualRangeTests.scala index 3b6a1571..6f96eed1 100644 --- a/StealthGame/src/test/scala/model/behaviours/enemies/EnemiesVisualRangeTests.scala +++ b/StealthGame/src/test/scala/model/behaviours/enemies/EnemiesVisualRangeTests.scala @@ -12,13 +12,15 @@ import sge.core.* import mocks.MockSwingIO import config.Config.CHARACTERS_WIDTH import config.Config.CHARACTERS_HEIGHT +import sge.swing.output.Animations.AnimationController +import sge.swing.output.Animations.Animation class EnemiesVisualRangeTests extends AnyFlatSpec with BeforeAndAfterEach: val width: Double = CHARACTERS_WIDTH val height: Double = CHARACTERS_HEIGHT val visualRangeSize: Double = height * 2 val enemy = - new Enemy("patrol.png", initialDirection = TOP)(visualRangeSize = + new Enemy(AnimationController.builder().addAnimation("patrol", Animation.uniform(Seq("patrol.png"), 0.1, false)).withDefault("patrol").withSize(width, height), initialDirection = TOP)(visualRangeSize = visualRangeSize ) @@ -34,7 +36,7 @@ class EnemiesVisualRangeTests extends AnyFlatSpec with BeforeAndAfterEach: "Enemies" should "have the right visual range dimensions at startup" in: test(engine) on scene soThat: _.onLateUpdate( - engine.find[VisualRange]().head.shapeWidth shouldBe enemy.imageWidth + engine.find[VisualRange]().head.shapeWidth shouldBe enemy.animationWidth ) it should "have the right offset at startup" in: diff --git a/docs/05_Implementazione.md b/docs/05_Implementazione.md index ae599bf8..3fa5a16c 100644 --- a/docs/05_Implementazione.md +++ b/docs/05_Implementazione.md @@ -2,34 +2,47 @@ -- [Engine](#engine) - * [Game Loop (run() e stop())](#game-loop-run-e-stop) - * [Delta time nanos](#delta-time-nanos) - * [Limite agli FPS (Frames Per Second)](#limite-agli-fps-frames-per-second) - * [Metodi per trovare oggetti](#metodi-per-trovare-oggetti) - * [Caricamento scene](#caricamento-scene) - * [Creazione/Distruzione degli oggetti](#creazionedistruzione-degli-oggetti) - * [Abilitazione e disabilitazione degli oggetti](#abilitazione-e-disabilitazione-degli-oggetti) -- [Storage](#storage) -- [Scene](#scene) - * [Motivazioni dietro a questo approccio](#motivazioni-dietro-a-questo-approccio) -- [SwingIO (Output)](#swingio-output) -- [SwingIO (Input)](#swingio-input) - * [Architettura](#architettura) - * [Implementazione](#implementazione) -- [Built-in behaviours](#built-in-behaviours) - * [Identifiable](#identifiable) - * [Positionable](#positionable) - * [PositionFollower](#positionfollower) - * [Velocity](#velocity) - * [Acceleration](#acceleration) - * [Scalable e SingleScalable](#scalable-e-singlescalable) - * [Collider](#collider) - + [RectCollider](#rectcollider) - + [CircleCollider](#circlecollider) - * [Renderer](#renderer) - * [InputHandler](#inputhandler) - * [Button](#button) +- [Implementazione](#implementazione) + - [Engine](#engine) + - [Game Loop (run() e stop())](#game-loop-run-e-stop) + - [Delta time nanos](#delta-time-nanos) + - [Limite agli FPS (Frames Per Second)](#limite-agli-fps-frames-per-second) + - [Metodi per trovare oggetti](#metodi-per-trovare-oggetti) + - [Caricamento scene](#caricamento-scene) + - [Creazione/Distruzione degli oggetti](#creazionedistruzione-degli-oggetti) + - [Abilitazione e disabilitazione degli oggetti](#abilitazione-e-disabilitazione-degli-oggetti) + - [Storage](#storage) + - [Scene](#scene) + - [Motivazioni dietro a questo approccio](#motivazioni-dietro-a-questo-approccio) + - [SwingIO (Output)](#swingio-output) + - [SwingIO (Input)](#swingio-input) + - [Architettura](#architettura) + - [Implementazione](#implementazione-1) + - [Built-in behaviours](#built-in-behaviours) + - [Identifiable](#identifiable) + - [Positionable](#positionable) + - [PositionFollower](#positionfollower) + - [Velocity](#velocity) + - [Acceleration](#acceleration) + - [Scalable e SingleScalable](#scalable-e-singlescalable) + - [Collider](#collider) + - [RectCollider](#rectcollider) + - [CircleCollider](#circlecollider) + - [Renderer](#renderer) + - [Animations](#animations) + - [AnimatedImageRenderer](#animatedimagerenderer) + - [ControlledAnimationRenderer](#controlledanimationrenderer) + - [InputHandler](#inputhandler) + - [Button](#button) + - [SoundIO (Audio)](#soundio-audio) + - [Architettura](#architettura-1) + - [Funzionalità principali](#funzionalità-principali) + - [Audio Behaviours](#audio-behaviours) + - [AudioPlayer](#audioplayer) + - [SoundEffect](#soundeffect) + - [BackgroundMusic](#backgroundmusic) + - [SoundEmitter](#soundemitter) + - [SwingWithSoundIO](#swingwithsoundio) @@ -408,6 +421,77 @@ val overlayText: UITextRenderer = new Behaviour with UITextRenderer( ``` +### Animations +Il sistema di animazioni permette di gestire facilmente sequenze di immagini animate. È composto da: + +- **AnimationFrame**: rappresenta un singolo frame con un percorso immagine e una durata in secondi +- **Animation**: gestisce una sequenza di frame con supporto per loop, pausa, reset e navigazione tra frame +- **AnimationController**: permette di gestire più animazioni con nomi (es. "idle", "walk", "run") e switchare tra di esse facilmente + +#### AnimatedImageRenderer +Behaviour per renderizzare una singola animazione. L'animazione viene aggiornata automaticamente ogni frame. + +```scala +// Creazione di un'animazione con durata uniforme +val walkAnimation = Animation.uniform( + Seq("walk_0.png", "walk_1.png", "walk_2.png", "walk_3.png"), + frameDuration = 0.1, // 100ms per frame + loop = true +) + +// Creazione da pattern di nomi (run_0.png, run_1.png, ...) +val runAnimation = Animation.fromPattern( + basePath = "sprites/run", + extension = "png", + frameCount = 6, + frameDuration = 0.08, + loop = true +) + +// Creazione con durate personalizzate per frame +val customAnimation = new Animation( + Seq( + AnimationFrame("frame1.png", 0.2), + AnimationFrame("frame2.png", 0.1), + AnimationFrame("frame3.png", 0.3) + ), + loop = false +) + +// Utilizzo come behaviour +val animatedSprite = new Behaviour + with AnimatedImageRenderer(walkAnimation, width = 1, height = 1) + with Positionable(0, 0) +``` + +#### ControlledAnimationRenderer +Behaviour per gestire più animazioni con un controller. Utile per personaggi con diversi stati (idle, walk, attack, ecc.). + +```scala +// Creazione del controller builder con più animazioni +val controller = AnimationController.builder() + .addAnimation("idle", Animation.uniform(Seq("idle_0.png", "idle_1.png"), 0.5)) + .addAnimation("walk", Animation.fromPattern("walk", "png", 4, 0.1)) + .addAnimation("attack", Animation.fromPattern("attack", "png", 6, 0.08, loop = false)) + .withDefault("idle") + +// Utilizzo come behaviour +class Player extends Behaviour + with ControlledAnimationRenderer(controller, width = 1, height = 2) + with Positionable(0, 0): + + override def onUpdate: Engine => Unit = engine => + super.onUpdate(engine) + + // Cambia animazione in base allo stato + if isMoving then + playAnimation("walk") + else if isAttacking then + playAnimation("attack") + else + playAnimation("idle") +``` + ### InputHandler Permette allo sviluppatore di definire associazioni del tipo `input -> azione` @@ -476,3 +560,115 @@ Mixa un RectRenderer per lo sfondo e ha internamente un TextRenderer per il test Permette di definire quali tasti (tastiera o mouse) possono premerlo. Viene premuto solo al rilascio del tasto ed inoltre si assicura che la pressione sia anche incominciata sul tasto. + +## SoundIO (Audio) +SoundIO è il componente audio dell'engine, e implementa il trait IO utilizzando le funzionalità della Java Sound API (`javax.sound.sampled`). + +### Architettura +Si è implementata la seguente architettura: +- `SoundIO` è l'implementazione di `IO` che utilizza la Java Sound API per la riproduzione audio. +- `AudioClipId` è un tipo opaco che rappresenta un identificatore univoco per ogni clip audio in riproduzione. + +### Funzionalità principali +Il metodo `play` di SwingSoundIO permette di avviare la riproduzione di un file audio, specificando: +- Il percorso del file (deve essere nella cartella resources) +- Se deve essere riprodotto in loop +- Il volume di riproduzione (da 0.0 a 1.0) + +Il metodo restituisce un `AudioClipId` che può essere utilizzato per controllare la riproduzione: +- `stop(clipId)`: ferma la riproduzione +- `pause(clipId)`: mette in pausa +- `resume(clipId)`: riprende la riproduzione +- `setVolume(clipId, volume)`: modifica il volume +- `isPlaying(clipId)`: verifica se è in riproduzione + +Inoltre, è possibile impostare un `masterVolume` globale che influenza tutti i clip audio. + +*Esempio* +```scala +val soundIO: SoundIO = SoundIO + .withMasterVolume(0.8) // imposta il volume master + .build() // costruisce la SoundIO + +// Riproduzione di un effetto sonoro +val clipId = soundIO.play("explosion.wav", volume = 0.7) + +// Riproduzione di musica in loop +val musicId = soundIO.play("bgm.wav", loop = true, volume = 0.5) + +// Controllo della riproduzione +soundIO.pause(musicId) +soundIO.resume(musicId) +soundIO.setVolume(musicId, 0.3) +soundIO.stop(musicId) + +// Ferma tutti i clip audio +soundIO.stopAll() +``` + +### Audio Behaviours +Sono stati implementati diversi behaviour per facilitare l'integrazione dell'audio nei giochi: + +#### AudioPlayer +È il behaviour base che fornisce metodi protetti per interagire con SoundIO: +- `playAudio(engine, path, loop, volume)`: avvia la riproduzione +- `stopAudio(engine)`: ferma la riproduzione +- `pauseAudio(engine)`: mette in pausa +- `resumeAudio(engine)`: riprende +- `setAudioVolume(engine, volume)`: modifica il volume +- `isAudioPlaying(engine)`: verifica lo stato + +#### SoundEffect +Un behaviour per effetti sonori che vengono riprodotti una volta quando l'oggetto viene abilitato. Utile per suoni come esplosioni, pickup, ecc. + +```scala +// Effetto sonoro che viene riprodotto quando l'oggetto entra in scena +val explosion = new Behaviour with SoundEffect("explosion.wav", volume = 0.8) {} +``` + +#### BackgroundMusic +Un behaviour per la musica di sottofondo che viene riprodotta in loop. La musica si avvia all'abilitazione e si mette in pausa alla disabilitazione. + +```scala +// Musica di sottofondo in loop +val bgMusic = new Behaviour with BackgroundMusic("bgm.wav", volume = 0.6) {} +``` + +#### SoundEmitter +Un behaviour che permette di riprodurre suoni on-demand tramite il metodo `playSound`. Utile quando si deve attivare un suono dalla logica di gioco. + +```scala +class Player extends Behaviour with SoundEmitter: + def shoot(): Unit = + playSound("shoot.wav", volume = 0.5) + + def startEngine(): AudioClipId = + playLoopingSound("engine.wav", volume = 0.7) +``` + +### SwingWithSoundIO +Per facilitare l'uso combinato di grafica e audio, è stato creato `SwingWithSoundIO` che combina le funzionalità di `SwingIO` e `SoundIO` in un unico IO. Questo permette di utilizzare sia i renderer grafici che i behaviour audio senza dover gestire due IO separati. + +*Esempio* +```scala +import sge.core.* +import sge.swing.* +import java.awt.Color + +object MyGame extends App: + val engine: Engine = Engine( + SwingWithSoundIO + .withTitle("My Game") + .withSize((800, 600)) + .withPixelsPerUnitRatio(50) + .withBackgroundColor(Color.black) + .withMasterVolume(0.8) + .build(), + Storage(), + 60 + ) + engine.run(myScene) +``` + +In questo modo, i behaviour che richiedono `SwingIO` (come i renderer) e quelli che richiedono `SoundIO` (come i behaviour audio) funzioneranno correttamente con lo stesso IO. +