checkLineOfSight() is BUGGED and needs to be fixed

Ask for help about creating mods and scripts for Grimrock 2 or share your tips, scripts, tools and assets with other modders here. Warning: forum contains spoilers!
Post Reply
MrChoke
Posts: 324
Joined: Sat Oct 25, 2014 7:20 pm

checkLineOfSight() is BUGGED and needs to be fixed

Post by MrChoke »

I reported a bug numerous times in the AI when the monster sees the party through DoorComponents and he should not be. Petri did comment on this issue once saying I must be using "sparse" doors. I was not. I asked for a confirmation of the bug, I did not get one. I have now found the bug is much broader than originally reported.

I decided to go back to this problem because I am sick of the garbage work around I had to do in my custom AI to avoid it. I thought let me use map:checkLineOfSight() on top of "brain:seesParty" to make sure the monster is really supposed to see the party. And I found that checkLineOfSight() is bugged too!! And its bugged in the exact same way. This tells me that the AI must be calling the same bugged code to calculate "seesParty". So the scope of the bug is much larger than a brain issue, it is in the map object itself. It is EASILY repeatable and clearly broken.

Below is a Dungeon.lua for the most simple dungeon you can get. A room, split in half by "castle_door_wood" objects. NOTE: castle_door_wood is not sparse. Just call door:getSparse() on one of them if you want to be sure. It returns false.

The party starts at (15, 19), against the line of doors and this is KEY. It seems to happen with squares next to doors. I set another square, (16, 13) as my target for the test. All you have to do is follow the row of pressure plates going north. Each calls map:checkLineOfSight(x, y, 16, 13, 0), where x and y is the current plate you just stepped on. You will see the following:

15, 18: true
15, 17: false
15, 16: true
15, 15: false
..... etc... all the way to 15,10

Why on earth would a call to determine whether you can "see" a square alternate true and false as you walk along the exact same line of doors. If this not the definition of bugged than I don't what is.

I feel a level of frustration for having reported this bug numerous times and have gotten no confirmation of it. Granted I did not realize it was actually on such a core object as "map". Perhaps now it will get the attention it needs.

Can I ask a favor of members of the modding community? Please download the below dungeon and confirm this. The more people that do, the better chance it will get fixed. For anybody doing custom AI or even anything where you have to check line of sight, it is an awful bug.

Can Pretri or someone at AH please look into this? Trying out the dungeon below should certainly show the problem.

Code: Select all

-- This file has been generated by Dungeon Editor 2.2.4

--- level 1 ---

newMap{
	name = "Unnamed",
	width = 32,
	height = 32,
	levelCoord = {0,0,0},
	ambientTrack = "dungeon",
	tiles = {
		"dungeon_floor",
		"dungeon_wall",
	}
}

loadLayer("tiles", {
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
	2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
})

