+3 votes
by (210 points)
I'm using the newest version of SugarCube, and to be more specific:
I want to add an inventory to the sidebar (I've already created a tab on the sidebar CALLED "Inventory", but I have no idea how to go about creating a functioning inventory) that adds labelled pictures of each item after you pick them up (so that you can easily and quickly inspect your inventory at any time, and immediately know which items you have).

I have pretty much no coding knowledge (I've essentially only learned the basics of Twine/SugarCube), so I apologize if this is a stupid question. Any responses are appreciated.

Future questions might include:
-How to allow items to be selected entirely by the player (to allow them to choose when they want to try using the item on a scene, rather than have a choice appear when they have the item needed for that scene... It would allow the game to feel more like a puzzle that the player is figuring out for themselves)?
-How to allow specific items to be combined in order to create new items (as you would see in many Point n' Click games)?

Feel free to ignore those two future questions, as I could always ask them in a future post (they aren't my main question at the moment, obviously).

3 Answers

0 votes
by (44.7k points)
I'm currently in the middle of making an inventory plugin for Twine 2 / SugarCube 2 to help out non-coders with this kind of stuff.  I'm a little over 50% complete at this point, so if you tell me exactly what you're looking for and what you want it to do, I'll see about making sure I include functions to help with that and get you a pre-release copy of it so you can try it out.
by (210 points)
That sounds great, yeah, just tell me when it's finished.
As for what I would want out of it, I think my original post pretty much explains it all (including the "future questions" bit). Do you have any questions (maybe I could clarify some parts of it)?
by (44.7k points)
If you have a mock-up of what you want it to look like, even if it's Twine with text where the images go (it doesn't have to be functional, just explanatory) or a picture from some drawing program (GIMP, MSPaint, whatever), that would help, especially for the "future questions" items.

Also, do you want me to just contact you through this thread, or some other way?
by (210 points)
I just created a new account that you can contact me on if you want:
twineboy321@gmail.com

I would've been fine with just discussing these things on this post, but since you brought it up, it probably does make more sense to discuss this privately/elsewhere (since it's sort of a different topic from the original post, even though it's closely connected).

Just send me a message whenever, and we can continue this discussion there.
0 votes
by (63.1k points)

I've got a few different implementations in my custom macro set that you can feel free to use or adapt. 

by (210 points)
Thanks, I'll try to check that out later :)
0 votes
by (8.6k points)

It's a complicated problem with lots of moving parts. So I'll just drop what I'm doing for a small part of it - managing the item attributes and database. For my use case, the items are stored in characters inventories like this:

$char.inv = {
	gp: 5,
	provision: 17,
	sword: 1,
	sapphire: 2,
}

That's someone who is carrying a sword, 17 provisions, 5 gold pieces and a sapphire with them. As you can see, I use a plain Object as my inventory, which means I don't store the position in the inventory. On the plus side, that's fast, SugarCube can reliably save and restore the inventory, and that's all I need. Adding position and other state to the items can be done in several ways, but unless you need it, don't bother.

The IDs you see above ("gp", "sword") and so on aren't the items - they are IDs of items inside an item database and the items themselves are instances of an "Item" class.

window.Item = function(id, name, options) {
	if(!id || !name) {
		throw 'The item needs a valid ID and plain-text name';
	}
	options = options || {};
	this.id = id;
	this.name = name;
	
	// Copy options
	this.long = options.long || undefined;
	this.short = options.short || undefined;
	this.icon = options.icon || "item_unknown";
	this.invisible = options.invisible;
	this.value = options.value;
	this.tags = options.tags || [];
	this.hasTag = (t => this.tags.includes(t));
	this.hasAnyTag = (ts => this.tags.includesAny(ts));
	this.hasAct = (a => !!(this.acts[a]));
	this.validFor = options.validFor || (ch => true);
	// Copy acts
	this.acts = {};
	Object.keys(options).filter(k => k.startsWith('act') && k.length > 3)
		.forEach(k => this.acts[k.slice(3)] = options[k]);
	// Copy anything starting with "_" (internal data)
	Object.keys(options).filter(k => k.startsWith('_') && k.length > 1)
		.forEach(k => this[k] = options[k]);
	
	// If the attribute's value is a simple value, return that
	// If the value is a function, call it with the character and attribute name,
	// If the value is an array, pick a value at random
	this.describe = ((ch, attr) => {
		var result = this[attr];
		if(typeof result === 'function') {
			console.log(this);
			return result.call(this, ch, attr);
		} else if(result instanceof Array) {
			return result.random();
		} else {
			return result;
		}
	});
	// Similar to describe, but for acts, so always returns some String.
	this.act = ((ch, act) => {
		if(!this.hasAct(act)) {
			return '';
		}
		var result = this.acts[act];
		if(typeof result === 'function') {
			return String(result.call(this, ch, act)) || '';
		} else if(result instanceof Array) {
			return String(result.random()) || '';
		} else {
			return String(result) || '';
		}
	});

	// Description functions using the above
	this.longDesc = (ch => this.describe(ch, 'long') || '');
	// Defaults to the name with a period after it, for character summary and the like
	this.shortDesc = (ch => (this.describe(ch, 'short') || this.name));
};

// Make sure "late" registering of the event just calls the method, like with jQuery.ready()
(function() {
	var lastEvent = undefined;
	var lastData = undefined;
	jQuery.event.special[":itemdone"] = {
		_default: function(event, data) {
			lastEvent = event;
			lastData = data;
		},
		add: function(handleObject) {
			handleObject.handler.call(this, lastEvent, lastData);
		},
	};
})();
jQuery(document).trigger(":itemdone");

This is in the context of a TweeGo project, so I use jQuery's custom events to make sure I don't try to create items before the class is ready to go. If you work in Twine and have all the JavaScript in the (singular) special script passage, you don't need to bother; just defining the `window.Item` object is enough.

The Item class is mostly a holder for the item data like its description, but it can also be used to define actions ("acts") - things you can do with the item. The only one I use is the "Use" action, but the code is set up in such a way as to allow any number of them.

Describing an item is done with `<<= _item.shortDesc($character) >>`, getting the icon image of the item to display with `<<= "[img[" + _item.icon + "]]">>` and calling whatever function was set up under the "Use" action with `<<= _item.act($character, 'Use')>>`. You can check if an item can be used with `_item.hasAct("Use")`.

But we don't have items in the inventory - we have item IDs. The missing part, the glue code, is an ItemDB, defined like this:

window.ItemDB = (function() {
	var _list = {};
	var _none = undefined;
	return {
		add: (cl => {
			if(cl && 'id' in cl && 'name' in cl) {
				_list[cl.id] = cl;
			}
		}),
		byId: (id => _list[id] || _none),
		allValidFor: (ch => Object.values(_list).filter(cl => cl.validFor(ch))),
		get all() { return Object.values(_list); }
	};
})();

// Make sure "late" registering of the event just calls the method, like with jQuery.ready()
(function() {
	var lastEvent = undefined;
	var lastData = undefined;
	jQuery.event.special[":itemdbdone"] = {
		_default: function(event, data) {
			lastEvent = event;
			lastData = data;
		},
		add: function(handleObject) {
			handleObject.handler.call(this, lastEvent, lastData);
		},
	};
})();
jQuery(document).trigger(":itemdbdone");

Again, for a simple Twine project you can skip the jQuery parts. This has just a few important methods: `ItemDB.add(...)` adds an item to the DB, `ItemDB.byId(id)` gets you an Item instance from the ID (or _none, which you can define to be some "empty spot" quasi-item if you like), and `ItemDB.all` returns an array with all items defined so far.

Now we can actually add our items:

jQuery(document).one(":itemdbdone", function() {
	ItemDB.add(new Item("provision", "Provision", {
		short: "Provisions to help with lost stamina [+4" + Icon.STAMINA + " when used]",
		tags: ["consumable"],
		actUse: function(ch) {
			Character.addStamina(ch, 4);
			Character.removeItem(ch, "provision");
			return "Munch! (+4 Stamina)";
		},
		icon: "item_chest",
	}));
	ItemDB.add(new Item("sword", "Sword", {
		short: "A simple sword",
		tags: ["weapon", "sword"],
		icon: "item_sword",
	}));
	ItemDB.add(new Item("gp", "Gold Pieces", {
		short: "Gold pieces; worth their weight in gold",
		icon: "item_gp",
	}));
	ItemDB.add(new Item("sapphire", "Sapphire", {
		short: "A sapphire",
		tags: ["jewel"],
		icon: "item_sapphire",
	}));
});

Since this is in a different JS file than the classes above, I wait for the ":itemdbdone" custom event to fire before registering my items. The "provision" one is an example of the "Use" action coded - it adds 4 Stamina points to the character using it and removes one of the items from the character's inventory.

by (8.6k points)

--- Added as a comment because I hit the post length limit and I wasn't even close to describing a full inventory system ---

For completeness' sake, the inventory-relevant parts of the `Character` object look like this - it encapsulates all the important character interactions on the JavaScript side.

window.Character = {
	addItem: function(ch, item, amount) {
		if(!ItemDB.byId(item)) {
			throw "Item ID '" + item + "' unknown.";
		}
		if(!amount || amount < 1) {
			amount = 1;
		}
		if(!ch.inv[item]) {
			ch.inv[item] = 0;
		}
		ch.inv[item] += amount;
	},
	
	getItem: function(ch, item) {
		if(!ItemDB.byId(item)) {
			throw "Item ID '" + item + "' unknown.";
		}
		if(ch.inv[item]) {
			return ItemDB.byId(item);
		} else {
			return null;
		}
	},
	
	removeItem: function(ch, item, amount) {
		if(!ItemDB.byId(item)) {
			throw "Item ID '" + item + "' unknown.";
		}
		if(!amount || amount < 0) {
			amount = 1;
		}
		if(ch.inv[item]) {
			ch.inv[item] = Math.max(ch.inv[item] - amount, 0);
		}
		if(ch.inv[item] === 0) {
			delete ch.inv[item];
		}
	},
	
	hasItem: function(ch, item, amount) {
		amount = amount || 1;
		if(!ItemDB.byId(item)) {
			throw "Item ID '" + item + "' unknown.";
		}
		return (ch.inv[item] >= amount);
	},
	
	getTaggedItems: function(ch, tags) {
		if(!Array.isArray(tags)) {
			tags = [tags];
		}
		return Object.keys(ch.inv)
			.map(i => ItemDB.byId(i)).filter(i => i.hasAnyTag(tags));
	},
	
	hasTaggedItem: function(ch, tag) {
		return (Object.keys(ch.inv)
				.map(i => ItemDB.byId(i)).filter(i => i.hasTag(tag)).length > 0);
	},
	
	removeTaggedItems: function(ch, tag) {
		Object.keys(ch.inv)
			.map(i => ItemDB.byId(i)).filter(i => i.hasTag(tag))
			.forEach(i => delete ch.inv[i.id]);
	},
};

Finally, to make it easier to use from SugarCube, I added a few macros dealing with the inventory and items. They assume the player character is in `$PC`, but can work with any other when explicitly given.

<<widget "addItem">>
	<<if _.isString($args[0])>>
		<<run Character.addItem($PC, $args[0], $args[1])>>
	<<else>>
		<<run Character.addItem($args[0], $args[1], $args[2])>>
	<</if>>
<</widget>>

<<widget "removeItem">>
	<<if _.isString($args[0])>>
		<<run Character.removeItem($PC, $args[0], $args[1])>>
	<<else>>
		<<run Character.removeItem($args[0], $args[1], $args[2])>>
	<</if>>
<</widget>>

<<widget "removeTaggedItems">>
	<<if _.isString($args[0])>>
		<<run Character.removeTaggedItems($PC, $args[0])>>
	<<else>>
		<<run Character.removeTaggedItems($args[0], $args[1])>>
	<</if>>
<</widget>>

The `_.isString(...)` method is from lodash. I could have used some other check for "is this variable a String", but that was quick enough and I have lodash in all my projects per default anyway.

 

...