Worum geht es?
Es geht nur um die Schlange. Jeder erinnert sich, was eine Schlange ist: Eine Schlange bewegt sich auf einem rechteckigen Feld. Findet Nahrung - wächst an Länge, findet sich selbst oder den Rand des Feldes - stirbt. Und der Benutzer kann nur Befehle senden: links, rechts, oben, unten.
Ich beschloss, hier etwas Action hinzuzufügen und die Schlange vor dem Pacman davonlaufen zu lassen. Und das alles auf die Schauspieler!
Daher werde ich heute am Beispiel einer Schlange darüber sprechen, wie ein MailboxProcessor
mit MailboxProcessor
aus der Standardbibliothek erstellt wird, nach welchen Punkten MailboxProcessor
muss und welche Fallstricke Sie erwarten können.
Der hier geschriebene Code ist nicht perfekt, verstößt möglicherweise gegen einige Prinzipien und ist möglicherweise besser geschrieben. Aber wenn Sie ein Anfänger sind und sich mit Postfächern befassen möchten, hoffe ich, dass dieser Artikel Ihnen hilft.
Wenn Sie ohne mich alles über Postfächer wissen, wird es Ihnen hier vielleicht langweilig.
Warum Schauspieler?
Zum Zwecke der Übung. Ich habe über das Modell der Schauspieler gelesen, das Video gesehen, alles hat mir gefallen, aber ich habe es nicht selbst ausprobiert. Jetzt habe ich es versucht.
Trotz der Tatsache, dass ich mich aus technologischen Gründen für Technologie entschieden habe, fiel das Konzept sehr erfolgreich auf diese Aufgabe.
Warum MailboxProcessor und nicht zum Beispiel Akka.net?
Für meine Aufgabe stammt ein MailboxProcessor
aus der Orbitalstation von Spatzen, MailboxProcessor
viel einfacher und Teil der Standardbibliothek, sodass Sie keine Pakete verbinden müssen.
Informationen zu Postfachprozessoren und zugehörigen Boilerplate
Der Punkt ist einfach. Das Postfach im Inneren hat eine Nachrichtenschleife und einen Status. Ihre Nachrichtenschleife aktualisiert diesen Status entsprechend der neuen Nachricht.
let actor = MailboxProcessor.Start(fun inbox -> // , // . inbox -- MailboxProcessor let rec messageLoop oldState = async { // let! msg = inbox.Receive()
Bitte beachten Sie, dass messageLoop
rekursiv ist und am Ende erneut aufgerufen werden muss, da sonst nur eine Nachricht verarbeitet wird, nach der dieser Akteur stirbt. messageLoop
ebenfalls asynchron und jede nachfolgende Iteration wird ausgeführt, wenn eine neue Nachricht empfangen wird: let! msg = inbox.Receive()
let! msg = inbox.Receive()
.
Somit geht die gesamte logische Last an die updateState
Funktion. updateState
bedeutet, dass wir zum Erstellen des Postfachs des Prozessors eine Konstruktorfunktion erstellen können, die eine updateState
und einen updateState
akzeptiert:
// applyMessage // (fun inbox -> ...) let buildActor applyMessage zeroState = MailboxProcessor.Start(fun inbox -> let rec loop state = async{ let! msg = inbox.Receive() let newState = applyMessage state msg return! loop newState } loop zeroState )
Cool! Jetzt müssen wir nicht mehr ständig überwachen, um die return! loop newState
nicht zu vergessen return! loop newState
return! loop newState
. Wie Sie wissen, speichert ein Schauspieler einen Zustand, aber jetzt ist nicht ganz klar, wie man diesen Zustand von außen erhält. Das Postfach des Prozessors verfügt über eine PostAndReply
Methode, die die Funktion AsyncReplyChannel<'Reply> -> 'Msg
als Eingabe verwendet. Zuerst hat es mich in eine Betäubung versetzt - es ist völlig unklar, woher diese Funktion kommt. In Wirklichkeit stellte sich jedoch heraus, dass alles einfacher war: Alle Nachrichten müssen in einen DU-Wrapper eingeschlossen werden, da wir jetzt zwei Operationen für unseren Akteur erhalten: Senden Sie die Nachricht selbst und fragen Sie nach dem aktuellen Status. So sieht es aus:
// . // Mail<_,_> , Post & Get -- . // F# , // compare & equals . // -- . // [<Struct>] . type Mail<'msg, 'state> = | Post of 'msg | Get of AsyncReplyChannel<'state>
Unsere Konstruktorfunktion sieht jetzt so aus:
let buildActor applyMessage zeroState = MailboxProcessor.Start(fun inbox -> let rec loop state = async{ let! msg = inbox.Receive() // , // . -- , // . // -- // . ! match msg with | Post msg -> let newState = applyMessage state msg return! loop newState | Get channel -> channel.Reply state return! loop state } loop zeroState )
Mail.Post
mit der Mailbox arbeiten zu können, müssen wir alle unsere Nachrichten in diese Mail.Post
. Um dies nicht jedes Mal zu schreiben, ist es besser, es in eine kleine App zu packen:
module Mailbox = let buildAgent applyMessage zeroState = MailboxProcessor.Start(fun inbox -> let rec loop state = async{ let! msg = inbox.Receive() match msg with | Post msg -> let newState = applyMessage state msg return! loop newState | Get channel -> channel.Reply state return! loop state } loop zeroState ) let post (agent: MailboxProcessor<_>) msg = Post msg |> agent.Post let getState (agent: MailboxProcessor<_>) = agent.PostAndReply Get let getStateAsync (agent: MailboxProcessor<_>) = agent.PostAndAsyncReply Get // Single Case Discriminated Union. // MailboxProcessor API. , // - , , // . , // . type MailAgent<'msg, 'state> = MailAgent of address:string * mailbox:MailboxProcessor<Mail<'msg, 'state>> // API with member this.Post msg = // let (MailAgent (address,this)) = this Mailbox.post this msg member this.GetState() = let (MailAgent (address,this)) = this Mailbox.getState this member this.GetStateAsync() = let (MailAgent (address,this)) = this Mailbox.getStateAsync this member this.Address = let (MailAgent (address, _)) = this address member this.Dispose() = let (MailAgent (_, this)) = this (this:>IDisposable).Dispose() interface IDisposable with member this.Dispose() = this.Dispose()
Ich werde Ihnen sagen, welche address:string
etwas später, aber für den Moment ist unser Boilerplate fertig.
Eigentlich die Schlange
In der Schlange gibt es eine Schlange, einen Benutzer mit seinen Befehlen, ein Feld und einen regelmäßigen Übergang zum nächsten Frame.
Hier ist alles zusammen und muss von unseren Schauspielern verschmiert werden.
Mein ursprüngliches Layout war wie folgt:
- Schauspieler mit Timer. Akzeptiert Start / Stopp / Pause-Meldungen. Sendet alle n Millisekunden eine
Flush
Nachricht an den Flush
. Speichert System.Timers.Timer
als Status - Schauspielerteams. Empfängt Nachrichten vom Benutzer
Move Up/Down/Left/Right
, AddPerk Speed/Attack
(ja, meine Schlange kann schnell kriechen und Bösewichte angreifen) und Flush
vom Timer. Es speichert eine Liste von Befehlen als Status, und mit einem Flush wird diese Liste zurückgesetzt. - Der Schauspieler ist eine Schlange. Es speichert den Zustand der Schlange - Vorteile, Länge, Richtung, Biegungen und Koordinaten.
Es akzeptiert eine Liste von Nachrichten vom Akteur der Befehle, eine Tick
Nachricht (um die Zelle der Schlange 1 vorwärts zu bewegen) und eine GrowUp
Nachricht vom Akteur des Feldes, wenn er Nahrung findet. - Schauspieler des Feldes. Es speichert eine Karte von Zellen, nimmt den Zustand einer Schlange in einer Nachricht auf und zeichnet Koordinaten auf einem vorhandenen Bild. Außerdem wird
GrowUp
Schlangenschauspieler und der Befehl Stop
an den Timer GrowUp
wenn das Spiel beendet ist.
Wie Sie sehen, ist die Nachrichtenzuordnung selbst bei einer so geringen Anzahl von Entitäten bereits nicht trivial. Und schon zu diesem Zeitpunkt traten Schwierigkeiten auf: Tatsache ist, dass F # standardmäßig keine zyklischen Abhängigkeiten zulässt. In der aktuellen Codezeile können Sie nur den oben beschriebenen Code verwenden. Gleiches gilt für die Dateien im Projekt. Dies ist kein Fehler, sondern eine Funktion, und ich liebe sie sehr, weil sie dazu beiträgt, den Code sauber zu halten. Aber was tun, wenn zyklische Links vom Design her benötigt werden? Natürlich können Sie den rec namespace
- und dann können Sie in einer Datei auf alles verweisen, was in dieser Datei enthalten ist, die ich verwendet habe.
Es wird erwartet, dass der Code durcheinander kommt, aber dann schien es die einzige Option zu sein. Und es hat funktioniert.
Das Problem der Außenwelt
Alles funktionierte, solange das gesamte System der Schauspieler von der Außenwelt isoliert war und ich nur Linien in der Konsole entlarvte und anzeigte. Als es an der Zeit war, die Abhängigkeit in Form der updateUI
Funktion zu implementieren, die für jeden Tick neu updateUI
werden sollte, konnte ich dieses Problem in der aktuellen Implementierung nicht lösen. Weder hässlich noch schön - auf keinen Fall. Und dann erinnerte ich mich an akku - schließlich können Sie dort gleich auf dem Weg Schauspieler generieren, und ich habe alle meine Schauspieler in der Kompilierungsphase beschrieben.
Die Lösung liegt auf der Hand - benutze akku! Nein, natürlich ist Akka immer noch übertrieben, aber ich habe beschlossen, bestimmte Punkte von dort zu lecken - nämlich ein Akteursystem zu erstellen, in dem Sie dynamisch neue Akteure hinzufügen und vorhandene Akteure an der Adresse abfragen können.
Da die Akteure jetzt zur Laufzeit hinzugefügt und gelöscht werden, aber über die Adresse und nicht über den direkten Link abgerufen werden, müssen Sie ein Szenario angeben, in dem die Adresse nirgendwo hinschaut und der Akteur nicht vorhanden ist. Nach dem Vorbild des gleichen Acca habe ich eine Box für tote Buchstaben hinzugefügt und sie anhand meiner Lieblings-DUs entworfen:
// Agent<_,_> -- , , // , . // , -- Box (mailagent), // , , , , // Deadbox. MailAgent, . // . // -- . type Agent<'message,'state> = | Box of MailAgent<'message,'state> | DeadBox of string * MailAgent<string * obj, Map<string,obj list>> with member this.Post msg = match this with | Box box -> box.Post msg | DeadBox (address, deadbox) -> (address, box msg) |> deadbox.Post interface IDisposable with member this.Dispose() = match this with | Box agent -> agent.Dispose() | DeadBox (_,agent) -> agent.Dispose()
Und das System selbst sieht so aus:
// . -- . type MailboxNetwork() as this = // . ! [<DefaultValue>] val mutable agentRegister: ConcurrentDictionary<string, obj> // do this.agentRegister <- ConcurrentDictionary<string, obj>() // , // Map -- let deadLettersFn deadLetters (address:string, msg:obj) = printfn "Deadletter: %s-%A" address msg match Map.tryFind address deadLetters with // | None -> Map.add address [msg] deadLetters // -- | Some letters -> // -- Map.remove address deadLetters |> Map.add address (msg::letters) let deadLettersAgent() = ("deadLetters", Map.empty |> Mailbox.buildAgent deadLettersFn) |> MailAgent member this.DeadLetters = deadLettersAgent() // - , member this.Box<'message,'state>(address) = match this.agentRegister.TryGetValue address with | (true, agent) when (agent :? MailAgent<'message,'state>) -> // , , let agent = agent :?> MailAgent<'message, 'state> Box agent | _ -> DeadBox (address, this.DeadLetters) // -- member this.KillBox address = this.agentRegister.TryRemove(address) |> ignore member this.RespawnBox (agent: MailAgent<'a,'b>) = this.KillBox agent.Address this.agentRegister.TryAdd (agent.Address, agent) |> ignore interface IDisposable with member this.Dispose() = for agent in this.agentRegister.Values do match agent with | :? IDisposable as agent -> agent.Dispose() | _ -> ()
Hier hat sich die gleiche address:string
, über die ich oben geschrieben habe, als nützlich erwiesen. Und wieder funktionierte es, die externe Abhängigkeit war jetzt leicht zu erreichen, wo Sie brauchen. Die Konstruktorfunktionen der Akteure akzeptierten nun das Akteursystem als Argumente und erhielten von dort die notwendigen Adressen:
// - ( ) - let gameAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<Command list, GameState>(gameAddress) // message loop let commandAgentFn (mailboxNetwork: MailboxNetwork) commands msg = let gameAgent = gameAgent mailboxNetwork match msg with | Cmd cmd -> cmd::commands | Flush -> commands |> gameAgent.Post []
Langsam
Aus offensichtlichen Gründen habe ich das Spiel während des Debuggens auf eine niedrige Geschwindigkeit eingestellt: Die Verzögerung zwischen den Ticks betrug mehr als 500 Millisekunden. Wenn Sie die Verzögerung auf 200 reduzieren, kamen die Nachrichten zu spät, und die Teams des Benutzers arbeiteten mit einer Verzögerung, die das gesamte Spiel beeinträchtigte. Eine zusätzliche Fliege in der Salbe war die Tatsache, dass der Timer im Falle eines Verlusts mehrmals den Stoppbefehl erhielt. Für den Benutzer erschien dies in keiner Weise, aber dennoch gab es eine Art Fehler.
Die unangenehme Wahrheit war, dass Schauspieler natürlich bequem großartig sind, aber direkte Methodenaufrufe viel schneller sind. Daher musste ich diese Idee im Namen der Geschwindigkeit aufgeben, obwohl es für eine Uhr des Spiels zu intensiv war, die Schlange selbst in einem separaten Akteur zu speichern, um den Code zu organisieren, da die Nachrichten zu intensiv waren:
- Der Benutzer sendet eine beliebige Anzahl von Befehlen direkt an den Befehlsakteur.
- Der Timer sendet ein Häkchen an den Teamdarsteller und in einer frühen Implementierung auch an den Schlangenschauspieler, damit er die Schlange in die nächste Zelle bewegt
- Der Befehlsakteur sendet eine Liste von Befehlen für die Schlange, wenn die entsprechende Nachricht vom Timer kommt.
- Der Schlangenschauspieler, der seinen Status gemäß den beiden oberen Nachrichten aktualisiert hat, sendet den Status an den Felddarsteller.
- Der Feldschauspieler zeichnet alles neu. Wenn die Schlange Nahrung gefunden hat, sendet sie eine
GrowUp
Nachricht an den Schlangenschauspieler, wonach er den neuen Status an den Feldschauspieler GrowUp
.
Und für all dies gibt es 1 Taktzyklus, was unter Berücksichtigung der Synchronisation im Darm des MailboxProcessor
nicht ausreicht. Darüber hinaus sendet der Timer in der aktuellen Implementierung die nächste Nachricht alle n Millisekunden, unabhängig von irgendetwas. Wenn wir also nicht einmal in die Messung 1 gekommen sind, beginnen sich die Nachrichten zu akkumulieren und die Situation verschlechtert sich. Es wäre viel besser, diese bestimmte Maßnahme zu „dehnen“, alles zu verarbeiten, was sich angesammelt hat, und weiterzumachen.
Endgültige Version
Offensichtlich muss das Nachrichtenschema vereinfacht werden, während es sehr wünschenswert ist, den Code so einfach und zugänglich wie möglich zu lassen - relativ gesehen möchte ich nicht alles in einen Gottschauspieler schieben, und dann haben die Schauspieler nicht viel Sinn.
Als ich mir meine Liste der Schauspieler ansah, wurde mir klar, dass es am besten ist, zuerst einen Schlangenschauspieler zu opfern. Es wird ein Timer benötigt, ein Puffer mit Benutzerbefehlen wird ebenfalls benötigt, um sie in Echtzeit zu akkumulieren, aber nur einmal pro Schlag zu gießen, und es besteht keine objektive Notwendigkeit, die Schlange in einem separaten Akteur zu halten. Dies wurde nur aus Bequemlichkeitsgründen durchgeführt. GrowUp
Sie es mit dem GrowUp
halten, können Sie das GrowUp
Skript außerdem unverzüglich verarbeiten. Tick
Nachricht für die Schlange macht auch nicht viel Sinn, denn wenn wir eine Nachricht vom Team-Schauspieler erhalten, bedeutet dies bereits, dass ein neuer Beat passiert ist. Wenn wir die Dehnung des Beats im Falle einer Verzögerung hinzufügen, haben wir die folgenden Änderungen:
- Wir entfernen
Tick
& GrowUp
Nachrichten. - Wir halten den Schlangenschauspieler in den Feldschauspieler - er wird nun die „Tapla“ dieser Staaten speichern.
- Wir entfernen
System.Timers.Timer
vom Timer-Akteur. Stattdessen sieht das Arbeitsschema wie folgt aus: Wenn er den Start
erhält, sendet er Flush
Teamdarsteller. Er sendet eine Liste von Befehlen an das Feld + Schlangenschauspieler, der letzte Akteur verarbeitet dies alles und sendet eine Next
Nachricht an den Timer, wodurch er ein neues Häkchen anfordert. Der Timer, der Next
wartet auf Thread.Sleep(delay)
und startet den gesamten Kreis von Thread.Sleep(delay)
. Alles ist einfach.
Zusammenfassend.
- In der vorherigen Implementierung war 500 ms die minimal zulässige Verzögerung. In der aktuellen Verzögerung können Sie es vollständig entfernen - der Felddarsteller benötigt einen neuen Schlag, wenn er bereit ist. Das Sammeln von Rohnachrichten aus früheren Maßnahmen ist nicht mehr möglich.
- Die Messaging-Karte ist stark vereinfacht - anstelle eines komplexen Diagramms haben wir die einfachste Schleife.
- Diese Vereinfachung löste den Fehler, als der Timer im Falle eines Verlusts mehrmals gestoppt wurde.
- Die Liste der Nachrichten wurde reduziert. Weniger Code - weniger böse!
Es sieht so aus:
let [<Literal>] commandAddress = "command" let [<Literal>] timerAddress = "timer" let [<Literal>] gameAddress = "game" // - let commandAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<CommandMessage, Command list>(commandAddress) let timerAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<TimerCommand, TimerState>(timerAddress) let gameAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<Command list, GameState>(gameAddress) // message loop let gameAgentFn (mailboxNetwork: MailboxNetwork) updateUi gameState cmd = let timerAgent = timerAgent mailboxNetwork // match gameState.gameFrame with // | Frame field -> // let gameState = Game.updateGameState gameState cmd timerAgent.Post Next // updateUi gameState // gameState // ! | End (Win _) -> timerAgent.Post PauseOrResume Game.updateGameState gameState cmd // | _ -> timerAgent.Post Stop // gameState // message loop let commandAgentFn (mailboxNetwork: MailboxNetwork) commands msg = let gameAgent = gameAgent mailboxNetwork match msg with | Cmd cmd -> cmd::commands // | Flush -> commands |> gameAgent.Post // [] // message loop let timerAgentFn (mailboxNetwork: MailboxNetwork) (state: TimerState) cmd = let commandAgent = commandAgent mailboxNetwork match cmd with | Start -> commandAgent.Post Flush; {state with active = true} | Next -> if state.active then // , Threading.Thread.Sleep(state.delay) commandAgent.Post Flush; state | Stop -> printfn "Stop received"; { state with active = false } | PauseOrResume -> if not state.active then //
Referenzen