The Unity Way #1: Sprite Sheet

Primo articolo relativo a codice per Unity, ambiente di sviluppo ormai arrivato alla versione 4.0.

Le mie abilità con questo IDE sono ancora limitate, ma proprio perchè sono tali voglio vedere di scrivere qualcosa a riguardo, in modo da motivarmi ancora di più a migliorare, oltre che a condividere con chi legge quello che ho imparato (anche se è poco).

In uno dei precedenti articoli su XNA ho parlato di come utilizzare gli SpriteSheet, ed ho provato ad ottenere un risultato simile anche con Unity.

Unity lavora molto con quelli che vengono chiamati Prefab, cioè un oggetto riutilizzabile.
Tanto per citare la documentazione ufficiale:

A Prefab is a type of asset — a reusable GameObject stored in Project View. Prefabs can be inserted into any number of scenes, multiple times per scene. When you add a Prefab to a scene, you create an instance of it. All Prefab instances are linked to the original Prefab and are essentially clones of it. No matter how many instances exist in your project, when you make any changes to the Prefab you will see the change applied to all instances.

Quindi per i miei SpriteSheet ho deciso di crearmi un Prefab apposito, che successivamente potrò personalizzare di volta in volta con le varie configurazioni ed immagini che mi servono.

In un altro articolo avevo fatto vedere come con XNA era molto facile dare in pasto gli sprite sheet al mio SpriteOrganizer ed usare il file di output per recuperare un’arbitrario frame dall’immagine.
Ho provato a fare una cosa del genere anche con Unity, con il quale però al momento non sono stato in grado di raggiungere una tale precisione ne di trovare un modo per posizionare l’offset della texture a livello di pixel. Inoltre ho notato un’altra differenza essenziale: essendo Unity strutturato per il multi-piattaforma, quando si va ad importare una texture questa viene scalata e riadatta secondo alcune misure massime (il lato più grande sarà al massimo 256px, 512px, etc, fino ad un massimo di 4096px di lato, con il vincolo che viene scalata alla potenza di due più vicina che supera la dimensione). Questo credo per garantire una migliore compatibilità del prodotto sui vari dispositivi ed OS.

Sta di fatto che tutto questo mi ha portato a non continuare (almeno al momento) le mie prove in questa direzione. Mi è risultato molto più semplice strutturare un prefab che gestisca gli sprite sheet nei quali i frame sono disposti in modo uniforme e regolare. Tutto sommato il mio SpriteOrganizer è in grado di selezionarmi e di compattarmi in una sola immagine i frame che mi interessano.

Il codice di partenza è tratto da questa serie di tutorial
walker boy studios
Solo che a me piace la gestione più Object Oriented, e mi piace che ogni oggetto abbia al suo interno le informazioni che gli servono.
Ho quindi esteso il loro codice con i campi della classe, ottenendo quanto sotto

using UnityEngine;
using System.Collections;
 
public class Simple_SpriteSheet : MonoBehaviour {
 
	public int rows, cols;
	public float framePerSecond = 1;
 
	public int rowFrameStart = 0;
	public int colFrameStart = 0;
	public int length = 1;
 
	void Update () {
		//Compute ();
	}
 
	public void Compute(){
		Compute (rows, cols, rowFrameStart, colFrameStart, length, (int)(Time.time * framePerSecond));
	}
 
	public void Compute(int rows, int cols, int rowFrameStart, int colFrameStart, int length, int index){
		index = index % length;
 
		int u = index % cols;
		int v = index / cols;
 
		Vector2 size = new Vector2(1.0f / cols, 1.0f/rows);
		renderer.material.mainTextureScale = size;
 
		Vector2 offset = new Vector2((u + colFrameStart) * size.x, (1 - size.y) - ((v + rowFrameStart) * size.y));
		renderer.material.mainTextureOffset = offset;
	}
}

ma se avessi fatto solo questo mi sarei limitato ad un puro e semplice copy/paste con piccola modifica del codice, quasi inutile direi.

Quello che voglio fare ora è estendere il tutto e creare un altro script che utilizzi questo appena sopra per animare lo sprite sheet usando delle informazioni che ha precedentemente caricato da un file esterno. Questo file esterno conterrà la struttura delle animazioni, e nello specifico la serie di frame da utilizzare.
Non voglio però che il file venga caricato ed impostato a Runtime, ma voglio invece che il file sia caricato ed utilizzare per modificare l’oggetto che ho nell’editor di Unity. Per fare questo mi servirà di creare un’estensione dell’Inspector associato al mio oggetto.

