Drop in Camera and Selection tools for RTS-type games

This is a small package that provides a blackbox camera creation and obj/group selection for RTS games or basically any game that uses a z=0 ground plane.

What I mean by blackbox is that you only have to add 2 lines of code to your program to get a camera that orbits with right click, edge pans, drag-select pan (with shift+leftmouse), group obj, individ/obj select, and ctrl select to add to an existing group. It only uses basic trig so it should be compatible with most (all?) programs. (The only problem is that it doesn’t do pixel perfect object selection, but most commericial games don’t either.)

It’s EXTREMELY easy to use so it’s good for newbies like me!

The original ideas came from piratepanda and Ninth’s rts camera discourse.panda3d.org/viewtopic … ht=camlens.

Usage: Save the code as GuiFuncs.py, and move to your progam directory.

Example 1:
In tut-5 of the solar system tutorial add:

w = world()

#new lines
from GuiFuncs import *
#Add the nodepaths that we want to be selectable to the selection tool
objSelectionTool.listConsideration = [w.moon, w.sun, w.mercury, w.mars, w.venus, w.earth]

run()

##(selected objs are placed in objSelectionTool.listSelected)
##rewrite clSelectionTool.func* to do your own selected item display
##Check guifuncs.py for behind the scenes stuff, main objs are
##builtin.objCameraHandler = clCameraHandler()
##builtin.objSelectionTool = clSelectionTool()
##builtin.objKeyBoardModifiers = clKeyBoardModifiers()

Example #2:
To add a new persepctive to ThomasEgi’s awesome cuboid clone discourse.panda3d.org/viewtopic.php?t=7506

In cuboid.py, comment out line 70
#taskMgr.add(self.cameraMovement,“CameraTask”)

Add
world()

from GuiFuncs import *

run()

import __builtin__
from direct.showbase import DirectObject
from direct.task import Task
import libpanda
from pandac.PandaModules import Vec4, CardMaker, LineSegs, Point2, Point3

import math

