Referencing custom struct types

From Adventure Game Studio | Wiki
Revision as of 20:31, 15 August 2013 by Monkey'05'06 (talk | contribs) (→‎The user interface: Repositioned LoadReference to be mentioned last.)
Jump to navigationJump to search

One of the major caveats of scripting in AGS is the lack of a way to reference custom struct types. This hopefully will be changed in a future version of AGS, but for now it leaves many left wanting. Several workarounds have been proposed, but they fall far short of ideal.

The ideal

Most fully-fledged programming languages have a mechanic for reference types, though the implementation varies. C++ uses a syntax similar to pointers but with an ampersand (&) instead of an asterisk (*), while C# has a separate keyword ref. Since AGS already uses a C++-style syntax for its reference-based pointers, the ideal code may look something like this:

 struct Weapon
 {
   int Damage;
   int Price;
   String Name;
 }
 
 void UseWeapon(Weapon &wpn)
 {
   // ...use the weapon, etc...
   // since wpn is passed by reference, any changes will be reflected in the original object
   // e.g.
   if (wpn.Damage > 0) wpn.Damage--; // weapon damage decreases with use
 }
 
 // elsewhere, in the code
 Weapon sword;
 sword.Damage = 10;
 sword.Price = 50;
 sword.Name = "Fine sword";
 UseWeapon(sword);
 Display("Damage: %d", sword.Damage); // Displays 9

Key features

Some key features from the ideal example include:

  • Pass a reference to an instance to a function, allowing the function to mutate the original object.
  • Accessing the reference and mutating is very fast.
  • There is no need to return the mutated object as the parameter is passed by reference.
  • User can create and use instances of the struct type directly.

While some techniques proposed to workaround the problem achieve the first of these, they typically cannot recreate all of them. As an example: it is possible to serialize object data into a String which could then be passed into a function, but this method would require the mutated data to be serialized, returned, and then unserialized back into the original object. Such serialization ultimately accomplishes the same task, but very inefficiently. For large data types this inefficiency would make that technique totally impractical.

A better workaround

While it is still a workaround (and therefore not ideal), there is one technique that stands out far ahead of the rest. By creating an internal cache and using attributes, it is possible to create a system that matches exactly all of the key features of the ideal method.

The internal cache

When I say, "internal cache" you may be left wondering what exactly it is I mean. This particular method uses a separate script/module for each type you want to reference. You could combine multiple definitions in a single script, but the internals of this method are not exactly for the faint of heart (hence this being in Advanced tutorials). The internal cache is simply storage for the user data, located inside the relevant script file.

Statically-sized cache example

A statically-sized cache is likely to be the most efficient route as it doesn't require any resizing. If your particular struct has any array members then this method would be significantly easier to implement than using a dynamically-sized cache. That being said, the actual implementation being showcased doesn't use a statically-sized cache. It's up to you which method you choose for your cache. This is here as an example of what a statically-sized cache would look like.

 // Weapon.ash
 enum WeaponSettings
 {
   eWeaponCacheSize = 250 // allows up to 250 simultaneous instances of the Weapon struct type
 };
 
 // ...Weapon struct definition here...
 
 // Weapon.asc
 struct CachedWeapon
 {
   int Damage;
   int Price;
   String Name;
 };
 
 CachedWeapon WeaponCache[eWeaponCacheSize];

You'll see more on how the internal cache is used below. As you can tell though, you are basically mimicking the end-user's Weapon struct.

Dynamically-sized cache

As stated, throughout this workaround we will be using a dynamically-sized cache. This allows better simulation of dynamic instanciation of the struct. This method also preserves memory as the cache does not grow unless it has to. When it does grow, we'll have it double its current size (in this case, slightly over-allocating is more efficient that constantly re-allocating). Here's what our dynamically-sized cache looks like:

 int Weapon_Damage[];
 int Weapon_Price[];
 String Weapon_Name[];
 int WeaponCacheSize = 0;

We'll handle resizing the cache later, and we'll initialize the dynamic arrays when the first item is assigned. Since we are using dynamic arrays, this prevents us from using a struct for our internal cache. This is the reason why it may be easier to use a statically-sized cache if you have array members.

Cache utility functions

