SFS2X Docs / ExamplesUnity / spacewar2-p2
» SpaceWar2
» Table of contents
- Setup & run
- Basic concepts
- The game flow
- Controlling the starship
- Firing the weapon
- What next?
- More resources
« Previous | Page 2 of 3 | Next » |
» The game flow
In the next paragraphs we are going to discuss the main game flow, showing portions of both client and server source code for better understanding.
On the client side, the general approach is the same we already implemented in other examples. The client is made of three scenes: Login, Lobby and Game. Each scene contains an empty Constroller game object with a dedicated script attached. The Login scene is where the connection to SmartFoxServer is established and login performed. The Lobby scene is where games can be launched or joined. Finally, the Game scene is the core of this example and where the actual game logic is executed.
In particular, the Game scene contains a second empty game object called Game Engine with the GameEngine script attached. The GameSceneController is in charge of controlling the scene's user interface and communicating with SmartFoxServer, while the GameEngine script controls the game sprites implementing the actual client-side simulation.
We strongly recommend to go through the Lobby tutorial series (starting from the Basics). In the next paragraphs we will highlight the specific changes made to the general flow with respect to those examples, and of course we will discuss the game logic in great detail.
» Connection and login
The connection and login process follows exactly the same steps discussed in the Lobby: Basics tutorial, with the GlobalManager singleton class sharing the connection among the different scenes by keeping a reference to the only instance of the SmartFox class used to communicate with the server.
The only differences with respect to previous examples are contained in the LobbySceneController class: we implemented the Awake() method, in which we set Unity's target framerate, and we updated the OnLogin() event handler, to enable SmartFox client's lag monitoring.
override protected void Awake() { base.Awake(); // Set target framerate QualitySettings.vSyncCount = 0; Application.targetFrameRate = 25; }
private void OnLogin(BaseEvent evt) { // Enable lag monitor sfs.EnableLagMonitor(true, 1, 5); // Load lobby scene SceneManager.LoadScene("Lobby"); }
Both changes above are related to client and server simulations synchronization, as we already discussed in the previous chapter of this tutorial.
» The lobby
As soon as the connection is established and the login performed successfully, the Lobby scene is loaded.
The scene is divided into three main elements: the level selection, the buddy list and chat panels, and the profile panel, accessible by means of the user icon in the top, right corner.
The main area shows the selectable game levels. The first one (Deep Space) is just a battle in empty space; the second one (Saturn) features a planet with gravity affecting ships and weapons trajectories. By clicking on a level, a new game is launched or an existing one is joined, as described in the next section.
The buddy list features have been described in great detail in the Lobby: Buddies tutorial. You can refer to it for all the details.
The profile panel instead, other than a couple of fields related to the buddy list, shows the user experience, rated from 0 to 5.
In order to implement a match-making system for the game, we had to define a player property to filter the Game Rooms a user could join, as explained in the next section. The properties, also called matching criteria can be any number: for sake of simplicity we decided to go with just one.
A common approach in multiplayer games is to let players face opponents with a similar skill level, so we defined the experience property: when a Game Room is created, it is assigned a minimum required experience to join it (equal to the the experience of the user who launched the game). In other words players will only be able to join existing Rooms if their experience is high enough; otherwise a new Room will be created. Experience is gained winning games (the last player standing has +1 experience) or lost if the player's starship is destroyed when there are still 3 or more players in game. This is determined by the game logic automatically, but for testing purposes the experience is also editable in the profile panel. Of course in a real case scenario this wouldn't be allowed.
The experience of a user is set to its default value when the Lobby scene is loaded for the first time during the current session, but in a real-case scenario its value should be retrieved from a database where the user state is stored and updated at the end of each game. In our example the value gets lost once the user disconnects from SmartFoxServer.
In order to keep track of the current value, and have direct access to it when implementing the match-making logic, we make use of a User Variable, which is one of the fundamental data structures in SmartFoxServer representing custom data attached to a User object.
private void InitPlayerProfile() { // Check if player experience is set in User Variables if (sfs.MySelf.GetVariable(USERVAR_EXPERIENCE) == null) { // If not, set set default value SFSUserVariable expVar = new SFSUserVariable(USERVAR_EXPERIENCE, 0); sfs.Send(new SetUserVariablesRequest(new List<UserVariable>() { expVar })); } else { // If yes, display player details in user profile panel userProfilePanel.InitPlayerProfile(sfs.MySelf); } }
Changing the experience in the profile panel causes a custom event to be dispatched by the attached script. The event is handled by the scene controller script, which updates the respective User Variable by means of the SetUserVariable request.
public void OnPlayerDetailChange(string varName, object value) { List<UserVariable> userVars = new List<UserVariable>(); userVars.Add(new SFSUserVariable(varName, value)); // Set User Variables sfs.Send(new SetUserVariablesRequest(userVars)); }
Whether the variable is set for the first time or it is updated later (manually from the profile panel or automatically by the game logic), the USER_VARIABLES_UPDATE event is fired by the SmartFox API.
public void OnUserVariablesUpdate(BaseEvent evt) { User user = (User)evt.Params["user"]; // Display player details in user profile panel if (user.IsItMe) userProfilePanel.InitPlayerProfile(user); }
In the event handler we have to check which user the event refers to: if the current user, we have to update the profile panel (required in particular if the User Variable has been set for the first time), otherwise we can ignore the event because in our example we are not showing the opponents experience anywhere else.
» Joining a Room to play in
In order to start the game, the user must click on one of the two available levels. This triggers a simple request to the Zone Extension containing the name of the selected level.
public void OnStartGameClick(string levelName) { // Disable start buttons EnableStartButtons(false); // Add level name to request parameters ISFSObject reqParams = new SFSObject(); reqParams.PutUtfString("name", levelName); // Send request to Zone Extension sfs.Send(new ExtensionRequest(ZONE_EXT_REQ_INIT, reqParams)); }
On the server side, the request is handled by the the handleClientRequest() method, which checks the game configuration to validate the selected level name and executes the createOrJoinRoom() method.
This is where the Extension creates the MMORoom representing the game, if needed, or joins the player in an existing one. The process relies on a single server-side API call, the ISFSApi.quickJoinOrCreateRoom() method.
private void createOrJoinRoom(User player, String levelName) throws SFSJoinRoomException, SFSCreateRoomException { // Set the MMORoom settings to create a new Room if needed CreateMMORoomSettings mmors = new CreateMMORoomSettings(); mmors.setGroupId(Constants.ROOMS_GROUP_NAME); mmors.setName("SW2_" + System.currentTimeMillis()); mmors.setMaxUsers(50); mmors.setDynamic(true); mmors.setAutoRemoveMode(SFSRoomRemoveMode.WHEN_EMPTY); mmors.setExtension(new CreateMMORoomSettings.RoomExtensionSettings("SpaceWar2", "sfs2x.extensions.games.spacewar2.SW2RoomExtension")); mmors.setDefaultAOI(new Vec3D(1300, 750, 0)); mmors.setUserMaxLimboSeconds(settings.getInt(Constants.SETTING_GAME_ABORT_SECS) + settings.getInt(Constants.SETTING_GAME_START_SECS) + 20); mmors.setProximityListUpdateMillis(20); // We need this to be almost realtime to make the client and server simulation almost identical mmors.setSendAOIEntryPoint(false); // Set requirements to allow users find the Room and other settings in Room Variables List<RoomVariable> roomVars = new ArrayList<RoomVariable>(); roomVars.add(new SFSRoomVariable(Constants.ROOMVAR_GAME_LEVEL, levelName)); roomVars.add(new SFSRoomVariable(Constants.ROOMVAR_USER_MIN_XP, player.getVariable(Constants.USERVAR_EXPERIENCE).getIntValue())); roomVars.add(new SFSRoomVariable(Constants.ROOMVAR_GAME_STATE, Constants.GAMESTATE_WAITING)); mmors.setRoomVariables(roomVars); // Set the match expression to search for an existing Room MatchExpression exp = new MatchExpression(Constants.ROOMVAR_GAME_LEVEL, StringMatch.EQUALS, levelName) .and(Constants.ROOMVAR_USER_MIN_XP, NumberMatch.LESS_THAN_OR_EQUAL_TO, player.getVariable(Constants.USERVAR_EXPERIENCE).getIntValue()) .and(Constants.ROOMVAR_GAME_STATE, NumberMatch.LESS_THAN_OR_EQUAL_TO, (settings.getBool(Constants.SETTING_ALLOW_MIDGAME_JOIN) ? Constants.GAMESTATE_RUNNING : Constants.GAMESTATE_WAITING)); // Execute sfsApi.quickJoinOrCreateRoom(player, exp, Arrays.asList(Constants.ROOMS_GROUP_NAME), mmors); }
The first part of the method is dedicated to collecting the settings of the MMORoom, in case an existing one to join is not found and it must be created. This is done through the CreateMMORoomSettings class, which extends the CreateRoomSettings class by adding parameters specific to MMORooms.
The first block of parameters includes some generic Room parameters, like the name, the maximum number of users, etc. Notice that the MMORoom is assigned to the "spacewar2" Group (more on this in a minute) and it is instructed to instantiate the Room Extension we mentioned previously.
The second block of parameters is related to the actual MMORoom configuration. Here you will find all the settings we discussed in the MMORoom configuration chapter.
The third block sets a number of Room Variables representing common data attached to the Room and shared among all users who joined it. These settings are all needed to make the Room "selectable" by the match-making system to let other users join it. The variables contain the level name, the minimum required experience to join the MMORoom (equal to the experience of the user who sent the request) and the initial state of the game when the Room is created, which is "waiting" (for players to join).
The last step before calling the API method is to define the match expression the server should apply to look for available Rooms to join the user in. A MatchExpression is a logical condition which allows to create search criteria in a very natural way to perform any type of queries on Room and User objects.
In the code above we concatenate three expressions with the and() method, representing one of the available logic operators (the other one is represented by the or() method of course). All three conditions must be satisfied to make an existing Room eligible to be joined by the user who sent the request: the game level played in the Room must be equal to the requested level; the minimum required experience set for the game must be equal to or lower than the experience of the requester; the game must not be running yet (unless mid-game join is allowed in the external game settings file).
After the Room configuration is ready, it is time to call the ISFSApi.quickJoinOrCreateRoom() method. As the name denotes, this method of the server-side API takes care of searching an existing game belonging to the "spacewar2" Group by means of the passed MatchExpression and automatically join the user in it; if an existing game can't be found, a new MMORoom is created based on the passed configuration, and again the user is automatically joined.
If a new MMORoom is created, the associated Room Extension is initialized. The Extension's init() method loads the game configuration from the external SpaceWar2.config file and adds the event and request handlers required by the game logic. We also need to schedule a task to abort the game in case the minimum number of players is not reached within the number of seconds set in the configuration.
@Override public void init() { room = (MMORoom) this.getParentRoom(); // Get a reference to the SmartFoxServer instance sfs = SmartFoxServer.getInstance(); // Get a reference to the MMO dedicated API mmoApi = sfs.getAPIManager().getMMOApi(); try { // Get level id from Room Variables String levelId = room.getVariable(Constants.ROOMVAR_GAME_LEVEL).getStringValue(); // Load configuration file and extract game configuration loadGameConfig(levelId); // Register handler for user leave room events addEventHandler(SFSEventType.USER_LEAVE_ROOM, UserLeaveRoomEventHandler.class); addEventHandler(SFSEventType.USER_DISCONNECT, UserLeaveRoomEventHandler.class); // Register handlers for client requests addRequestHandler(Constants.REQ_INIT, InitRequestHandler.class); addRequestHandler(Constants.REQ_CONTROL, ControlRequestHandler.class); // A task scheduler is needed to control the game start flow initially, and the game logic later scheduler = new TaskScheduler(1); // Schedule a task to abort the game if it's not yet started after a number of seconds abortTask = scheduler.schedule(new AbortTask(this), settings.getInt(Constants.SETTING_GAME_ABORT_SECS), TimeUnit.SECONDS); } catch (IOException e) { trace(ExtensionLogLevel.ERROR, "SpaceWar 2 configuration could not be loaded"); } trace(ExtensionLogLevel.INFO, "SpaceWar 2 Room Extension initialized for Room " + room.getName()); }
Note that we use a single thread for the task scheduler, because we will be using the same scheduler to execute the game logic and we need to avoid concurrency issues when checking game objects collisions. More information on the task scheduler provided by the SmartFoxServer 2X server-side API can be found here.
When the Room, whether it is an existing one or a new one, is joined by the user, on the client side the ROOM_JOIN event is notified by the SmartFox API: its handler sets the user as "away" in the Buddy List system and switches the current scene from Lobby to Game. If, for any reason, an error occurs during the Room creation or no valid existing Room could be found by the server, the ROOM_CREATION_ERROR or ROOM_JOIN_ERROR events are fired respectively.
private void OnRoomJoin(BaseEvent evt) { Room room = (Room)evt.Params["room"]; // Set user as "Away" in Buddy List system if (sfs.BuddyManager.MyOnlineState) sfs.Send(new SetBuddyVariablesRequest(new List<BuddyVariable> { new SFSBuddyVariable(ReservedBuddyVariables.BV_STATE, "Away") })); // Load game scene SceneManager.LoadScene("Game"); } private void OnRoomJoinError(BaseEvent evt) { // Show Warning Panel prefab instance warningPanel.Show("Room join failed: " + (string)evt.Params["errorMessage"]); // Re-enable start buttons EnableStartButtons(true); } private void OnRoomCreationError(BaseEvent evt) { // Show Warning Panel prefab instance warningPanel.Show("Room creation failed: " + (string)evt.Params["errorMessage"]); // Re-enable start buttons EnableStartButtons(true); }
» Before the game starts
On the client side, as soon as the Game scene is loaded, its Start() method: 1) adds a few listeners to specific SmartFox events; 2) shows a "waiting for players" interface where the user can selected their starship and invite buddies to play; and 3) sends a "init.ready" request to the Room Extension.
On the server side, if the minimum number fo players to start the game is reached, a countdown is started, at the end of which the actual game begins. Let's discuss some details.
Event listeners
The SmartFox event listeners added by the game scene include the PING_PONG, EXTENSION_RESPONSE, USER_VARIABLES_UPDATE and PROXIMITY_LIST_UPDATE events, other than a bunch of listeners related to the Buddy List system. Most listeners will be discussed in a minute. Here we want to mention the OnPingPong() listener, which is called by the SmartFox API when the lag in client-server communication is measured. In fact the lag monitoring system (enabled right after the login) evaluates the mean value of the client-server-client roundtrip, taking the last five measurements into account. This is used later when processing the PROXIMITY_LIST_UPDATE and USER_VARIABLES_UPDATE events. The lag is divided by 2 because we are interested in the server-to-client lag only.
public void OnPingPong(BaseEvent evt) { clientServerLag = (int)evt.Params["lagValue"] / 2; }
The "ready" request
The simple "init.ready" request, without parameters, signals the Room Extension that the client is ready to play. On the server side, all the init.* requests are processed by the InitRequestHandler class, registered at Extension's initialization time. In particular, the handler's onUserReady() method: 1) sets a flag on the User object to mark the player as ready; 2) sends the level, starships and weapons settings to the client by means of the "config" response; and 3) checks if the game is already started by means of the "gameState" Room Variable initialized at Room creation time and containing the game state, as its name implies.
private void onUserReady(User user) { SW2RoomExtension roomExt = (SW2RoomExtension) this.getParentExtension(); ISFSApi sfsApi = roomExt.getApi(); Room room = roomExt.getParentRoom(); //---------------------------------------- // Mark user as ready by means of User properties user.setProperty("ready", true); // Send level, starships and weapons configuration to client ISFSObject configuration = new SFSObject(); configuration.putSFSObject(Constants.CONFIG_LEVEL, roomExt.getLevelCfg()); configuration.putSFSObject(Constants.CONFIG_STARSHIPS, roomExt.getStarshipsCfg()); configuration.putSFSObject(Constants.CONFIG_WEAPONS, roomExt.getWeaponsCfg()); sfsApi.sendExtensionResponse(Constants.RESP_CONFIG, configuration, user, room, false); // Check if game is already started, as set in Room Variables boolean gameStarted = room.getVariable(Constants.ROOMVAR_GAME_STATE).getIntValue() == Constants.GAMESTATE_RUNNING; if (!gameStarted) { // Get reference to countdown task from Room Extension ScheduledFuture<?> countdownTask = roomExt.getCountdownTask(); // Check if final countdown is already in progress if (countdownTask != null) { // Get remaining time int delay = (int) countdownTask.getDelay(TimeUnit.SECONDS); // Send countdown start notification to user ISFSObject resParams = new SFSObject(); resParams.putInt("delay", delay); sfsApi.sendExtensionResponse(Constants.RESP_COUNTDOWN, resParams, user, room, false); } else { // Get game settings ISFSObject settings = roomExt.getSettings(); // Retrieve minimum number of players to start the game from game settings int minPlayers = settings.getInt(Constants.SETTING_MIN_PLAYERS); // Get list of ready users List<User> readyUsers = roomExt.getReadyUsers(); if (readyUsers.size() >= minPlayers) { // Start countdown roomExt.startCountdown(); // Send countdown start notification to ready users ISFSObject resParams = new SFSObject(); resParams.putInt("delay", settings.getInt(Constants.SETTING_GAME_START_SECS)); sfsApi.sendExtensionResponse(Constants.RESP_COUNTDOWN, resParams, readyUsers, room, false); } } } else { // Nothing to do } }
If the game is already started, the method doesn't need to do anything else. In fact the user will be added to the game as soon as they select the starship (see next). Remember that in our example a user can join a game already in progress if allowed in the external configuration file.
If the game is not yet started, but the countdown to start it is already in progress, the remaining seconds are sent to the client by means of the "count" Extension response. Otherwise the current number of users who already signaled they are ready to play is checked: if the minimum number of players to start the game (as set in the external configuration) is reached, the countdown is started and a notification is sent to all those users by means of the "count" response again.
When the countdown reaches zero, the Extension's startGame() method is called. Here the game state is updated and the "start" response is sent to all players ready to play. The method also takes care of instantiating the Game class, which contains the core logic of the simulation controlling the starships and weapon shots as they move in the virtual space.
This class implements the Runnable interface and it's run() method is scheduled to be executed every 40 milliseconds (matching the framerate of the client) by means of the task scheduler we already mentioned.
public void startGame() { trace(ExtensionLogLevel.WARN, "Starting game in Room " + room.getName()); // Set game as running in Room Variables RoomVariable rv = new SFSRoomVariable(Constants.ROOMVAR_GAME_STATE, Constants.GAMESTATE_RUNNING); sfsApi.setRoomVariables(null, room, Arrays.asList(new RoomVariable[]{rv})); // Send start notification to all ready clients sfsApi.sendExtensionResponse(Constants.RESP_START, null, getReadyUsers(), room, false); // Create main game core game = new Game(this); // Add planet game.createPlanet(levelCfg); // Get ready users List<User> users = getReadyUsers(); for (User user : users) { // Check if selected a starship; if not, assign one randomly if (user.getVariable(Constants.USERVAR_SHIP_MODEL) == null) { // Add random ship model to User Variables List<String> shipModels = new ArrayList<String>(starshipsCfg.getKeys()); Collections.shuffle(shipModels); UserVariable shipModelUV = new SFSUserVariable(Constants.USERVAR_SHIP_MODEL, shipModels.get(0)); List<UserVariable> userVars = new ArrayList<UserVariable>(); userVars.add(shipModelUV); sfsApi.setUserVariables(user, userVars); } // Add user to game addStarship(user); } // Schedule task: executes the game logic on the same frame basis (25 fps) used by the client gameTask = scheduler.scheduleAtFixedRate(game, 0, 40, TimeUnit.MILLISECONDS); }
Before the task execution is started, a random starship is assigned to all players who didn't choose one and the level's planet (if any) and players' starships are added to the simulation. The game is now actually started!
On the client side, the "start" Extension message causes the "waiting for players" view to be hidden, the in-game UI to be displayed and the sprite of the level's planet (if any) to be added to the scene. Also, a few custom event handlers to control the starship are added.
private void OnExtensionResponse(BaseEvent evt) { string cmd = (string)evt.Params["cmd"]; ISFSObject data = (SFSObject)evt.Params["params"]; ... else if (cmd == RESP_START) { Debug.Log("Game start!"); // Hide pre-game UI preGameUI.SetActive(false); // Show mid-game UI midGameUI.SetActive(true); // Create planet gameEngine.CreatePlanet(level); // Register listeners gameEngine.Rotate += this.Rotate; gameEngine.Thrust += this.Thrust; gameEngine.Fire += this.Fire; } ... }
Last but not least, if the minimum number of players is not reached within the time set in the external configuration file, the users waiting for the game to start are notified to leave the Room by means of the "leave" Extension response, and the clients go back to the Lobby scene.
Starship selection
On the client side, all Extension messages are processed by the OnExtensionResponse() listener. When the "config" response is received, the scene controller saves the level, starships and weapons configuration and shows the starships characteristics. The player can select the starship to use by clicking it. If this is not done before the game actually starts, the Room Extension will assign a random ship to the player, as seen before.
When the starship is selected, the "init.ship" request with the model name is sent to the Room Extension, which saves it in a User Variable. In this way all clients will later know which starship to render on screen to represent each player, and which properties should be applied by the client-side simulation. At this stage, if the game is already running, the player also joins the actual game.
private void onShipSelected(User user, ISFSObject params) { SW2RoomExtension roomExt = (SW2RoomExtension) this.getParentExtension(); ISFSApi sfsApi = roomExt.getApi(); Room room = roomExt.getParentRoom(); //---------------------------------------- // Save selected starship model in User Variables UserVariable shipModelUV = new SFSUserVariable(Constants.USERVAR_SHIP_MODEL, params.getUtfString("model")); List<UserVariable> userVars = new ArrayList<UserVariable>(); userVars.add(shipModelUV); sfsApi.setUserVariables(user, userVars); // Check if game is already started; if yes add user to the game boolean gameStarted = room.getVariable(Constants.ROOMVAR_GAME_STATE).getIntValue() == Constants.GAMESTATE_RUNNING; if (gameStarted) { // Send start notification to all ready clients sfsApi.sendExtensionResponse(Constants.RESP_START, null, user, room, false); // Add user to game roomExt.addStarship(user); } }
Inviting buddies
On the client side, in the "waiting for players" view, the user's buddies available to play (they must be online and their state in the buddy list system set to "available") are listed and can be invited to join the game. Invitations have already been discussed in the Lobby: Matchmaking tutorial, which we recommend to check. The difference here is that the invitation process involves the server-side Extension too. In fact when the little joystick icon is clicked, the "init.invite" request is sent to the Room Extension.
public void OnInviteBuddyButtonClick(BuddyInviteItem buddyinviteItem, Buddy buddy) { // Disable button buddyinviteItem.inviteButton.interactable = false; // Send request to Extension which, in turn, will send the invitation to the buddy ISFSObject data = new SFSObject(); data.PutUtfString("buddy", buddy.Name); sfs.Send(new ExtensionRequest(REQ_INVITE, data, sfs.LastJoinedRoom)); }
On the server side, the request is again processed by the InitRequestHandler instance, which sends an invitation to the target client by means of the ISFSGameApi.sendInvitation() method. Please note that at this stage all matching criteria set for the Room are ignored, and the invited player can join the game even if their experience doesn't match the required one.
private void onBuddyInvited(User user, ISFSObject params) { SW2RoomExtension roomExt = (SW2RoomExtension) this.getParentExtension(); ISFSGameApi sfsGameApi = SmartFoxServer.getInstance().getAPIManager().getGameApi(); Room room = roomExt.getParentRoom(); //---------------------------------------- // Retrieve user to be invited (buddy name = user name) User invitee = room.getZone().getUserByName(params.getUtfString("buddy")); if (invitee != null) { Invitation invitation = new SFSInvitation(user, invitee, 15); ISFSObject invParams = new SFSObject(); invParams.putInt("roomId", room.getId()); invParams.putInt("zoneId", room.getZone().getId()); invParams.putUtfString("level", room.getVariable("gameLevel").getStringValue()); invitation.setParams(invParams); // Send the invitation sfsGameApi.sendInvitation(invitation, new InvitationCallback() { @Override public void onRefused(Invitation invObj, ISFSObject params) { // Nothing to do; we could notify the inviter } @Override public void onExpired(Invitation invObj) { // Nothing to do; we could notify the inviter } @Override public void onAccepted(Invitation invObj, ISFSObject params) { ISFSApi sfsApi = SmartFoxServer.getInstance().getAPIManager().getSFSApi(); int zoneId = invObj.getParams().getInt("zoneId"); int roomId = invObj.getParams().getInt("roomId"); Room room = SmartFoxServer.getInstance().getZoneManager().getZoneById(zoneId).getRoomById(roomId); // Join invited player in Room try { sfsApi.joinRoom(invObj.getInvitee(), room); } catch (SFSJoinRoomException e) { // Nothing to do } } }); } }
The client is notified of the invitation by means of the INVITATION event, which is handled just like in the linked tutorial. If the invited user accepts the invitation, the server-side code makes them join the Room, following by the same flow described above.
» Creating the starships
As discussed above, when the game start countdown reaches zero, for every ready player in the Room its Extension calls the Game.createStarship() method to add an instance of the Starship class to the master simulation. The method receives the identifier of the starship owner and the starship and weapons configuration retrieved from the external configuration file.
public void createStarship(int ownerId, ISFSObject shipSettings, ISFSObject weaponsCfg) { Starship ship = new Starship(ownerId, shipSettings); // Set starship random position within 1000 pixels from the space (0,0) coords // If a planet exists, keep the ship far from its surface (four times the planet radius from its center) int maxDist = 1000; int minDist = planet.radius * 4; int dist = minDist + (int)(Math.random() * (maxDist - minDist)); int angle = (int) Math.round(Math.random() * 360 * Math.PI / 180); ship.x = Math.cos(angle) * dist; ship.y = Math.sin(angle) * dist; // Set starship random rotation angle ship.rotation = (int) Math.round(Math.random() * 360 * Math.PI / 180); ship.lastRenderTime = System.currentTimeMillis(); // Init weapons ISFSObject weapon1 = weaponsCfg.getSFSObject(shipSettings.getUtfString("weapon1")); ship.initWeapon(weapon1.getUtfString("model"), weapon1.getInt("maxShots")); ISFSObject weapon2 = weaponsCfg.getSFSObject(shipSettings.getUtfString("weapon2")); ship.initWeapon(weapon2.getUtfString("model"), weapon2.getInt("maxShots")); // Add starship to simulation starships.put(ownerId, ship); // Save initial position, velocity and shield to User Variables and position to the SFS2X Proximity Manager system // As this is a new starship and the User Variables are set before the position is set in the Proximity Manager system, // only the owner user will receive the User Variables update event saveStarshipPosition(ship, true); }
The method sets the initial coordinates and rotation angle of the starship to random values (avoiding the planet if existing). The method also sets the starship's lastRenderTime property, which will be used later during the actual simulation. The ship "enters" the simulation when it is added to the collection referencing all starships contained in the current MMORoom. In fact the collection is continuously scanned by the scheduled task mentioned before, to calculate the next position of each starship. We'll discuss this topic in the section describing the starships animation.
It is now time to make the fundamental step of setting the starship position at SmartFoxServer level too. This is done in two steps and it is responsibility of the setStarshipState() method on the Room Extension. The logic implemented in this method is very important and we'll refer to it again later.
public void setStarshipState(int userId, double x, double y, double vx, double vy, double direction, boolean thrust, int rotation, int shield, boolean fireClientEvent) { User user = room.getUserById(userId); if (user != null) { // (A) Set User Variables List<UserVariable> vars = new ArrayList<UserVariable>(); vars.add(new SFSUserVariable(Constants.USERVAR_X, x)); vars.add(new SFSUserVariable(Constants.USERVAR_Y, y)); vars.add(new SFSUserVariable(Constants.USERVAR_VX, vx)); vars.add(new SFSUserVariable(Constants.USERVAR_VY, vy)); vars.add(new SFSUserVariable(Constants.USERVAR_DIR, direction)); vars.add(new SFSUserVariable(Constants.USERVAR_THRUST, thrust)); vars.add(new SFSUserVariable(Constants.USERVAR_ROTATE, rotation)); vars.add(new SFSUserVariable(Constants.USERVAR_SHIELD, shield)); getApi().setUserVariables(user, vars, fireClientEvent, false); // (B) Set user position in Proximity Manager system ... } }
The first half (A) of this method is responsible of saving a number of informations in the User Variables. They include:
- the x and y coordinates of the starship
- the x and y components of its velocity vector
- the rotation angle of the starship
- the thruster state (on or off)
- the rotation state (the starship is rotating or not, and in which direction)
- the remaining shield level of the starship
At this stage, players are not yet aware of each other's presence, because this doesn't happen until their position is set in the Proximity Manager system governing the MMORoom. When setting the User Variables with the fireClientEvent parameter set to true, only clients for which the position has already been set and the current client receive the USER_VARIABLES_UPDATE event. We can take advantage of this behavior to make the current client render its own starship in the scene before everything else.
On the client side, in fact, the OnUserVarsUpdate() listener on the scenes's controller class detects that the User Variables for the current player have been set for the first time and calls the GameEngine.CreateStarship method. Mimicking the behavior of the server side counterpart, this method creates a new Starship instance and adds it to the Dictionary containing all the starships currently rendered by the client (in this case just one). The OnUserVarsUpdate() listener then sets the position of the starship in the virtual space, also taking the velocity vector into account: in fact the ship could be already moving if, for example, it was created inside the gravity field of the planet. From now on the main simulation loop controlled by the GameEngine.Update() method will take care of moving the starship on the stage, as described later on.
Please note that the position, velocity and direction values are of type float, because rounding them would make the client and server simulations diverge on the long run.
» Proximity update
The second half (B) of the setStarshipState() method of the Room Extension is responsible of setting the user position in the Proximity Manager system for the first time using this simple call:
public void setStarshipState(int userId, double x, double y, double vx, double vy, double direction, boolean thrust, int rotation, int shield, boolean fireClientEvent) { User user = room.getUserById(userId); if (user != null) { // (A) Set User Variables ... // (B) Set user position in Proximity Manager system // Note that we convert the coordinates (expressed as double) to integers as we don't need the proximity to be very precise int intX = (int)Math.round(x); int intY = (int)Math.round(y); Vec3D pos = new Vec3D(intX, intY, 0); mmoApi.setUserPosition(user, pos, this.getParentRoom()); } }
Setting the player position triggers (after a maximum of 20 milliseconds, as per MMORoom configuration we discussed before) the PROXIMITY_LIST_UPDATE event on the client side. This event makes the player aware of all the opponents located within his Area of Interest, and the opponents aware of the player presence.
From the player's point of view, the OnProximityListUpdate() handler does the simple job of creating a starship for each opponent listed in the addedUsers list from the event's parameters, retrieving its actual position, velocity, direction, thruster and rotation state, shield from the User Variables. The same goes with any weapon shot that might be flying around; these are listed in the addedItems array.
Please note that as the actual position coordinates are retrieved from the User Variables, the values set in the Proximity Manager system on the server side can be rounded to integers, because we don't need an extreme precision. In fact the Proximity Manager only governs the mutual visibility of users, and we already configured an oversized AoI.
Back to the client, if we just pay attention to the addedUsers list, we have the following code:
public void OnProximityListUpdate(BaseEvent evt) { ... List<User> addedUsers = (List<User>)evt.Params["addedUsers"]; foreach (User au in addedUsers) { // Create starship string shipModel = au.GetVariable(UV_MODEL).GetStringValue(); gameEngine.CreateStarship(au.Id, au.Name, false, shipModel, starshipModels.GetSFSObject(shipModel)); // Get position-related User Variables float x = (float)au.GetVariable(UV_X).GetDoubleValue(); float y = (float)au.GetVariable(UV_Y).GetDoubleValue(); float vx = (float)au.GetVariable(UV_VX).GetDoubleValue(); float vy = (float)au.GetVariable(UV_VY).GetDoubleValue(); float d = (float)au.GetVariable(UV_DIR).GetDoubleValue(); bool t = au.GetVariable(UV_THRUST).GetBoolValue(); int r = au.GetVariable(UV_ROTATE).GetIntValue(); int s = au.GetVariable(UV_SHIELD).GetIntValue(); // Set starship rotating flag gameEngine.SetStarshipRotating(au.Id, r); // Set starship position gameEngine.SetStarshipPosition(au.Id, x, y, vx, vy, d, t, s, clientServerLag + 10); } ... }
It is important to note that right after each starship is created, the GameEngine.SetStarshipPosition() method is called (last line in the code snippet above). This, in turn, calls the GameEngine.RenderStarship() method, which is responsible of making the starship move at each frame (in fact it is the same method called by the GameEngine.Update() method). The reason is that we need to synchronize the position of the starships (which probably were already moving, as these opponents entered the game before the player) on the client with their position on the server.
By adding the number of milliseconds passed since the server sent the message (server-to-client lag) and half of the proximityListUpdateMillis setting for the MMORoom, the GameEngine.RenderStarship() method extrapolates the current position of each starship on the server and aligns the client before the simulation loop (the GameEngine.Update() method) kicks in. We'll describe this method in detail in a minute.
We now have all the starships rendered on the client... it is now time to make them move around.
» Animating the starships
After setting the position/velocity/state of a starship in the User Variables and its position in the Proximity Manager system for the first time after the game started, it is now time for the server simulation to kick in and take care of handling the starships movement.
This is a responsibility of the server-side Game.run() method, which is executed every 40 milliseconds as already discussed before. The method updates the position of all weapon shots existing in the MMORoom (calling method Game.renderWeaponShot) and the position and velocity of all starships (calling method Game.renderStarship); it also checks the collision of starships with weapon shots or the planet (if any) and checks the end game condition (one last player remaining). In particular, the Game.renderStarship method does the following:
private void renderStarship(Starship ship) { long now = System.currentTimeMillis(); long elapsed = now - ship.lastRenderTime; for (long i = 0; i < elapsed; i++) { // Ship rotation ship.rotation += ship.rotatingDir * ship.getRotationSpeed(); // Thruster force if (ship.thrust) { ship.velocity.vx += Math.cos(ship.rotation) * ship.getThrustAcceleration(); ship.velocity.vy += Math.sin(ship.rotation) * ship.getThrustAcceleration(); } // Limit speed ship.velocity.limitSpeed(ship.getMaxSpeed()); // Planet gravitational attraction applyGravityForce(ship); // Update ship position based on its velocity ship.x += ship.velocity.vx; ship.y += ship.velocity.vy; } ship.lastRenderTime = now; }
Based on the number of milliseconds passed since the starship was last updated (exactly 40), the method first calculates the new starship rotation angle (if the starship is rotating and in which direction) and the new velocity vector (if the thruster is on and the planet gravity force is applied); then the starship coordinates are updated accordingly to the velocity.
Just like we did when the starship was created for the first time, we now have to set the new starship state at SmartFoxServer level and synchronize the clients accordingly: this is done by the Game.run() method by calling the Game.saveStarshipPosition() method, which in turn calls the Room Extension' setStarshipState() method we already discussed before. To recap, this method first sets the User Variables and then the position in the Proximity Manager.
In particular, when setting the User Variables we can control if the information should be propagated to the clients or not. Propagating means that the client will synchronize the starship state with the one in the master simulation: then why don't we always set the fireClientEvent parameter to true? How to know when to do it or not?
The answer to the first question is simple and actually we already provided it when discussing the clients synchronization: we want to keep the bandwidth usage as low as possible. Suppose we have 10'000 users connected to the same MMORoom and all of them have 100 users in their AoI: we would send a number of user variables update messages (of varying size, depending on the number of variables actually updated) equal to 100x10'000=1'000'000, every 40 milliseconds... it's 25 million messages per second! This wouldn't even be a sustainable traffic.
Before providing an answer to the second question instead, you should note that when dealing with the starships movement, we can have two situations: 1) the starship moves along a calculated trajectory resulting from inertia, planet gravity and thruster status (on/off and thrust direction); 2) the starship suddenly changes its speed and direction due to a weapon hit.
In the first case the client is aware of the starship trajectory, because it knows the current velocity vector of the starships (inertia), the planet's gravity force and the thruster status: we can avoid sending the User Variables update continuously, letting the client and server simulations proceed in parallel, as they both do the same math. Conversely we should send the update when there's a change in the factors affecting the trajectory. This happens when the user presses or releases the thruster and/or rotation keys (see the "Controlling the starship" section), or in case 2 above, when a weapon shot hits the starship.
This is why the Game.run() method sends the User Variables update to the client when a collision is detected only. The Proximity Manager instead is always updated because players must always be informed when an opponent enters/leaves their AoI. In this case the bandwidth usage optimization is part of the inner logic of the MMO API and we don't have to worry about it.
On the client side, the GameEngine.Update() method governs the simulation. It takes care of updating the positions of all starships and weapon shots in the player's AoI, other than checking of keyboard buttons are pressed or released.
void Update() { if (starships != null) { foreach (var ship in starships) RenderStarship(ship.Value); if (weaponShots != null) { foreach (var shot in weaponShots) RenderWeaponShot(shot.Value); } OnKeyboardDown(); OnKeyboardUp(); } }
In particular the GameEngine.RenderStarship() method mimics its server side counterpart. In addition to the same tasks performed by the server side method, it also updates the starship' sprite position in the scene and takes care of moving the camera when the player reaches the predefined distance from viewport borders, as discussed when describing the AoI setting.
In case the server logic detects that the User Variables update must be propagated to the client, the controller's OnUserVarsUpdate listener receives such update and it calls the GameEngine.SetStarshipPosition() method. This resets the starship position, velocity, etc, to the values sent by the server and sets the lastRenderTime variable on the basis of the measured server-to-client lag.
public void SetStarshipPosition(int userId, float x, float y, float vx, float vy, float d, bool t, int s, int elapsed) { if (starships.ContainsKey(userId)) { Starship ship = starships[userId]; // Set position and velocity ship.xx = x; ship.yy = y; ship.velocity.vx = vx; ship.velocity.vy = vy; ship.lastRenderTime = GetTimer() - elapsed; // Set thruster ship.doThrust = t; // Set rotation angle ship.rotation = d; // Set shield ship.shield = s; // Render the starship // This simulates the starship movement taking into account the elapsed time since the server sent the new position/speed // and places the starship in the current coordinates RenderStarship(ship); } }
The GameEngine.RenderStarship() method is then called (in addition to the recurring Update() iteration) to align the simulation to the server-side one by extrapolating the current position and velocity based on the passed milliseconds (server-to-client lag).
private void RenderStarship(Starship ship) { float now = GetTimer(); float elapsed = now - ship.lastRenderTime; for (int i = 0; i < elapsed; i++) { // Ship rotation ship.rotation += ship.rotatingDir * ship.rotationSpeed; // Thruster force if (ship.doThrust) { ship.velocity.vx += (float)Math.Cos(ship.rotation) * ship.thrustAcceleration; ship.velocity.vy += (float)Math.Sin(ship.rotation) * ship.thrustAcceleration; } // Limit speed ship.velocity.LimitSpeed(ship.maxSpeed); // Planet gravitational attraction ApplyGravityForce(ship); // Update ship position due to the calculated velocity ship.xx += ship.velocity.vx; ship.yy += ship.velocity.vy; } // Update starship sprite position ship.position = ConvertCoords(ship.xx, ship.yy); ship.lastRenderTime = now; // Move camera if ship is near borders if (ship.isMine) UpdateCameraPosition(ship.position); }
One last word on the client side PROXIMITY_LIST_UPDATE event. When this event is fired, it means that an opponent' starship (or a weapon shot, but we will discuss it later) entered or left the player's Area of Interest.
We already discussed the case of a ship entering the AoI (see the "Proximity update" section). In case the opponent left the AoI instead, the removedUsers array in the event's parameters simply contains the list of users (and therefore starships) to be removed from the stage and from the GameEngine class inner collection (see the client's GameEngine.RemoveStarship() method).
» Game over
As mentioned before, other than calculating the new positions of starships and weapon shots in the simulation, the Game.run() method on the server side also takes care of checking the collision of starships with the shots and the planet, if present.
We'll discuss collision with weapon shots in greater detail later on, but for now we want to highlight that when a collision occurs, the starship's shield level is reduced by the damage value set for the weapon in the external configuration file. If the shield level reaches zero, the starship is destroyed.
The same goes with the planet collision, where we just need to check if the distance of a starship from the virtual space center (where the planet is located) is less than the planet's radius. In such case, again the starship is destroyed.
@Override public void run() { try { ... List<Integer> gameOverIds = new ArrayList<Integer>(); // Move starships to next coordinates // Check starships collisions with weapon shots in proximity // Check collision with planet for (Iterator<Map.Entry<Integer, Starship>> it = starships.entrySet().iterator(); it.hasNext();) { ... // Check collision with planet if (planet.radius > 0) { // Distance from planet center (always 0,0) double dist = Math.sqrt(Math.pow(ship.x, 2) + Math.pow(ship.y, 2)); if (dist < planet.radius) { // Remove ship from simulation starships.remove(ship.getOwnerId()); // Notify starship destruction ext.notifyStarshipExplosion(ship.getOwnerId(), ship.x, ship.y); // Add ship's owner id to game over notification list gameOverIds.add(ship.getOwnerId()); } } ... } // Notify game over to owners of destroyed ships // The number of remaining ships is passed, so that the player ranking can be deduced and the overall user xp can be updated if (gameOverIds.size() > 0) ext.notifyGameOver(gameOverIds.toArray(new Integer[0]), starships.size()); // Check if there's a winner if (starships.size() == 1) { // Retrieve id of the last remaining ship int lastShipId = starships.keySet().iterator().next(); // Notify game over ext.notifyGameOver(new Integer[]{ lastShipId }, 0); } } catch (Exception e) { ... } }
When a starship is destroyed, the Room Extension notifies the event to all clients who have that ship in their AoI (including its owner) by means of the "ship_xplode" response, sent by the notifyStarshipExplosion() method. On the client side this makes the scene controller remove the destroyed starship from the simulation.
The user (or users) whose starship was destroyed also receives the "game_over" Extension response. Also the last player remaining in game receives the same notification, sent in both cases by the Extension's notifyGameOver() method. The method also evaluates the user ranking in the game.
public void notifyGameOver(Integer[] userIds, int remainingPlayers) { int ranking = remainingPlayers + 1; boolean isTied = userIds.length > 1; // Retrieve list of users List<User> users = new ArrayList<User>(); for (int userId : userIds) { User user = room.getUserById(userId); users.add(user); int userXp = user.getVariable(Constants.USERVAR_EXPERIENCE).getIntValue(); // Winner increases its experience if (ranking == 1) { if (userXp < Constants.SETTING_MAX_USER_XP) { UserVariable uv = new SFSUserVariable(Constants.USERVAR_EXPERIENCE, userXp++); sfsApi.setUserVariables(user, Arrays.asList(new UserVariable[]{uv})); } } // Losers (from ranking 4 and below) reduce their experience if (ranking <= 4) { if (userXp > 0) { UserVariable uv = new SFSUserVariable(Constants.USERVAR_EXPERIENCE, userXp++); sfsApi.setUserVariables(user, Arrays.asList(new UserVariable[]{uv})); } } } ISFSObject params = new SFSObject(); params.putInt("r", ranking); params.putBool("t", isTied); // Send Extension response this.send(Constants.RESP_GAME_OVER, params, users); // Stop game if (remainingPlayers == 0) { // Set game as over in Room Variables RoomVariable rv = new SFSRoomVariable(Constants.ROOMVAR_GAME_STATE, Constants.GAMESTATE_OVER); sfsApi.setRoomVariables(null, room, Arrays.asList(new RoomVariable[]{rv})); // Stop scheduled task gameTask.cancel(false); gameTask = null; trace(ExtensionLogLevel.INFO, "Game in Room " + room.getName() + " is over"); } }
If the user ranked 1st, their experience is increased; if they ranked 4th or more, the experience is decreased. As already discussed before, the updates experience value should be saved in a database for persistence through different game sessions. For sake of simplicity in our example we just update the temporary value held in a User Variable.
In case the game is over for all players, the game is stopped and its state updated in the dedicated Room Variable. The scheduled task running the simulation is stopped and the Room will be destroyed automatically as soon as all the players leave it.
« Previous | Page 2 of 3 | Next » |