class clCameraHandler(DirectObject.DirectObject):
	def __init__(self, tupCameraPos=(0, 0, 45), tupCameraDir=(0, -90, 0),  tupThetaLimit=(5, 90), booDebug=False): 

		# Gives the camera an initial position and rotation.
		self.booDebug = booDebug
		self.booOrbiting = False
		self.booDragPanning = False
		self.fEdgeBound = 0.95
		self.fLastMouseX, self.fLastMouseY = 0, 0
		self.fDragPanRate = 30
		self.fPanRate = 1
		self.fPhiRate = 100
		self.fThetaRate = 35
		self.fZoomRate = 1.3

		self.tupCurrentCameraPos = tupCameraPos
		self.tupCurrentCameraDir = tupCameraDir
		self.tupInitCameraDir = tupCameraDir
		self.tupInitCameraPos = tupCameraPos
		self.tupThetaLimit = tupThetaLimit

		if not self.booDebug:
			self.accept("mouse3", self.StartCameraOrbit)
			self.accept("mouse3-up", self.StopCameraOrbit)
			self.accept("wheel_down", self.ZoomCameraOut)
			self.accept("wheel_up", self.ZoomCameraIn)
			self.accept("shift-mouse1", self.StartDragPan)
			self.accept("mouse1-up", self.StopDragPan)
			taskMgr.add(self.CamTask,'CamTask')
			self.GoHome()

	def CamTask(self, task):
		if base.mouseWatcherNode.hasMouse():
			mpos = base.mouseWatcherNode.getMouse()
			t_mpos_x = mpos.getX()
			t_mpos_y = mpos.getY()
			
			if (self.booOrbiting):
				self.DoOrbit(t_mpos_x, t_mpos_y)
				return task.cont
				
			if (self.booDragPanning):
				self.DoDragPan(t_mpos_x, t_mpos_y)
				return task.cont

			if (t_mpos_x < -0.9) | (t_mpos_x > 0.9) | (t_mpos_y < -0.9) | (t_mpos_y > 0.9):
				#If i'm making a selection, don't pan the camera. It's very annoying.
				try:
					if objSelectionTool.booSelecting:
						return task.cont
				except:
					pass
				self.DoEdgePan(t_mpos_x, t_mpos_y)
				return task.cont
		return task.cont 

	def DoEdgePan(self, fMX, fMY):	#MX, MY: mouse x, mouse y
		dx, dy = 0, 0
		old_Phi = 180 + self.tupCurrentCameraDir[0]
		if fMX > self.fEdgeBound:
			dx = math.cos(math.radians(old_Phi+180))*(fMX-self.fEdgeBound)/(1-self.fEdgeBound)*self.fPanRate/self.fZoomRate
			dy = math.sin(math.radians(old_Phi+180))*(fMX-self.fEdgeBound)/(1-self.fEdgeBound)*self.fPanRate/self.fZoomRate
			#print fMX, fMY, dx, dy
			
		if fMX < -1*self.fEdgeBound:
			dx = math.cos(math.radians(old_Phi+180))*(fMX+self.fEdgeBound)/(1-self.fEdgeBound)*self.fPanRate/self.fZoomRate
			dy = math.sin(math.radians(old_Phi+180))*(fMX+self.fEdgeBound)/(1-self.fEdgeBound)*self.fPanRate/self.fZoomRate
			#print fMX, fMY, dx ,dy
			
		if fMY > self.fEdgeBound:
			dx = -1*math.cos(math.radians(old_Phi+90))*(fMY-self.fEdgeBound)/(1-self.fEdgeBound)*self.fPanRate/self.fZoomRate
			dy = -1*math.sin(math.radians(old_Phi+90))*(fMY-self.fEdgeBound)/(1-self.fEdgeBound)*self.fPanRate/self.fZoomRate
			#print fMX, fMY, dx ,dy
		if fMY < -1*self.fEdgeBound:
			dx = -1*math.cos(math.radians(old_Phi+90))*(fMY+self.fEdgeBound)/(1-self.fEdgeBound)*self.fPanRate/self.fZoomRate
			dy = -1*math.sin(math.radians(old_Phi+90))*(fMY+self.fEdgeBound)/(1-self.fEdgeBound)*self.fPanRate/self.fZoomRate
			#print fMX, fMY, dx ,dy
			
		if not self.booDebug:
			self.tupCurrentCameraPos = (self.tupCurrentCameraPos[0] + dx, self.tupCurrentCameraPos[1] + dy, self.tupCurrentCameraPos[2])
			base.camera.setPos(self.tupCurrentCameraPos[0], self.tupCurrentCameraPos[1], self.tupCurrentCameraPos[2])
			
	def DoDragPan(self, fMX, fMY):
		dx, dy = 0, 0
		old_Phi = 180 + self.tupCurrentCameraDir[0]
		dMX = fMX - self.fLastMouseX
		dMY = fMY - self.fLastMouseY
		x_p = dMX*self.fDragPanRate/self.fZoomRate
		y_p = dMY*self.fDragPanRate/self.fZoomRate
		c_ = math.cos(math.radians(old_Phi+180))
		s_ = math.sin(math.radians(old_Phi+180))
		dx = -1*(c_*x_p - s_*y_p)
		dy = -1*(s_*x_p + c_*y_p)
		if self.booDebug:
			print dx, dy
		if not self.booDebug:
			self.tupCurrentCameraPos = (self.tupCurrentCameraPos[0] + dx, self.tupCurrentCameraPos[1] + dy, self.tupCurrentCameraPos[2])
			base.camera.setPos(self.tupCurrentCameraPos[0], self.tupCurrentCameraPos[1], self.tupCurrentCameraPos[2])
			self.fLastMouseX = fMX
			self.fLastMouseY = fMY
		
	def DoOrbit(self, fMX, fMY):
		if not self.booDebug:
			dMX = fMX - self.fLastMouseX
			dMY = fMY - self.fLastMouseY
		else:
			dMX = fMX
			dMY = fMY
		dTheta = -1*dMY * self.fThetaRate
		dPhi = -1*dMX * self.fPhiRate
		
		old_Theta = -1*self.tupCurrentCameraDir[1]
		old_Phi = 180 + self.tupCurrentCameraDir[0]
		old_xyz_r = self.tupCurrentCameraPos[2]/math.sin(abs(math.radians(self.tupCurrentCameraDir[1])))
		old_xy_r = self.tupCurrentCameraPos[2]/math.tan(abs(math.radians(self.tupCurrentCameraDir[1])))
		old_x = -1*old_xy_r*math.cos(math.radians(self.tupCurrentCameraDir[0]+90))
		old_y = -1*old_xy_r*math.sin(math.radians(self.tupCurrentCameraDir[0]+90))

		if self.booDebug:
			print "Old target 2 cam theta", old_Theta
			print "Old Camera pos", self.tupCurrentCameraPos
			print "Old Camera dir", self.tupCurrentCameraDir
		
		new_Theta = old_Theta + dTheta
		if new_Theta < self.tupThetaLimit[0]:
			new_Theta = 5
		if new_Theta > self.tupThetaLimit[1]:
			new_Theta = 90
		new_Phi = old_Phi + dPhi

		nz = 1.*old_xyz_r*math.sin(math.radians(new_Theta))
		new_xy_r = 1.*old_xyz_r*math.cos(math.radians(new_Theta))
		new_x = new_xy_r*math.cos(math.radians(new_Phi+90))
		new_y = new_xy_r*math.sin(math.radians(new_Phi+90))
		
		self.tupCurrentCameraPos = (self.tupCurrentCameraPos[0] + new_x - old_x, self.tupCurrentCameraPos[1] + new_y - old_y, nz)
		self.tupCurrentCameraDir = (new_Phi - 180, -1*new_Theta, 0)

		if self.booDebug:
			print "New target 2 cam theta", new_Theta
			print "New Camera Pos", self.tupCurrentCameraPos
			print "New Camera dir", self.tupCurrentCameraDir
			
		if not self.booDebug:
			base.camera.setPos(self.tupCurrentCameraPos[0], self.tupCurrentCameraPos[1], self.tupCurrentCameraPos[2])
			base.camera.setHpr(self.tupCurrentCameraDir[0], self.tupCurrentCameraDir[1], self.tupCurrentCameraDir[2])
			self.fLastMouseX = fMX
			self.fLastMouseY = fMY
		
	def GoHome(self):
		base.camera.setPos(self.tupInitCameraPos[0], self.tupInitCameraPos[1], self.tupInitCameraPos[2])
		base.camera.setHpr(self.tupInitCameraDir[0], self.tupInitCameraDir[1], self.tupInitCameraDir[2])
		self.tupCurrentCameraPos = self.tupInitCameraPos
		self.tupCurrentCameraDir = self.tupInitCameraDir

	def StartDragPan(self):
		self.booDragPanning = True
		if base.mouseWatcherNode.hasMouse():
			mpos = base.mouseWatcherNode.getMouse()
			self.fLastMouseX = mpos.getX()
			self.fLastMouseY = mpos.getY()
	
	def StopDragPan(self):
		self.booDragPanning = False
		
	def StartCameraOrbit(self):
		self.booOrbiting = True
		if base.mouseWatcherNode.hasMouse():
			mpos = base.mouseWatcherNode.getMouse()
			self.fLastMouseX = mpos.getX()
			self.fLastMouseY = mpos.getY()

	def StopCameraOrbit(self):
		self.booOrbiting = False

	def ZoomCameraOut(self):
		old_Theta = -1*self.tupCurrentCameraDir[1]
		old_Phi = 180 + self.tupCurrentCameraDir[0]
		old_xyz_r = self.tupCurrentCameraPos[2]/math.sin(abs(math.radians(self.tupCurrentCameraDir[1])))
		old_xy_r = self.tupCurrentCameraPos[2]/math.tan(abs(math.radians(self.tupCurrentCameraDir[1])))
		old_x = -1*old_xy_r*math.cos(math.radians(self.tupCurrentCameraDir[0]+90))
		old_y = -1*old_xy_r*math.sin(math.radians(self.tupCurrentCameraDir[0]+90))

		new_xyz_r = old_xyz_r*self.fZoomRate
		nz = 1.*new_xyz_r*math.sin(math.radians(old_Theta))
		new_xy_r = 1.*new_xyz_r*math.cos(math.radians(old_Theta))
		new_x = new_xy_r*math.cos(math.radians(old_Phi+90))
		new_y = new_xy_r*math.sin(math.radians(old_Phi+90))
		
		self.tupCurrentCameraPos = (self.tupCurrentCameraPos[0] + new_x - old_x, self.tupCurrentCameraPos[1] + new_y - old_y, nz)
		base.camera.setPos(self.tupCurrentCameraPos[0], self.tupCurrentCameraPos[1], self.tupCurrentCameraPos[2])

	def ZoomCameraIn(self):
		old_Theta = -1*self.tupCurrentCameraDir[1]
		old_Phi = 180 + self.tupCurrentCameraDir[0]
		old_xyz_r = self.tupCurrentCameraPos[2]/math.sin(abs(math.radians(self.tupCurrentCameraDir[1])))
		old_xy_r = self.tupCurrentCameraPos[2]/math.tan(abs(math.radians(self.tupCurrentCameraDir[1])))
		old_x = -1*old_xy_r*math.cos(math.radians(self.tupCurrentCameraDir[0]+90))
		old_y = -1*old_xy_r*math.sin(math.radians(self.tupCurrentCameraDir[0]+90))

		new_xyz_r = old_xyz_r/self.fZoomRate
		nz = 1.*new_xyz_r*math.sin(math.radians(old_Theta))
		new_xy_r = 1.*new_xyz_r*math.cos(math.radians(old_Theta))
		new_x = new_xy_r*math.cos(math.radians(old_Phi+90))
		new_y = new_xy_r*math.sin(math.radians(old_Phi+90))
		
		self.tupCurrentCameraPos = (self.tupCurrentCameraPos[0] + new_x - old_x, self.tupCurrentCameraPos[1] + new_y - old_y, nz)
		base.camera.setPos(self.tupCurrentCameraPos[0], self.tupCurrentCameraPos[1], self.tupCurrentCameraPos[2])

