While working on my kick-ass stealth-tactical platformer game (built on Flixel and its super cool power tools), I came across the need for a limited vision system. In other words, enemies need to have a limited vision cone. I also needed a very fast method that could support the multitude of enemies in a platformer. Because games are worth a thousand words, here’s a demo of the final vision system.
For the sake of your own sanity let’s assume the view origins from a single point, and not two distinct eyes. It still looks good anyways. The last assumption is that the player is a circle. You’ll understand later why. But for now, try to fit your character into a circle and mark its radius.
Not so bad, right? The character’s sprite is 24*24 pixels. If there are any imprecisions they’re pretty much off by a pixel or two, so don’t obsess over it. What we need is cheap, fast vision checking.
Back to circles. Circles are awesome. Circle-circle collision is like the best thing ever. All you need to do is check if the distance between both centers is smaller than the sum of both radiuses.
Okay, now let me outline the general steps for checking collision between the vision cone and the player’s circle.
- Check if both circles collide. Circle-circle collision.
- Check if the player’s circle’s center is between the two delimiting lines.
- Check if the player’s circle’s center’s distance to either one of the lines is smaller than the player’s circle’s radius. In short, circle-line collision.
- Check if the line from the observer’s eye towards the player collides with the tilemap, in which case the view is obstructed.
The player is visible if 1,4, and 2 or 3 return true.
Take note that these steps go from least computationally expensive to most expensive, this is plain common sense.
So here’s the class I used for the game. It also handles drawing! However I write code like crap when I’m in a hurry, so it’s not neat. It does need a bit of adjustment for use in another game, but most stuff to change should be picked up by the compiler should you try to adapt it. If there is a demand for an in-depth explanation of the 4 vision system steps, I’ll try to expand on this tutorial as much as I can. Also I pretty much omitted the drawing part which is crucial after all. Cheers!
package
{
import flash.display.Sprite;
import flash.geom.Point;
import org.flixel.FlxPoint;
import org.flixel.FlxSprite;
import org.flixel.FlxTilemap;
import flash.display.Shape;
import flash.display.BitmapData;
import flash.display.Bitmap;
public class Vision extends FlxSprite
{
public var state:Level;
public var radius:Number;
public var fov:Number;
public var zangle:Number;
public var parent;
public var xoffset:Number;
public var yoffset:Number;
public var view:Sprite;
public var vheight:int;
public var deg_to_rad:Number = 0.0174532925;
public function Vision(State:Level, Radius:Number, FOV:Number, Angle:Number, Parent, Xoffset:int, Yoffset:int)
{
state = State;
radius = Radius;
fov = FOV;
zangle = Angle;
parent = Parent;
x = parent.x;
y = parent.y;
xoffset = Xoffset;
yoffset = Yoffset;
view = new Sprite();
vheight = int(Math.tan(fov / 2 * deg_to_rad) * radius) * 2;
//trace("vheight", vheight);
view.graphics.lineStyle(1, 0xff910000);
view.graphics.beginFill(0xFF0000,0.35);
var finishp:Point = draw_arc(view, parent.width/2, vheight/2, parent.visionlength, -fov/2, fov/2, 1);
view.graphics.lineTo(parent.width/2, vheight/2);
view.graphics.lineTo(finishp.x, finishp.y);
height = vheight;
width = parent.visionlength*1.5;
var b:BitmapData = new BitmapData( parent.visionlength*1.5, vheight, true, 0x00000000);
b.draw(view,null,null,null,null,true);
pixels = b;
state.views.add(this);
origin = new FlxPoint(xoffset, vheight / 2);
//fov += 90;
}
override public function update():void
{
var parentradius:Number = parent.visionlength * parent.vision;
if (parentradius != radius)
{
scale = new FlxPoint(parentradius/radius,parentradius/radius);
radius = parent.visionlength * parent.vision;
}
x = parent.x;
y = parent.y - vheight/2 + yoffset;
}
public function draw_arc(movieclip,center_x,center_y,radius,angle_from,angle_to,precision):Point {
var angle_diff = angle_to - angle_from;
var deg_to_rad=0.0174532925;
var steps=Math.round(angle_diff*precision);
var angle=angle_from;
var px=center_x+radius*Math.cos(angle*deg_to_rad);
var py = center_y + radius * Math.sin(angle * deg_to_rad);
var initp;
//movieclip.graphics.lineTo(center_x, center_y);
movieclip.graphics.moveTo(px, py);
for (var i:int = 1; i <= steps; i++) {
angle=angle_from+angle_diff/steps*i;
movieclip.graphics.lineTo(center_x + radius * Math.cos(angle * deg_to_rad), center_y + radius * Math.sin(angle * deg_to_rad));
if (i == 1) initp = new Point(center_x + radius * Math.cos(angle * deg_to_rad), center_y + radius * Math.sin(angle * deg_to_rad));
}
return initp;
}
public function checkIfSee():Boolean
{
if (checkCircle())
{
var deg_to_rad=0.0174532925;
var playerpos:FlxPoint = state.player.getMidpoint();
var relativep:FlxPoint = new FlxPoint((playerpos.x - (x)), (playerpos.y - (y + vheight/2)));
var theta:Number = (zangle) * deg_to_rad;
var rotatedp:Point = new Point(0,0);
rotatedp.x = Math.cos(theta) * relativep.x - Math.sin(theta) * relativep.y;
rotatedp.y = Math.sin(theta) * relativep.x + Math.cos(theta) * relativep.y;
if (inBetween(rotatedp))
{
if (checkRay()) return true;
}
if (touchingSight(rotatedp))
{
if (checkRay()) return true;
}
}
return false;
}
public function checkRay():Boolean
{
var playerpos:FlxPoint = state.player.getMidpoint();
if (state.collidemap.ray(new FlxPoint(x, y + vheight/2), new FlxPoint(playerpos.x, playerpos.y), null, 1)) return true;
else return false;
}
public function checkCircle():Boolean
{
var playerpos:FlxPoint = state.player.getMidpoint();
var eyepos:FlxPoint = new FlxPoint(x + xoffset, y + yoffset);
if (PtoPdist2(eyepos, playerpos) < (state.player.RADIUS + radius)*(state.player.RADIUS + radius))
{
//trace("Alert!");
return true;
}
else
{
return false;
}
}
static public function PtoPdist2(p1:FlxPoint, p2:FlxPoint):Number
{
return ((p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y));
}
public function inBetween(rotatedp:Point):Boolean
{
if ((rotatedp.y < Math.tan(fov * deg_to_rad / 2) * rotatedp.x) && (rotatedp.y > -1 * Math.tan(fov* deg_to_rad / 2) * rotatedp.x))
{
return true;
}
return false;
}
public function touchingSight(rotatedp:Point):Boolean
{
var point1:Point = new Point(radius, radius*(Math.tan(fov * deg_to_rad/ 2)));
var point2:Point = new Point(radius, radius*( -1 * Math.tan(fov * deg_to_rad/ 2)));
var dist1:Number = segmentDistToPoint(new Point(0, 0), point1, rotatedp);
//trace("dist1: ", dist1);
if (dist1 < state.player.RADIUS)
{
//trace("dist1: ", dist1);
return true;
}
var dist2:Number = segmentDistToPoint(new Point(0, 0), point2, rotatedp);
if (dist2 < state.player.RADIUS) { //trace("dist2: ", dist2); return true; } return false; } public static function segmentDistToPoint(segA:Point, segB:Point, p:Point):Number { var p2:Point = new Point(segB.x - segA.x, segB.y - segA.y); var something:Number = p2.x*p2.x + p2.y*p2.y; var u:Number = ((p.x - segA.x) * p2.x + (p.y - segA.y) * p2.y) / something; if (u > 1)
u = 1;
else if (u < 0)
u = 0;
var x:Number = segA.x + u * p2.x;
var y:Number = segA.y + u * p2.y;
var dx:Number = x - p.x;
var dy:Number = y - p.y;
var dist:Number = Math.sqrt(dx*dx + dy*dy);
return dist;
}
}
}