Page 1 of 1

Sliding Tile Puzzle - Collision Bounds and Tileset

Posted: Mon Aug 22, 2016 11:13 pm
by straydogstrut
Hi all,

I'm trying to create a sliding tile puzzle - ie. an image made of various tiles with one blank space that must be rearranged into the correct configuration. Here is an example.

I'm new to Love2D so have been working my way through the various tutorials. In particular the gridlocked player example, the 0-1 collision system described here, and the tileset loading example using quads in the first iteration found here.

Now i'm trying to combine these concepts to get closer to what I want. I rewrote the keypressed code to use mousepressed so that the tiles swap when clicked on but I have two issues/queries:

Firstly, i'm getting an 'attempt to index a nil value' on my left and right detection calls (presumably an out of bounds exception) in the mousepressed function when clicking certain tiles near the edges of the set from the following code:

Code: Select all

function love.load()
	
	map = {
		{ 1, 1, 1, 1 },
		{ 1, 1, 1, 1 },
		{ 1, 1, 1, 1 },
		{ 1, 1, 1, 0 }
	}

end
 
function love.update(dt)
	
end
 
function love.draw()

	-- highlight solid tiles
	for row=1, #map do
		for column=1, #map[row] do
			if map[row][column] == 1 then
				love.graphics.rectangle("line", row * 32, column * 32, 32, 32)
			end
		end
	end

	-- print some debugging information
	local grid_x, grid_y = getTile() -- mouse cursor location, snapped to grid
   	print_info(grid_x, grid_y) -- print grid position
end
 
function love.mousepressed(x, y, button)
      
	 if button == 1 then
   	 	-- if left mouse button pressed, get tile coordinates
      	local tile_x, tile_y = getTile()
      	
      	-- check we have clicked on a solid tile
      	if(map[tile_x][tile_y] == 1) then

      		-- if there is space above
      		if (map[tile_x][tile_y-1] == 0) then
      			map[tile_x][tile_y-1] = 1
				map[tile_x][tile_y] = 0
				
			-- if there is space below
			elseif (map[tile_x][tile_y+1] == 0) then
				map[tile_x][tile_y+1] = 1
				map[tile_x][tile_y] = 0

			-- if there is space to the left
			elseif (map[tile_x-1][tile_y] == 0) then
				map[tile_x-1][tile_y] = 1
				map[tile_x][tile_y] = 0

			-- if there is space to the right
			elseif (map[tile_x+1][tile_y] == 0) then
				map[tile_x+1][tile_y] = 1
				map[tile_x][tile_y] = 0
			end
		end
 	end
 end

 function getTile()

   local mouse_x, mouse_y = love.mouse.getPosition()
   
   -- convert cursor coordinates to grid coordinates
   mouse_x = math.floor(mouse_x/32)
   mouse_y = math.floor(mouse_y/32)

   return mouse_x, mouse_y

end

function print_info(grid_x, grid_y)
	love.graphics.setColor(255, 0, 0)
	love.graphics.rectangle("line", grid_x * 32, grid_y * 32, 32, 32)

	love.graphics.setColor(255, 255, 255)
   	love.graphics.print(grid_x .." , "..grid_y, grid_x * 32, grid_y * 32)
end
Secondly, as the above 'map' table essentially only serves as a collision map, I want to combine it with a tilemap of different images.

I'm not sure if it's the best approach, but I thought to have a second table to hold this info. I know the format of this can be made more efficient than using numerical values (such as using strings and ipairs) but for now I just want to get it working this way.

However, i'm not sure how I would update the graphics similar to how I swap the collision map values, or indeed if there is a simpler approach to this? I did try using the tile_x, tile_y values from the collision check to look up the corresponding values in the timely table, store them, and swap them but it seemed to affect tiles elsewhere.

Code: Select all

function love.load()

	map = {
		{ 1, 1, 1, 1 },
		{ 1, 1, 1, 1 },
		{ 1, 1, 1, 1 },
		{ 1, 1, 1, 0 }
	}

	Tileset = love.graphics.newImage("/assets/countryside.png") -- load the tileset
	TileW, TileH = 32, 32 -- global tile width and height

	local tilesetW, tilesetH = Tileset:getWidth(), Tileset:getHeight() -- tileset dimensions

	-- define the tile images from the tileset using quads: tile left(x),tile top(y), width, height, tilset width, tileset height
	Quads = {
		love.graphics.newQuad(0, 0, TileW, TileH, tilesetW, tilesetH), -- 1 grass
		love.graphics.newQuad(32, 0, TileW, TileH, tilesetW, tilesetH), -- 2 box
		love.graphics.newQuad(0, 32, TileW, TileH, tilesetW, tilesetH), -- 3 flowers
		love.graphics.newQuad(32, 32, TileW, TileH, tilesetW, tilesetH) -- 4 boxtop
	}

	tilemap = {
		{ 3, 2, 3, 4 },
		{ 3, 3, 3, 4 },
		{ 4, 2, 3, 4 },
		{ 4, 2, 2, 1 }	
	}

