diff --git a/OpenRA.Game/Game.cs b/OpenRA.Game/Game.cs index 0e4f30a368..5f6871ba31 100644 --- a/OpenRA.Game/Game.cs +++ b/OpenRA.Game/Game.cs @@ -561,7 +561,7 @@ namespace OpenRA Cursor.Tick(); } - var worldTimestep = world == null ? Timestep : world.Timestep; + var worldTimestep = world == null ? Timestep : world.IsLoadingGameSave ? 1 : world.Timestep; var worldTickDelta = tick - orderManager.LastTickTime; if (worldTimestep != 0 && worldTickDelta >= worldTimestep) { @@ -645,7 +645,10 @@ namespace OpenRA { Renderer.BeginFrame(worldRenderer.Viewport.TopLeft, worldRenderer.Viewport.Zoom); Sound.SetListenerPosition(worldRenderer.Viewport.CenterPosition); - worldRenderer.Draw(); + + // Use worldRenderer.World instead of OrderManager.World to avoid a rendering mismatch while processing orders + if (!worldRenderer.World.IsLoadingGameSave) + worldRenderer.Draw(); } else Renderer.BeginFrame(int2.Zero, 1f); @@ -743,6 +746,13 @@ namespace OpenRA var maxFramerate = Settings.Graphics.CapFramerate ? Settings.Graphics.MaxFramerate.Clamp(1, 1000) : 1000; var renderInterval = 1000 / maxFramerate; + // Tick as fast as possible while restoring game saves, capping rendering at 5 FPS + if (OrderManager.World != null && OrderManager.World.IsLoadingGameSave) + { + logicInterval = 1; + renderInterval = 200; + } + var now = RunTime; // If the logic has fallen behind too much, skip it and catch up @@ -762,7 +772,7 @@ namespace OpenRA LogicTick(); // Force at least one render per tick during regular gameplay - if (OrderManager.World != null && !OrderManager.World.IsReplay) + if (OrderManager.World != null && !OrderManager.World.IsLoadingGameSave) forceRender = true; } diff --git a/OpenRA.Game/Network/GameSave.cs b/OpenRA.Game/Network/GameSave.cs new file mode 100644 index 0000000000..00d34d02c1 --- /dev/null +++ b/OpenRA.Game/Network/GameSave.cs @@ -0,0 +1,313 @@ +#region Copyright & License Information +/* + * Copyright 2007-2019 The OpenRA Developers (see AUTHORS) + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using OpenRA.Primitives; +using OpenRA.Server; + +namespace OpenRA.Network +{ + public class SlotClient + { + public readonly Color Color; + public readonly string Faction; + public readonly int SpawnPoint; + public readonly int Team; + public readonly string Slot; + public readonly string Bot; + public readonly bool IsAdmin; + + public readonly string BotName; + + public SlotClient() { } + + public SlotClient(Session.Client client) + { + Color = client.Color; + Faction = client.Faction; + SpawnPoint = client.SpawnPoint; + Team = client.Team; + Slot = client.Slot; + Bot = client.Bot; + IsAdmin = client.IsAdmin; + + if (client.Bot != null) + BotName = client.Name; + } + + public void ApplyTo(Session.Client client) + { + client.Color = Color; + client.Faction = Faction; + client.SpawnPoint = SpawnPoint; + client.Team = Team; + client.Slot = Slot; + client.Bot = Bot; + client.IsAdmin = IsAdmin; + + if (Bot != null) + client.Name = BotName; + } + + public static SlotClient Deserialize(MiniYaml data) + { + return FieldLoader.Load(data); + } + + public MiniYamlNode Serialize(string key) + { + return new MiniYamlNode("SlotClient@{0}".F(key), FieldSaver.Save(this)); + } + } + + public class GameSave + { + public const int EOFMarker = -2; + public const int MetadataMarker = -1; + public const int TraitDataMarker = -3; + + readonly MemoryStream ordersStream = new MemoryStream(); + + // Loaded from file and updated during gameplay + public int LastOrdersFrame { get; private set; } + public int LastSyncFrame { get; private set; } + byte[] lastSyncPacket = new byte[0]; + + // Loaded from file or set on game start + public Session.Global GlobalSettings { get; private set; } + public Dictionary Slots { get; private set; } + public Dictionary SlotClients { get; private set; } + public Dictionary TraitData = new Dictionary(); + + // Set on game start + int[] clientsBySlotIndex = { }; + int firstBotSlotIndex = -1; + + public GameSave() + { + LastOrdersFrame = -1; + Slots = new Dictionary(); + } + + public GameSave(string filepath) + { + using (var rs = File.OpenRead(filepath)) + { + rs.Seek(-12, SeekOrigin.End); + var metadataOffset = rs.ReadInt32(); + var traitDataOffset = rs.ReadInt32(); + if (rs.ReadInt32() != EOFMarker) + throw new InvalidDataException("Invalid orasav file"); + + rs.Seek(metadataOffset, SeekOrigin.Begin); + if (rs.ReadInt32() != MetadataMarker) + throw new InvalidDataException("Invalid orasav file"); + + LastOrdersFrame = rs.ReadInt32(); + LastSyncFrame = rs.ReadInt32(); + lastSyncPacket = rs.ReadBytes(5); + + var globalSettings = MiniYaml.FromString(rs.ReadString(Encoding.UTF8, Connection.MaxOrderLength)); + GlobalSettings = Session.Global.Deserialize(globalSettings[0].Value); + + var slots = MiniYaml.FromString(rs.ReadString(Encoding.UTF8, Connection.MaxOrderLength)); + Slots = new Dictionary(); + foreach (var s in slots) + { + var slot = Session.Slot.Deserialize(s.Value); + Slots.Add(slot.PlayerReference, slot); + } + + var slotClients = MiniYaml.FromString(rs.ReadString(Encoding.UTF8, Connection.MaxOrderLength)); + SlotClients = new Dictionary(); + foreach (var s in slotClients) + { + var slotClient = SlotClient.Deserialize(s.Value); + SlotClients.Add(slotClient.Slot, slotClient); + } + + if (rs.Position != traitDataOffset || rs.ReadInt32() != TraitDataMarker) + throw new InvalidDataException("Invalid orasav file"); + + var traitData = MiniYaml.FromString(rs.ReadString(Encoding.UTF8, Connection.MaxOrderLength)); + foreach (var td in traitData) + TraitData.Add(int.Parse(td.Key), td.Value); + + rs.Seek(0, SeekOrigin.Begin); + ordersStream.Write(rs.ReadBytes(metadataOffset), 0, metadataOffset); + } + } + + public void StartGame(Session lobbyInfo, MapPreview map) + { + // Game orders are mapped from a client index to the slot that they occupy + // Orders from spectators are ignored, which is not a problem in practice + // because all immediate orders are also ignored + clientsBySlotIndex = lobbyInfo.Slots.Keys.Select(s => + { + var client = lobbyInfo.ClientInSlot(s); + return client != null ? client.Index : -1; + }).ToArray(); + + // Perform a deep clone by round-tripping the data + GlobalSettings = Session.Global.Deserialize(lobbyInfo.GlobalSettings.Serialize().Value); + Slots = new Dictionary(); + SlotClients = new Dictionary(); + foreach (var s in lobbyInfo.Slots) + { + Slots[s.Key] = Session.Slot.Deserialize(s.Value.Serialize().Value); + + var playerReference = map.Players.Players[s.Value.PlayerReference]; + var client = lobbyInfo.ClientInSlot(s.Key); + + // Only save the client state relevant to the game (faction, team, etc). + // Admin and bot controller state is inherited and/or reassigned by the server at load time + if (playerReference.Playable && client != null) + { + SlotClients[s.Key] = new SlotClient(client); + + // See HACK comment in DispatchOrders about reassigning bot orders + if (client.Bot != null && firstBotSlotIndex < 0) + firstBotSlotIndex = clientsBySlotIndex.IndexOf(client.Index); + } + } + } + + public void DispatchOrders(Connection conn, int frame, byte[] data) + { + // Sync packet - we only care about the last value + if (data.Length > 0 && data[0] == 0x65 && frame > LastSyncFrame) + { + LastSyncFrame = frame; + lastSyncPacket = data; + } + + if (frame <= LastOrdersFrame) + return; + + // Ignore immediate orders + if (data.Length > 0 && data[0] == 0xFE) + return; + + var clientSlot = clientsBySlotIndex.IndexOf(conn.PlayerIndex); + + // Handle orders that were sent by spectators + if (clientSlot == -1) + { + // HACK: Assume that this is a bot order sent by its controller client + // who is a spectator. The network data doesn't contain enough information + // for us to confirm this, or to know which bot this is supposed to belong to... + // + // For skirmish games it is sufficient to map everything to the first bot, + // because even if the bot choice is wrong, the bot-to-client remapping in ParseOrders + // will give the right client as there is only one human client to choose from! + // TODO: This will need to be fixed properly before implementing multiplayer saves + clientSlot = firstBotSlotIndex; + } + + ordersStream.WriteArray(BitConverter.GetBytes(data.Length + 8)); + ordersStream.WriteArray(BitConverter.GetBytes(frame)); + ordersStream.WriteArray(BitConverter.GetBytes(clientSlot)); + ordersStream.WriteArray(data); + LastOrdersFrame = frame; + } + + public void ParseOrders(Session lobbyInfo, Action packetFn) + { + // Send the trait data first to guarantee that it is available when needed + foreach (var kv in TraitData) + { + var data = new List() { new MiniYamlNode(kv.Key.ToString(), kv.Value) }.WriteToString(); + packetFn(0, 0, new ServerOrder("SaveTraitData", data).Serialize()); + } + + ordersStream.Seek(0, SeekOrigin.Begin); + while (ordersStream.Position < ordersStream.Length) + { + var dataLength = ordersStream.ReadInt32() - 8; + var frame = ordersStream.ReadInt32(); + var slot = ordersStream.ReadInt32(); + var data = ordersStream.ReadBytes(dataLength); + + // Remap bot orders to their controller client + var clientIndex = clientsBySlotIndex[slot]; + var client = lobbyInfo.ClientWithIndex(clientIndex); + if (client.Bot != null) + clientIndex = client.BotControllerClientIndex; + + packetFn(frame, clientIndex, data); + } + + // Send sync hash to validate restore + packetFn(LastSyncFrame, 0, lastSyncPacket); + } + + public void AddTraitData(int traitIndex, MiniYaml data) + { + TraitData[traitIndex] = data; + } + + public void Save(string path) + { + // File format: + // - List of orders in network frame format + // - Metadata start marker + // - Last frame number containing orders (int32) + // - Last frame number containing sync hash (int32) + // - Last sync packet (5 x byte) + // - Lobby global settings (yaml) + // - Lobby slots (yaml) + // - Lobby slot-client data (yaml) + // - Trait data start marker + // - Custom trait yaml + // - File offset of metadata start marker + // - File offset of custom trait data + // - Metadata end marker + var file = File.Create(path); + + ordersStream.Seek(0, SeekOrigin.Begin); + ordersStream.CopyTo(file); + file.Write(BitConverter.GetBytes(MetadataMarker), 0, 4); + file.Write(BitConverter.GetBytes(LastOrdersFrame), 0, 4); + file.Write(BitConverter.GetBytes(LastSyncFrame), 0, 4); + file.Write(lastSyncPacket, 0, 5); + + var globalSettingsNodes = new List() { GlobalSettings.Serialize() }; + file.WriteString(Encoding.UTF8, globalSettingsNodes.WriteToString()); + + var slotNodes = Slots + .Select(s => s.Value.Serialize()) + .ToList(); + file.WriteString(Encoding.UTF8, slotNodes.WriteToString()); + + var slotClientNodes = SlotClients + .Select(s => s.Value.Serialize(s.Key)) + .ToList(); + file.WriteString(Encoding.UTF8, slotClientNodes.WriteToString()); + + var traitDataOffset = file.Length; + file.Write(BitConverter.GetBytes(TraitDataMarker), 0, 4); + + var traitDataNodes = TraitData + .Select(kv => new MiniYamlNode(kv.Key.ToString(), kv.Value)) + .ToList(); + file.WriteString(Encoding.UTF8, traitDataNodes.WriteToString()); + + file.Write(BitConverter.GetBytes(ordersStream.Length), 0, 4); + file.Write(BitConverter.GetBytes(traitDataOffset), 0, 4); + file.Write(BitConverter.GetBytes(EOFMarker), 0, 4); + } + } +} diff --git a/OpenRA.Game/Network/OrderManager.cs b/OpenRA.Game/Network/OrderManager.cs index cfda029aea..0930f838de 100644 --- a/OpenRA.Game/Network/OrderManager.cs +++ b/OpenRA.Game/Network/OrderManager.cs @@ -45,6 +45,9 @@ namespace OpenRA.Network public bool GameStarted { get { return NetFrameNumber != 0; } } public IConnection Connection { get; private set; } + internal int GameSaveLastFrame = -1; + internal int GameSaveLastSyncFrame = -1; + List localOrders = new List(); List chatCache = new List(); @@ -70,8 +73,10 @@ namespace OpenRA.Network generateSyncReport = !(Connection is ReplayConnection) && LobbyInfo.GlobalSettings.EnableSyncReports; NetFrameNumber = 1; - for (var i = NetFrameNumber; i <= FramesAhead; i++) - Connection.Send(i, new List()); + + if (GameSaveLastFrame < 0) + for (var i = NetFrameNumber; i <= FramesAhead; i++) + Connection.Send(i, new List()); } public OrderManager(string host, int port, string password, IConnection conn) @@ -105,7 +110,7 @@ namespace OpenRA.Network public void TickImmediate() { var immediateOrders = localOrders.Where(o => o.IsImmediate).ToList(); - if (immediateOrders.Count != 0) + if (immediateOrders.Count != 0 && GameSaveLastFrame < NetFrameNumber + FramesAhead) Connection.SendImmediate(immediateOrders.Select(o => o.Serialize()).ToList()); localOrders.RemoveAll(o => o.IsImmediate); @@ -178,13 +183,18 @@ namespace OpenRA.Network if (!IsReadyForNextFrame) throw new InvalidOperationException(); - Connection.Send(NetFrameNumber + FramesAhead, localOrders.Select(o => o.Serialize()).ToList()); + if (GameSaveLastFrame < NetFrameNumber + FramesAhead) + Connection.Send(NetFrameNumber + FramesAhead, localOrders.Select(o => o.Serialize()).ToList()); + localOrders.Clear(); foreach (var order in frameData.OrdersForFrame(World, NetFrameNumber)) UnitOrders.ProcessOrder(this, World, order.Client, order.Order); - Connection.SendSync(NetFrameNumber, OrderIO.SerializeSync(World.SyncHash())); + if (NetFrameNumber + FramesAhead >= GameSaveLastSyncFrame) + Connection.SendSync(NetFrameNumber, OrderIO.SerializeSync(World.SyncHash())); + else + Connection.SendSync(NetFrameNumber, OrderIO.SerializeSync(0)); if (generateSyncReport) using (new PerfSample("sync_report")) diff --git a/OpenRA.Game/Network/Session.cs b/OpenRA.Game/Network/Session.cs index b287ffb046..5f72ef540c 100644 --- a/OpenRA.Game/Network/Session.cs +++ b/OpenRA.Game/Network/Session.cs @@ -202,6 +202,7 @@ namespace OpenRA.Network public bool EnableSingleplayer; public bool EnableSyncReports; public bool Dedicated; + public bool GameSavesEnabled; [FieldLoader.Ignore] public Dictionary LobbyOptions = new Dictionary(); diff --git a/OpenRA.Game/Network/UnitOrders.cs b/OpenRA.Game/Network/UnitOrders.cs index f0165c1956..32e82b85cb 100644 --- a/OpenRA.Game/Network/UnitOrders.cs +++ b/OpenRA.Game/Network/UnitOrders.cs @@ -9,6 +9,7 @@ */ #endregion +using System; using System.Collections.Generic; using System.Linq; using OpenRA.Primitives; @@ -114,11 +115,45 @@ namespace OpenRA.Network break; } - Game.AddChatLine(Color.White, ServerChatName, "The game has started."); + if (!string.IsNullOrEmpty(order.TargetString)) + { + var data = MiniYaml.FromString(order.TargetString); + var saveLastOrdersFrame = data.FirstOrDefault(n => n.Key == "SaveLastOrdersFrame"); + if (saveLastOrdersFrame != null) + orderManager.GameSaveLastFrame = + FieldLoader.GetValue("saveLastOrdersFrame", saveLastOrdersFrame.Value.Value); + + var saveSyncFrame = data.FirstOrDefault(n => n.Key == "SaveSyncFrame"); + if (saveSyncFrame != null) + orderManager.GameSaveLastSyncFrame = + FieldLoader.GetValue("SaveSyncFrame", saveSyncFrame.Value.Value); + } + else + Game.AddChatLine(Color.White, ServerChatName, "The game has started."); + Game.StartGame(orderManager.LobbyInfo.GlobalSettings.Map, WorldType.Regular); break; } + case "SaveTraitData": + { + var data = MiniYaml.FromString(order.TargetString)[0]; + var traitIndex = int.Parse(data.Key); + + if (world != null) + world.AddGameSaveTraitData(traitIndex, data.Value); + + break; + } + + case "GameSaved": + if (!orderManager.World.IsReplay) + Game.AddChatLine(Color.White, ServerChatName, "Game saved"); + + foreach (var nsr in orderManager.World.WorldActor.TraitsImplementing()) + nsr.GameSaved(orderManager.World); + break; + case "PauseGame": { var client = orderManager.LobbyInfo.ClientWithIndex(clientId); diff --git a/OpenRA.Game/OpenRA.Game.csproj b/OpenRA.Game/OpenRA.Game.csproj index 626cefa408..8a6d60b24e 100644 --- a/OpenRA.Game/OpenRA.Game.csproj +++ b/OpenRA.Game/OpenRA.Game.csproj @@ -100,6 +100,7 @@ + diff --git a/OpenRA.Game/Server/Server.cs b/OpenRA.Game/Server/Server.cs index 554ca753b4..2fe6e986ff 100644 --- a/OpenRA.Game/Server/Server.cs +++ b/OpenRA.Game/Server/Server.cs @@ -54,6 +54,7 @@ namespace OpenRA.Server // Managed by LobbyCommands public MapPreview Map; + public GameSave GameSave = null; readonly int randomSeed; readonly TcpListener listener; @@ -558,6 +559,9 @@ namespace OpenRA.Server InterpretServerOrders(conn, data); else DispatchOrdersToClients(conn, frame, data); + + if (GameSave != null && conn != null) + GameSave.DispatchOrders(conn, frame, data); } void InterpretServerOrders(Connection conn, byte[] data) @@ -661,6 +665,119 @@ namespace OpenRA.Server SyncClientPing(); + break; + } + + case "GameSaveTraitData": + { + if (GameSave != null) + { + var data = MiniYaml.FromString(so.Data)[0]; + GameSave.AddTraitData(int.Parse(data.Key), data.Value); + } + + break; + } + + case "CreateGameSave": + { + if (GameSave != null) + { + // Sanitize potentially malicious input + var filename = so.Data; + var invalidIndex = -1; + var invalidChars = Path.GetInvalidFileNameChars(); + while ((invalidIndex = filename.IndexOfAny(invalidChars)) != -1) + filename = filename.Remove(invalidIndex, 1); + + var baseSavePath = Platform.ResolvePath( + Platform.SupportDirPrefix, + "Saves", + ModData.Manifest.Id, + ModData.Manifest.Metadata.Version); + + if (!Directory.Exists(baseSavePath)) + Directory.CreateDirectory(baseSavePath); + + GameSave.Save(Path.Combine(baseSavePath, filename)); + DispatchOrdersToClients(null, 0, new ServerOrder("GameSaved", filename).Serialize()); + } + + break; + } + + case "LoadGameSave": + { + if (Dedicated || State >= ServerState.GameStarted) + break; + + // Sanitize potentially malicious input + var filename = so.Data; + var invalidIndex = -1; + var invalidChars = Path.GetInvalidFileNameChars(); + while ((invalidIndex = filename.IndexOfAny(invalidChars)) != -1) + filename = filename.Remove(invalidIndex, 1); + + var savePath = Platform.ResolvePath( + Platform.SupportDirPrefix, + "Saves", + ModData.Manifest.Id, + ModData.Manifest.Metadata.Version, + filename); + + GameSave = new GameSave(savePath); + LobbyInfo.GlobalSettings = GameSave.GlobalSettings; + LobbyInfo.Slots = GameSave.Slots; + + // Reassign clients to slots + // - Bot ordering is preserved + // - Humans are assigned on a first-come-first-serve basis + // - Leftover humans become spectators + + // Start by removing all bots and assigning all players as spectators + foreach (var c in LobbyInfo.Clients) + { + if (c.Bot != null) + { + LobbyInfo.Clients.Remove(c); + var ping = LobbyInfo.PingFromClient(c); + if (ping != null) + LobbyInfo.ClientPings.Remove(ping); + } + else + c.Slot = null; + } + + // Rebuild/remap the saved client state + // TODO: Multiplayer saves should leave all humans as spectators so they can manually pick slots + var adminClientIndex = LobbyInfo.Clients.First(c => c.IsAdmin).Index; + foreach (var kv in GameSave.SlotClients) + { + if (kv.Value.Bot != null) + { + var bot = new Session.Client() + { + Index = ChooseFreePlayerIndex(), + State = Session.ClientState.NotReady, + BotControllerClientIndex = adminClientIndex + }; + + kv.Value.ApplyTo(bot); + LobbyInfo.Clients.Add(bot); + } + else + { + // This will throw if the server doesn't have enough human clients to fill all player slots + // See TODO above - this isn't a problem in practice because MP saves won't use this + var client = LobbyInfo.Clients.First(c => c.Slot == null); + kv.Value.ApplyTo(client); + } + } + + SyncLobbyInfo(); + SyncLobbyClients(); + SyncClientPing(); + break; } } @@ -810,6 +927,12 @@ namespace OpenRA.Server if (LobbyInfo.NonBotClients.Count() == 1) LobbyInfo.GlobalSettings.OrderLatency = 1; + // Enable game saves for singleplayer missions only + // TODO: Enable for skirmish once the AI supports state-restoration + // TODO: Enable for multiplayer (non-dedicated servers only) once the lobby UI has been created + LobbyInfo.GlobalSettings.GameSavesEnabled = !Dedicated && Map.Visibility == MapVisibility.MissionSelector && + LobbyInfo.NonBotClients.Count() == 1; + SyncLobbyInfo(); State = ServerState.GameStarted; @@ -817,11 +940,37 @@ namespace OpenRA.Server foreach (var d in Conns) DispatchOrdersToClient(c, d.PlayerIndex, 0x7FFFFFFF, new byte[] { 0xBF }); + if (GameSave == null && LobbyInfo.GlobalSettings.GameSavesEnabled) + GameSave = new GameSave(); + + var startGameData = ""; + if (GameSave != null) + { + GameSave.StartGame(LobbyInfo, Map); + if (GameSave.LastOrdersFrame >= 0) + { + startGameData = new List() + { + new MiniYamlNode("SaveLastOrdersFrame", GameSave.LastOrdersFrame.ToString()), + new MiniYamlNode("SaveSyncFrame", GameSave.LastSyncFrame.ToString()) + }.WriteToString(); + } + } + DispatchOrders(null, 0, - new ServerOrder("StartGame", "").Serialize()); + new ServerOrder("StartGame", startGameData).Serialize()); foreach (var t in serverTraits.WithInterface()) t.GameStarted(this); + + if (GameSave != null && GameSave.LastOrdersFrame >= 0) + { + GameSave.ParseOrders(LobbyInfo, (frame, client, data) => + { + foreach (var c in Conns) + DispatchOrdersToClient(c, client, frame, data); + }); + } } } } diff --git a/OpenRA.Game/Traits/TraitsInterfaces.cs b/OpenRA.Game/Traits/TraitsInterfaces.cs index d1f9df8fc1..a17acda400 100644 --- a/OpenRA.Game/Traits/TraitsInterfaces.cs +++ b/OpenRA.Game/Traits/TraitsInterfaces.cs @@ -356,6 +356,15 @@ namespace OpenRA.Traits public interface INotifySelection { void SelectionChanged(); } public interface IWorldLoaded { void WorldLoaded(World w, WorldRenderer wr); } + public interface INotifyGameLoading { void GameLoading(World w); } + public interface INotifyGameLoaded { void GameLoaded(World w); } + public interface INotifyGameSaved { void GameSaved(World w); } + + public interface IGameSaveTraitData + { + List IssueTraitData(Actor self); + void ResolveTraitData(Actor self, List data); + } [RequireExplicitImplementation] public interface ICreatePlayers { void CreatePlayers(World w); } diff --git a/OpenRA.Game/World.cs b/OpenRA.Game/World.cs index bb1ee5ac5b..f6c0669d74 100644 --- a/OpenRA.Game/World.cs +++ b/OpenRA.Game/World.cs @@ -98,6 +98,16 @@ namespace OpenRA get { return OrderManager.Connection is ReplayConnection; } } + public bool IsLoadingGameSave + { + get { return OrderManager.NetFrameNumber <= OrderManager.GameSaveLastFrame; } + } + + public int GameSaveLoadingPercentage + { + get { return OrderManager.NetFrameNumber * 100 / OrderManager.GameSaveLastFrame; } + } + void SetLocalPlayer(Player localPlayer) { if (localPlayer == null) @@ -162,6 +172,8 @@ namespace OpenRA public bool RulesContainTemporaryBlocker { get; private set; } + bool wasLoadingGameSave; + internal World(ModData modData, Map map, OrderManager orderManager, WorldType type) { Type = type; @@ -230,6 +242,14 @@ namespace OpenRA public void LoadComplete(WorldRenderer wr) { + if (IsLoadingGameSave) + { + wasLoadingGameSave = true; + Game.Sound.DisableAllSounds = true; + foreach (var nsr in WorldActor.TraitsImplementing()) + nsr.GameLoading(this); + } + // ScreenMap must be initialized before anything else using (new PerfTimer("ScreenMap.WorldLoaded")) ScreenMap.WorldLoaded(this, wr); @@ -344,6 +364,12 @@ namespace OpenRA public int WorldTick { get; private set; } + Dictionary gameSaveTraitData = new Dictionary(); + internal void AddGameSaveTraitData(int traitIndex, MiniYaml yaml) + { + gameSaveTraitData[traitIndex] = yaml; + } + public void SetPauseState(bool paused) { if (PauseStateLocked) @@ -360,6 +386,29 @@ namespace OpenRA public void Tick() { + if (wasLoadingGameSave && !IsLoadingGameSave) + { + foreach (var kv in gameSaveTraitData) + { + var tp = TraitDict.ActorsWithTrait() + .Skip(kv.Key) + .FirstOrDefault(); + + if (tp.Actor == null) + break; + + tp.Trait.ResolveTraitData(tp.Actor, kv.Value.Nodes); + } + + gameSaveTraitData.Clear(); + + Game.Sound.DisableAllSounds = false; + foreach (var nsr in WorldActor.TraitsImplementing()) + nsr.GameLoaded(this); + + wasLoadingGameSave = false; + } + if (!Paused) { WorldTick++; @@ -460,6 +509,35 @@ namespace OpenRA } } + public void RequestGameSave(string filename) + { + // Allow traits to save arbitrary data that will be passed back via IGameSaveTraitData.ResolveTraitData + // at the end of the save restoration + // TODO: This will need to be generalized to a request / response pair for multiplayer game saves + var i = 0; + foreach (var tp in TraitDict.ActorsWithTrait()) + { + var data = tp.Trait.IssueTraitData(tp.Actor); + if (data != null) + { + var yaml = new List() { new MiniYamlNode(i.ToString(), new MiniYaml("", data)) }; + IssueOrder(new Order("GameSaveTraitData", null, false) + { + IsImmediate = true, + TargetString = yaml.WriteToString() + }); + } + + i++; + } + + IssueOrder(new Order("CreateGameSave", null, false) + { + IsImmediate = true, + TargetString = filename + }); + } + public bool Disposing; public void Dispose() @@ -470,6 +548,8 @@ namespace OpenRA Game.Sound.StopAudio(); Game.Sound.StopVideo(); + if (IsLoadingGameSave) + Game.Sound.DisableAllSounds = false; ModelCache.Dispose();