Since we are using dynamic arrays and there's no built-in mechanic for resizing them, we'll need a couple of utility functions for our cache. Note that if you are defining several types, these utility functions are generic. It is recommended to put these functions in a separate script from the rest of the struct code.

 ///Utility function: Resizes an int[] and returns the result (Does not modify original).
 int[] ResizeIntArray(int arr[], int oldSize, int newSize)
 {
   if (newSize <= 0) return null;
   if (oldSize > 1000000) oldSize = 1000000;
   if (newSize > 1000000) newSize = 1000000;
   if (oldSize == newSize) return arr;
   int newArr[] = new int[newSize];
   int i = 0;
   while (i < oldSize)
   {
     newArr[i] = arr[i];
     i++;
   }
   return newArr;
 }
 
 ///Utility function: Resizes a String[] and returns the result (Does not modify original).
 String[] ResizeStringArray(String arr[], int oldSize, int newSize)
 {
   if (newSize <= 0) return null;
   if (oldSize > 1000000) oldSize = 1000000;
   if (newSize > 1000000) newSize = 1000000;
   if (oldSize == newSize) return arr;
   String newArr[] = new String[newSize];
   int i = 0;
   while (i < oldSize)
   {
     newArr[i] = arr[i];
     i++;
   }
   return newArr;
 }

While these two functions are nearly identical, we (unfortunately) have to duplicate the code since we're working with different types of arrays. Again though, these functions are generic so you may find other use for them outside of this as well.

Finding an open slot in the cache

