My first attempt simply checked if the player had signed out, and if that was the case, the current screen was exited as if the player had aborted. This was possible because every screen supports abortion. The problem with this approach is that all animations and sound effects associated to screen transitions would be played at once.
To avoid that problem, I could have added a special abortion path where all these effects are not played, but it did not feel right.
Another alternative is to "restart" the game, i.e. kill all tasks and let the top level reinitialize the game. Instant death is actually easy: it's a matter of not calling Scheduler.RunFor and discarding the scheduler, replacing it by a new empty one.
There is a problem, though. The screen manager will still have references to the screens, only the tasks have been killed. This particular problem is easy solve by the addition of a RemoveAll method, but there is more to it. The real problem is that clean-up code in tasks is never executed.
The definitive solution requires all clean-up code to be located in finally blocks, or in Dispose methods of IDisposable objects bound with "use" or "using". That's where all clean-up code should be anyway. The code below shows a new method in ScreenBase which makes it easy to add a screen, do something the remove the screen.
type ScreenManager(game, ui_content_provider : IUiContentProvider) = ... member this.AddDoRemove(s : Screen, t : Eventually<'T>) = task { try this.AddScreen(s) return! t finally this.RemoveScreen(s) }
Once this condition is met, the process of killing can be implemented by throwing an exception. The environment is given a new field indicating whether killing is going on. All system calls check this field before and after yielding or sleeping, and raise a specific exception if needed.
exception TaskKilled of obj type Environment(scheduler : Scheduler) = // When killing all tasks, the data to embed in TaskKilled exceptions. let killing_data : obj option ref = ref None let checkAndKill = task { match !killing_data with | Some o -> raise(TaskKilled(o)) | None -> () } member this.StartKillAll(data) = killing_data := Some data scheduler.WakeAll() member this.StopKillAll() = killing_data := None member this.Wait dt = task { do! checkAndKill do! wait dt do! checkAndKill }
Using an exception makes it possible to survive a killing attempt by catching the exception and discarding it. There are two ways to go at this point: Either refrain from doing such a thing (in which case one should never ever catch and discard all exceptions), or embrace the idea and use it for "targeted killing". The exception thrown during killing (TaskKilled) carries an object which can be used in any way programmers see fit. It can for instance be a predicate which when evaluated indicates if the exception should be rethrown or discarded. Beware though: Tasks wake up early from long sleeps when they are killed. If the task is meant to survive, it's up to the programmer to make sure the task "goes back to bed".
The last piece in the puzzle is to detect sign-outs and react by killing and reinitializing the game. This process should not be enabled at all time though, it depends in what "top state" the game is. What I call "top state" here is an abstract view of the game state. See the code below for the complete list of states:
type TopState = | Initializing | AtPressStartScreen | AnonPlayer | Player of SignedInGamer | KillingAllTasks
State AnonPlayer is active when a player is playing the game (i.e. has press "start" on the "press start screen") without being signed in.
State Player corresponds to a signed-in player playing the game.
A typical flow is Initializing -> AtPressStartScreen -> Player or AnonPlayer -> AtPressStartScreen...
Another flow involving signing out: Initializing -> AtPressStartScreen -> Player or AnonPlayer-> KillingAllTasks -> Initializing -> ...
The code below updates the state machine.
type TopState = | Initializing | AtPressStartScreen | AnonPlayer | Player of SignedInGamer | KillingAllTasks with member this.Update(transition) = match this, transition with | Initializing, InitDone -> AtPressStartScreen | Initializing, _ -> invalidOp "Invalid transition from Initializing" | AtPressStartScreen, AnonPressedStart -> AnonPlayer | AtPressStartScreen, PlayerPressedStart(p) -> Player p | AtPressStartScreen, _ -> invalidOp "Invalid transition from AtPressStartScreen" | AnonPlayer, Back -> AtPressStartScreen | AnonPlayer, _ -> invalidOp "Invalid transition from AnonPlayer" | Player p, SignOut -> KillingAllTasks | Player p, Back -> AtPressStartScreen | Player p, _ -> invalidOp "Invalid transition from Player" | KillingAllTasks, AllTasksKilled -> Initializing | KillingAllTasks, _ -> invalidOp "Invalid transition from KillingAllTasks" and TopStateTransition = | InitDone | AnonPressedStart | PlayerPressedStart of SignedInGamer | SignOut | AllTasksKilled | Back
See how I used parallel pattern-matching? Whenever I have to write this kind of code in C# I find myself swearing silently...
Finally, the piece of code doing the dirty business.
// Initialization and killing of tasks. // Killing happens when a signed in player signs out. // Initialization happens during start-up and after killing. match !top_state with | Initializing -> scheduler.AddTask(main_task) top_state := (!top_state).Update(InitDone) | Player p when not(Gamer.IsSignedIn(p.PlayerIndex)) -> top_state := (!top_state).Update(SignOut) sys.StartKillAll(null) | KillingAllTasks when not(scheduler.HasLiveTasks) -> sys.StopKillAll() top_state := (!top_state).Update(AllTasksKilled) // Ideally, screen removal is done in finally handlers, and // killing should take care of removing all screens. // Nevertheless, we remove all screens here to be on the safe side. screen_manager.RemoveAllScreens() | _ -> ()