Cominciamo con il definire le informazioni che voglio siano contenute nel file che voglio caricare:

  • Numero di colonne e righe in cui sono divisi i frame nell’immagine
  • Una serie di animazioni, ognuna delle quali avrà al suo interno informazioni come nome, numero di frame per secondo (in questo modo posso definire animazioni che hanno una velocità di esecuzione diversa tra di loro) ed infime la lista dei frame

Il file conterrà anche quindi una collezione delle animazioni che sono state definite utilizzando lo spritesheet associato.
Per la memorizzazione delle informazioni nel file ho deciso di utilizzare un file xml, in modo da sfruttare le funzioni già pronte per il recupero dei dati.

Mi piace lasciare il vecchio codice così com’è, in modo da non costringermi a riscrivere le vecchia applicazioni lo usano nel caso cambi l’interfaccia pubblica (anche per errore o distrazione). Parto quindi con li definire una nuova classe che chiamerò Anim_SpriteSheet, e che riporto di seguito:

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
 
public class Anim_SpriteSheet : Simple_SpriteSheet {
 
	public List<SpriteAnimation> myAnims;
 
	public int currentAnimation = 0;
 
	float elapsedTime;
	int cc = 0;
 
	public void Start(){
	}
 
	void Update () {
		elapsedTime += Time.deltaTime;
		if(elapsedTime > 1f/myAnims[currentAnimation % myAnims.Count].framePerSecond){
			elapsedTime = 0;
			cc = (cc + 1) % myAnims[currentAnimation % myAnims.Count].FrameList.Count;
		}
		Compute (cc);
	}
 
	public void Compute(int index){	
		base.Compute(rows, cols, rowFrameStart, colFrameStart, length, index);
	}
}

Ed anche la classe SpriteAnimation

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
 
[System.Serializable]
public class SpriteAnimation {
 
	public string Name = "";
	public List<int> FrameList;
	public int framePerSecond = 0;
 
	public SpriteAnimation()
	{
		FrameList = new List<int>();
	}
}

Il [System.Serializable] fa in modo che l’inspector di Unity possa visualizzare le informazioni e quindi darci un’interfaccia grafica per la loro modifica (credo sia dovuto al fatto che si rendono serializzabili i dati dell’oggetto, e che quindi può essere passato da una parte all’altra dell’applicazione e sfruttato anche per la rappresentazione grafica nell’interfaccia).
Comunque questa è solo una classe contenitore per le informazioni delle animazioni, quindi molto semplice e su di essa non ho nulla da aggiungere.

Spendo una parola in più sull’Anim_SpriteSheet. Il metodo Compute ha una firma diversa, e semplicemente riproduce all’infinito l’animazione corrente (definita tramite l’indice currentAnimation). Per fare questo ha bisogno di un campo per tenere a mente quanto tempo è passato dall’ultimo cambio frame, campo che viene aggiornato ad ogni esecuzione del metodo update. Quando il valore è più grande del tempo di un frame (in questo caso definito dal rapporto 1f/fps) il frame viene aggiornato. Nulla di difficile (e avevo fatto la stessa cosa con XNA).

Mi piace l’idea di caricare i dati da file, ma vorrei anche che si possa personalizzare il caricamento fino ad un certo punto. Ho deciso di definire la possibilità di modificare il nome dei tag che nel file xml contengono le informazioni di nostro interesse. In questo modo cambiando il valore delle proprietà di riuscirà a leggere file con la stessa struttura anche se avranno tag con nomi diversi.
La struttura del file dovrà comunque essere come questa:

<spriteSheet>
	<rows>6</rows>
	<cols>5</cols>
	<anim>
		<name>prima</name>
		<fps>10</fps>
		<fid>0</fid>
		<fid>1</fid>
		<fid>2</fid>
		<fid>3</fid>
	</anim>
	<anim>
		<name>seconda</name>
		<fps>10</fps>
		<fid>0</fid>
		<fid>1</fid>
		<fid>2</fid>
		<fid>3</fid>
	</anim>
</spriteSheet>

Questa personalizzazione però verrà fatta a livello di Inspector non a livello di codice del nostro oggetto. Il codice dell’oggetto animation_spriteSheet rimarrà sempre lo stesso. Quello che cambia è cosa viene visualizzato nell’inspector dell’oggetto a cui è associato lo script per gli sprite, e di conseguenza le nuove funzioni associate all’interfaccia.
Nel nostro inspector personalizzato (per gli sprite sheet) ci saranno dei campi che permetteranno di definire i nomi dei tag (dei semplici textField nei quali scrivere il nome del tag). Più che per vera utilità questo è servito a me per fare un po’ di minima esperienza sulla gestione dell’inspector e per personalizzarlo.
Modificando, personalizzando o ricreando l’inspector associato ad un nostro oggetto possiamo nascondere alcune informazioni (per esempio possiamo non rendere modificabile attraverso l’inspector una proprietà pubblica, che però sarà sempre modificabile attraverso il codice) oppure visualizzarne alcune in modo diverso o rendendo più facile la loro modofica.