spawn("starting_location",15,19,0,0,"starting_location")
spawn("torch_holder",11,15,3,0,"torch_holder_1")
torch_holder_1.controller:setHasTorch(true)
spawn("torch_holder",19,15,1,0,"torch_holder_2")
torch_holder_2.controller:setHasTorch(true)
spawn("torch_holder",12,20,2,0,"torch_holder_3")
torch_holder_3.controller:setHasTorch(true)
spawn("torch_holder",17,20,2,0,"torch_holder_4")
torch_holder_4.controller:setHasTorch(true)
spawn("torch_holder",12,10,0,0,"torch_holder_5")
torch_holder_5.controller:setHasTorch(true)
spawn("torch_holder",17,10,0,0,"torch_holder_6")
torch_holder_6.controller:setHasTorch(true)
spawn("castle_door_wood",16,20,3,0,"castle_door_wood_1")
spawn("castle_door_wood",16,19,3,0,"castle_door_wood_2")
spawn("castle_door_wood",16,18,3,0,"castle_door_wood_3")
spawn("castle_door_wood",16,17,3,0,"castle_door_wood_4")
spawn("castle_door_wood",16,16,3,0,"castle_door_wood_5")
spawn("castle_door_wood",16,15,3,0,"castle_door_wood_6")
spawn("castle_door_wood",16,14,3,0,"castle_door_wood_7")
spawn("castle_door_wood",15,13,1,0,"castle_door_wood_8")
spawn("castle_door_wood",15,12,1,0,"castle_door_wood_9")
spawn("castle_door_wood",15,11,1,0,"castle_door_wood_10")
spawn("castle_door_wood",16,10,3,0,"castle_door_wood_11")
spawn("spider_eggs_broken",16,13,0,0,"spider_eggs_broken_1")
spawn("dungeon_pressure_plate",15,18,3,0,"dungeon_pressure_plate_1")
dungeon_pressure_plate_1.floortrigger:setTriggeredByParty(true)
dungeon_pressure_plate_1.floortrigger:setTriggeredByMonster(true)
dungeon_pressure_plate_1.floortrigger:setTriggeredByItem(true)
dungeon_pressure_plate_1.floortrigger:setTriggeredByDigging(false)
dungeon_pressure_plate_1.floortrigger:setDisableSelf(false)
dungeon_pressure_plate_1.floortrigger:addConnector("onActivate", "script_entity_1", "plateActivate")
spawn("dungeon_pressure_plate",15,17,3,0,"dungeon_pressure_plate_2")
dungeon_pressure_plate_2.floortrigger:setTriggeredByParty(true)
dungeon_pressure_plate_2.floortrigger:setTriggeredByMonster(true)
dungeon_pressure_plate_2.floortrigger:setTriggeredByItem(true)
dungeon_pressure_plate_2.floortrigger:setTriggeredByDigging(false)
dungeon_pressure_plate_2.floortrigger:setDisableSelf(false)
dungeon_pressure_plate_2.floortrigger:addConnector("onActivate", "script_entity_1", "plateActivate")
spawn("dungeon_pressure_plate",15,16,3,0,"dungeon_pressure_plate_3")
dungeon_pressure_plate_3.floortrigger:setTriggeredByParty(true)
dungeon_pressure_plate_3.floortrigger:setTriggeredByMonster(true)
dungeon_pressure_plate_3.floortrigger:setTriggeredByItem(true)
dungeon_pressure_plate_3.floortrigger:setTriggeredByDigging(false)
dungeon_pressure_plate_3.floortrigger:setDisableSelf(false)
dungeon_pressure_plate_3.floortrigger:addConnector("onActivate", "script_entity_1", "plateActivate")
spawn("dungeon_pressure_plate",15,15,2,0,"dungeon_pressure_plate_4")
dungeon_pressure_plate_4.floortrigger:setTriggeredByParty(true)
dungeon_pressure_plate_4.floortrigger:setTriggeredByMonster(true)
dungeon_pressure_plate_4.floortrigger:setTriggeredByItem(true)
dungeon_pressure_plate_4.floortrigger:setTriggeredByDigging(false)
dungeon_pressure_plate_4.floortrigger:setDisableSelf(false)
dungeon_pressure_plate_4.floortrigger:addConnector("onActivate", "script_entity_1", "plateActivate")
spawn("script_entity",0,0,0,0,"script_entity_1")
script_entity_1.script:setSource("function plateActivate(ft)\
\9local b = party.map:checkLineOfSight(ft.go.x, ft.go.y, 16, 13, 0)\
\9print(\"Plate: (\"..ft.go.x..\", \"..ft.go.y..\"), to (16,13), lineOfSight=\"..tostring(b))\9\
end")
spawn("dungeon_pressure_plate",15,14,2,0,"dungeon_pressure_plate_5")
dungeon_pressure_plate_5.floortrigger:setTriggeredByParty(true)
dungeon_pressure_plate_5.floortrigger:setTriggeredByMonster(true)
dungeon_pressure_plate_5.floortrigger:setTriggeredByItem(true)
dungeon_pressure_plate_5.floortrigger:setTriggeredByDigging(false)
dungeon_pressure_plate_5.floortrigger:setDisableSelf(false)
dungeon_pressure_plate_5.floortrigger:addConnector("onActivate", "script_entity_1", "plateActivate")
spawn("dungeon_pressure_plate",15,13,1,0,"dungeon_pressure_plate_6")
dungeon_pressure_plate_6.floortrigger:setTriggeredByParty(true)
dungeon_pressure_plate_6.floortrigger:setTriggeredByMonster(true)
dungeon_pressure_plate_6.floortrigger:setTriggeredByItem(true)
dungeon_pressure_plate_6.floortrigger:setTriggeredByDigging(false)
dungeon_pressure_plate_6.floortrigger:setDisableSelf(false)
dungeon_pressure_plate_6.floortrigger:addConnector("onActivate", "script_entity_1", "plateActivate")
spawn("dungeon_pressure_plate",15,12,3,0,"dungeon_pressure_plate_7")
dungeon_pressure_plate_7.floortrigger:setTriggeredByParty(true)
dungeon_pressure_plate_7.floortrigger:setTriggeredByMonster(true)
dungeon_pressure_plate_7.floortrigger:setTriggeredByItem(true)
dungeon_pressure_plate_7.floortrigger:setTriggeredByDigging(false)
dungeon_pressure_plate_7.floortrigger:setDisableSelf(false)
dungeon_pressure_plate_7.floortrigger:addConnector("onActivate", "script_entity_1", "plateActivate")
spawn("dungeon_pressure_plate",15,11,2,0,"dungeon_pressure_plate_8")
dungeon_pressure_plate_8.floortrigger:setTriggeredByParty(true)
dungeon_pressure_plate_8.floortrigger:setTriggeredByMonster(true)
dungeon_pressure_plate_8.floortrigger:setTriggeredByItem(true)
dungeon_pressure_plate_8.floortrigger:setTriggeredByDigging(false)
dungeon_pressure_plate_8.floortrigger:setDisableSelf(false)
dungeon_pressure_plate_8.floortrigger:addConnector("onActivate", "script_entity_1", "plateActivate")
spawn("dungeon_pressure_plate",15,10,2,0,"dungeon_pressure_plate_9")
dungeon_pressure_plate_9.floortrigger:setTriggeredByParty(true)
dungeon_pressure_plate_9.floortrigger:setTriggeredByMonster(true)
dungeon_pressure_plate_9.floortrigger:setTriggeredByItem(true)
dungeon_pressure_plate_9.floortrigger:setTriggeredByDigging(false)
dungeon_pressure_plate_9.floortrigger:setDisableSelf(false)
dungeon_pressure_plate_9.floortrigger:addConnector("onActivate", "script_entity_1", "plateActivate")
Thank you
alois
Posts: 112
Joined: Mon Feb 18, 2013 7:29 am

