0 votes
by (400 points)

Hey,

Using: SugarCube 2.20 / Twine 2.1.3

I have an issue with (probably) saving and loading my game.

I implemented the inventory system mentioned here:

https://twinery.org/forum/discussion/8215/inventory-system-sugarcube-2-0-twine-2

When saving and loading my game, the array in which my objects are saved becomes empty (and who knows what other black magic happens in the background). I know that StoryInit doesn't run when a game is loaded (which is good), but the question remains: how do I make sure that my arrays of complex objects get properly saved and when I do so, how do I reinitialize my items and restore their state after the game has been loaded?

I read something on MadExile's SugarCube2 documentation that I need to use the metadata variable, but there are no examples how this needs to be implemented for an array of objects. 

I have a feeling the story doesn't just stop there and I need to serialize my objects "correctly". Which I somewhat do with JSON.strigify() to carry out integrity checks and to generate unique IDs for my objects, but I do 't use that information to make the object storable.

Some guidance would be appreciated on how to approach this issue, please.

1 Answer

+1 vote
by (68.6k points)
selected by
 
Best answer

First, some clarification.

  1. You mention "arrays of complex objects".  None of the examples in the linked thread are "complex" objects—meaning, non-generic.
  2. There is nothing in my documentation saying you need to use save metadata—especially not for normal usage.  May use, yes; need to use, no.

There's no reason that the inventory systems from the linked thread should be causing issues, nor should they require special handling on your part—unless you're attempting to use broken code, of which there were several instances within that multi-page thread.  So, at this point, I can only assume that you've either implemented the systems incorrectly or made modifications which are the source of your troubles.

You're going to need to give examples of your code, or a compiled HTML file, because there's no way we can help you resolve this with a description alone.

by (400 points)
Sure, I'll post the code, once I get home.
by (400 points)
edited by

Sorry, for answering so late, My life is a terrible mess lately.

Anyways, I did a lot of debugging and fiddling around in the console. First things first, you are right. There's something fishy with my code. The arrays of objects that I mentioned are still in tact and available in game, I just can't list them because the <<initContainer>> macro wasn't called on the containers (inventory variables - which are nothing else but arrays of objects that have member functions, which are added by initContainer.) 

Here's my code for InitContainer:
 

setup.initContainer = function(container)
{
	if (!Array.isArray(container)) 
	{
		console.error('initContainer: container is not an array');
		throw new Error('initContainer: container is not an array');
	}
	
	// delete all items that are inside the container
	container.splice(0, container.length);
	
	// if findByUniqueID() has not been defined
	// TODO: rename to findByUniqueId()
	if (!container.findItem) 
	{
		// define new member function findItem
		container.findItem = function(obj) 
		{	
			if (
				this.length === 0 ||
				typeof obj !== 'object' ||
				!obj.hasOwnProperty('id') ||
				!obj.hasOwnProperty('uid')
			) 
			{
				console.error("findItem: inventory item malformed");
				throw new Error("findItem: inventory item malformed");
			}
			var idx = this.findIndex(function (item) 
			{
				return item === obj;
			});
			if (idx !== -1) 
			{
				return true;
			}
			else
			{
				return false;
			}
		}
	}
	if(!container.getItemByIuid)
	{
		container.getItemByIuid = function(searchIuid)
		{
			if (this.length === 0)
			{
				return;
			}
			if (typeof searchIuid !== 'string') 
			{
				console.error("container.getItemByIuid: args[0] searchUid is not a string!");
				throw new Error("container.getItemByIuid: args[0] is not a string!");
			}
			var searched_item = this.find(function(item)
			{
				console.log("container.getItemByIuid, item.iuid: " + item.iuid);
				return item.iuid === searchIuid;
			});
			return searched_item;
		};
	}
	if(!container.getItemsByUid)
	{
		container.getItemsByUid = function(searchUid)
		{
			if (this.length === 0)
			{
				return;
			}
			if (typeof searchUid !== 'string') 
			{
				console.error("container.getItemsById: args[0] searchUid is not a string!");
				throw new Error("container.getItemsById: args[0] is not a string!");
			}
			var searched_items = [];
			this.forEach(function(item)
			{
				if(item.uid === searchUid)
				{
					console.log("container.getItemsById, item.name: " + item.name + " item.id: " + item.id + " item.uid: " + item.uid);
					searched_items.push(item);
				}
			});
			searched_items.forEach(function(item)
			{
				console.log("searched_items: " + item.uid);
			});
			return searched_items;
		};
	}
	if(!container.getItemsById)
	{
		container.getItemsById = function(searchId)
		{
			if (this.length === 0)
			{
				return;
			}
			if (typeof searchId !== 'string') 
			{
				console.error("container.getItemsById: args[0] searchId is not a string!");
				throw new Error("container.getItemsById: args[0] is not a string!");
			}
			var searched_items = [];
			this.forEach(function(item)
			{
				if(item.id === searchId)
				{
					console.log("container.getItemsById, item.name: " + item.name + " item.id: " + item.id + " item.uid: " + item.uid);
					searched_items.push(item);
				}
			});
			searched_items.forEach(function(item)
			{
				console.log("searched_items: " + item.id);
			});
			return searched_items;
		};
	}
	if(!container.getItemsByName)
	{
		container.getItemsByName = function(searchName)
		{
			if (this.length === 0)
			{
				return;
			}
			if (typeof searchName !== 'string') 
			{
				console.error("container.getItemsByName: args[0] searchName is not a string!");
				throw new Error("container.getItemsByName: args[0] is not a string!");
			}
			var searched_items = [];
			this.forEach(function(item)
			{
				if(item.name === searchName)
				{
					console.log("cotainer.getItemsByName, item.name: " + item.name + " item.id " + item.id + " item.uid: " + item.uid + " retreived!");
					searched_items.push(item);
				}
			});
			searched_items.forEach(function(item)
			{
				console.log("searched_items: " + item.name);
			});
			return searched_items;
		};
	}
	/*
	About filtering: call <container>.filter(filterFunc) if you need items filtered!
	*/
}

