Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixing game loop rebuild race condition #496

Merged
merged 4 commits into from
Mar 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import indigo.shared.subsystems.SubSystemFrameContext
import indigo.shared.subsystems.SubSystemId

// Provides "at least once" message delivery for updates on a bundle's loading status.
object AssetBundleLoader extends SubSystem {
object AssetBundleLoader extends SubSystem:
type EventType = GlobalEvent
type SubSystemModel = AssetBundleTracker

Expand All @@ -35,7 +35,7 @@ object AssetBundleLoader extends SubSystem {
def update(
frameContext: SubSystemFrameContext,
tracker: AssetBundleTracker
): GlobalEvent => Outcome[AssetBundleTracker] = {
): GlobalEvent => Outcome[AssetBundleTracker] =
// Asset Bundle Loader Commands
case AssetBundleLoaderEvent.Load(key, assets) =>
createBeginLoadingOutcome(key, assets, tracker)
Expand Down Expand Up @@ -70,7 +70,6 @@ object AssetBundleLoader extends SubSystem {
// Everything else.
case _ =>
Outcome(tracker)
}

def present(frameContext: SubSystemFrameContext, model: AssetBundleTracker): Outcome[SceneUpdateFragment] =
Outcome(SceneUpdateFragment.empty)
Expand All @@ -79,7 +78,7 @@ object AssetBundleLoader extends SubSystem {
key: BindingKey,
assets: Set[AssetType],
tracker: AssetBundleTracker
): Outcome[AssetBundleTracker] = {
): Outcome[AssetBundleTracker] =
val assetPrimitives = AssetType.flattenAssetList(assets.toList)

val events: Batch[GlobalEvent] =
Expand All @@ -91,77 +90,69 @@ object AssetBundleLoader extends SubSystem {
Outcome(
tracker.addBundle(key, assetPrimitives)
).addGlobalEvents(AssetBundleLoaderEvent.Started(key) :: events)
}

private def processAssetUpdateEvent(
path: AssetPath,
completedSuccessfully: Boolean,
tracker: AssetBundleTracker
): Outcome[AssetBundleTracker] = {
): Outcome[AssetBundleTracker] =
val updatedTracker =
tracker.assetLoadComplete(path, completedSuccessfully)

val statusBasedEvents: List[GlobalEvent] =
updatedTracker.register
.filter(_.containsAsset(path))
.flatMap { bundle =>
bundle.status match {
bundle.status match
case AssetBundleStatus.LoadComplete(completed, count) =>
List[GlobalEvent](
List(
AssetBundleLoaderEvent.LoadProgress(bundle.key, 100, completed, count),
AssetEvent.LoadAssetBatch(bundle.giveAssetSet, bundle.key, true)
)

case AssetBundleStatus.LoadFailed(percent, completed, count, _) =>
List[GlobalEvent](
List(
AssetBundleLoaderEvent.LoadProgress(bundle.key, percent, completed, count),
AssetEvent
.AssetBatchLoadError(bundle.key, s"Asset batch with key '${bundle.key.toString}' failed to load")
)

case AssetBundleStatus.LoadInProgress(percent, completed, count) =>
List[GlobalEvent](
List(
AssetBundleLoaderEvent.LoadProgress(bundle.key, percent, completed, count)
)
}
}

Outcome(updatedTracker, Batch.fromList(statusBasedEvents))
}
}

sealed trait AssetBundleLoaderEvent extends GlobalEvent derives CanEqual
object AssetBundleLoaderEvent {
enum AssetBundleLoaderEvent extends GlobalEvent derives CanEqual:
// commands
final case class Load(key: BindingKey, assets: Set[AssetType]) extends AssetBundleLoaderEvent with SubSystemEvent
final case class Retry(key: BindingKey) extends AssetBundleLoaderEvent with SubSystemEvent
case Load(key: BindingKey, assets: Set[AssetType]) extends AssetBundleLoaderEvent with SubSystemEvent
case Retry(key: BindingKey) extends AssetBundleLoaderEvent with SubSystemEvent

// result events
final case class Started(key: BindingKey) extends AssetBundleLoaderEvent
final case class LoadProgress(key: BindingKey, percent: Int, completed: Int, total: Int)
extends AssetBundleLoaderEvent
final case class Success(key: BindingKey) extends AssetBundleLoaderEvent
final case class Failure(key: BindingKey, message: String) extends AssetBundleLoaderEvent
}

final case class AssetBundleTracker(val register: List[AssetBundle]) {
case Started(key: BindingKey) extends AssetBundleLoaderEvent
case LoadProgress(key: BindingKey, percent: Int, completed: Int, total: Int) extends AssetBundleLoaderEvent
case Success(key: BindingKey) extends AssetBundleLoaderEvent
case Failure(key: BindingKey, message: String) extends AssetBundleLoaderEvent

final case class AssetBundleTracker(val register: List[AssetBundle]):
val bundleCount: Int =
register.length

def addBundle(key: BindingKey, assets: List[AssetTypePrimitive]): AssetBundleTracker =
if (assets.isEmpty || findBundleByKey(key).isDefined) this
else {
if assets.isEmpty || findBundleByKey(key).isDefined then this
else
val newBundle =
new AssetBundle(
AssetBundle(
key,
assets.size,
assets.map { assetType =>
(assetType.path -> new AssetToLoad(assetType, false, false))
(assetType.path -> AssetToLoad(assetType, false, false))
}.toMap
)

AssetBundleTracker(register ++ List(newBundle))
}

def findBundleByKey(key: BindingKey): Option[AssetBundle] =
register.find(_.key == key)
Expand All @@ -183,12 +174,12 @@ final case class AssetBundleTracker(val register: List[AssetBundle]) {

def checkBundleStatus(key: BindingKey): Option[AssetBundleStatus] =
findBundleByKey(key).map(_.status)
}
object AssetBundleTracker {

object AssetBundleTracker:
val empty: AssetBundleTracker =
new AssetBundleTracker(Nil)
}
final case class AssetBundle(key: BindingKey, assetCount: Int, assets: Map[AssetPath, AssetToLoad]) {
AssetBundleTracker(Nil)

final case class AssetBundle(key: BindingKey, assetCount: Int, assets: Map[AssetPath, AssetToLoad]):
private given CanEqual[Option[AssetToLoad], Option[AssetToLoad]] = CanEqual.derived

def assetLoadComplete(assetPath: AssetPath, loaded: Boolean): AssetBundle =
Expand All @@ -197,11 +188,11 @@ final case class AssetBundle(key: BindingKey, assetCount: Int, assets: Map[Asset
assetCount,
assets.updatedWith(assetPath) {
case None => None
case Some(v) => Some(new AssetToLoad(v.asset, true, loaded))
case Some(v) => Some(AssetToLoad(v.asset, true, loaded))
}
)

def status: AssetBundleStatus = {
def status: AssetBundleStatus =
val assetList = assets.toList
val count = assetList.length
val errors = assetList.filter(p => p._2.complete && !p._2.loaded).map(_._1)
Expand All @@ -212,13 +203,9 @@ final case class AssetBundle(key: BindingKey, assetCount: Int, assets: Map[Asset
val percentage = Math.round(100.0d * combined.toDouble / count.toDouble).toInt
val clampedPercentage = Math.min(100, Math.max(0, percentage))

if (errorCount + successCount < count)
AssetBundleStatus.LoadInProgress(clampedPercentage, combined, count)
else if (errorCount > 0)
AssetBundleStatus.LoadFailed(clampedPercentage, combined, count, errors)
else
AssetBundleStatus.LoadComplete(combined, count)
}
if errorCount + successCount < count then AssetBundleStatus.LoadInProgress(clampedPercentage, combined, count)
else if errorCount > 0 then AssetBundleStatus.LoadFailed(clampedPercentage, combined, count, errors)
else AssetBundleStatus.LoadComplete(combined, count)

def giveAssetLoadState(path: AssetPath): Option[AssetToLoad] =
assets.get(path)
Expand All @@ -228,19 +215,14 @@ final case class AssetBundle(key: BindingKey, assetCount: Int, assets: Map[Asset

def containsAsset(path: AssetPath): Boolean =
assets.contains(path)
}

final case class AssetToLoad(asset: AssetTypePrimitive, complete: Boolean, loaded: Boolean) derives CanEqual

sealed trait AssetBundleStatus {
val percent: Int
val completed: Int
val count: Int
}
object AssetBundleStatus {
final case class LoadComplete(completed: Int, count: Int) extends AssetBundleStatus {
val percent: Int = 100
}
final case class LoadFailed(percent: Int, completed: Int, count: Int, failures: List[AssetPath])
extends AssetBundleStatus
final case class LoadInProgress(percent: Int, completed: Int, count: Int) extends AssetBundleStatus
}
enum AssetBundleStatus(val percent: Int, val completed: Int, val count: Int) derives CanEqual:
case LoadComplete(completedLoading: Int, loadCount: Int) extends AssetBundleStatus(100, completedLoading, loadCount)

case LoadFailed(percentLoaded: Int, completedLoading: Int, loadCount: Int, failures: List[AssetPath])
extends AssetBundleStatus(percentLoaded, completedLoading, loadCount)

case LoadInProgress(percentLoaded: Int, completedLoading: Int, loadCount: Int)
extends AssetBundleStatus(percentLoaded: Int, completedLoading: Int, loadCount: Int)
16 changes: 12 additions & 4 deletions indigo/indigo/src/main/scala/indigo/gameengine/GameEngine.scala
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ final class GameEngine[StartUpData, GameModel, ViewModel](
IndigoLogger.info("Starting Indigo")

storage = Storage.default
globalEventStream = new GlobalEventStream(rebuildGameLoop(parentElement, false), audioPlayer, storage, platform)
globalEventStream = new GlobalEventStream(audioPlayer, storage, platform)
gamepadInputCapture = GamepadInputCaptureImpl()

// Intialisation / Boot events
Expand Down Expand Up @@ -167,6 +167,7 @@ final class GameEngine[StartUpData, GameModel, ViewModel](
@SuppressWarnings(Array("scalafix:DisableSyntax.throw"))
def rebuildGameLoop(parentElement: Element, firstRun: Boolean): AssetCollection => Unit =
ac => {
if (!firstRun) gameLoopInstance.lock()

fontRegister.clearRegister()
boundaryLocator.purgeCache()
Expand Down Expand Up @@ -216,13 +217,15 @@ final class GameEngine[StartUpData, GameModel, ViewModel](
m <- modelToUse(startUpSuccessData)
vm <- viewModelToUse(startUpSuccessData, m)
initialisedGameLoop <- GameEngine.initialiseGameLoop(
parentElement,
this,
boundaryLocator,
sceneProcessor,
gameConfig,
m,
vm,
frameProccessor
frameProccessor,
!firstRun // If this isn't the first run, start with it frame locked.
)
} yield {
renderer = rendererAndAssetMapping._1
Expand All @@ -241,6 +244,7 @@ final class GameEngine[StartUpData, GameModel, ViewModel](

gameLoop = firstTick

gameLoopInstance.unlock()
()

case oe @ Outcome.Error(e, _) =>
Expand Down Expand Up @@ -349,23 +353,27 @@ object GameEngine {
}

def initialiseGameLoop[StartUpData, GameModel, ViewModel](
parentElement: Element,
gameEngine: GameEngine[StartUpData, GameModel, ViewModel],
boundaryLocator: BoundaryLocator,
sceneProcessor: SceneProcessor,
gameConfig: GameConfig,
initialModel: GameModel,
initialViewModel: GameModel => ViewModel,
frameProccessor: FrameProcessor[StartUpData, GameModel, ViewModel]
frameProccessor: FrameProcessor[StartUpData, GameModel, ViewModel],
startFrameLocked: Boolean
): Outcome[GameLoop[StartUpData, GameModel, ViewModel]] =
Outcome(
new GameLoop[StartUpData, GameModel, ViewModel](
gameEngine.rebuildGameLoop(parentElement, false),
boundaryLocator,
sceneProcessor,
gameEngine,
gameConfig,
initialModel,
initialViewModel(initialModel),
frameProccessor
frameProccessor,
startFrameLocked
)
)

Expand Down
34 changes: 32 additions & 2 deletions indigo/indigo/src/main/scala/indigo/gameengine/GameLoop.scala
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package indigo.gameengine

import indigo.platform.assets.AssetCollection
import indigo.shared.BoundaryLocator
import indigo.shared.IndigoLogger
import indigo.shared.Outcome
import indigo.shared.collections.Batch
import indigo.shared.config.GameConfig
import indigo.shared.dice.Dice
import indigo.shared.events.FrameTick
import indigo.shared.events.GlobalEvent
import indigo.shared.events.IndigoSystemEvent
import indigo.shared.events.InputEvent
import indigo.shared.events.InputState
import indigo.shared.platform.SceneProcessor
Expand All @@ -15,16 +18,19 @@ import indigo.shared.time.GameTime
import indigo.shared.time.Millis
import indigo.shared.time.Seconds

import scala.collection.mutable
import scala.scalajs.js.JSConverters._

final class GameLoop[StartUpData, GameModel, ViewModel](
rebuildGameLoop: AssetCollection => Unit,
boundaryLocator: BoundaryLocator,
sceneProcessor: SceneProcessor,
gameEngine: GameEngine[StartUpData, GameModel, ViewModel],
gameConfig: GameConfig,
initialModel: GameModel,
initialViewModel: ViewModel,
frameProcessor: FrameProcessor[StartUpData, GameModel, ViewModel]
frameProcessor: FrameProcessor[StartUpData, GameModel, ViewModel],
startFrameLocked: Boolean
):

@SuppressWarnings(Array("scalafix:DisableSyntax.var"))
Expand All @@ -37,12 +43,19 @@ final class GameLoop[StartUpData, GameModel, ViewModel](
private var _inputState: InputState = InputState.default
@SuppressWarnings(Array("scalafix:DisableSyntax.var"))
private var _running: Boolean = true
@SuppressWarnings(Array("scalafix:DisableSyntax.var"))
private var _frameLocked: Boolean = startFrameLocked

private val systemActions: mutable.Queue[IndigoSystemEvent] =
new mutable.Queue[IndigoSystemEvent]()

private val frameDeltaRecord: scala.scalajs.js.Array[Double] = scala.scalajs.js.Array(0.0d, 0.0d, 0.0d, 0.0d, 0.0d)

def gameModelState: GameModel = _gameModelState
def viewModelState: ViewModel = _viewModelState
def runningTimeReference: Double = _runningTimeReference
def lock(): Unit = _frameLocked = true
def unlock(): Unit = _frameLocked = false

private val runner: (Double, Double, Double) => Unit =
gameConfig.frameRateLimit match
Expand Down Expand Up @@ -88,9 +101,13 @@ final class GameLoop[StartUpData, GameModel, ViewModel](
if _running then runner(time, timeDelta, lastUpdateTime)
}

@SuppressWarnings(Array("scalafix:DisableSyntax.throw"))
private def runFrame(time: Double, timeDelta: Double): Unit =
if _frameLocked then ()
else if systemActions.size > 0 then performSystemActions(systemActions.dequeueAll(_ => true).toList)
else runFrameNormal(time, timeDelta)

@SuppressWarnings(Array("scalafix:DisableSyntax.throw"))
private def runFrameNormal(time: Double, timeDelta: Double): Unit =
val gameTime =
new GameTime(Millis(time.toLong).toSeconds, Millis(timeDelta.toLong).toSeconds, gameConfig.frameRateLimit)
val events = gameEngine.globalEventStream.collect ++ Batch(FrameTick)
Expand Down Expand Up @@ -126,7 +143,9 @@ final class GameLoop[StartUpData, GameModel, ViewModel](
case Outcome.Result((gameModel, viewModel, sceneUpdateFragment), globalEvents) =>
_gameModelState = gameModel
_viewModelState = viewModel

globalEvents.foreach(e => gameEngine.globalEventStream.pushGlobalEvent(e))

sceneUpdateFragment

// Play audio
Expand All @@ -145,3 +164,14 @@ final class GameLoop[StartUpData, GameModel, ViewModel](

// Render scene
gameEngine.renderer.drawScene(sceneData, gameTime.running)

// Process system events
events
.collect { case e: IndigoSystemEvent => e }
.foreach(systemActions.enqueue)

def performSystemActions(systemEvents: List[IndigoSystemEvent]): Unit =
systemEvents.foreach { case IndigoSystemEvent.Rebuild(assetCollection) =>
IndigoLogger.info("Rebuilding game loop from new asset collection.")
rebuildGameLoop(assetCollection)
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class Platform(
def createTextureAtlas(assetCollection: AssetCollection): Outcome[TextureAtlas] =
Outcome(
TextureAtlas.create(
assetCollection.images.map(i => ImageRef(i.name, i.data.width, i.data.height, i.tag)),
assetCollection.images.map(i => ImageRef(i.name, i.data.width, i.data.height, i.tag)).toList,
(name: AssetName) => assetCollection.images.find(_.name == name),
TextureAtlasFunctions.createAtlasData
)
Expand Down
Loading