Search Unity

Adding a button to specific tiles in a tilemap

Discussion in '2D' started by Safirau, Mar 29, 2020.

  1. Safirau

    Safirau

    Joined:
    Apr 25, 2015
    Posts:
    7
    Hey guys,

    I started learning C# / Unity some months ago, I can fix most of my problems with patchwork-y solutions but I've been stuck with this one for a few weeks and I couldn't find a solution for that anywhere.
    I am trying to make the tiles in my tilemap act like Buttons (or add a button component on them) but I can't select them or add a component in the inspector or elsewhere. I also have no idea how to access a specific tile (wether it is already placed at runtime or beforehand).

    I have looked up dozens of topic but none seemed to cover that and I am at a loss of which keywords to use just to look up information on this subject.

    Thanks a lot if you have a solution ! :)
     
  2. Lo-renzo

    Lo-renzo

    Joined:
    Apr 8, 2018
    Posts:
    1,511
    Take a look at this:
    Code (CSharp):
    1.  
    2. var pos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
    3. var noZ = new Vector3(pos.x, pos.y);
    4. Vector3Int mouseCell = grid.WorldToCell(noZ);
    5. Debug.Log(mouseCell);
    6.  
    Here, I get the mouse position in worldspace. Then I convert that into a cellspace position. Now we have the cell that is under the mouse. If you were to put this into an Update loop, you could observed the cell every frame. Note: this script assumes you have access to the Grid component, which is on the parent of your tilemap.

    Let's say we want to get a particular Tilemap tile:
    Code (CSharp):
    1.  
    2. var tileUnderMouse = myTilemap.GetTile(mouseCell);
    3. Debug.Log(tileUnderMouse);
    4.  
    Here, we use a reference to our tilemap, plugged in from the inspector, to get the tile under the mouseCell. The methods that are used to access, set, and manipulate the tilemap can be found here: https://docs.unity3d.com/ScriptReference/Tilemaps.Tilemap.html

    Unfortunately, you can't quite add a component to a tile. Tiles - in typical usage - have one instance for the entire kind. That's one of the performance advantages to tilemap: a low memory footprint. You could, if you want, instantiate a tile in each cell, but unless it's really necessary (like needing unique behavior for each) there's not much reason to.

    How might we create something like a button? Well, we could get the mouseCell when the click is released:
    Code (CSharp):
    1. if(Input.GetMouseButtonUp(0))
    2. {
    3.    var tileUnderMouse = myTilemap.GetTile(mouseCell);
    4. }
    Now, we have a reference to that tile. If you derive a new Tile class (from Tile or TileBase), then you could do:
    Code (CSharp):
    1. tileUnderMouse.OnClick(myTilemap, mouseCell);
    And then in that method you could put whatever behavior you want for your button. The reason I fed mouseCell back into the tile is that because the same instance of a tile may be placed in multiple cells, it doesn't know where it exists. Thus, unlike a GameObject, which has a transform.position, we have no equivalent. So, the tile needs to receive it externally.

    I also provided the tilemap reference so that I can show you one more thing:
    Code (CSharp):
    1. void OnClick(Tilemap tilemap, Vector3Int mouseCell)
    2. {
    3.         tilemap.SetColor(mouseCell, Color.black);
    4. }
    On click, the tile colored itself black.

    Another approach to this problem would be to use the TilemapCollider to get callbacks, but I show you manually so you can understand the essence of how the tilemaps work. To learn more, you should check out the example projects: Robodash demo, Unity 2D Techdemos.
     
    Olleus and Gemplay like this.
  3. Safirau

    Safirau

    Joined:
    Apr 25, 2015
    Posts:
    7
    Thank you for your answer, I understood what you explained there and it seems pretty hard to use for my specific problem, altho it helps me get a deeper understanding of the tilemap object.

    With your approach, every tile I would click would call the same function. My idea was to have a map where the player can navigate after a fight (the fighting system is already finished) and chose to explore different rooms, some being the next level with an enemy, some having loot, traps, shops etc...
    With this way however, I could only make the player do one of those thing right? Since The tile under the mouse has not (cannot?) be set up before runtime. Is there a way to differentiate the tiles?

    My patchwork solution would be to replace the tiles by Buttons and make prefabs out of them, which would also be easier to translate to procedurally generated map afterwards, what would be the downside if any?

    Thanks a lot!
     
  4. Lo-renzo

    Lo-renzo

    Joined:
    Apr 8, 2018
    Posts:
    1,511
    Not necessarily. The first way to go about this would be to define subtypes of MyTile which then implement the OnClick method. Each different behavior would require a different subtype.

    If it were me, I'd prefer a more compositional approach without subtyping MyTile. For example, you could have your tile class own another class (something derived from ScriptableObject) which could control the "what happens when you click here". This would be akin to the Strategy Pattern where the behavior of a method is controlled by another object. So, we might imagine you have a Tile that compositionally "has a" ClickStrategy.
    Code (CSharp):
    1. public class MyTile : TileBase
    2. {
    3.    public ClickStrategy clickStrategy; // plugged in from inspector
    4.    public void OnClick(Tilemap tilemap, Vector3Int cell)
    5.   {
    6.       clickStrategy.OnClick(tilemap, cell);
    7.   }
    8. }
    Classes that derive from the abstract ClickStrategy class define the behavior that is done on click. Your different instances of MyTile (each defining a different kind of tile, e.g. wall vs grass) could then have different behaviors plugged in from the inspector.
    Code (CSharp):
    1. public abstract class ClickStrategy : ScriptableObject
    2. {
    3.    public abstract void OnClick(Tilemap tilemap, Vector3Int mouseCell);
    4. }
    ClickStrategy above is defined as a ScriptableObject, so if you slap a CreateAssetMenu attribute above its subtypes you'll be able to create new assets for the different behaviors that happen on click. All that is to say: each click could then open different loot, a different room, a different shop interface, etc.

    This also all still works during design-time within the Editor. Since these assets all exist in your project, your workflow is to create new varieties of MyTile and plug in different behaviors. With tile asset in hand, you can drag and drop it into the TilePalette and then paint it onto a scene. No runtime procedural generation is required.
     
    Last edited: Mar 30, 2020
    Gemplay likes this.
  5. Safirau

    Safirau

    Joined:
    Apr 25, 2015
    Posts:
    7
    Oh my god thank you. I think all the answers were in your first comment but the second one helped me understand. After getting the tile under the mouse, there is always a difference between every tile so we can simply use an if() loop in order to differentiate which method to use. That way, all the tiles with the same name have the same usage.

    Thanks a lot I had been stuck on this problem for 2 weeks and it was my first time getting stuck that long, it really made me want to stop even though I only started a few months ago.

    Thanks! Have a nice day !
     
  6. Safirau

    Safirau

    Joined:
    Apr 25, 2015
    Posts:
    7
    Ok so I was trying to implement this and got stuck on multiple occasions :
    - When I tried your first comment, the tiles didnt turn black, also my print("tile turned black") in OnClick() would trigger every time I clicked anywhere on the screen
    - I tried replacing it with tilemap.gameObject.SetActive(false); --> Here it actually did something but as expected removed the entire tilemap and not just the tile.
    - I then tried your 2nd comment and added a [CreateAssetMenu] on ClickStrategy (I already have ScriptableObjects for my inventory so I see how they work) but everytime I try to create one i get an error saying I can't create an instance of this abstract class
    - Finally, I cannot attach my MyTiles script to any asset (just doesn't work) / empty gameobject (says it has to derive from monobehaviour)

    I don't know if i'll stick with Tilemaps as I might be using them for something that is not their primary goal and that might be why I am struggling so much with them (along the lack of ressources on them). Which is weird because I am actually trying to make a map with tiles but I think having each tile have a different function isn't the primary goal.

    It feels weird because everything I've dealt with for now (event manager, scriptable objects, classes, scenes, data persistance etc...) in my learning has been so well documented but with tilemaps the main thing I still can't grasp is simply "Select this specific tile and make it do something simple". I'll go back to the simple button solution for now but if someone has a solution feel free to post it as I think more documentation on Tilemaps would be welcome for the next time I try using them (and to help any new learner that would come by this post in the future) :p
     
  7. Lo-renzo

    Lo-renzo

    Joined:
    Apr 8, 2018
    Posts:
    1,511
    It's very likely that your tile is missing the correct TileFlag. Every tile when placed at a particular position may be associated with a TileFlag. To illustrate what this does let me pull from the Robodash example project. Take a look here:
    Code (CSharp):
    1.     public override void GetTileData(Vector3Int position, ITilemap tilemap, ref TileData tileData)
    2.     {
    3.         // REMOVED FOR BREVITY
    4.         tileData.sprite = m_Sprites[mask];
    5.         tileData.flags = TileFlags.LockTransform;
    6. }
    Here, this tile has "LockTransform" set. What this does is it "locks" the transform so it cannot be set externally; this tile class to have everything besides its transform set externally. In contrast, for your case, the tile has "LockColor" set for that tilemap's cell position - preventing color changes! You need to set the TilesFlags setting to None or something that does not LockColor. This will allow you to set that color externally. Thus, myTilemap.SetColor(cell, someColor) will work. TileFlags are an unfortunate "gotcha" I forgot to tell you about :p

    You could also change your OnClick method like so:
    Code (CSharp):
    1. void OnClick(Tilemap tilemap, Vector3Int cell)
    2. {
    3.     tilemap.SetTileFlags(cell, TileFlags.None);
    4.     tilemap.SetColor(cell, Color.black);
    5. }
    This - in contrast to the code from Robodash - changes the flags at this particular cell so that it may be recolored. So, instead of changing TileFlags for the entire class, it overrides whatever the class might have suggested instead sets its flags at a particular cell. Which approach is better: class or particular cell? It depends on what you want to accomplish. The class-based approach may be more consistent, whereas the "override it at this cell" approach is more versatile.

    Also, you may also want to null-check, assuming that your map has empty cells without tiles:
    Code (CSharp):
    1.     if(Input.GetMouseButtonUp(0))
    2.     {
    3.        var tileUnderMouse = myTilemap.GetTile(mouseCell);
    4.        if(tileUnderMouse != null)
    5.            tileUnderMouse.OnClick(tilemap, mouseCell);
    6.     }
    7.  
    Also, you may have already figured this out but you also need to type-check the tile to ensure that this specific tile is a kind where it's supposed to have a ClickStrategy, e.g. renaming "MyTile" to "ClickableTile". This would allow you to mix normal Tile-class tiles and your specific types together. Somethings could be clickable, others not.
    Code (CSharp):
    1.     if(Input.GetMouseButtonUp(0))
    2.     {
    3.        var tileUnderMouse = myTilemap.GetTile(mouseCell);
    4.        if(tileUnderMouse is ClickableTile clickableTIle)
    5.            clickableTIle.OnClick(tilemap, mouseCell);
    6.     }
    7.  
    There's a subtle distinction that I probably should've bolded in the original comment: add it to the subtype. So, I'm suggesting you have an abstract parent class ClickStrategy; then, you derive subtypes e.g. class TurnTileBlackOnClick, class OpenMenuOnClick, etc. It is this subtype that you add [CreateAssetMenu] above precisely for the reason you discovered.
    That's because tiles are supposed to be painted to a tilemap, not associated with a GameObject. In typical usage, there's one tile instance per kind (grass vs wall). SpriteRenderer, in contrast, is a component associated with GameObjects. What you should do is create a tile instance, give it some sprite, add it to your TilePalette and then paint it within the scene.

    Yeah, the documentation could be better. I learned through trial and error before there way documentation. That's tough especially for a beginner. What I think most throws people off is, unlike GameObjects, there's not an instance per tile and so instancey stuff has to be supplied externally like I showed you with using the mouseCell.
     
    Gemplay likes this.
  8. BassFight

    BassFight

    Joined:
    Apr 14, 2020
    Posts:
    1
    I've been reading and rereading this post for 2 hours, and I can't figure out what I'm missing. When I try to call the onClick method on tileundermouse, I get an error because ofcourse my MyTile class doesn't override the TileBase or Tile class. But when I try to fix this error by declaring tileundermouse a mytile instead of a tile (using an explicit conversion), i get a runtime error saying the cast isn't valid, because I guess the mytile class doesn't inherit tilebase or tile 's behaviour properly? I'm obviously doing something completely wrong because I don't see this issue mentioned anywhere here, but I can't figure out where I misunderstood you. Any chance you still have your code from this and could just post the whole thing so I can compare and check where I went wrong?

    EDIT: I figured it out! The problem was I hadn't actually created tiles of the type mytile (which is why there wasn't anywhere to cast from). I created one using the createassetmenu functionaltiy and then could select specific tiles to have that tiletype.
     
    Last edited: Nov 7, 2020