Macro.add(['initContainer', 'emptyContainer'], 
{
	handler : function ()  
	{
		try
		{
			setup.initContainer(this.args[0]);
		}
		catch(e)
		{
			console.error(e.message);
		}
	}
});

I'm using many containers in my game since almost every room has one, my character and other NPCs also have at least one NPC.inventory array of objects. Now, if I would call <<initContainer>> when the game loads it would delete all the items from my containers, which would suck. Also, here's a function that I use to check wether my container was initialized:

// use to identify valid containers
setup.isContainer = function(container)
{
	if(!Array.isArray(container))
	{
		console.error("isContainer: args[0] is not a container!");
		return false;
	}
	if(!container.findItem ||
	   !container.getItemByIuid ||
	   !container.getItemsByUid || 
	   !container.getItemsById || 
	   !container.getItemsByName)
	{
		console.error("isContainer: args[0] container is uninitialized!");
		return false;
	}
	return true;
}

This is basically the code that helped me spot the problem in the console. So, basically, the functions getItemsByName() findItem(), etc. are not defined when I load a saved game.

I'm sure this information will help you in helping me how to figure out a solution :-)

Update: I just did a quick console hack and added the missing functions to the containers after the game was loaded and my inventory started to work again, now I just need to figure out how to attach my fix to function that loads my game. Please help :)

by (63.1k points)

I don't think that's a valid / effective way to create member functions: you should probably be using a constructor or class and assigning functions either to that class itself (i.e. static methods) or to the prototype (i.e. instance methods). In some cases you might want to assign it within the constructor, but that's usually not necessary. 

