Robot 41
I think we need to improve the map display a bit. We’ll spike, and that’s going to prove a point that I made yesterday, I hope. Or disprove, in which case we can laugh at me hahaha.
In testing the game on screen, I’ve noticed something that seems important. The “radar” is inactive. That is, you have to do a “look” command, and when you do, you get a scan of the column and row you’re in (and nothing else). The radar remembers what it has seen, so that you can build up a picture of the world. (Of course, if there are other robots, they can move, so that there are at least some things that could come into view without your knowing.)
I’ve noticed, in moving around, that I forget to look, with the result that there could be something in front of me that I’m not aware of. My plan is to change the display so that a cell that has been scanned and seen empty looks different from a cell that has never been scanned.
In the picture above, I’ve scanned the two rows that show obstacles and pits … but I have also scanned the column that the robot is in, but there’s no indication of that. My plan is simple:
Change the display such that cells that have been looked at but are empty have a lighter display from cells that have never been scanned.
The question is … how should we do this?
Quick Design Session
One of the things that I find valuable is to have a “quick design session” before starting some story. This is particularly useful when pairing or working in ensemble, so that everyone is on the same page. But it’s also useful whenever a solo programmer sees multiple ways to do the story, or sees no good ways.
In short, think.
Among my thinks, I think I’d like to review how the display of a cell works now.
function drawCell(x,y, size)
local fact = CurrentRobot:factAt(x,y)
local gd = GridDisplay:display(fact)
if gd.color then
fill(gd.color)
else
noFill()
end
rect(x*size,y*size,size,size)
if gd.text then
fill(0,256,0)
text(gd.text, x*size,y*size)
end
if gd.asset then
pushMatrix()
scale(0.8, 0.8)
sprite(gd.asset,x,y)
popMatrix()
end
end
We get the fact at that location—no provision for more than one—and create a GridDisplay for it, which we then interpret in various ways to display the thing. (This is not brilliant code. Why aren’t we asking the GridDisplay to display itself? I’m glad we had this little think.) Anyway, what does GridDisplay do?
Reading GridDisplay answers some questions: GridDisplay isn’t a class providing instances. Instead it just returns little tables:
function GridDisplay:display(content)
if content == "OBSTACLE" then
return {color=color(0,256,0,128)}
elseif content == "PIT" then
return { color=color(0)}
elseif content and content:sub(1,1) == "R" then
local a = self:assetInDirection(content)
return {asset=a, color = color(0,256,0,48)}
else
return {color=color(0,256,0,48)}
end
end
Mostly, it returns a table specifying a color. And we notice that it has a default, which is good, because factAt
can return nil:
function Knowledge:factAt(x,y)
for i,fact in ipairs(self.facts) do
if fact.x == x and fact.y == y then return fact.content end
end
return nil
end
We see that the “facts” are just triples with x, y, and content, and that if there is no fact at a given location, we return nil.
So … what if when we do a “look”, when we look at a cell, if it has no fact, we give it a new fact, indicating that it has been scanned?
Let’s look at look
:
Here’s the code that creates the information LookPacket
that we accumulate and return from look
:
-- createPacketFor and directionTo
-- are in progress and need to support turns
-- seems like feature envy of some kind
function World:createPacketFor(lens,x,y)
local content = lens:factAt(x,y)
if not content then return nil end
local dir = self:directionTo(x,y)
local distance = math.max(math.abs(x), math.abs(y))
return LookPacket(dir, distance, content)
end
I include those comments because they are germane. I don’t usually comment much at all, but I do like to leave clues in the code as to where trouble lurks. We’ll not deal with those, but the comments certainly raise my hackles a bit: I’d best be careful.
Let’s try an experiment. Let’s add a new fact when there is no content …
No! We’re on the wrong track. We don’t want to look at World. Our problem is local to the robot.
Could we do something in the Robot’s look? Well, at first glance, no. Robot look sends the request to the World and gets back only packets where there are things. So we’ll do a look and get back, in the case of our sample world, something like
W,5,OBSTACLE
E,3,PIT
The LookPacket class converts those to relative x and y coordinates, such as
{x=-5,y=0,OBSTACLE}
{x=3, y=0,PIT}
And we tuck those into our Knowledge, via our current Lens.
This Is More Interesting Than I Thought
Now it happens that we don’t know the order that things come back in. We don’t even know whether the look command will or won’t return information on the far side of obstacles or pits. (We know what we implemented, but the spec isn’t clear.)
We should probably be conservative, assuming that the scan stops at whatever thing is provided. So … I hypothesize that if we are given a packet like
x=-5,y=0,OBSTACLE
We can legitimately mark all the cells from -1 to -4 as scanned but we shouldn’t mark -6 and so on. What about rows or columns where we get no return ping? We don’t even know how large the world is. How far should our indication extend?
What if we take a different approach? What if, rather than mark cells, we were to display a line across a row we have scanned, and up and down any column?
Atypically, I’ve looked at the spec. When you launch a robot, you get back some important information. Values include visibility, reload time, repair time, mine-setting time, and maximum shield strength. (That last is unclear in the spec.) The important one for us is visibility, the maximum distance you can see when doing a look. So that’s useful … we can use that figure to limit how far we mark cells as seen.
I’m rather glad that I finally looked. I’m sure I must have read or scanned that fact, but I certainly didn’t remember it.
So … what if when we do a look, we were to begin by loading a “scanned” fact into the row and column we’re in, out to the limit of visibility? One problem with that is that as it’s written right now, you can’t have two facts in the same location. Well, you can, but the second one will never be found.
Let’s see what facts really look like. I don’t think we have a Fact class yet.
Well … we have and we haven’t:
function Knowledge:addFactAt(content, x,y)
self:privateaddFactAt(Fact(x,y,content))
end
function Knowledge:factAt(x,y)
for i,fact in ipairs(self.facts) do
if fact.x == x and fact.y == y then return fact.content end
end
return nil
end
We’ve created and stored a Fact:
local Fact = class()
function Fact:init(x,y,content)
self.x = x
self.y = y
self.content = content
end
But when we do factAt
, we just return the content. Maybe that’s OK, we’ll find out.
A Plan Emerges
Here’s a sketch of a notion of an idea for a plan. What if, before we do a look, we create LOOK facts out to the visibility distance in all directions. Then we do the look, which will store some new facts, trying to overwrite the old ones. We’ll have a bug where the look stops working. We’ll fix it.
I’m going to spike that. I’m on a clean repo, so I’m safe to revert when this is over.
A Spike
I’ll start with the display, producing a new color.
function GridDisplay:display(content)
if content == "OBSTACLE" then
return {color=color(0,256,0,128)}
elseif content == "PIT" then
return { color=color(0)}
elseif content and content:sub(1,1) == "R" then
local a = self:assetInDirection(content)
return {asset=a, color = color(0,256,0,48)}
else
return {color=color(0,256,0,48)}
end
end
Let’s just add this:
function GridDisplay:display(content)
if content == "OBSTACLE" then
return {color=color(0,256,0,128)}
elseif content == "LOOK" then
return {color=color(0,256,0, 172)}
elseif content == "PIT" then
return { color=color(0)}
elseif content and content:sub(1,1) == "R" then
local a = self:assetInDirection(content)
return {asset=a, color = color(0,256,0,48)}
else
return {color=color(0,256,0,48)}
end
end
So far so good. We can test and commit that if we want to, but let’s not, we might change the whole idea. It’ll be easy to do again if this works.
Now in the robot’s look:
function Robot:look()
local callback = function(response) self:lookCallback(response) end
self._world:look(self._name,callback)
end
Let’s mark visibility:
function Robot:look()
self:markVisibility()
local callback = function(response) self:lookCallback(response) end
self._world:look(self._name,callback)
end
And implement that:
local visibility = 8
function Robot:markColumnVisibility()
for y = -visibility,visibility do
if y ~= 0 then
self:addFactAt("LOOK", 0,y)
end
end
end
function Robot:markRowVisibility()
for x = -visibility,visibility do
if x ~= 0 then
self:addFactAt("LOOK", x,0)
end
end
end
function Robot:markVisibility()
self:markRowVisibility()
self:markColumnVisibility()
end
This, surprisingly enough, works. After I do a look from the starting position, I get this picture:
We might want to tweak the color, but that’s doing what I expected. But of course what we’ve seen doesn’t show up, because we don’t handle multiple facts per cell. The base addition looks like this:
function Knowledge:addFactAt(content, x,y)
self:privateaddFactAt(Fact(x,y,content))
end
function Knowledge:privateaddFactAt(aFact)
table.insert(self.facts, aFact)
end
What if we were to insert new facts at the beginning of the table instead of the end? That would make recent facts override old facts. Sounds about right.
function Knowledge:privateaddFactAt(aFact)
table.insert(self.facts, 1, aFact)
end
That works a treat. Here’s the same look again:
I like this. When I move, the cell under me isn’t marked. We should let it get marked, just once:
function Robot:markRowVisibility()
for x = -visibility,visibility do
self:addFactAt("LOOK", x,0)
end
end
I left the check for zero in markColumnVisibility
, just to spare me the double marks. This is just a spike, why am I being so careful?
Let me work on the color. I think I’d like the color to be lighter than the background but not that bright. I think I want it between background and obstacle.
This
function GridDisplay:display(content)
if content == "OBSTACLE" then
return {color=color(0,256,0,128)}
elseif content == "LOOK" then
return {color=color(0,256,0, 96)}
elseif content == "PIT" then
return { color=color(0)}
elseif content and content:sub(1,1) == "R" then
local a = self:assetInDirection(content)
return {asset=a, color = color(0,256,0,48)}
else
return {color=color(0,256,0,48)}
end
end
looks about right:
Spike complete. Let’s reflect.
Reflection
The first thing with a spike like this, for me, is that I want to keep it. The “rule” is that when we do a spike, we always throw it away. The good news here is that we have some broken tests that are seeing LOOK facts and previously expected nil facts. So unless I want to chase those, I’ve got to toss it.
I gather my strength and do the revert right now. There, we’re safe.
What have we learned?
- Color green transparency 96 looks pretty good;
- Using a Fact will work but we have to deal with duplicate facts somehow. (There is an ignored test about this, by the way, in case you’ve forgotten.)
- We need to fetch visibility from World.
- World needs to provide visibility.
- We should probably default visibility in case no one provides it.
- Don’t trust Ron to do everything perfectly.
As I reflect—that’s why we’re here after all—I wonder whether it would be better to have the visibility in a separate table. Or perhaps as a separate flag in a Fact.
I think I’d avoid separate tables, because of the need to maintain the Lens-Knowledge relationship. It would be easy enough but I see no big advantage to duplicating the tables.
We should probably be taking more advantage of the Fact object. Currently we just return its content but we could certainly extend Knowledge and Lens to have a few different fetching and storing methods. Fact could include both content and visibility, separately, for example. Of course, existence of a fact implies visibility, doesn’t it, since we only learn facts from doing the look.
The trick of pushing new facts to the top of the list is clever, but not smart. We should decide what to do about more than one fact per cell and implement it directly rather than by luck or implicit behavior.
Keeping my promise
What we have seen, however, is what I alluded to in the blurb: the fact that the implementation of Knowledge keeps its storage structure to itself means that we can change how it works with impunity. We reversed the order of its table and no one noticed. (The tests that failed were not due to that but to the “fact” that we store new facts that the tests didn’t anticipate.)
This is one reason why it’s generally a good idea to cover even simple collections with objects: we will surely find that they don’t quite want to be simple.
Summary
We’ll call it a morning with this spike, which has had very happy results, and soon, probably next time, we’ll implement something at least this good, with this experience fresh in our “mind”.
See you then!