Ciò che segue è quanto ho realizzato

using UnityEditor;
using UnityEngine;
 
using System.Xml;
 
[CustomEditor(typeof(Anim_SpriteSheet))]
public class Animation_SS_Inspector : Editor {
 
	string colsTag = "cols", rowsTag = "rows", animationTag = "anim",
		animationNameTag = "name", frameTag = "fid", fpsTag = "fps";
 
	private static GUIContent
		loadFile = new GUIContent("load file", "load sprite sheet animation info");
 
	private static GUILayoutOption
		buttonWidth = GUILayout.MaxWidth(80f);
 
	//riferimento all'oggetto in questione
	private SerializedObject sprite;
 
	void OnEnable () {
		sprite = new SerializedObject(target);
	}
 
	public override void OnInspectorGUI () {
 
		EditorGUILayout.LabelField("For Base Infos");
		//uso il valore della variabile e lo rimemorizzo nella stessa variabile per non perdere le eventuali modifiche
		//altrimenti il textField tornerebbe a mostrare valore di default
		colsTag = EditorGUILayout.TextField("Columns Tag: ", colsTag);
		rowsTag = EditorGUILayout.TextField("Rows Tag: ", rowsTag);
 
		EditorGUILayout.LabelField("For Animations Infos");
		animationTag = EditorGUILayout.TextField("Animation Tag: ", animationTag);
		animationNameTag = EditorGUILayout.TextField("Animation Name Tag: ", animationNameTag);
		frameTag = EditorGUILayout.TextField("Frame Tag: ", frameTag);
		fpsTag = EditorGUILayout.TextField("Frame Per Second Tag: ", fpsTag);
 
		//controllo se il bottone viene premuto, ed in caso positivo eseguo il codice
		if(GUILayout.Button(loadFile, EditorStyles.miniButtonLeft, buttonWidth)){
			//apro una finestra di selezione file
			string path = EditorUtility.OpenFilePanel("Sprite Sheet Infos", "", "*.*");
			if(path.Length != 0){//controllo che il percoso sia valido
				//apro file
				XmlDocument doc = new XmlDocument();
				doc.Load (System.IO.Path.GetFullPath(path));
 
				//riferimento all'oggetto selezionato convertito nel tipo corretto
				Anim_SpriteSheet spriteTarget = (target as Anim_SpriteSheet);
				//leggo info di base				
				spriteTarget.rows = int.Parse (doc.GetElementsByTagName(rowsTag)[0].InnerText);
				spriteTarget.cols = int.Parse (doc.GetElementsByTagName(colsTag)[0].InnerText);
 
				//svuoto le animazioni precedentemente contenute
				spriteTarget.myAnims.Clear();
				//carico ed imposto le animazioni
				foreach(XmlElement elm in doc.GetElementsByTagName(animationTag)){
					SpriteAnimation sa = new SpriteAnimation();
					sa.framePerSecond = int.Parse (elm.GetElementsByTagName(fpsTag)[0].InnerText);
 
					if(elm.GetElementsByTagName(animationNameTag).Count != 0)
						sa.Name = elm.GetElementsByTagName(animationNameTag)[0].InnerText;
 
					foreach(XmlElement frame in elm.GetElementsByTagName(frameTag))
						sa.FrameList.Add (int.Parse (frame.InnerText));
 
					spriteTarget.myAnims.Add (sa);
				}
			}
		}
		//visualizzo comunque anche l'inspector di default, in modo da permettere di agire normalmente sull'oggetto e le sue proprietà
		DrawDefaultInspector();
 
		sprite.ApplyModifiedProperties();
	}
}

Alla fine il risultato non è poi così male. Sicuramente lo devo migliorare , ma come inizio mi pare accettabile.

Come utilizzarlo?
Basta associare un Animation_SpriteSheet ad un oggetto, associargli una texture/spriteSheet (magari potrei implementare in futuro l’import automatico della texture se non è già presente nella libreria degli asset, e l’associazione diretta allo sprite), e poi far caricare il un file con le informazioni relative (oppure inserire manualmente tutti i dati).
Il campo “Current Animation” è quello che determina l’animazione corrente che dobbiamo eseguire tra quelle memorizzate nella collezione interna.

Vedrò di caricare appena potrò un package con i file necessari e con un esempio.

Al momento vi saluto e vi auguro buone feste.

Lascia un commento

We use cookies to ensure that we give you the best experience on our website.
Ok