class clKeyBoardModifiers(DirectObject.DirectObject):
	def __init__(self):
		self.booAlt = False
		self.booControl = False
		self.booShift = False
		self.accept("alt", self.OnAltDown)
		self.accept("alt-up", self.OnAltUp)
		self.accept("control", self.OnControlDown)
		self.accept("control-up", self.OnControlUp)
		self.accept("shift", self.OnShiftDown)
		self.accept("shift-up", self.OnShiftUp)
	
	def OnAltDown(self):
		self.booAlt = True
		
	def OnAltUp(self):
		self.booAlt = False
		
	def OnControlDown(self):
		self.booControl = True
	
	def OnControlUp(self):
		self.booControl = False
		
	def OnShiftDown(self):
		self.booShift = True
		
	def OnShiftUp(self):
		self.booShift = False
		
class clSelectionTool(DirectObject.DirectObject):
	def __init__(self, listConsideration=[]):
		#Create a selection window using cardmaker
		#We will use the setScale function to dynamically scale the quad to the appropriate size in UpdateSelRect
		temp = CardMaker('')
		temp.setFrame(0, 1, 0, 1)
		#self.npSelRect is the actual selection rectangle that we dynamically hide/unhide and change size
		self.npSelRect = render2d.attachNewNode(temp.generate())
		self.npSelRect.setColor(1,1,0,.2)
		self.npSelRect.setTransparency(1)
		self.npSelRect.hide()
		LS = LineSegs()
		LS.moveTo(0,0,0)
		LS.drawTo(1,0,0)
		LS.drawTo(1,0,1)
		LS.drawTo(0,0,1)
		LS.drawTo(0,0,0)
		self.npSelRect.attachNewNode(LS.create())
		self.listConsideration = listConsideration
		self.listSelected = []
		self.listLastSelected = []
		
		self.pt2InitialMousePos = (-12, -12)
		self.pt2LastMousePos = (-12, -12)
		
		####----Used to differentiate between group selections and point selections
		#self.booMouseMoved  = False
		self.fFovh, self.fFovv = base.camLens.getFov()
		
		####--Used to control how frequently update_rect is updated;
		self.fTimeLastUpdateSelRect = 0 
		self.fTimeLastUpdateSelected = 0
		self.UpdateTimeSelRect = 0.015
		self.UpdateTimeSelected = 0.015
		
		####------Register the left-mouse-button to start selecting 
		self.accept("mouse1", self.OnStartSelect)
		self.accept("control-mouse1", self.OnStartSelect)
		self.accept("mouse1-up", self.OnStopSelect)
		self.taskUpdateSelRect = 0

	def TTest(self):
		print "hello control-mouse1"
	def funcSelectActionOnObject(self, obj):
		obj.showBounds()
		
	def funcDeselectActionOnObject(self, obj):
		obj.hideBounds()
		
	def OnStartSelect(self):
		if not base.mouseWatcherNode.hasMouse():
			return
		self.booMouseMoved = False
		self.booSelecting = True
		self.pt2InitialMousePos = Point2(base.mouseWatcherNode.getMouse())
		self.pt2LastMousePos = Point2(self.pt2InitialMousePos)
		self.npSelRect.setPos(self.pt2InitialMousePos[0], 1, self.pt2InitialMousePos[1])
		self.npSelRect.setScale(1e-3, 1, 1e-3)
		self.npSelRect.show()
		self.taskUpdateSelRect = taskMgr.add(self.UpdateSelRect, "UpdateSelRect")
		self.taskUpdateSelRect.lastMpos = None
		
	def OnStopSelect(self):
		if not base.mouseWatcherNode.hasMouse():
			return
		if self.taskUpdateSelRect != 0:
			taskMgr.remove(self.taskUpdateSelRect)
		self.npSelRect.hide()
		self.booSelecting = False
		#If the mouse hasn't moved, it's a point selection
		if (abs(self.pt2InitialMousePos[0] - self.pt2LastMousePos[0]) <= .01) & (abs(self.pt2InitialMousePos[1] - self.pt2LastMousePos[1]) <= .01):
			objTempSelected = 0
			fTempObjDist = 2*(base.camLens.getFar())**2
			for i in self.listConsideration:
				if type(i) != libpanda.NodePath:
					raise 'Unknown objtype in selection'
				else:
					sphBounds = i.getBounds()
					#p3 = base.cam.getRelativePoint(render, sphBounds.getCenter())
					p3 = base.cam.getRelativePoint(i.getParent(), sphBounds.getCenter())
					r = sphBounds.getRadius()
					screen_width = r/(p3[1]*math.tan(math.radians(self.fFovh/2)))
					screen_height = r/(p3[1]*math.tan(math.radians(self.fFovv/2)))
					p2 = Point2()
					base.camLens.project(p3, p2)
					#If the mouse pointer is in the "roughly" screen-projected bounding volume 
					if (self.pt2InitialMousePos[0] >= (p2[0] - screen_width/2)):
						if (self.pt2InitialMousePos[0] <= (p2[0] + screen_width/2)):
							if (self.pt2InitialMousePos[1] >= (p2[1] - screen_height/2)):
								if (self.pt2InitialMousePos[1] <= (p2[1] + screen_height/2)):
									#We check the obj's distance to the camera and choose the closest one
									dist = p3[0]**2+p3[1]**2+p3[2]**2 - r**2
									if dist < fTempObjDist:
										fTempObjDist = dist
										objTempSelected = i

			if objTempSelected != 0:
				if objKeyBoardModifiers.booControl:
					self.listSelected.append(objTempSelected)
				else:
					for i in self.listSelected:
						self.funcDeselectActionOnObject(i)
					self.listSelected = [objTempSelected]
				self.funcSelectActionOnObject(objTempSelected)

	def UpdateSelRect(self, task):
		#Make sure we hae the mouse
		if not base.mouseWatcherNode.hasMouse():
			return Task.cont
		mpos = base.mouseWatcherNode.getMouse()
		t = globalClock.getRealTime()
		#First check the mouse position is different
		if self.pt2LastMousePos != mpos: 
			self.booMouseMoved = True
			#We only need to check this function every once in a while
			if (t - self.fTimeLastUpdateSelRect) > self.UpdateTimeSelRect:
				self.fTimeLastUpdateSelRect =  t
				self.pt2LastMousePos = Point2(mpos)
					
				#Update the selection rectange graphically
				d = self.pt2LastMousePos - self.pt2InitialMousePos
				self.npSelRect.setScale(d[0] if d[0] else 1e-3, 1, d[1] if d[1] else 1e-3)

		if (abs(self.pt2InitialMousePos[0] - self.pt2LastMousePos[0]) > .01) & (abs(self.pt2InitialMousePos[1] - self.pt2LastMousePos[1]) > .01):
			if (t - self.fTimeLastUpdateSelected) > self.UpdateTimeSelected:
				#A better way to handle a large number of objects is to first transform the 2-d selection rect into
				#its own view fustrum and then check the objects in world space. Adding space correlation/hashing
				#will make it go faster. But I'm lazy.
				self.fTimeLastUpdateSelected = t
				self.listLastSelected = self.listSelected
				self.listSelected = []
				#Get the bounds of the selection box
				fMouse_Lx = min(self.pt2InitialMousePos[0], self.pt2LastMousePos[0])
				fMouse_Ly = max(self.pt2InitialMousePos[1], self.pt2LastMousePos[1])
				fMouse_Rx = max(self.pt2InitialMousePos[0], self.pt2LastMousePos[0])
				fMouse_Ry = min(self.pt2InitialMousePos[1], self.pt2LastMousePos[1])
				for i in self.listConsideration:
					if type(i) != libpanda.NodePath:
						raise 'Unknown objtype in selection'
					else:
						#Get the loosebounds of the nodepath
						sphBounds = i.getBounds()
						#Put the center of the sphere into the camera coordinate system
						#p3 = base.cam.getRelativePoint(render, sphBounds.getCenter())
						p3 = base.cam.getRelativePoint(i.getParent(), sphBounds.getCenter())
						#Check if p3 is in the view fustrum
						p2 = Point2()
						if base.camLens.project(p3, p2):
							if (p2[0] >= fMouse_Lx) & (p2[0] <= fMouse_Rx) & (p2[1] >= fMouse_Ry) & (p2[1] <= fMouse_Ly):
								self.listSelected.append(i)
								self.funcSelectActionOnObject(i)
				for i in self.listLastSelected:
					if not objKeyBoardModifiers.booControl:
						if i not in self.listSelected:
							self.funcDeselectActionOnObject(i)
					else:
						self.listSelected.append(i)

		return Task.cont

base.disableMouse()                 #disable mouse control of the camera
__builtin__.objCameraHandler = clCameraHandler()
__builtin__.objSelectionTool = clSelectionTool()
__builtin__.objKeyBoardModifiers = clKeyBoardModifiers()

if __name__ == "__main__":
	a = clCameraHandler(booDebug=True)
	a.fLastMouseX = 0
	a.fLastMouseY = 0
	a.DoDragPan(0, .5)

Awesome stuff, many thanks for this, and you should probably explain it better so many others can find it and see how useful it is.

And the critique :slight_smile: : you have some “line continuation character” errors in the code.

I found like \ signs in the code at the end of some lines in GuiFuncs where there’s a space after the “” and that’s apparently not allowed.
See if you can edit the code segment above so others that copy&paste it will have it working from the first try.

regards and congrats.

thanks, and fixed!