Now that we have defined our cache and our utility functions, we can search our cache to find an unused slot. If the entire cache is full/in-use, then we can resize the cache (Note: if a statically-sized cache becomes full and you reach this point, you'll probably need to throw an error and increase the cache size manually).

 ///Finds an unused item from the local cache and returns its ID.
 int WeaponCacheGetFreeIndex()
 {
   int i = 0;
   while (i < WeaponCacheSize)
   {
     if (Weapon_Name[i] == null) return i; // found an unused slot! return it
     i++;
   }
   if (WeaponCacheSize == 524288) AbortGame("Tried to grow cache beyond 524288 items. I honestly don't believe this will ever occur, so as soon as it does, let me know about it. -monkey");
   if (WeaponCacheSize == 0) WeaponCacheSize = 1;
   else WeaponCacheSize = (WeaponCacheSize * 2);
   Weapon_Damage = ResizeIntArray(Weapon_Damage, i, WeaponCacheSize);
   Weapon_Price = ResizeIntArray(Weapon_Price, i, WeaponCacheSize);
   Weapon_Name = ResizeStringArray(Weapon_Name, i, WeaponCacheSize);
   return i; // no unused slots were found, cache is resized and first new item is our slot
 }

The first thing we do is iterate the cache to see if any of the "Name" members in our cache are set to null. Your mileage may vary, but for this particular implementation we will use null to indicate an unassigned/unused slot, and an empty string ("") to indicate a Weapon with no name. If we don't find a free slot after iterating the entire cache, then we resize it using our utility functions. Make sure that you assign the returned arrays into their relevant owners as the utility functions do not modify the original arrays.

The Reference pseudo-type

As part of this workaround, every type you want to reference will have an associated Reference pseudo-type. By "pseudo-type" I mean that we are using autocomplete and a bit of magic to hide the nastiness of the implementation from the end-user. The Reference pseudo-type is considered nullable (it can be assigned and compared against null), and can be used in any way that an AGS pointer can be used (as a function parameter or return type, as a struct member, as a defaulted function parameter by assigning 0, etc.). This code belongs in the script header file (e.g., "Weapon.ash"). Here's what our Reference pseudo-type looks like:

 managed struct WeaponReference {};
 
 #define WeaponReference String // $AUTOCOMPLETEIGNORE$

As you can see, the underlying data is really of type String, but the end-user doesn't strictly need to know or care about that. We aren't serializing the struct data or anything like that, so the only thing that truly matters is how the Reference pseudo-type can be used.

The user interface

Next up we'll look at defining the user interface. This is what the end-user will ultimately see and use. For the most part it will look and feel a lot like the ideal implementation. Along with the Reference pseudo-type, this is the only portion of the code that goes in the script header file (e.g., "Weapon.ash").

 struct Weapon
 {
   import attribute int Damage;
   import attribute int Price;
   import attribute String Name;
   writeprotected WeaponReference Reference; // NOT an attribute!
   ///Creates a new copy of this weapon, and returns a reference to it.
   import WeaponReference CreateCopy();
   ///Destroys this weapon and frees its memory (You MUST call this to release your weapon's memory!).
   import void Destroy();
   ///Loads a reference to a weapon into this instance.
   import void LoadReference(WeaponReference, bool destroyCurrentInstance=true);
 };

The properties we've added are all imported as attributes. Again, this is essential to the way this particular workaround works. The actual data is stored in the internal cache, but we need to give the user a way to access that data. The Reference member is an instance of our WeaponReference pseudo-type, and is not an attribute. We actually want the Reference member to be associated with the individual struct instances, not with the internal cache itself. Finally, we have the methods the user will call in working with references of this type.

CreateCopy

This method is an example of how you can use references to clone your struct instances. While you could copy each member manually, this method makes it simpler. If you later add other properties to your struct, you won't have to remember to go back and copy them everywhere that you were swapping references.

 Weapon wpn;
 wpn.LoadReference(sword.CreateCopy()); // creates a copy of the sword instance, and loads that copy into wpn
 wpn.Damage += 5; // increases the damage of the copy without affecting sword.Damage

Destroy

This method is an unfortunate side-effect of this workaround. Wherever possible you should look for opportunities to handle it automatically instead of forcing the user to remember to call it. While it won't leak system memory and cause fatal errors, failure to call this method could leave dangling references in the internal cache, causing it to grow unnecessarily large. Note that this method isn't actually necessary for global instances or temporaries that just load a reference to a global instance. This method is expressly for local instances, to ensure that their memory is freed from the global cache (in lieu of destructors in AGS, reference counting is not an option).

 function MyFunc()
 {
   Weapon temp;
   temp.Damage = 10;
   temp.Price = 200;
   temp.Name = "Outrageously priced dagger";
   // use temp throughout the function
   temp.Destroy(); // make sure temp is freed before it goes out of scope, UNLESS we're returning a reference to it
   // also, DON'T call Destroy if temp is a loaded global reference
 }

LoadReference

This method is used to load a WeaponReference into this struct instance. The optional destroyCurrentInstance parameter controls whether the existing instance data will be freed from memory. Unless you're loading several separate WeaponReferences into a single temporary, you'll most likely want to leave this as the default of true. Failure to free items from the internal cache could cause it to grow unnecessarily large.

 Weapon wpn;
 wpn.LoadReference(sword.Reference); // loads a reference to the sword instance into wpn
 wpn.Damage += 5; // increases sword.Damage by 5

The implementation

Without further adieu we'll finally get down to the real nitty-gritty of this workaround.

Creating entries in the internal cache

Whenever a user defines a new instance, we'll need to create an entry for it in our internal cache.

 ///Creates a weapon reference from a given ID, or null if it is invalid.
 WeaponReference MakeWeaponReferenceFromID(int ID)
 {
   if ((ID < 0) || (ID >= WeaponCacheSize) || (Weapon_Name[ID] == null)) return null;
   return String.Format("%d", ID);
 }
 
 ///Creates a new entry in the local cache.
 WeaponReference CreateWeapon(int damage, int price, String name)
 {
   if (name == null) name = "";
   int ID = WeaponCacheGetFreeIndex();
   Weapon_Damage[ID] = damage;
   Weapon_Price[ID] = price;
   Weapon_Name[ID] = name;
   return MakeWeaponReferenceFromID(ID);
 }

MakeWeaponReferenceFromID is just a helper method to abstract the implementation. It ensures that the ID is valid and then returns the appropriately formatted String. The CreateWeapon method finds a free slot and then assigns the appropriate values.

Linking the user instances and the internal cache

When the user first creates an instance of our struct, the Reference member will be set to null. These methods help us link that instance together with the internal cache.

 ///Validates a reference and returns its ID, or -1 if it is invalid.
 int GetWeaponReferenceID(WeaponReference ref)
 {
   if (ref == null) return -1;
   int ID = ref.AsInt;
   if ((ID < 0) || (ID >= WeaponCacheSize) || (Weapon_Name[ID] == null)) return -1;
   return ID;
 }
 
 ///Ensures that this instance has an entry in the local cache.
 void Validate(this Weapon*)
 {
   int ID = GetWeaponReferenceID(this.Reference);
   if (ID == -1) this.Reference = CreateWeapon(0, 0, "");
 }

GetWeaponReferenceID validates that a WeaponReference refers to a valid entry in the internal cache. The Validate method checks that result, and if the WeaponReference is not valid, then it creates a new entry in the cache and links the user instance to it.

Public method implementations

Here we have the implementations of our public methods: LoadReference, CreateCopy, and Destroy.

LoadReference implementation
 void Weapon::LoadReference(WeaponReference ref, bool destroyCurrentInstance)
 {
   if (destroyCurrentInstance) this.Destroy();
   // loading a different reference, no need to validate this instance
   int ID = GetWeaponReferenceID(ref);
   if (ID == -1) AbortGame("Error! Attempted to load non-existent or invalid weapon.");
   this.Reference = ref;
 }

Since all we're doing is assigning the Reference property, this method is extremely efficient (as opposed to implementations using serialization techniques).

CreateCopy implementation
 WeaponReference Weapon::CreateCopy()
 {
   this.Validate();
   int ID = GetWeaponReferenceID(this.Reference);
   return CreateWeapon(Weapon_Damage[ID], Weapon_Price[ID], Weapon_Name[ID]);
 }

Before creating a copy we ensure that this instance refers to a valid entry in the cache. Then, we simply create a new entry with the same values.

Destroy implementation
 void Weapon::Destroy()
 {
   // if we're destroying it, there's hardly a need to validate the instance
   int ID = GetWeaponReferenceID(this.Reference);
   if (ID == -1) return;
   Weapon_Damage[ID] = 0;
   Weapon_Price[ID] = 0;
   Weapon_Name[ID] = null;
   this.Reference = null;
 }

This simply frees up an entry in the cache so it can be reused. The user instance can still be used after calling this method, in which case a new entry would be created and assigned.

Attribute accessor methods

The final bit of our implementation is the accessor methods for our attributes.

 int get_Damage(this Weapon*)
 {
   this.Validate();
   return Weapon_Damage[GetWeaponReferenceID(this.Reference)];
 }
 
 void set_Damage(this Weapon*, int value)
 {
   this.Validate();
   if (value < 0) AbortGame("Error! Cannot set weapon damage to less than 0.");
   Weapon_Damage[GetWeaponReferenceID(this.Reference)] = value;
 }
 
 int get_Price(this Weapon*)
 {
   this.Validate();
   return Weapon_Price[GetWeaponReferenceID(this.Reference)];
 }
 
 void set_Price(this Weapon*, int value)
 {
   this.Validate();
   if (value < 0) AbortGame("Error! Cannot set weapon price to less than 0.");
   Weapon_Price[GetWeaponReferenceID(this.Reference)] = value;
 }
 
 String get_Name(this Weapon*)
 {
   this.Validate();
   return Weapon_Name[GetWeaponReferenceID(this.Reference)];
 }
 
 void set_Name(this Weapon*, String value)
 {
   this.Validate();
   if (value == null) AbortGame("Error! Cannot set weapon name to null.");
   Weapon_Name[GetWeaponReferenceID(this.Reference)] = value;
 }

Note that every accessor validates the instance. This ensures that the instance is linked to an entry in the cache before attempting to use it (in lieu of constructors).

A working example

With the above code in the Weapon script, we can use it like this:

 // GlobalScript.asc
 Weapon sword;
 
 function game_start()
 {
   sword.Damage = 10;
   sword.Price = 50;
   sword.Name = "Fine sword";
 }
 
 function UseWeapon(WeaponReference ref)
 {
   Weapon wpn;
   wpn.LoadReference(ref);
   // ...use weapon...
   if (wpn.Damage > 0) wpn.Damage--; // weapon damage decreases with use
 }
 
 // elsewhere...
 UseWeapon(sword.Reference);
 Display("Damage: %d", sword.Damage); // Displays 9

Recapping features

The example above demonstrates the same key features as the ideal implementation:

  • Pass a reference to an instance to a function, allowing the function to mutate the original object.
  • Accessing the reference and mutating is very fast.
  • There is no need to return the mutated object as the parameter is passed by reference.
  • User can create and use instances of the struct type directly.

Also note that while it is not shown in the example a WeaponReference could also be stored as a member of another struct, and you can create dynamic arrays of WeaponReferences.

Closing

While it's not the ideal, this workaround has several obvious advantages over other techniques. The implementation is a bit heavy (over 100 lines for this simple example) but adding more members is easy once you understand the mechanics of it, and each new property only adds a small amount to the code.

Source

If this article was TL;DR, or you just feel like browsing the source code, you can download the source code! It is heavily commented with notations from the article, and should serve as a good resource on following this method.