Anyway, in SugarCube, you need to create a toJSON method and use JSON.reviveWrapper() to make sure the custom object you create gets serialized correctly. I have an inventory system that allows for containers and player inventories, among other things, and you can use it as is, use it as an example, or adapt it to your needs if you want. (Warning, it's not a perfect system, and there may be some bugs or stupidly written code.)

You can find (incomplete and not-yet-proofread) docs for it here

by (68.6k points)

The system you're attempting to copy there—and, if I'm not mistaken, it has bugs—was never intended to to be used with umpteen numbers of inventories.  It's also missing a few vital parts of a full solution, which I assume the original author, Disane, had but never documented within that thread.

The core issue is that it has no way to reattach the custom methods to each Array object you're using as inventories.  To be clear, you'd need to do that each time the arrays were either cloned or restored from serialization, which happens a lot.

The best way to handle this would be to use a custom object instead of adding expando methods to Array instances.  If Chapel's linked system isn't palatable to you, I can give an example based on what you're attempting now.

by (400 points)

If Chapel's linked system isn't palatable to you, I can give an example based on what you're attempting now.

That would be great! Chapel's code seems to be  using a different way of listing items, labeling items with UIDs and assigning containers to locations. Adopting their code would require me to rewrite everything that I have right now. Which is not worth doing for a silly concept game. Maybe if I decide to write a real game, I might start out with his inventory system.

by (68.6k points)

Looking at it further, the example you gave is a mess.  Inventory objects having IDs, UIDs, IUIDs, types, names, etc—the three (separate?) types of IDs alone are probably overkill.

What do your inventory objects look like?

by (400 points)
I'll post examples once I get home.
by (400 points)
edited by

It is indeed a mess. I was experimenting and I left the code there.

Anyways, here's the implementation. I might need to get rid of the "id" property though. The code is as follows:

/*
IUID - is the hash code over every property of the object except for 'count', uid and iuid. This creates an IUID (Inventory UID) that is unique to the items inside the inventory by item type. It is used to identify items that have more or less the same properties. We use this information to merge items in the inventory that have the same uid. Meaning that they probably have the same values relevant to the game logic and therefore we merge them into one item and calculate their "count" property.

UID - it is supposed to be unique to all other uids in the game
*/

// standard Java 32 bit hash code that can be generated over any string
// we use it to calculate the hash code over the string provided by JSON.stringify()
String.prototype.hashCode = function()
{
    var hash = 0;
    if (this.length == 0) return hash;
    for (var i = 0; i < this.length; i++) {
        var character = this.charCodeAt(i);
        hash = ((hash<<5)-hash)+character;
        hash = hash & hash; // Convert to 32bit integer
    }
    return hash;
}


setup.calculateInventoryUniqueId = function(obj)
{
	obj.uid = JSON.stringify(obj).replace(/,\"uid\":\"item:[a-z0-9:-]+\"/g,"").replace(/,\"count\":[0-9-]+/g,"").replace(/,\"iuid\":\"[0-9-]+\"/g,"").hashCode().toString();
	return obj.iuid;
}

// attempt to create a unique ID that is unique to all uids inside and outside of the current inventory
// it calculates the integrity over the count as well
// and adds a date as well as a random character string, which is going to be used as the salt
setup.calculateUniqueId = function(obj)
{
	integrityHash = JSON.stringify(obj).replace(/,\"uid\":\"item:[a-z0-9:-]+\"/g,"").replace(/,\"iuid\":\"[0-9-]+\"/g,"").hashCode().toString();
	var salt = Math.random().toString(36).substring(7);
	obj.uid = ('item:' + integrityHash + ':' + Date.now() + ':' + salt);
	return obj.uid;
}

I know the way this thing is implemented is very unusual, but it works very well in the game.

Here's an example of an item that I use in my game:

<<set $flower_dress =
{
	id: "flower.dress",
	name: "Flower Dress",
	count: 4,
	value: 70,
	type: "top",
	image: "images/Items/Wearables/flower_dress.png",
	isEquipped: false,
	attributes: [],
	status: [{ name: "hotness", bonus: 12 }],
	iuid:"-1186697761",
	onEquip:
	{
		passages: {"bathroom":"PutOnTopBathroom"},
		tags: ['bathroom']
	},
	onUnEquip:
	{
		passages: {"bathroom":"RemoveTopBedroom",
			   "home":"RemoveTopBedroom"},
		tags: ['bathroom','home']
	},
	uid:"item:-887800947:1512698440234:th9gsl",
	enhancements: [],
	description: "This is a colorful dress with some flowers sewn into the fabric. It's very comfy."
}>>

<<set $flower_dress =
{
	id: "flower.dress",
	name: "Flower Dress",
	count: 1,
	value: 70,
	type: "top",
	image: "images/Items/Wearables/flower_dress.png",
	isEquipped: false,
	attributes: [],
	status: [{ name: "hotness", bonus: 12 }],
	iuid:"-1186697761",
	onEquip:
	{
		passages: {"bathroom":"PutOnTopBathroom"},
		tags: ['bathroom']
	},
	onUnEquip:
	{
		passages: {"bathroom":"RemoveTopBedroom",
			   "home":"RemoveTopBedroom"},
		tags: ['bathroom','home']
	},
	uid:"item:476897104:1512698479412:hsdd0s",
	enhancements: [],
	description: "This is a colorful dress with some flowers sewn into the fabric. It's very comfy."
}>>

You can see that the 'uid' stays the same because both items are supposed to be seen by the game as the same item.All relevant properties that are important to the logic of the game are the same. Therefore, these items can be merged / stacked. When you do so, you get the same item with a new 'iuid' and the 'count' raised to 5.

by (68.6k points)

Here's an example of a custom inventory object which is interoperable with the story history—it's really just a wrapper around an Array object to provide the appropriate plumbing.  The example also includes a handful of search methods which should get you started.  You'll need to add methods to add/remove items and, probably, additional search methods.

 

Usage example:

/* Create a new, empty, inventory instance. */
… = new setup.Inventory();

/* Create a new inventory instance and populate it with some items. */
… = new setup.Inventory([ item1, item2, … itemN ]);

 

Code example:

The important bits pertaining to integration with the story history are the constructor, clone(), and toJSON() methods.

/***********************************************************************
	Inventory system example for CeLezte.
***********************************************************************/

/*
	Create a new custom object constructor as `setup.Inventory`.
*/
setup.Inventory = function (initial) {
	if (initial != null && !Array.isArray(initial)) { // lazy equality for null
		throw new TypeError('setup.Inventory: initial parameter must be an Array');
	}

	// Set up an array to serve as our internal item store.
	this.items = [];

	// Copy values from the `initial` array parameter, if specified.
	if (initial) {
		initial.forEach(function (val) {
			this.items.push(clone(val));
		});
	}
};

/*
	Allows the object to be properly cloned from passage to passage.
*/
setup.Inventory.prototype.clone = function () {
	return new setup.Inventory(this.items);
};

/*
	Allows the object to be properly restored from serializations.
*/
setup.Inventory.prototype.toJSON = function () {
	return JSON.reviveWrapper('new setup.Inventory($ReviveData$)', this.items);
};

/**********************************************************************/

/*
	Returns whether any objects with the same UID as the given object or
	UID were found.
*/
setup.Inventory.prototype.hasByUid = function (itemOrUid) {
	if (itemOrUid == null) { // lazy equality for null
		throw new Error('hasByUid: itemOrUid parameter is required');
	}

	var uid = typeof itemOrUid === 'object' ? itemOrUid.uid : itemOrUid;

	return this.items.some(function (invItem) {
		return invItem.uid === uid;
	});
};

/*
	Returns the first object with the same UID as the given object or UID,
	or undefined if no such object exists.
*/
setup.Inventory.prototype.getByUid = function (itemOrUid) {
	if (itemOrUid == null) { // lazy equality for null
		throw new Error('getByUid: itemOrUid parameter is required');
	}

	var uid = typeof itemOrUid === 'object' ? itemOrUid.uid : itemOrUid;

	return this.items.find(function (invItem) {
		return invItem.uid === uid;
	});
};

/*
	Returns whether any objects with the same name as the given object or
	name were found.
*/
setup.Inventory.prototype.hasByName = function (itemOrName) {
	if (itemOrName == null) { // lazy equality for null
		throw new Error('hasByName: itemOrName parameter is required');
	}

	var name = typeof itemOrName === 'object' ? itemOrName.name : itemOrName;

	return this.items.some(function (invItem) {
		return invItem.name === name;
	});
};

/*
	Returns the first object with the same name as the given object or name,
	or undefined if no such object exists.
*/
setup.Inventory.prototype.getByName = function (itemOrName) {
	if (itemOrName == null) { // lazy equality for null
		throw new Error('getByName: itemOrName parameter is required');
	}

	var name = typeof itemOrName === 'object' ? itemOrName.name : itemOrName;

	return this.items.find(function (invItem) {
		return invItem.name === name;
	});
};

/*
	Returns a new arry of all objects with the same name as the given object
	or name, or an empty array if no such objects exist.
*/
setup.Inventory.prototype.getAllByName = function (itemOrName) {
	if (itemOrName == null) { // lazy equality for null
		throw new Error('getAllByName: itemOrName parameter is required');
	}

	var name = typeof itemOrName === 'object' ? itemOrName.name : itemOrName;

	return this.items.filter(function (invItem) {
		return invItem.name === name;
	});
};

 

...