end
 
function love.update(dt)
	
end
 
function love.draw()

        -- display the graphic tiles from the timely
	for rowIndex = 1, #tilemap do 		-- for each 'row' of tilemap
		local row = tilemap[rowIndex]		-- store the current row index (row)
		for columnIndex = 1, #row do		-- for each 'column' of the row
			local number = row[columnIndex]	-- store the current column index (number)
			local x = (columnIndex)*TileW	-- get x coordinate using column and tile width
			local y = (rowIndex)*TileH	-- get y coordinate using row and tile height
			
			-- draw the relevant Tile Type from the Tileset, at the x and y
			love.graphics.draw(Tileset, Quads[number], x, y)
		end
	end

	-- highlight solid tiles
	for row=1, #map do
		for column=1, #map[row] do
			if map[row][column] == 1 then
				love.graphics.rectangle("line", row * 32, column * 32, 32, 32)
			end
		end
	end

	-- print some debugging information
	local grid_x, grid_y = getTile() -- mouse cursor location, snapped to grid
   	print_info(grid_x, grid_y) -- print grid position
end
 
function love.mousepressed(x, y, button)
      
	 if button == 1 then
   	 	-- if left mouse button pressed, get tile coordinates
      	local tile_x, tile_y = getTile()
      	
      	-- check we have clicked on a solid tile
      	if(map[tile_x][tile_y] == 1) then

      		-- if there is space above
      		if (map[tile_x][tile_y-1] == 0) then
      			map[tile_x][tile_y-1] = 1
				map[tile_x][tile_y] = 0
				
			-- if there is space below
			elseif (map[tile_x][tile_y+1] == 0) then
				map[tile_x][tile_y+1] = 1
				map[tile_x][tile_y] = 0

			-- if there is space to the left
			elseif (map[tile_x-1][tile_y] == 0) then
				map[tile_x-1][tile_y] = 1
				map[tile_x][tile_y] = 0

			-- if there is space to the right
			elseif (map[tile_x+1][tile_y] == 0) then
				map[tile_x+1][tile_y] = 1
				map[tile_x][tile_y] = 0
			end
		end
 	end
 end

 function getTile()

   local mouse_x, mouse_y = love.mouse.getPosition()
   
   -- convert cursor coordinates to grid coordinates
   mouse_x = math.floor(mouse_x/32)
   mouse_y = math.floor(mouse_y/32)

   return mouse_x, mouse_y

end

function print_info(grid_x, grid_y)
        -- highlight the tile under the mouse
	love.graphics.setColor(255, 0, 0)
	love.graphics.rectangle("line", grid_x * 32, grid_y * 32, 32, 32)
	
        -- print the tile row and column numbers
	love.graphics.setColor(255, 255, 255)
   	love.graphics.print(grid_x .." , "..grid_y, grid_x * 32, grid_y * 32)
end
I would be grateful for any help, thank you

Re: Sliding Tile Puzzle - Collision Bounds and Tileset

Posted: Tue Aug 23, 2016 2:37 pm
by pgimeno
You are indexing something that is not a table. That happens when you try to access map[tile_x+1][tile_y] or map[tile_x-1][tile_y] when at the border.

Why? Imagine that tile_x is 1 and tile_y is 1. Then tile_x-1 is 0, and map[0] is nil. Since map[0] is not a table, when you try to index map[0][1], it gives an error. It does not happen for e.g. map[1][0] because map[1] is a table and can be indexed, and map[1][0] is nil and can be compared.

A fix would be to check if map[tile_x-1] and map[tile_x+1] exist prior to the other comparison, like this:

Code: Select all

         -- if there is space to the left
         elseif map[tile_x-1] and (map[tile_x-1][tile_y] == 0) then
...
         -- if there is space to the right
         elseif map[tile_x+1] and (map[tile_x+1][tile_y] == 0) then

Re: Sliding Tile Puzzle - Collision Bounds and Tileset

Posted: Tue Aug 23, 2016 6:31 pm
by zorg
First, welcome to the forums! :3

Now, one half of your issues is what pgimeno stated, though to be a bit more concise, you're getTile function, as it is now, will only work under very limited conditions:
  • It won't work if you'll want to have your game world's size be bigger than the screen.
  • It won't always work if your game world is -less- than the screen size (since it can return indices that can reference nonexistent tiles)
  • As pgimeno noticed, the floor(x/32),floor(y/32) thing starts at (0,0), not (1,1), and your map data starts with the latter, since lua uses 1-based indices/indexing.