Re: checkLineOfSight() is BUGGED and needs to be fixed

Post by alois »

Confirmed (also moving both the "target point" and the pressure plates). Maybe the problem is in the algorithm used to compute the "line" between the two points. I mean: if you have to go from (0,0) to (1,2), you can either do (0,0) -> (0,1) -> (1,2) (diagonally) or (0,0) -> (1,1) -> (1,2), but the algorithm chooses only one path. If there is a door in square (0,1), and you "move" along the other path, you will never see that door, and viceversa (you encounter a door while "moving", but there is another path which allows you to "see" the target).

alois :)
User avatar
petri
Posts: 1917
Joined: Thu Mar 01, 2012 4:58 pm
Location: Finland

Re: checkLineOfSight() is BUGGED and needs to be fixed

Post by petri »

Thanks for the bug report! I added this to my bug list.

The fastest way to workaround this issue is to make a custom Lua script with a custom version of checkLineOfSight that first calls the built-in checkLineOfSight and if it returns true do an extra check for doors along the line. You can use Map:entitiesAt() to check for doors in the squares on the line.
MrChoke
Posts: 324
Joined: Sat Oct 25, 2014 7:20 pm

Re: checkLineOfSight() is BUGGED and needs to be fixed

Post by MrChoke »

petri wrote:Thanks for the bug report! I added this to my bug list.

