Character Data Tutorial
|While technically correct, the introduction of HeroEngine's Replication features make this tutorial dated as you are more likely to use replication now than remote calls as documented here. The Replication Tutorial makes good supplemental reading to this tutorial when you design your game's implementation|
Characters are common to all MMOs. But the particular information any specific MMO game needs to maintain about each character can vary greatly. Further, how that data is interacted with, what sub-set is replicated on the various connected clients (and when this is done) will also vary greatly from game to game.
The design requirements for your game's character data are likely to be dramatically different from those of another game, and as a consequence, HeroEngine does not include nor force any particular implementation. This tutorial illustrates how you can go about implementing your own character data system.
You might be expecting that there is some mechanism that works sorta like this:
- On the server (area server) there is a node that represents each character logged into that area.
- Each client logged into that area is notified when a character enters or leaves the area.
- A node exists to represent each character on the client.
- Identical fields to track game-specific stuff can be defined on these client and server node classes
- A change to a value in a field on the client and/or server will be automatically reflected to all "interested parties"... such as characters in the area.
Items 1 through 3 are true of HeroEngine. And item 4 can be true, but you have to define these "identical" fields in both the client and server DOM yourself.
Item #5 is not true of HeroEngine.
There are many reasons for this, but it boils down to the fact that no generic system for reflection can properly express the range of possibilities that come up in real game implementations. Even if it were to function this way, it would not be sufficient.
For example... given the above, you would be able to know the health of every character in an area on the client like you want. However, you would not be able to know the health of, say, a member of your group who is in another area (a very common thing in MMOs) because that character would have no representation under this scheme on your client. By extension you can come up with any number of other scenarios where this scheme does not work.
So the HeroEngine "way" is to not try and implement an automatic system for this, since there is no one-size-fits-all solution. Instead, we leave it up to you to build some of this fundamental framework in a way that matches your particular game design needs exactly.
Therefore, what you need to do is establish a repository of information on each client that is fed by the server based on whatever game design and technical design issues you have. Game design requirements are obvious, but there are many technical design decision that have to be made as regards efficiency and scalability. We won't concern ourselves much with that at the moment, most are obvious.
Simple Implementation Example
Our goal in this example is to be able to take a bit of information "say, a particular character's HitPoints" and broadcast it to all attached clients in an area and have those clients keep track of it on the right character node.
Serverside: The Combatant Class
For Hero's Journey, we decided it would be useful to be able to handle participants in combat via a common parent class which we named Combatant encapsulating the fields and behaviors for combat. Any object in our world (npcs, characters, doors, etc) can be a combatant and our Combat System does not care. In Hero's Journey, the combatant class has member fields such as; curHealth, maxHealth, curEndurance, etc and methods for working with them.
For the purposes of this discussion, we are going to work under the following assumptions:
- You have created a Combatant class to handle behaviors/fields for character/npc combat, ignoring for the moment how you add the class to those nodes.
- Your combatant class has an
integerthat is named "HitPoints"
Combatant Class Methods
So we'll add a method to manage adjusting the HitPoints. Create a server-side script named CombatantClassMethods if it doesn't already exist and add a method:
method AdjustHitPoints(Amount as Integer) me.HitPoints = me.HitPoints + Amount .
Yay! Now hitpoints can be adjusted. They are still not communicated to any clients, so let's make a method to deal with that problem.
Notify Clients of New HitPoint Value
We'll add another method to the CombatantClassMethods script to handle notifying all clients connected to the area in which our character resides.
method TellEveryoneMyHitPoints() // Prepare a RemoteCall rco as Class RemoteCallClientOut rco.toScript = "CombatantClassMethods" rco.toFunction = "UpdateHitPoints" rco.failScript = DebugUtils rco.failFunction = "ClientRMCFail" rco.args["player"] = me.GetMyAccount() rco.args["hitpoints"] = me.HitPoints // Loop through every player and tell them about my HitPoints foreach playerID in $ACCOUNT._OnlinePlayers() rco.toPlayer = playerID RemoteCallClient( rco ) . .
Note: The method GetMyAcount() returns a reference to the _playerAccount node, whose ID is what the client needs to identify the character.
Note: DebugUtils is a script included with Clean Engine and has a function named "ClientRMCFail" that is kind of a "bitbucket" you can use for any errors that happen during a remotecall (it just does a scripterror). You'd probably implement your own failure function callback in real implementation.
Now that we have a method to communicate the change to clients, we will want to call the TellEveryoneMyHitPoints()method in three circumstances:
- Whenever the character's hitpoints change. This is easy, we modify the AdjustHitPoints() method:
- Whenever a character attaches to the area (to make sure every client know's his HPs)
- When a character attaches to an area, that character's client needs to get updated with everyone else's hitpoints. So in that same override script we'll call this method:
Notify Clients: Whenever the character's hitpoints change
method AdjustHitPoints(Amount as Integer) me.HitPoints = me.HitPoints + Amount TellEveryoneMyHitPoints() .
Notify Clients: Whenever a character attaches to the area
To get it so that characters introduce their hitpoints at logon, you will need to add a call to the TellEveryoneMyHitPoints() method in the game-specific override class for your $ACCOUNT system node (which deals with logon events). If you don't have one yet, see: Adapting Clean Engine
method HE_PostClientReady( theAccount as NodeRef ) // Used by SYSTEM.NODE.ACCOUNT // First check to see if the character is located in an area where updates should not be sent // The holding area and character creation areas are prime examples if GetAreaNumber <> 1 and $CHARACTERCREATIONSYSTEM._isCCSARea <> true // do not send HitPoint info else theAccount.GetMyChar().TellEveryoneMyHitPoints() . .
Notify Clients: Get Everyone's HitPoints for a Client that is attaching to the area
The previous two methods dealt with telling other players about your hitpoints, during logon and whenever a change is made. When your character logs into an area, it needs to be told about the hitpoints of other characters in the area since it has not yet received any information about them.
method GetEveryonesHitPoints( toAccount as ID ) // Loop through every player and get their HitPoints everyone as List of ID = $ACCOUNT._OnlinePlayers() theList as LookupList indexed by ID of Integer playerNode as NodeRef of Class _playerAccount characterNode as NodeRef of Class Combatant foreach playerID in everyone playerNode = playerID characterNode = playerNode.GetMyChar() theList[playerID] = characterNode.HitPoints . // Make a RemoteCall rco as Class RemoteCallClientOut rco.toPlayer = toAccount rco.toScript = "CombatantClassMethods" rco.toFunction = "ReceiveAreaHitPoints" rco.failScript = DebugUtils rco.failFunction = "ClientRMCFail" marshal theList to rco.args["hitpointdata"] RemoteCallClient( rco ) .
This method is a little more complex because it's trying to be efficient. We could make a remote per character, but instead we choose to collect all of the character's and their hitpoints into a big list and send it at once. The marshal command helps greatly with this, see: MARSHAL
Okay, so we should call GetEveryonesHitPoints() when the character logs in too (in your override script for logon).
That's all we need on the server side. On the client, we have to respond to these remote function calls and then do something with the data. First, we need a place to store the information. Some technical decisions need to be made here. The obvious thing to do is to store the data with the character node on the client by GLOMing on a similar class there.
Clientside: The Combatant Class
Let's assume you've created a Combatant class on the client-side DOM as well, with an identical Integer field named "HitPoints". So we have the identical class with the identical field on both the server and the client.
Let's respond to the first remote call, the one where we are being told about someone's hitpoints. We'll add this function to the CombatantClassMethods script on the client. Remember, this is a CLIENT script, and not the same one as before which was the SERVER script. This is the script that implements methods for the client-side class named Combatant. Remote calls occur on remote functions, but we can mix functions and methods in a class script for logical-organizational reasons:
Combatant Class Methods
method AdjustHitPoints(Amount by Integer) me.HitPoints = me.HitPoints + Amount // THIS IS WHERE YOU'D CALL SOMETHING ELSE TO UPDATE ANY GUI .
Notice that we have a logical spot to put in a hook to updating whatever GUI is needed. Like a red bar above their character's head, or whatever.
Recieve Update for a Single Character
Now the remote call to handle updating a single character's hitpoints:
remote function UpdateHitPoints( rci as Class RemoteCallServerIn ) target as NodeRef = rci.args["player"] GlomClass( "Combatant", target ) where target is kindof Combatant target.AdjustHitPoints( rci.args["hitpoints"] ) . .
This function is marked as remote so it can receive the remote call from the server. See: Remote Call
Notice that we access the node for the player on the client by the value returned by GetAccount() on the server (stuffed in the "player" arg of the remote call). This is common ID between the two.
We immediately GLOM on the Combatant class. Note that if the class is already on the node, this does nothing so it's safe to do it constantly like this. In a real implementation you'd probably pre-GLOM this on when the character logsin (aka, _OnCharacterCreate() called on the $CHARACTERSYSTEM system node ). But this works fine for our example and is a quick way to get up and running.
Recieve Update for a Multiple Characters
We also need to handle the second remote function for when we are given a list of all characters and their hit points:
remote function ReceiveAreaHitPoints( rci as Class RemoteCallServerIn ) theList as LookupList indexed by ID of Integer unmarshal theList from rci.args["hitpointdata"] foreach player in theList GlomClass( "TK_Combatant", player ) where player is kindof TK_Combatant player.AdjustHitPoints( theList[player] ) . . .
And we are done. Now every time hit points are updated on the server, every client in the area is notified. And when we move from area to area, we learn of everyone else's hit points and everyone learns of our hit points. In this way, we are all in sync... all clients in an area know about all character's hit points!
Now, this brings up some obvious issues. The first is: Holy Crap that's a lot of work! To do this for EVERY stat and piece of information is a lot of code. But, there are some things that can be done to minimize this. First, we can collect all of a group's set of information and move it around at once, say everything related to combat stats. We can use the marshal and unmarshal capabilities to package up the entire Combatant class and transmit it as a whole. See: Marshaling functions
These functions can also be selective about what fields are marshaled and even do on-the-fly remapping operations, etc.
There are other issues you want to consider in a real implementation. For example, do we really want to transmit hitpoints to everyone? Should every client know every character's hitpoints? For efficiency we might want to do it only for characters you are close too. For anti-cheating reasons we might want to limit it as well depending on the game design and vulnerabilities to hacking we anticipate. We might even want to send hitpoint information about character's we have interest in but are not in the area at all (say members of our group).
And these decisions are really done on a case-by-case basis. For example, when we move beyond the simple cases of combat stats and consider very complex things like inventory we have to concern ourselves with multiple representations: Your character needs to know a LOT about your inventory, but you need to know a much smaller subset of information about other character's (perhaps just enough to see what weapon they hold?) and so on.
As you can see, this is why no generic "reflection" system will do.
Adding game-specific data and functionality to characters
One of the first things you will want to do when you start to implement Character Data is to change the classes used to create characters to use classes specified to your game rather than the ones provided in HeroEngine. The classes implementing your game's mechanics we call "Game-Specific" classes, and for characters there are specific requirements for the structure of the classes you use to replace the defaults.
Once you create your game-specific classes, you can start adding member fields and new behaviors via inheritance or dynamically GLOMming new classes onto your character nodes.
Without your having done something to change the behavior, player characters are created using a prototype specified by the $CHARACTERSELECTIONSYSTEM's call to _CSSUseCharacterPrototype() which may be overridden by implementing the override method HE_CSSUseCharacterPrototype() in your game-specific override class for the system node. The advantage of using a prototype in this manner is that it provides a place to set up initial values and may have additional classes GLOMed onto it supporting concepts like our Combatant class, the only requirement for the prototype is that its base class must inherit from the clean engine class _playerCharacter.
Creating a Game-Specific Character Prototype
The steps to creating a game-specific character prototype for use in the creation of new characters are:
- Create a game-specific character class that inherits from _playerCharacter
- Create a prototype from your game-specific class
- Create a game-specific override class and add it to the $CHARACTERSELECTIONSYSTEM system node
- Create the class methods script for your override class
- Implement the override method HE_CSSUseCharacterPrototype
Create a game-specific character class
HeroEngine requires that all characters have a common parent class _playerCharacter. Keeping that requirement in mind, when we want to create our game-specific character class we need to have it inherit from _playerCharacter. Using the DOM Editor, create a new game-specific character class (for Hero's Journey, we choose HJCharacter_class)
Create a prototype for the game-specific character
Now that we have a class defined, we need to create a prototype from that class to use as a template from which our new characters will be created. Using the CLI command CPFC in the console, we will create a new server prototype. By default, HeroEngine uses the prototype HE_CharacterPrototype as the template from which all new characters are created.
\cpfc HJCharacter_Class, HJCharacterPrototype; description="Prototype used as the template from which new characters are created."
Note: The example detailed above is the preferred class hierarchy and prototype construction, due to legacy issues HJ does not follow the described pattern. For HJ, the prototype character_prototype is used for new characters with the base class _playerCharacter. We GLOM HJCharacter_Class onto our new characters during the creation process.
Create a Game-Specific Override for $CHARACTERSELECTIONSYSTEM
If you have not already created a game-specific override class for $CHARACTERSELECTIONSYSTEM system node, you will need to create one so that you can specify the prototype you wish to have used for new characters. Check out the section on Adding Game-Specific Functionality to the system node.
Note: Hero's Journey does not currently use the $CHARACTERSELECTIONSYSTEM nor the $CHARACTERCREATIONSYSTEM system nodes due to the highly complex character creation process in a procedural manner that was created prior to the existence of system nodes and the productization of HeroEngine.
Create the Class Methods Script for your Override Class
Using Script Editor create the (server) class methods script for your override class (if one does not already exist. Once created, implement the HE_CSSUseCharacterPrototype override method to specify your game-specific character prototype instead of the clean engine one.
method HE_CSSUseCharacterPrototype( args as LookupList indexed by String of String, proto references NodeRef ) as Boolean // Used by $CHARACTERSELECTIONSYSTEM // // return a reference to the prototype from which you want character nodes to be created, args lookup // is the args passed in the original remote call from the client GUI requesting a new character // be created so you could pass some data in it to specify which character prototype is appropriate proto = getPrototype("HJCharacterPrototype") return true .
Creatures/NPCs are created using the Creatures and NPCs System, which exposes an override method for the class to be used to instantiate a new creature character.
Creating a Game-Specific Creature/NPC
The steps to creating a game-specific npc class for use in the creation of new (creature/npc) characters are:
- Create a game-specific class that inherits from _nonPlayerCharacter with the archetype creature
- Create a game-specific override class and add it to the $NPC system node
- Create the class methods script for your override class
- Implement the override method HE_NPCClassUsedForInstantiation
Creating a Game-Specific Creature/NPC Class
HeroEngine requires that all cureater/npc characters have a common parent class _nonPlayerCharacter and that the base class have the creature archetype. Keeping that requirement in mind, when we want to create our game-specific creature/npc character class we need to have it inherit from _nonPlayerCharacter. Using the DOM Editor, create a new game-specific creature/npc character class (for Hero's Journey, we choose HJNpc_class)
Create a Game-Specific Override for $NPC
If you have not already created a game-specific override class for $NPC system node, you will need to create one so that you can specify the class you wish to have used for new creature/npc characters. Check out the section on Adding Game-Specific Functionality to the system node.
Note: Hero's Journey does not use this simplistic approach to creatures/npcs for actual gameplay. Rather it uses multiple spec oracles (see Spec System) as factories for new creatures allowing for extremely complex setup to be performed during instantiation from the spec.
Create the Class Methods Script for your Override Class
Using Script Editor create the (server) class methods script for your override class (if one does not already exist. Once created, implement the HE_NPCClassUsedForInstantiation override method to specify your game-specific creature character class instead of the clean engine one.
method HE_NPCClassUsedForInstantiation( useClass references String ) as Boolean // Used by SYSTEM.NODE.NPC // // Allows a game-specific class to override the class used to instantiate npcs in the required system // please note the class must be a child of the required HeroEngine class _nonPlayerCharacter and it must be of the // CREATURE archetype. // // Return true to indicate you have set the class you wish to be used useClass = "HJNpc_Class" return true .
Real World Game Systems
The Simple Implementation Example is very basic. It'll work, but doesn't scale to more sophisticated game designs . . . what we call "real world" game systems. The difference between prototype implementations like the "Simple Implementation Example" and "Real World Game Systems" is the difference between something that "works" and a strongly architected system with the sophistication and flexibility to match the complexities of say a World of Warcraft game mechanics implementation.
For example, using the generic implementation of the observer pattern in the MMO Foundation Framework, you can let systems that are interested subscribe to, say, changes in hitpoints. This is an alternative to calling directly to, say GUI methods. That way a character might show an impact animation when their HPs go down, and GUIs would update, and sounds would play... all just by listening.
For the purposes of a quick prototype attempting to deal with the various real world considerations might be over-kill. You'll have to decide that. If it is, you can just go with the simplest thing similar to the Simple Implementation Example.
Representation of Character Statistics (Strength/Dexterity/Intelligence/...)
Some of this discussion depends on your game design, for Hero's Journey we do not have the standard Strength/Dexterity/Intelligence model of stats so there is no sample code there for those particular concepts. You must first consider whether or not these types of stats are mutable or immutable values.
- Data that, on a per object base, can differ and be modified. Examples of common mutable data in an MMO: Durability, Temporary Enhancements, Charges.
- Data that all objects share, and cannot change on a per object basis. Examples of common immutable data in an MMO: Name, Description, DPS.
Why do we care about mutable vs immutable data? Simply put, the memory required to represent a single character is a very important consideration for MMOs and is classically one of the most problematic issues. Carefully designing your game from the start to be efficient about its data storage as you can will reduce your game's hardware requirements and ultimately increase the odds of a successful launch.
For example, if you have a Diablo II style of game where part of character advancement has the addition of X points among your stats then you do not really gain anything (from a technical perspective) by having a spec/template for the values. That said, you might consider having a spec/template that represents the initial values for a level 1 Barbarian vs a level 1 Mage.
For the sake of discussion, I am going to assume your game design is an asymmetrical one for character stats where player characters "improve" their fully mutable stats during "level-up" and creatures/npcs use fixed values stored in a spec (see Spec System).
Designing Your Interface
Ideally, we would like to treat characters whether played by a player or controlled by the server (creatures/npcs) the same. That means we need a common interface which can be overridden to return mutable field values for player characters and immutable values from a spec for server controlled characters (npcs).
Lets start by creating an abstract interface with a method for retrieving the various stats (getCharacterStatValue()), with initially two child classes one for characters (class characterStatistics) and one for npcs (class npcStatistics). The major difference between the two would be that the character version would read field values directly from the character node while the npc version would retrieve those values from a spec minimizing the memory use for those values.
Notice that no matter what type of character we might have, we can use the same methods for working with character statistics.
The following HSL is a sample partial implementation of our hypothetical ChracterStatisticsClassMethods script.
// This is a server script // Assume the existence of a enum CharacterStatistics with values STRENGTH, INTELLIGENCE, DEXTERITY...) // method getCharacterStatValue( stat as enum CharacterStatistics ) as integer when stat is STRENGTH return me.strength . is INTELLIGENCE return me.intelligence . // //... etc // default ScriptError("Unknown stat(" + stat + ") specified, that means someone added a new stat but did not update this script.") . . . method incrementStat( stat as enum CharacterStatistics ) // During LevelUp, our hypothetical design allows players to spend X points // spread between their stats as desired. // // Validate that we have points to spend, then increment the specified stat by 1 // if not me.hasUnspentStatPoints() // no points, so just return return . me.decrementUnspentStatPoints() when stat is STRENGTH me.Strength = me.Strength + 1 . // // ... etc // default ScriptError("Unknown stat(" + stat + ") specified, that means someone added a new stat but did not update this script.") . . // Now we need to notify our client so we can update the GUIs etc // // Because our class is part of the character, we can use a method in _playerCharacter to // retrieve our accountID me.notifyClientUpdatedStat( me.GetMyAccount(), stat ) . method addUnspentStatPoints( num as integer ) // Part of LevelUp is the addition of X stat points we can later spend // assert( num > -1, "Attempted to add a negative number of UnspentStatPoints.") assert( num < 5, "Attempted to add more than the max allowed number of UnspentStatPoints.") me.unspentStatPoints = me.unspentStatPoints + num . method hasUnspentStatPoints() if me.unspentStatPoints > 0 return true . return false . method decrementUnspentStatPoints() if me.unspentStatPoints > 0 me.unspentStatPoints = unspentStatPoints - 1 . . method notifyClientUpdatedStat( notifyAccountID as id, stat as enum CharacterStatistics ) // This assumes that a client side class with the same name was created // and the class methods script for the client class has a remote function to accept // this remote call updating our local information rout as class RemoteCallClientOut rout.toScript = SYSTEM.EXEC.THISSCRIPT rout.toFunction = "UpdateCharacterStatistic" rout.failScript = DebugUtils rout.failFunction = "ClientRMCFail" // Send the accountID which is how clients identify characters rout.args["player"] = me.GetMyAccount() rout.args["character"] = me rout.args["stat"] = stat rout.args["statValue"] = me.getCharacterStatValue( stat ) remoteCallClient( rout ) .
Adding characterStatistics to Player Characters
Once you have created and implemented your characterStatistics class, it would be nice if characters created in our game had that class so we can start using it. If you have already created a game-specific character class and prototype as detailed above in Adding Game-Specific Data and Functionality, then all you need to do is use the DOM Editor to have your game-specific character class inherit characterStatistics. (Note: Characters you originally created out via the HeroEngine default mechanics will not have your class only "new" characters created from your game-specific character prototype)
Using the HJCharacter_Class described above, I would simply use the DOM Editor to add characterStatistics as a parent class. Suddenly, all characters would have the new fields and behaviors supporting character statistics.
Adding npcStatistics to Creature/NPC Characters
Once you have created and implemented your npcStatistics class, it would be nice if creatures/npcs created in our game had that class so we can start using it. If you have already created a game-specific character class and prototype as detailed above in Adding Game-Specific Data and Functionality, then all you need to do is use the DOM Editor to have your game-specific npc class inherit npcStatistics. (Note: creatures/npcs originally created via the HeroEngine default mechanics will not have your class only "new" creatures/npcs will)
Using the HJNpc_Class described above, I would simply use the DOM Editor to add npcStatistics as a parent class. Suddenly, all characters would have the new fields and behaviors supporting npc statistics.
In the Simple Implementation Example, we cached information on the HBNode that handles the visualization of the character in the area. This simplistic design does the job, but is incapable of handling the more common requirements of MMOs such as the ability to display the health of group members who may or may not be located in the same area as your character. If their character is not even in the same area, your client would have no node upon which it could GLOM a class to cache the data.
The problem of how one deals with needing to cache information for characters that may not exist in the same area suggests that we need to separate the caching of character data from the HBNode created to visualize a character in an area. If you are familiar with the | Model View Controller Design Pattern, you may think of the client-side cache as the client's model.
- Representation of the data for our application. For the purposes of character data, this would be a data object with fields to store information such as "hitpoints".
- It is the rendering of the data into a from with which the user may interact. Classic example of a view in MMOs is the health bar that updates to properly display the current health of a character.
- Responds to input from the user (via the view), processing and generating events. It may modify the model.
The architectural design of your model tends to be extremely specific to your particular game's needs, consequently this discussion will operate on the assumption that you have designed your game's character statistics in the manner described above in the Simple Implementation Example (i.e. an asymmetric model where character stats are mutable and creature/npc stats are immutable values stored in some template/spec).
As with the design of statistics, it is convenient if our code can handle both player characters and creatures/npcs the same. Consequently, our design should probably have an interface that each implements. Which will be discussed in the Character Cache Objects below.
We also need some convenient way to locate the cache node instantiated for a character/npc, for systems that need a well known node to call methods on and/or serve as an anchor for other nodes a System Node fits the needs nicely. Which will be discussed in the $CHARACTERCACHE section below.
Character Cache Objects
We need some kind of data object in which we can store the character/creature data for our cache. As previously noted, it is extremely helpful to be able to treat both characters and creatures/npcs the same in script code so we ideally would like a design where they both have a common parent class, overriding methods as needed to handle the different needs for the objects they represent.
Each cache object will be the representation of a single character/creature's data, its model. Views of a model are generally updated as the result of an event the model generates for which the view listens. Consequently, we are going to reuse the generic implementation of the Observer Pattern included in the MMO Foundation Framework using the classes obsSubject/obsListener to handle those needs by making obsSubject a parent class of our abstractCharacterCacheObject class.
This class method script implements the basic mechanics necessary for our cache objects to function including:
- A singleton event object for use with the obsSubject class to provide events such as "experiencechanged" for systems and GUIs that are interested.
- An onInstantiationOfCacheObject method that would allow for a pseudo-constructor type of behavior
- Setter/Getters for the ID of the node for which the cache object serves as a proxy
While the following code is important from a framework perspective for our cache objects, the interesting stuff happens when you actually update some of the data stored.
method onInstantiationOfCacheObject() // Called when a CacheObject is instantiated by the $CHARACTERCACHE system node // Allows you to do somethinig if you want to following creation of a cacheObject . method deleteCacheObject() // Tell the system node to clean up its map // Send an event to anything registered to listen to this cacheObject eventObject as NodeRef of Class eventObject = me.getCacheObjectEventObject() eventObject.eventType = "deletingcacheobject" eventObject.eventAffectsID = me.getCacheObjectProxyForID() eventObject.eventCausedByID = me me.setChanged() me.notifyListeners( eventObject ) $CHARACTERCACHE.removeCharacterIDFromMap( me.cacheObjectProxyForID ) destroyNode( me ) . method setCacheObjectProxyForID( characterID as ID ) // This tells the cache object the ID for which it is proxy data storage // in the case of a player character it would be the accountID // in the case of a creature/npc it would be the _nonPlayerCharacter node's ID // assert( me.cacheObjectProxyForID = 0, "Attempted to set the proxy id for a cache object that already had one stored(" + me.getCacheObjectProxyForID() + ")") me.cacheObjectProxyForID = characterID . method getCacheObjectProxyForID() as ID // Retrieve the ID for which the cache object acts as proxy data storage // in the case of a player character it would be the accountID // in the case of a creature/npc it would be the _nonPlayerCharacter node's ID // return me.cacheObjectProxyForID . method getCacheObjectEventObject() as NodeRef of Class eventObject // Singleton to avoid unnecessary repeated instantiation // we store a reference to it in the field CacheObjectEventObject as noderef // if me.CacheObjectEventObject = None me.CacheObjectEventObject = CreateNodeFromClass("EventObject") // hard associate it so that if we delete the cacheObject its singleton EventObject goes with it AddAssociation( me, "base_hard_association", me.characterCacheEventObject ) . return me.CacheObjectEventObject .
Updating Cache Objects
Updates to character cache objects are achieved by a Remote Call issued by the server to the CharacterCacheClassMethods script, which will retrieve the appropriate cache object for the specified character/npc and call a method on that object to update some data (ex. experience).
This code sample makes the following assumptions:
- '$CHARACTERCACHE.RemoteUpdateCharacterExperience() has been called by the server
- $CHARACTERCACHE properly located a character cache object
- $CHARACTERCACHE then called a setExperience method on the cache object
// The following method would be located in the CharacterStatisticsClassMethods (client) script method setExperience( experience as integer ) me.experience = experience // Send an event to anything registered to listen to this cacheObject eventObject as NodeRef of Class eventObject = me.getCacheObjectEventObject() eventObject.eventType = "experiencechanged" eventObject.eventAffectsID = me.getCacheObjectProxyForID() eventObject.eventCausedByID = me me.setChanged() me.notifyListeners( eventObject ) . method getExperience() as integer return me.experience .
This code updates the cache the experience field of the cache object and then generates and event object which will be passed to any listeners that subscribed to listen to this particular cache object. Typically, some sort of UI would subscribe as a listener when it is created so that it can update its display when the value changes.
System nodes provide convenient access to methods and for our purposes serve as an ideal anchor node to which we can associate the data (a node) for a particular character/creature. The $CHARACTERCACHE System node will implement methods for Create/Remove/Getting the instantiation that stores data for a particular character/creature.
Creating the System Node
Creating a System Node generally requires you create a new class to serve as the base class for your system node. So, lets use the DOM Editor to create a new client class named characterCache. Once the class is created, we now need to create a prototype from that class using the CLI command CPFC.
As one of the objects making up our model we probably want the capability of generating events for systems/objects that want to receive them (ex. charactercachecreated, charactercacheremoved etc...). Clean Engine includes the classes obsSubject/obsListener which are a generic implementation of the Observer Pattern, so for the purposes of this example we are going to use the DOM Editor to add the class obsSubject as a parent class of our abstractCharacterCacheObject class.
We also want to support a fast lookup for the cache object representing a particular character or creature/npc. So lets create a field named CacheMap with a type of lookuplist indexed by ID of ID, we will update this field when charactercache objects are added/removed using the methods of our system node.
|cpfc CharacterCache, CHARACTERCACHE; description="System Node for the local cache of character/npc data"
Now that the prototype is created, you can access a singleton instantiation of that prototype in HSL using the syntax $CHARACTERCACHE. However, we have not yet created a class methods script for our new class so lets do that now.
Using the Script Editor create a new client script named characterCacheClassMethods.
The following script code implements a basic framework for our cache with the ability to create nodes for characters or creatures/npcs and retrieve those objects. It implements remote functions allowing the server to request the creation of a cache object, regardless of whether or not thing the cache object represents exists in the current area or not. It also implements a remote function for updating experience, which would be a member field of our CharacterStatistics class (note that characterCacheObject's have CharacterStatistics as a parent class).
The way this works is that during the login process to an area, the server would make remote calls to the client instructing the $CHARACTERCACHE create character and npc cache objects for all of the characters/npcs in the area. This call could include any data you want the client to know for a given character/npc, or you could send a subsequent remote call to update any data the cache objects are intended to store.
#define debug remote function RequestCreateCharacterCacheObject( rmc as Class RemoteCallServerIn ) // Allow the server to instruct the client to create a character cache object for a character/npc // that may or may not be located in the current area characterID as ID = rmc.args["characterID"] var cacheObject = $CHARACTERCACHE.getCharacterCacheForID( characterID ) . remote function RequestCreateNPCCacheObject( rmc as Class RemoteCallServerIn ) // Allow the server to instruct the client to create a npc cache object for a character/npc // that may or may not be located in the current area characterID as ID = rmc.args["characterID"] var cacheObject = $CHARACTERCACHE.getNpcCacheObjectForID( characterID ) . remote function RequestDeleteCharacterCacheObject( rmc as Class RemoteCallServerIn ) // Allow the server to instruct the client to delete a cache object for a character/npc // that may or may not be located in the current area characterID as ID = rmc.args["characterID"] var cacheObject = $CHARACTERCACHE.getCacheObjectForID( characterID ) if cacheObject <> NONE cacheObject.deleteCacheObject() . . remote function RemoteUpdateCharacterExperience( rmc as Class RemoteCallServerIn ) // Accepts remote calls from server to update atomically just the experience for // a particular cache object. characterID as ID = rmc.args["characterID"] experience as Integer = rmc.args["characterExp"] var cacheObject = $CHARACTERCACHE.getCacheObjectForID( characterID ) assert( hasMethod( cacheObject, "setExperience" ), "Server attempted to update the experience for a cache object that does not implement $QsetExperience()$Q" ) cacheObject.setExperience( experience ) . method getCacheObjectForID( characterID as ID ) as NodeRef of Class abstractCharacterCacheObject // Whether the cacheObject is for a character or npc, this method will retrieve it if one exists // // cacheObject as NodeRef of Class abstractCharacterCacheObject // check for excisting cacheObject if me.cacheObjectExistsForCharacterID( characterID ) cacheObject = me.localCacheMap[characterID] . return cacheObject . method getCharacterCacheObjectForID( characterID as ID ) as NodeRef of Class abstractCharacterCacheObject // For these types of systems, it is often easier to work with if you implement the get routine as a Singleton // where it finds the cache node for a particular characterID or creates it if necessary, either way returning // a cacheNode // characterCache as NodeRef of Class abstractCharacterCacheObject // check for excisting cacheObject if me.cacheObjectExistsForCharacterID( characterID ) characterCache = me.localCacheMap[characterID] . // no cacheObject or invalid ID stored if characterCache = None characterCache = me.createCharacterCacheObject( characterID ) debug("No characterCacheObject found for characterID(" + characterID + ") created(" + characterCAche + ").") . return characterCache . method getNpcCacheObjectForID( characterID as ID ) as NodeRef of Class abstractCharacterCacheObject // For these types of systems, it is often easier to work with if you implement the get routine as a Singleton // where it finds the cache node for a particular creature/npcID or creates it if necessary, either way returning // a cacheNode // characterCache as NodeRef of Class abstractCharacterCacheObject // check for excisting cacheObject if me.cacheObjectExistsForCharacterID( characterID ) characterCache = me.localCacheMap[characterID] . // no cacheObject or invalid ID stored if characterCache = None characterCache = me.createNPCCacheObject( characterID ) debug("No npcCacheObject found for characterID(" + characterID + ") created(" + characterCAche + ").") . return characterCache . method cacheObjectExistsForCharacterID( characterID as ID ) as Boolean // Check for whether or not a cache object exists representing the specified // ID // cacheObject as NodeRef of Class abstractCharacterCacheObject if me.localCacheMap has characterID cacheObject = me.localCacheMap[characterID] . if cacheObject <> None return true . return false . method deleteCharacterCacheObject( characterID as ID ) // Delete the cache object for the specified character ID // if me.cacheObjectExistsForCharacterID( characterID ) debug("Requesting deletion of characterCacheObject for characterID(" + characterID + ")") var cacheObject = me.getCharacterCacheForID( characterID ) // clean up my mapping me.removeCharacterIDFromMap( characterID ) cacheObject.deleteCacheObject() . . method removeCharacterIDFromMap( characterID as ID ) // Clear out the mapping of the characterID to the cache object acting proxy for its data // if me.localCacheMap has characterID remove characterID from me.localCacheMap . . method createCharacterCacheObject( characterID as ID ) as NodeRef of Class abstractCharacterCacheObject // create a cache object for the specified ID, it is an error to call this if a cacheObject already exists for // the specified ID // assert( characterID <> 0, "Invalid characterID(" + characterID + ") specified.") assert( me.cacheObjectExistsForCharacterID( characterID ) = false, "Create called but there was already an existing cacheObject for characterID(" + characterID + ")") // Instantiate a cache object to store data cacheObject as NodeRef of Class characterCacheObject = CreateNodeFromClass("characterCacheObject") me.setupCacheObject( cacheObject ) return CacheObject . method createNpcCacheObject( characterID as ID ) as NodeRef of Class npcCacheObject // create a cache object for the specified ID, it is an error to call this if a cacheObject already exists for // the specified ID // assert( characterID <> 0, "Invalid characterID(" + characterID + ") specified.") assert( me.cacheObjectExistsForCharacterID( characterID ) = false, "Create called but there was already an existing cacheObject for characterID(" + characterID + ")") // Instantiate a cache object to store data cacheObject as NodeRef of Class npcCacheObject = CreateNodeFromClass("npcCacheObject") me.setupCacheObject( cacheObject ) return CacheObject . method setupCacheObject( cacheObject as noderef of class abstractCharacterCacheObject // Performs the necessary setup for a newly created cacheObject whether it is a character or creature/npc // object // // Insert into the map me.localCacheMap[characterID] = cacheObject // Store the ID for which this object stores data cacheObject.setCacheObjectProxyForID( characterID ) // While not an absolute necessity, it can be convenient if you associate your cacheObjects to the // system node AddAssociation( me, "base_hard_association", cacheObject ) // Notify the cacheObject that it was just created in case it wants to send a request to the server to refresh its // data or whatever else you want. cacheObject.OnInstantiationOfCacheObject() . function debug( message as String ) #if debug println( message ) #endif .
Experience UI Example
This section details how you use everything we have discussed up until this point to create a UI that initializes itself from the cache object representing your character, and listens to the cache object for any updates that may occur.
The following assumptions are made:
- You know how to make a new GUIControl using the GUI Editor for a class you will create
- $CHARACTERCACHE system node implemented as described in this tutorial
- cache objects are implemented as described in this tutorial
- character statistics are implemented as described in this tutorial
Using the DOM Editor, lets create a new class to handle the mechanics of our experience bar UI.
- Create a client class named "GUIExperiencePanel", it must have an archetype of GUIControl and should inherit from GUIPanel and obsListener classes.
- Create the client class methods script for the class you created (ex GUIExperiencePanelClassMethods)
- Using the GUI Editor create a new GUIControl from the class GUIExperiencePanel named "experiencePanel"
- Expand the panel to some reasonable size
- add two labels as children of the panel named:
- lblExperience with a text value of "Experience:" dockMode=TOP
- lblExperienceValue with a text value of "NotSet" dockMode=TOP
- Save your new control
- Implement the HSL detailed below in the class methods script
- Use the CLI to invoke the creation function:
call GUIExperiencePanelClassMethods create
public function create() as NodeRef of Class GUIControl // Create the experience panel experiencePanel as NodeRef of Class GUIExperiencePanel = CreateNodeFromPrototype( "experiencePanel" ) experiencePanel.build = true // get the cache Object for MY character var cacheObject = $CHARACTERCACHE.getCacheObjectForID( getAccountID() ) experiencePanel.setExperience( cacheObject.getExperience() ) // Add the experience panel as a listener of the cacheObject for which it functions as a display cacheObject.addListener( experiencePanel ) return experiencePanel . method setExperience( experience as Integer ) // Find the value label in our experience panel and update the value // it displays explabel as NodeRef of Class GUILabel = FindGuiControlByName( me, "lblExperienceValue" ) explabel.text = experience . method EventRaised( subject as NodeRef of Class ObsSubject, data as NodeRef ) // This method would be called as a result of the characterStatistics method "setExperience()" // calling the "notifyListeners()" method passing in an event object // where data is kindof eventObject when tolower( data.eventType ) is "experiencechanged" me.setExperience( subject.getExperience() ) . . . . function test() // Simple test function that changes the cache object's experience value so we can watch the updates // called from the cli // var cacheObject = $CHARACTERCACHE.getCacheObjectForID( getAccountID() ) cacheObject.setExperience( cacheObject.getExperience() + 10 ) .