Solutions for that are many, either you can test for valid tiles, or edit your getTile function to return more correct values (like adding 1 to both tile indices).

Also, since you're using getTile inside the mousepressed callback, you don't need to get the mouse cursor's position yourself, just pass in two parameters to the function, x and y, that mousepressed already provides. (Though, you do need getPosition in love.draw)



As for your other issue, currently you don't really differentiate tiles by content at all, just by position, and whether they are empty or "filled", so i'd do something like the following:

Code: Select all


local map
local tileset
local tiles
local width, height
local tileW, tileH

function love.load()

	local tileset = love.graphics.newImage("/assets/countryside.png")
	local tilesetW, tilesetH = tileset:getDimensions()
	tileW, tileH = 32, 32

	tiles = {
		love.graphics.newQuad(0, 0, TileW, TileH, tilesetW, tilesetH),  -- 1 grass
		love.graphics.newQuad(32, 0, TileW, TileH, tilesetW, tilesetH), -- 2 box
		love.graphics.newQuad(0, 32, TileW, TileH, tilesetW, tilesetH), -- 3 flowers
		love.graphics.newQuad(32, 32, TileW, TileH, tilesetW, tilesetH) -- 4 boxtop
	}

	width, height = 4, 4 -- dimensions of the puzzle

	for i=1, width do
		map[i] = {}
		for j=1, height do
			map[i][j] = love.math.random(1,4) -- If 0, tile is empty, else the number denotes the image it will display, from the tiles table; we're randomizing here for no reason. :3
		end
	end

	-- A dirty hack, just make the last tile be empty
	--map[width][height] = 0
	-- A less dirty hack, make a random tile be empty
	map[love.math.random(1,width)][love.math.random(1,height)] = 0

end

function love.draw()

	-- mouse cursor location in tiles
	local mx, my = love.mouse.getPosition()
	local mi, mj = math.floor(mx*tileW)+1, math.floor(my*tileH)+1

	-- reset color to (fully opaque) white so we don't tint anything
	love.graphics.setColor(255,255,255,255)

	for i=1, width do
		for j=1, height do
			if map[i][j] > 0 then
				-- The (something-1) things are needed because we're indexing from 1, so if we don't, the first tile would have a topleft coordinate of (32,32) instead of (0,0)
				love.graphics.draw(tileset, tiles[map[i][j]], (i-1)*tileW, (j-1)*tileH)
				-- Since every tile you defined is "solid", we can have the highlight in here, instead of another loop
				love.graphics.rectangle('line', (i-1)*tileW, (j-1)*tileH, tileW, tileH)
			end
		end
	end

	-- Highlight tile that has cursor over with light blue; we need to do this after drawing the tiles, so it will be visible.
	love.graphics.setColor(0,0,255,32) -- semi-transparent blue
	love.graphics.rectangle('fill', (mi-1)*tileW, (mj-1)*tileH, tileW, tileH)

end

function love.mousepressed(mx, my, button)
	if button = 1 then -- left mouse button (lmb)
		local mi, mj = math.floor(mx*tileW)+1, math.floor(my*tileH)+1

		-- Check if we clicked on a solid tile
		if map[mi] and map[mi][mj] > 0 then -- indexing a nonexistent table member will probably error, that's why it's needed to check whether the first table exists or not, then check for the second that may be inside it

			-- Check around for space, and swap the tile contents.
			if     map[mi-1] and map[mi-1][mj] == 0 then map[mi][mj], map[mi-1][mj] == map[mi-1][mj], map[mi][mj]
			elseif map[mi+1] and map[mi+1][mj] == 0 then map[mi][mj], map[mi+1][mj] == map[mi+1][mj], map[mi][mj]
			elseif map[mi]   and map[mi][mj-1] == 0 then map[mi][mj], map[mi][mj-1] == map[mi][mj-1], map[mi][mj]
			elseif map[mi]   and map[mi][mj+1] == 0 then map[mi][mj], map[mi][mj+1] == map[mi][mj+1], map[mi][mj]
			end

		end
	end
end

And don't forget:
Knuth wrote:Beware of bugs in the above code; I have only proved it correct, not tried it.

Re: Sliding Tile Puzzle - Collision Bounds and Tileset

Posted: Wed Aug 24, 2016 10:22 pm
by straydogstrut
Thank you both for your replies and clear explanations.

I've been able to implement your suggestions and it works perfectly. I'm really really impressed!
zorg wrote:And don't forget:
Knuth wrote:Beware of bugs in the above code; I have only proved it correct, not tried it.
Your code was great; I only needed to make a few changes to get it working:

love.draw() and love.mousepressed(button, x, y)

These lines had a multiplication

Code: Select all

local column, row = math.floor(mousex*TileW)+1, math.floor(mousey*TileH)+1
However it should be a division like so:

Code: Select all

local column, row = math.floor(mousex/TileW)+1, math.floor(mousey/TileH)+1 
love.mousepressed(button, x, y)

The following line needed a check for the rows (y axis) as well as columns, otherwise I got a crash when clicking below the grid:

Code: Select all

if (Map[column] and Map[column][row] > 0) then
Should be:

Code: Select all

if (Map[column] and Map[row] and Map[column][row] > 0) then
These statements should be assignments rather than comparisons:

Code: Select all

Map[column][row], Map[column-1][row] == Map[column-1][row], Map[column][row]
Like so:

Code: Select all

Map[column][row], Map[column-1][row] = Map[column-1][row], Map[column][row] 
Thank you both for all your help.

Here is my complete code with the changes (i've made some variable names more verbose for my own readability)

Next on my list will be figuring out how to compare tables for a win condition and how to offset the starting position of the map so that it's drawn in the centre of the screen.

Code: Select all

local Map = {}
local Tileset
local Tiles
local Width, Height
local TileW, TileH

function love.load()

	Tileset = love.graphics.newImage("/assets/countryside.png") -- load the Tileset
	local TilesetW, TilesetH = Tileset:getDimensions() -- Tileset dimensions
	TileW, TileH = 32, 32 -- assign values to global tile width and height

	-- define the tile images from the Tileset using quads: tile left(x),tile top(y), width, height, tilset width, Tileset height
	Tiles = {
		love.graphics.newQuad(0, 0, TileW, TileH, TilesetW, TilesetH), -- 1 grass
		love.graphics.newQuad(32, 0, TileW, TileH, TilesetW, TilesetH), -- 2 box
		love.graphics.newQuad(0, 32, TileW, TileH, TilesetW, TilesetH), -- 3 flowers
		love.graphics.newQuad(32, 32, TileW, TileH, TilesetW, TilesetH) -- 4 boxtop
	}

	Width, Height = 4, 4 -- dimensions of the puzzle

	for i=1, Width do
		Map[i] = {}
		for j=1, Height do
			Map[i][j] = love.math.random(1,4) -- Generate a number between 1 and 4 - number denotes image displayed from the Tiles table
		end
	end

	Map[love.math.random(1,Width)][love.math.random(1,Height)] = 0 -- set a random tile to 0 (ie. empty)

end
 
function love.update(dt)
	
end
 
function love.draw()

	-- mouse cursor location
	local mousex, mousey = love.mouse.getPosition()
	local column, row = math.floor(mousex/TileW)+1, math.floor(mousey/TileH)+1 -- convert mouse coordinates to grid coordinates

	-- reset color to (fully opaque) white so we don't tint anything
	love.graphics.setColor(255, 255, 255)

	for column=1, Width do
		for row=1, Height do
			if Map[column][row] > 0 then
				-- The (something-1) things are needed because we're indexing from 1, so if we don't, the first tile would have a topleft coordinate of (32,32) instead of (0,0)
				love.graphics.draw(Tileset, Tiles[Map[column][row]], (column-1)*TileW, (row-1)*TileH)
				
				-- Since every tile you defined is "solid", we can have the highlight in here, instead of another loop
				love.graphics.rectangle('line', (column-1)*TileW, (row-1)*TileH, TileW, TileH)
			end
		end
	end
	
	-- debug info: print the column,row for the tile under the mouse cursor
	love.graphics.print(column .." , ".. row)
	
	-- set color to light blue to highlight tile under the mouse
	love.graphics.setColor(0, 0, 255, 32)
	love.graphics.rectangle('fill', (column-1)*TileW, (row-1)*TileH, TileW, TileH)

end
 

function love.mousepressed(mousex, mousey, button)
      
	if button == 1 then -- if lmb
   		-- if left mouse button pressed, get tile coordinates
    	local column, row = math.floor(mousex/TileW)+1, math.floor(mousey/TileH)+1 -- convert mouse coordinates to grid coordinates
      	
      	-- check we have clicked on a solid tile
      	if (Map[column] and Map[row] and Map[column][row] > 0) then -- ADDED Map[row] here

      		if(Map[column-1] and Map[column-1][row] == 0) then
      			Map[column][row], Map[column-1][row] = Map[column-1][row], Map[column][row]
      		elseif (Map[column+1] and Map[column+1][row] == 0) then
      			Map[column][row], Map[column+1][row] = Map[column+1][row], Map[column][row]
      		elseif (Map[column] and Map[column][row-1] == 0) then
      			Map[column][row], Map[column][row-1] = Map[column][row-1], Map[column][row]
      		elseif (Map[column] and Map[column][row+1] == 0) then
      			Map[column][row], Map[column][row+1] = Map[column][row+1], Map[column][row]
      		end
      	end
    end
end