The fastest way to workaround this issue is to make a custom Lua script with a custom version of checkLineOfSight that first calls the built-in checkLineOfSight and if it returns true do an extra check for doors along the line. You can use Map:entitiesAt() to check for doors in the squares on the line.
Thanks Petri! I am glad it will be addressed. Please note, checkLineOfFire() has the same problem as well.

Regarding work-around, I think I have a decent one. One more comment on the bug first though. The erroneous behavior is different (and not as bad) if the distances between the two points is greater than 1. In that case, it returns true correctly EXCEPT when the two points are at a direct diagonal from each other. Then it will return true through doors that it should not. The distance between the points doesn't matter.

My work-around takes the erroneous behavior in mind. If checkLineOfSight() returns true when directly using the points, then I do checkLineOfSight two more times. I determine the two adjacent squares to point 2 that are in the direction toward point 1. I then call checkLineOfSights from point 1 to each of these squares. If either of these return FALSE then it means that the TRUE given in the first call was bad. By checking the other two squares, at least one of them will fall into the logic in checkLineOfSights() that is not bugged. I'll post some code later today.

Anyway, thanks for the update.
alois
Posts: 112
Joined: Mon Feb 18, 2013 7:29 am

Re: checkLineOfSight() is BUGGED and needs to be fixed

Post by alois »

Maybe an easier thing is to check twice with checkLineOfSight: if (px,py) is one point and (qx,qy) is the other one, then do

Code: Select all

     local pq = party.map:checkLineOfSight(px,py,qx,qy,0)
     local qp = party.map:checkLineOfSight(qx,qy,px,py,0)
     local notblocked = pq and qp
In your example, the answer is always "false", and indeed the doors are there...

Alois :)
MrChoke
Posts: 324
Joined: Sat Oct 25, 2014 7:20 pm

Re: checkLineOfSight() is BUGGED and needs to be fixed

Post by MrChoke »

So my new work around works well. The monster will now see the party only when it should. My garbage code before had me setting a threshold of X number of turns that he sees the party before I consider him seeing them. But that stunk because I made the AI less responsive than he should be.

Anyway here is a replacement function to give a good brain.seesParty value if anybody wants it. It calls checkLineOfSight() two extra times as explained above.

Code: Select all

function customSeesParty(mobGO)
    if mobGO.brain.seesParty then   -- Still need brain.seesParty because it takes mob facing into account
        -- Game is broke.  It returns true in error because checkLineOfSight is broken.
        local dx, dy
        local dirTbl = lib_scr.script.getOrthDirectionsFromPoints(party.x, party.y, mobGO.x, mobGO.y)
        -- check each square adjacent to party but in the direction to mob and only if BOTH are also true do we consider party seen
        for i = 1, #dirTbl do
            dx, dy = getForward(dirTbl[i])
            if not mobGO.map:checkLineOfSight(mobGO.x, mobGO.y, (party.x + dx), (party.y + dy), mobGO.elevation) then
                return false
            end
        end
        
        return true
    else
        return false
    end    
end
I use one of my helper functions, getOrthDirectionsFromPoints() to give me the two directions that are valid for a diagonal (example: point 2 is northeast of point 1. The valid orthogonal directions that get you closer to point 2 from point 1 are: 0 and 1)

Code: Select all

function getOrthDirectionsFromPoints(x1, y1, x2, y2)
    local dx = (x2 - x1)
    local dy = (y2 - y1)
    local absDX = math.abs(dx)
    local absDY = math.abs(dy)
    
    if dx < 0 then
        if dy < 0 then
            if absDX >= absDY then
                return {3, 0}
            else
                return {0, 3}
            end            
        elseif dy > 0 then
            if absDX > absDY then
                return {3, 2}
            else
                return {2, 3}            
            end         
        else
            return {3}
        end
    elseif dx > 0 then
        if dy < 0 then
            if absDX > absDY then
                return {1, 0}
            else
                return {0, 1}           
            end
        elseif dy > 0 then
            if absDX >= absDY then
                return {1, 2}
            else
                return {2, 1}            
            end
        else
            return {1}              
        end
    else
        if dy < 0 then            
            return {0}      
        elseif dy > 0 then           
            return {2}      
        else
            -- same square, dx and dy = 0
            return nil
        end
    end  
end
User avatar
petri
Posts: 1917
Joined: Thu Mar 01, 2012 4:58 pm
Location: Finland

Re: checkLineOfSight() is BUGGED and needs to be fixed

Post by petri »

Nice!

Optimization tip: precompute tables returned by getOrthDirectionsFromPoints and store them in a script level global table. Then your getOrthDirectionsFromPoints just becomes a table lookup:

Code: Select all

dirs[math.sign(dy+1)*3+math.sign(dx)+2].
MrChoke
Posts: 324
Joined: Sat Oct 25, 2014 7:20 pm

Re: checkLineOfSight() is BUGGED and needs to be fixed

Post by MrChoke »

Very good idea. I do need to think optimization more as I go.

Regarding your algorithm, it uses math.sign(). I don't see that in the LUA reference. Maybe you guys made a custom function. I see it always returns 1, 0, or -1. The thing is though, the algorithm you wrote doesn't quite work. It returns the same value of 3 for both y=0 and y=1. That is because math.sign() returned 1 for both 0+1 and 1+1.

I know what you were trying to achieve though. You want a single index, range 1-9 returned for all 9 X,Y combinations. I think you meant this algorithm instead:

Code: Select all

dirs[(math.sign(dy)*3+3)+math.sign(dx)+2]

Thanks for the suggestion.
MrChoke
Posts: 324
Joined: Sat Oct 25, 2014 7:20 pm

Re: checkLineOfSight() is BUGGED and needs to be fixed

Post by MrChoke »

In case anybody ever wants it, below is the optimized getOrthDirectionsFromPoints() function. The one thing I did add to the algorithm approach is setting the sort of the table returned. From observation, the built-in getDirection(dx, dy) function always returns a direction value that is the greater delta. I had to beef up the function a bit in order to keep that functionality.

Code: Select all

--Tables are populated in initLib.  It is used by getOrthDirectionsFromPoints() below
orthDirTblX = {}
orthDirTblY = {}

function getOrthDirectionsFromPoints(x1, y1, x2, y2)
    local dx = (x2 - x1)
    local dy = (y2 - y1)
    local absDX = math.abs(dx)
    local absDY = math.abs(dy)
    
    if absDX > absDY then
        return orthDirTblX[(math.sign(dy)*3+3)+math.sign(dx)+2]
    else
        return orthDirTblY[(math.sign(dy)*3+3)+math.sign(dx)+2]
    end    
end

function initLib()
    --init orthDirTbl for getOrthDirectionsFromPoints
    -- Note, this table order is always X-dir first (1 or 3)
    orthDirTblX[1] = {3, 0}  -- (-1, -1) 
    orthDirTblX[2] = {0}     -- (0, -1)
    orthDirTblX[3] = {1, 0}  -- (1, -1), 
    orthDirTblX[4] = {3}     -- (-1, 0)
    orthDirTblX[5] = nil     -- (0, 0)
    orthDirTblX[6] = {1}  -- (1, 0)
    orthDirTblX[7] = {3, 2}  -- (-1, 1)
    orthDirTblX[8] = {2}  -- (0, 1)
    orthDirTblX[9] = {1, 2}  -- (1, 1)    
    -- Note, this table order is always Y-dir first (0 or 2)
    orthDirTblY[1] = {0, 3}  -- (-1, -1) 
    orthDirTblY[2] = {0}     -- (0, -1)
    orthDirTblY[3] = {0, 1}  -- (1, -1), 
    orthDirTblY[4] = {3}     -- (-1, 0)
    orthDirTblY[5] = nil     -- (0, 0)
    orthDirTblY[6] = {1}  -- (1, 0)
    orthDirTblY[7] = {2, 3}  -- (-1, 1)
    orthDirTblY[8] = {2}  -- (0, 1)
    orthDirTblY[9] = {2, 1}  -- (1, 1)        
    
end

initLib()
Post Reply