def move_map_avatar(self, move, stop): # print move # avatar is mapped assuming c_range of 0.5. What do I need to # change to use a different c_range? c_range of one is twice # the if move: avt = LineSegs() avt.setThickness(1) avt.setColor(1, 1, 1) # print 'last', self.last_avt avt.move_to(self.last_avt[0], -5, self.last_avt[1]) # print 'move', move new_move = [i + (j * self.avt_factor) for i, j in zip(self.last_avt, move)] # new_move = [i + j for i, j in zip(self.last_avt, move)] # would it be better to have a local stop condition? if stop[0]: new_move[0] = self.last_avt[0] # print 'stop x', self.last_avt[0] if stop[1]: new_move[1] = self.last_avt[1] # print 'stop y', self.last_avt[1] # print 'new', new_move self.last_avt = [new_move[0], new_move[1]] avt.draw_to(new_move[0], -5, new_move[1]) self.map_avt_node.append(self.render2d.attach_new_node(avt.create())) # print self.map_avt_node[-1] # can't let too many nodes pile up if len(self.map_avt_node) > 299: # removing the node does not remove the object from the list for i, j in enumerate(self.map_avt_node): j.removeNode() if i > 49: break del self.map_avt_node[0:50]
def draw_multiselect_box(self, task): if base.mouseWatcherNode.isButtonDown(MouseButton.one()): self.multi_select = True self.select_box.remove() ls = LineSegs() ls.move_to(self.box_x, self.box_y, 1) ls.draw_to(self.model.getX(), self.box_y, 1) ls.draw_to(self.model.getX(), self.model.getY(), 1) ls.draw_to(self.box_x, self.model.getY(), 1) ls.draw_to(self.box_x, self.box_y, 1) node = ls.create() #text = TextNode('text') #text.setText(str(self.box_x)+","+str(self.box_y)+"\n"+str(self.model.getX())+","+str(self.model.getY())) #textnp = NodePath(text) #textnp.setPos(self.box_x,self.box_y,1) #textnp.setHpr(0,-90,0) #textnp.setScale(20.0) self.select_box = NodePath(node) #textnp.reparentTo(self.select_box) self.select_box.reparentTo(render) return task.cont else: self.select_box.hide() taskMgr.add(self.task_select_check, "updatePicker") return task.done
class LineEffects(): def __init__(self): self.linesegs = LineSegs("lines") self.bullet = None def remove_bullet(self): if self.bullet: self.bullet.detach_node() def draw_bullet(self, a, b, color): if color == 1: color = (1,0,1,1) elif color == 2: color = (0,1,1,1) else: color = (1,1,1,1) self.linesegs.set_color(color) a = a.get_pos(render) self.linesegs.move_to(a) self.linesegs.draw_to(b) lines = self.linesegs.create() self.bullet = render.attach_new_node(lines) impact = base.icons["impact"] impact = impact.copy_to(self.bullet) impact.set_pos(b)
def plot_border(self): border = LineSegs() border.setThickness(2.0) corner = self.win_size/100 * 5/6 #print('corner', corner) border.move_to(corner, 25, corner) border.draw_to(corner, 25, -corner) border.draw_to(-corner, 25, -corner) border.draw_to(-corner, 25, corner) border.draw_to(corner, 25, corner) self.base.render.attach_new_node(border.create(True))
def __init__(self, name: str = 'cube_mesh', wireframe_thickness: float = 5) -> None: self.name = name self.__vertex_data_format = GeomVertexFormat.getV3n3() self.__vertex_data = GeomVertexData(name, self.__vertex_data_format, Geom.UHStatic) self.geom = Geom(self.__vertex_data) self.__triangles = GeomTriangles(Geom.UHStatic) self.__triangle_data = self.__triangles.modifyVertices() self.__vertex = GeomVertexWriter(self.__vertex_data, 'vertex') self.__normal = GeomVertexWriter(self.__vertex_data, 'normal') self.__face_count = 0 def add_face(face: Face) -> None: self.__make_face(face) self.__make_face(Face.LEFT) self.__make_face(Face.RIGHT) self.__make_face(Face.BACK) self.__make_face(Face.FRONT) self.__make_face(Face.BOTTOM) self.__make_face(Face.TOP) self.__triangles.close_primitive() self.geom.add_primitive(self.__triangles) def is_connected(x, y, z, x1, y1, z1): return (abs(x - x1) == 1 and abs(y - y1) != 1 and abs(z - z1) != 1) or \ (abs(x - x1) != 1 and abs(y - y1) == 1 and abs(z - z1) != 1) or \ (abs(x - x1) != 1 and abs(y - y1) != 1 and abs(z - z1) == 1) ls = LineSegs() ls.set_thickness(wireframe_thickness) arr_x = [0, 0, 0, 0, 1, 1, 1, 1] arr_y = [0, 0, 1, 1, 1, 1, 0, 0] arr_z = [0, -1, -1, 0, 0, -1, -1, 0] for pos1 in range(len(arr_x) - 1): for pos2 in range(pos1, len(arr_x)): x = arr_x[pos1] y = arr_y[pos1] z = arr_z[pos1] x1 = arr_x[pos2] y1 = arr_y[pos2] z1 = arr_z[pos2] if (is_connected(x, y, z, x1, y1, z1)): ls.move_to(x, y, z) ls.draw_to(x1, y1, z1) self.__wireframe_node = ls.create()
def plot_match_square(self, corners): print 'plot match square' print corners match = LineSegs() match.setThickness(1.5) match.setColor(0, 0, 0) match.move_to(corners[0][0], -5, corners[1][0]) match.draw_to(corners[0][1], -5, corners[1][0]) match.draw_to(corners[0][1], -5, corners[1][1]) match.draw_to(corners[0][0], -5, corners[1][1]) match.draw_to(corners[0][0], -5, corners[1][0]) # print self.render2d self.match_square = self.render2d.attach_new_node(match.create())
def draw_grid(x_size, y_size, s): lines = LineSegs() lines.set_color((0, 0, 0, 1)) offset = s / 2 for x in range(x_size): x = (x * s) - offset lines.move_to(x, -offset, 0) lines.draw_to(x, (y_size * s) - offset, 0) for y in range(y_size): y = (y * s) - offset lines.move_to(-offset, y, 0) lines.draw_to((x_size * s) - offset, y, 0) grid = NodePath(lines.create()) return grid
def linesegs_sample(x_segs, y_segs, wrap_x=False, wrap_y=False, **columns): segs = LineSegs() for x in range(x_segs + 1): for y in range(y_segs + 1): x_e, y_e = x, y if wrap_x and x == x_segs: x_e = 0 if wrap_y and y == y_segs: y_e = 0 values = { name: f( float(x_e) / float(x_segs), float(y_e) / float(y_segs), ) for name, (dtype, f) in columns.items() } if y == 0: segs.set_color(values['color']) segs.move_to(values['vertex']) else: segs.set_color(values['color']) segs.draw_to(values['vertex']) for y in range(y_segs + 1): for x in range(x_segs + 1): x_e, y_e = x, y if wrap_x and x == x_segs: x_e = 0 if wrap_y and y == y_segs: y_e = 0 values = { name: f( float(x_e) / float(x_segs), float(y_e) / float(y_segs), ) for name, (dtype, f) in columns.items() } if x == 0: segs.set_color(values['color']) segs.move_to(values['vertex']) else: segs.set_color(values['color']) segs.draw_to(values['vertex']) return segs.create()
def draw_path(self, current_position, path): from panda3d.core import LineSegs, Vec4, Vec3 path = [Vec3(*v) for v in path] segments = LineSegs() segments.set_thickness(2.0) segments.set_color((1, 1, 0, 1)) segments.move_to(current_position) for point in path: segments.draw_to(point) if self._path_node: self._path_node.remove_node() node = segments.create() self._path_node = render.attach_new_node(node) self._replan_timer = Timer(1.5) self._replan_timer.on_target = self._replan
def task_mouse_place(self,task): if base.mouseWatcherNode.isButtonDown(MouseButton.one()): self.placing_object = True self.place_pos = (self.anchor_x,self.anchor_y) self.line_dir.remove() ls = LineSegs() ls.move_to(self.anchor_x,self.anchor_y,1) ls.draw_to(self.model.getX(),self.model.getY(),1) node = ls.create() angle1 = math.atan2(self.anchor_y - self.anchor_y,self.anchor_x - self.anchor_x+50) angle2 = math.atan2(self.anchor_y - self.model.getY(),self.anchor_x - self.model.getY()); final_angle = angle1-angle2; self.model.setHpr(final_angle,0,0) self.line_dir = NodePath(node) self.line_dir.reparentTo(render) return task.again else: self.line_dir.hide() taskMgr.add(self.task_mouse_press_check, "checkMousePress") return task.done
def task_mouse_place(self, task): if base.mouseWatcherNode.isButtonDown(MouseButton.one()): self.placing_object = True self.place_pos = (self.anchor_x, self.anchor_y) self.line_dir.remove() ls = LineSegs() ls.move_to(self.anchor_x, self.anchor_y, 1) ls.draw_to(self.model.getX(), self.model.getY(), 1) node = ls.create() angle1 = math.atan2(self.anchor_y - self.anchor_y, self.anchor_x - self.anchor_x + 50) angle2 = math.atan2(self.anchor_y - self.model.getY(), self.anchor_x - self.model.getY()) final_angle = angle1 - angle2 self.model.setHpr(final_angle, 0, 0) self.line_dir = NodePath(node) self.line_dir.reparentTo(render) return task.again else: self.line_dir.hide() taskMgr.add(self.task_mouse_press_check, "checkMousePress") return task.done
def move_map_avatar(self, move, stop): # print move # avatar is mapped assuming c_range of 0.5. What do I need to # change to use a different c_range? c_range of one is twice # the if move: avt = LineSegs() avt.setThickness(1) avt.setColor(1, 1, 1) # print 'last', self.last_avt avt.move_to(self.last_avt[0], -5, self.last_avt[1]) # print 'move', move new_move = [ i + (j * self.avt_factor) for i, j in zip(self.last_avt, move) ] # new_move = [i + j for i, j in zip(self.last_avt, move)] # would it be better to have a local stop condition? if stop[0]: new_move[0] = self.last_avt[0] # print 'stop x', self.last_avt[0] if stop[1]: new_move[1] = self.last_avt[1] # print 'stop y', self.last_avt[1] # print 'new', new_move self.last_avt = [new_move[0], new_move[1]] avt.draw_to(new_move[0], -5, new_move[1]) self.map_avt_node.append( self.render2d.attach_new_node(avt.create())) # print self.map_avt_node[-1] # can't let too many nodes pile up if len(self.map_avt_node) > 299: # removing the node does not remove the object from the list for i, j in enumerate(self.map_avt_node): j.removeNode() if i > 49: break del self.map_avt_node[0:50]
def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **exclude_from_dict(kwargs, ["wireframe_thickness"])) wireframe_thickness: float = kwargs[ "wireframe_thickness"] if "wireframe_thickness" in kwargs else 5 def is_connected(x, y, z, x1, y1, z1): return (abs(x - x1) == 1 and abs(y - y1) != 1 and abs(z - z1) != 1) or \ (abs(x - x1) != 1 and abs(y - y1) == 1 and abs(z - z1) != 1) or \ (abs(x - x1) != 1 and abs(y - y1) != 1 and abs(z - z1) == 1) ls = LineSegs() ls.set_thickness(wireframe_thickness) for i, j, k in np.ndindex(self.structure.shape): if bool(self.structure[i, j, k] & self.mask): self.arr_x = [0, 0, 0, 0, 1, 1, 1, 1] self.arr_y = [0, 0, 1, 1, 1, 1, 0, 0] self.arr_z = [0, -1, -1, 0, 0, -1, -1, 0] for pos1 in range(len(self.arr_x) - 1): for pos2 in range(pos1, len(self.arr_x)): x = self.arr_x[pos1] + i y = self.arr_y[pos1] + j z = self.arr_z[pos1] + k x1 = self.arr_x[pos2] + i y1 = self.arr_y[pos2] + j z1 = self.arr_z[pos2] + k if (is_connected(x, y, z, x1, y1, z1)): ls.move_to(x, y, z) ls.draw_to(x1, y1, z1) self.wireframe.attach_new_node(ls.create()) for idx in np.ndindex(self.structure.shape): if bool(self.structure[idx] & self.mask): self._add_cube_faces(idx) self._triangles.close_primitive() self.mesh.add_primitive(self._triangles)
def makeCubeOutline(mins, maxs, color, thickness=1.0): lines = LineSegs() lines.setColor(color) lines.setThickness(thickness) lines.move_to(mins) lines.draw_to(Point3(mins.get_x(), mins.get_y(), maxs.get_z())) lines.draw_to(Point3(mins.get_x(), maxs.get_y(), maxs.get_z())) lines.draw_to(Point3(mins.get_x(), maxs.get_y(), mins.get_z())) lines.draw_to(mins) lines.draw_to(Point3(maxs.get_x(), mins.get_y(), mins.get_z())) lines.draw_to(Point3(maxs.get_x(), mins.get_y(), maxs.get_z())) lines.draw_to(Point3(mins.get_x(), mins.get_y(), maxs.get_z())) lines.move_to(Point3(maxs.get_x(), mins.get_y(), maxs.get_z())) lines.draw_to(maxs) lines.draw_to(Point3(mins.get_x(), maxs.get_y(), maxs.get_z())) lines.move_to(maxs) lines.draw_to(Point3(maxs.get_x(), maxs.get_y(), mins.get_z())) lines.draw_to(Point3(mins.get_x(), maxs.get_y(), mins.get_z())) lines.move_to(Point3(maxs.get_x(), maxs.get_y(), mins.get_z())) lines.draw_to(Point3(maxs.get_x(), mins.get_y(), mins.get_z())) return lines.create()
def draw_lines(base): linesegs = LineSegs("lines") # Border xs, ys = base.map_size sequence = SequenceNode("border") for color in ((0, 0, 0.4, 1), (0, 0, 0.5, 1), (0, 0, 0.6, 1), (0, 0, 0.5, 1)): linesegs.set_color(color) linesegs.set_thickness(3) linesegs.move_to((-xs, 0, 0)) linesegs.draw_to((-xs, ys, 0)) linesegs.draw_to((xs, ys, 0)) linesegs.draw_to((xs, 0, 0)) linesegs.draw_to((-xs, 0, 0)) lines = linesegs.create() sequence.add_child(lines) sequence.loop(True) sequence.set_frame_rate(30) base.border = render.attach_new_node(sequence) for i in range(2): n = NodePath("border") base.border.instance_to(n) n.reparent_to(render) n.set_z(-(i * 5)) # Mine cross base.models["lines"] = {} base.models["lines"]["cross"] = NodePath("cross") sequence = SequenceNode("cross") for color in ((1, 0, 0, 1), (1, 0, 1, 1), (0, 1, 0, 1), (1, 1, 0, 1)): linesegs.set_thickness(3) linesegs.move_to((1, 0, 0)) linesegs.draw_to((-1, 0, 0)) linesegs.move_to((0, 1, 0)) linesegs.draw_to((0, -1, 0)) linesegs.set_color(color) lines = linesegs.create() sequence.add_child(lines) sequence.loop(True) sequence.set_frame_rate(60) base.models["lines"]["cross"].attach_new_node(sequence) base.linesegs = linesegs
class WartsApp(ShowBase): """ The application running all the graphics. """ def __init__(self, graphicsInterface): ShowBase.__init__(self) # This is available as a global, but pylint gives an undefined-variable # warning if we use it that way. Looking at # https://www.panda3d.org/manual/index.php/ShowBase # I would have thought we could reference it as either # self.globalClock, direct.showbase.ShowBase.globalClock, or possibly # direct.showbase.globalClock, but none of those seems to work. To # avoid the pylint warnings, create self.globalClock manually. self.globalClock = ClockObject.getGlobalClock() self.graphicsInterface = graphicsInterface # Mapping from gids to entities. self.entities = {} # Set up event handling. self.mouseState = {} self.keys = {} self.setupEventHandlers() # Set up camera control. self.cameraHolder = self.render.attachNewNode('CameraHolder') self.cameraHolder.setPos(0, 0, 100) self.prevCameraHpr = (0, -80, 0) self.usingCustomCamera = True self.setCameraCustom() self.prevMousePos = None self.selectionBox = None self.selectionBoxNode = None self.selectionBoxOrigin = None # TODO[#3]: Magic numbers bad. self.resourceDisplay = OnscreenText(pos=(-0.98,.9), align=TextNode.ALeft, mayChange=True) # Define the ground plane by a normal (+z) and a point (the origin). self.groundPlane = core.Plane(core.Vec3(0, 0, 1), core.Point3(0, 0, 0)) self.graphicsInterface.graphicsReady(self) def cleanup(self): pass def interfaceMessage(self, data): # Messages from GraphicsInterface to Graphics are always internal # client messages, so no need to catch InvalidMessageError. message = deserializeMessage(data) if isinstance(message, messages.Tick): pass elif isinstance(message, cmessages.AddEntity): self.addEntity(message.gid, message.pos, message.modelPath, message.isExample, message.isUnit, message.goalSize) elif isinstance(message, cmessages.RemoveEntity): self.removeEntity(message.gid) elif isinstance(message, cmessages.MoveEntity): self.moveEntity(message.gid, message.pos) elif isinstance(message, cmessages.MarkEntitySelected): self.markSelected(message.gid, message.isSelected) elif isinstance(message, cmessages.DisplayResources): self.displayResources(message.resourceAmt) else: badIMessageCommand(message, log) def addEntity(self, gid, pos, modelPath, isExample, isUnit, goalSize): """ pos is given in graphics coordinates. goalSize, if specified, is a pair (width, height) -- the model will be scaled in the xy plane so that it's as large as possible while still fitting within that width and height. Don't pass 0 as the width or the height, because that's just not nice. """ if gid in self.entities: raise RuntimeError("Already have entity with gid {gid}." .format(gid=gid)) log.debug("Adding graphical entity %s at %s", gid, pos) x, y = pos if isExample: # The example panda from the Panda3D "Hello world" tutorial. # TODO[#9]: Figure out a more general way of specifying animations. model = Actor(modelPath, {"walk": "models/panda-walk4"}) else: model = self.loader.loadModel(getModelPath(modelPath)) # Put the model in the scene, but don't position it yet. rootNode = self.render.attachNewNode("") model.reparentTo(rootNode) # Rescale the model about its origin. The x and y coordinates of the # model's origin should be chosen as wherever it looks like the model's # center of mass is, so that rotation about the origin (in the xy # plane) feels natural. goalWidthX, goalWidthY = goalSize bound1, bound2 = model.getTightBounds() modelWidthX = abs(bound2[0] - bound1[0]) modelWidthY = abs(bound2[1] - bound1[1]) # Scale it to the largest it can be while still fitting within the goal # rect. If the aspect ratio of the goal rect is different from that of # the model, then it'll only fill that rect in one dimension. # altScaleFactor is used for sanity checks below. scaleFactor, altScaleFactor = minmax(goalWidthX / modelWidthX, goalWidthY / modelWidthY) # Sanity check the scale factor. if scaleFactor <= 0.0: if scaleFactor == 0.0: log.warn("Graphical entity %s will be scaled negatively!", gid) else: log.warn("Graphical entity %s will be scaled to zero size.", gid) else: # TODO[#9]: Currently the example panda triggers this warning. # TODO[#3]: Magic numbers bad. if altScaleFactor / scaleFactor > 1.001: log.warn("Graphical entity %s has different aspect ratio than " "its model: model of size %.3g x %.3g being scaled " "into %.3g x %.3g.", gid, modelWidthX, modelWidthY, goalWidthX, goalWidthY) model.setScale(scaleFactor) # Place the model at z=0. The model's origin should be placed so that # this looks natural -- for most units this means it should be right at # the bottom of the model, but if we add any units that are intended to # float above the ground, then this can be accomplished by just # positioning the model above its origin. rootNode.setPos(x, y, 0.0) entity = Entity(gid, model, rootNode, isExample) self.entities[gid] = entity if isUnit: # TODO[#52]: Sigh. This is a terrible hack. I guess we could pipe # through yet another bool for "is this my unit", but I don't want # to have a growing collection of bools that need to be passed into # the graphics for each unit. For now, "is this an example model?" # and "is this my unit" are equivalent, so I guess we'll just # piggyback off of isExample.... if isExample: entity.setIndicator(self.loader.loadModel( getModelPath("unit-indicator-mine.egg") )) else: entity.setIndicator(self.loader.loadModel( getModelPath("unit-indicator-notmine.egg") )) def removeEntity(self, gid): log.debug("Removing graphical entity %s", gid) entity = self.entities.pop(gid) entity.cleanup() def moveEntity(self, gid, newPos): log.debug("Moving graphical entity %s to %s", gid, newPos) entity = self.entities[gid] x, y = newPos oldX, oldY, oldZ = entity.rootNode.getPos() z = oldZ # Ensure the entity is facing the right direction. heading = math.atan2(y - oldY, x - oldX) heading *= 180.0 / math.pi # Magic angle adjustment needed to stop the panda always facing # sideways. # TODO[#9]: Establish a convention about which way _our_ models face; # figure out whether we need something like this. (Hopefully not?) heading += 90.0 entity.rootNode.setHpr(heading, 0, 0) moveInterval = entity.rootNode.posInterval(config.TICK_LENGTH, (x, y, z)) moveInterval.start() if entity.isActor and "walk" in entity.model.getAnimNames(): currFrame = entity.model.getCurrentFrame("walk") if currFrame is None: currFrame = 0 # Supposedly, it's possible to pass a startFrame and a duration to # actorInterval, instead of calculating the endFrame ourself. But # for some reason, that doesn't seem to work; if I do that, then # the animation just keeps jumping around the early frames and # never gets past frame 5 or so. I'm not sure why. For now at # least, just calculate the endFrame ourselves to work around this. log.debug("Animating entity %s from frame %s/%s", gid, currFrame, entity.model.getNumFrames("walk")) frameRate = entity.model.getAnimControl("walk").getFrameRate() endFrame = currFrame + int(math.ceil(frameRate * config.TICK_LENGTH)) animInterval = entity.model.actorInterval( "walk", loop=1, startFrame=currFrame, endFrame=endFrame ) animInterval.start() def markSelected(self, gid, isSelected): log.debug("Marking graphical entity %s as %sselected", gid, "" if isSelected else "not ") entity = self.entities[gid] if isSelected: entity.setIndicator(self.loader.loadModel( getModelPath("unit-indicator-selected.egg") )) else: # You can't currently select others' units, so if a unit is being # deselected it must be mine. entity.setIndicator(self.loader.loadModel( getModelPath("unit-indicator-mine.egg") )) def displayResources(self, resourceAmt): self.resourceDisplay.setText("Resource: {}".format(resourceAmt)) def createSelectionBox(self, corner1, corner2): """ Create a selection "box" given the coordinates of two opposite corners. The corners are given in world coordinates (well, 3d graphics coordinates). """ assert self.selectionBox is None p1, p2, p3, p4 = self.convert3dBoxToScreen(corner1, corner2) x1, y1 = p1 x2, y2 = p2 x3, y3 = p3 x4, y4 = p4 # TODO[#3]: Magic numbers bad. self.selectionBox = LineSegs("SelectionBox") self.selectionBox.setThickness(3.0) self.selectionBox.setColor(0.0, 1.0, 0.25, 1.0) self.selectionBox.move_to(x1, 0, y1) self.selectionBox.draw_to(x2, 0, y2) self.selectionBox.draw_to(x3, 0, y3) self.selectionBox.draw_to(x4, 0, y4) self.selectionBox.draw_to(x1, 0, y1) self.selectionBoxNode = self.render2d.attachNewNode( self.selectionBox.create()) def moveSelectionBox(self, corner1, corner2): assert self.selectionBox is not None p1, p2, p3, p4 = self.convert3dBoxToScreen(corner1, corner2) x1, y1 = p1 x2, y2 = p2 x3, y3 = p3 x4, y4 = p4 self.selectionBox.setVertex(0, x1, 0, y1) self.selectionBox.setVertex(1, x2, 0, y2) self.selectionBox.setVertex(2, x3, 0, y3) self.selectionBox.setVertex(3, x4, 0, y4) self.selectionBox.setVertex(4, x1, 0, y1) def removeSelectionBox(self): self.selectionBoxNode.removeNode() self.selectionBox = None self.selectionBoxNode = None def convert3dBoxToScreen(self, corner1, corner3): """ Return screen coordinates of the 4 corners of a box, given in 3d coordinates. The box is specified using 2 opposite corners. """ wx1, wy1, wz1 = corner1 wx3, wy3, wz3 = corner3 wx2, wy2 = (wx1, wy3) wx4, wy4 = (wx3, wy1) # Note: corner1 and corner2 could have nonzero z because floating-point # calculations, but they should at least be close. We'll just average # their z and not worry about it. wz2 = wz4 = 0.5 * (wz1 + wz3) p1 = self.coord3dToScreen((wx1, wy1, wz1)) p2 = self.coord3dToScreen((wx2, wy2, wz2)) p3 = self.coord3dToScreen((wx3, wy3, wz3)) p4 = self.coord3dToScreen((wx4, wy4, wz4)) return (p1, p2, p3, p4) def setCameraCustom(self): """ Change to using our custom task to control the camera. """ # Disable the default mouse-based camera control task, so we don't have # to fight with it for control of the camera. self.disableMouse() # Face the camera in the appropriate angle. self.camera.setHpr(self.prevCameraHpr) # Put it in the same location as the cameraHolder, and make it stay # put relative to the cameraHolder (so we can move the camera around by # changing the cameraHolder's position). self.camera.reparentTo(self.cameraHolder) self.camera.setPos(0, 0, 0) # Substitute our own camera control task. self.taskMgr.add(self.updateCameraTask, "UpdateCameraTask") self.usingCustomCamera = True # Need a task to handle mouse-dragging because there doesn't seem to be # a built-in mouseMove event. self.taskMgr.add(self.mouseMoveTask, "MouseMoveTask") def setCameraDefault(self): """ Change to using the default mouse-based camera controls. """ self.taskMgr.remove("UpdateCameraTask") # Save current location for when this control style is restored. self.prevCameraHpr = self.camera.getHpr() # Use the existing camera location, rather than jumping back to the one # from last time the default camera controller was active. # Copied from https://www.panda3d.org/manual/index.php/Mouse_Support mat = Mat4(self.camera.getMat()) mat.invertInPlace() self.mouseInterfaceNode.setMat(mat) self.enableMouse() self.usingCustomCamera = False def toggleCameraStyle(self): """ Switch to whichever style of camera control isn't currently active. """ if self.usingCustomCamera: self.setCameraDefault() else: self.setCameraCustom() # We don't use task, but we can't remove it because the function signature # is from Panda3D. def updateCameraTask(self, task): # pylint: disable=unused-argument """ Move the camera sensibly. """ dt = self.globalClock.getDt() translateSpeed = 30 * dt rotateSpeed = 50 * dt # Separately track whether the camera should translate in each of the 4 # directions. These 4 are initialized based on the various inputs that # might tell us to scroll, and different inputs saying the same thing # don't stack. That way if we get inputs saying both "left" and # "right", they can cancel and the camera just won't move along that # axis -- even if, say, there are two inputs saying "left" and only one # saying "right'. moveLeft = self.keys["arrow_left"] moveRight = self.keys["arrow_right"] moveUp = self.keys["arrow_up"] moveDown = self.keys["arrow_down"] # Check if the mouse is over the window. if self.mouseWatcherNode.hasMouse(): # Get the position. # Each coordinate is normalized to the interval [-1, 1]. mousePos = self.mouseWatcherNode.getMouse() xPos, yPos = mousePos.getX(), mousePos.getY() # Only move if the mouse is close to the edge, and actually within # the window. if (1.0 - EDGE_SCROLL_WIDTH) < xPos <= 1.0: moveRight = 1 if -(1.0 - EDGE_SCROLL_WIDTH) > xPos >= -1.0: moveLeft = 1 if (1.0 - EDGE_SCROLL_WIDTH) < yPos <= 1.0: moveUp = 1 if -(1.0 - EDGE_SCROLL_WIDTH) > yPos >= -1.0: moveDown = 1 forward = translateSpeed * (moveUp - moveDown) sideways = translateSpeed * (moveRight - moveLeft) self.cameraHolder.setPos(self.cameraHolder, sideways, forward, 0) if sideways != 0 or forward != 0: self.updateSelectionBox() rotate = rotateSpeed * (self.keys["a"] - self.keys["d"]) self.cameraHolder.setHpr(self.cameraHolder, rotate, 0, 0) return Task.cont def zoomCamera(self, inward): """ Zoom in or out. """ dt = self.globalClock.getDt() zoomSpeed = 100 * dt zoom = -zoomSpeed if inward else zoomSpeed self.cameraHolder.setPos(self.cameraHolder, 0, 0, zoom) def centerView(self): """ Center the view sensibly. """ message = cmessages.RequestCenter() self.graphicsInterface.graphicsMessage(message.serialize()) # We don't use task, but we can't remove it because the function signature # is from Panda3D. def mouseMoveTask(self, task): # pylint: disable=unused-argument """ Handle mouse movement. """ mousePos = self.getMousePos() # NOTE: We don't handle clicking and dragging at the same time. if mousePos is not None and mousePos != self.prevMousePos: for (buttonId, state) in self.mouseState.iteritems(): state.lastPos = mousePos if state.hasMoved: self.handleMouseDragMove(buttonId, state.modifiers, state.startPos, mousePos) else: startX, startY = state.startPos mouseX, mouseY = mousePos distance = math.hypot(mouseX - startX, mouseY - startY) # TODO[#3]: Magic numbers bad. # Check if the mouse has moved outside the dead zone. if distance > 0.0314: self.handleMouseDragStart(buttonId, state.modifiers, state.startPos, mousePos) state.hasMoved = True if mousePos != self.prevMousePos: self.prevMousePos = mousePos return Task.cont def pandaEventMouseDown(self, buttonId, modifiers): if buttonId in self.mouseState: # Call pandaEventMouseUp just to clear any state related to the # button being down, so we can handle this buttonDown event as if # it were a fresh press of the button. log.warn("Mouse button %s is already down.", buttonId) self.pandaEventMouseUp(buttonId) assert buttonId not in self.mouseState state = MouseButtonState(modifiers[:], self.getMousePos()) self.mouseState[buttonId] = state def pandaEventMouseUp(self, buttonId): if buttonId not in self.mouseState: # Drop the event, since there's nothing to do. log.warn("Mouse button %s is already up.", buttonId) return state = self.mouseState[buttonId] if state.hasMoved: endPos = self.getMousePos() if endPos is None: endPos = state.lastPos self.handleMouseDragEnd(buttonId, state.modifiers, state.startPos, endPos) else: self.handleMouseClick(buttonId, state.modifiers, state.startPos) del self.mouseState[buttonId] def handleMouseClick(self, button, modifiers, pos): # Make sure the mouse is inside the screen # TODO: Move this check to pandaEventMouseUp? if self.mouseWatcherNode.hasMouse() and self.usingCustomCamera: x, y, _z = self.coordScreenTo3d(pos) if modifiers == []: # TODO: This component should take care of decoding the # click as far as "left" or "right"; we shouldn't send a # numerical button id to the graphicsInterface. message = cmessages.Click(button, (x, y)) elif button == 1 and modifiers == ["shift"]: message = cmessages.ShiftLClick((x, y)) elif button == 1 and modifiers == ["control"]: message = cmessages.ControlLClick((x, y)) elif button == 3 and modifiers == ["shift"]: message = cmessages.ShiftRClick((x, y)) elif button == 3 and modifiers == ["control"]: message = cmessages.ControlRClick((x, y)) else: thisShouldNeverHappen( "Unhandled modifiers for click: {}".format(modifiers)) self.graphicsInterface.graphicsMessage(message.serialize()) def handleMouseDragStart(self, buttonId, modifiers, startPos, endPos): log.debug("Start dragging from %s to %s", startPos, endPos) if buttonId == 1 and modifiers == []: assert self.selectionBoxOrigin is None self.selectionBoxOrigin = self.coordScreenTo3d(startPos) endPos = self.coordScreenTo3d(endPos) self.createSelectionBox(self.selectionBoxOrigin, endPos) def handleMouseDragMove(self, buttonId, modifiers, startPos, endPos): log.debug("Continue dragging from %s to %s", startPos, endPos) if buttonId == 1 and modifiers == []: assert self.selectionBoxOrigin is not None endPos = self.coordScreenTo3d(endPos) self.moveSelectionBox(self.selectionBoxOrigin, endPos) def handleMouseDragEnd(self, buttonId, modifiers, startPos, endPos): log.debug("End dragging from %s to %s", startPos, endPos) if buttonId == 1 and modifiers == []: # Actually select the units. endPos = self.coordScreenTo3d(endPos) # TODO[#55]: Use 3d graphics coords in messages so we don't have to # remove the z coordinates everywhere. message = cmessages.DragBox(self.selectionBoxOrigin[:2], endPos[:2]) self.graphicsInterface.graphicsMessage(message.serialize()) # Clear the selection box; we're done dragging. self.selectionBoxOrigin = None self.removeSelectionBox() def updateSelectionBox(self): if self.selectionBoxOrigin is not None: mousePos = self.getMousePos() if mousePos is not None: endPos = self.coordScreenTo3d(mousePos) self.moveSelectionBox(self.selectionBoxOrigin, endPos) def getMousePos(self): # Check if the mouse is over the window. if self.mouseWatcherNode.hasMouse(): # Get the position. # Each coordinate is normalized to the interval [-1, 1]. mousePoint = self.mouseWatcherNode.getMouse() # Create a copy of mousePoint rather than returning a reference to # it, because mousePoint will be modified in place by Panda. return (mousePoint.getX(), mousePoint.getY()) else: return None def handleWindowClose(self): log.info("Window close requested -- shutting down client.") message = cmessages.RequestQuit() self.graphicsInterface.graphicsMessage(message.serialize()) def setupEventHandlers(self): def pushKey(key, value): self.keys[key] = value for key in ["arrow_up", "arrow_left", "arrow_right", "arrow_down", "w", "a", "d", "s"]: self.keys[key] = False self.accept(key, pushKey, [key, True]) self.accept("shift-%s" % key, pushKey, [key, True]) self.accept("%s-up" % key, pushKey, [key, False]) # Camera toggle. self.accept("f3", self.toggleCameraStyle, []) self.accept("shift-f3", self.toggleCameraStyle, []) # Center view. self.accept("space", self.centerView, []) # Handle mouse wheel. self.accept("wheel_up", self.zoomCamera, [True]) self.accept("wheel_down", self.zoomCamera, [False]) # Handle clicking. self.accept("mouse1", self.pandaEventMouseDown, [1, []]) self.accept("mouse1-up", self.pandaEventMouseUp, [1]) # TODO: Make sure this is always the right mouse button. self.accept("mouse3", self.pandaEventMouseDown, [3, []]) self.accept("mouse3-up", self.pandaEventMouseUp, [3]) # Handle clicking with modifier keys. self.accept("shift-mouse1", self.pandaEventMouseDown, [1, ["shift"]]) self.accept("control-mouse1", self.pandaEventMouseDown, [1, ["control"]]) self.accept("shift-mouse3", self.pandaEventMouseDown, [3, ["shift"]]) self.accept("control-mouse3", self.pandaEventMouseDown, [3, ["control"]]) # Handle window close request (clicking the X, Alt-F4, etc.) self.win.set_close_request_event("window-close") self.accept("window-close", self.handleWindowClose) def coord3dToScreen(self, coord3d): # Empirically, Lens.project takes coordinates in the *camera*'s # coordinate system, not its parent or the render. This was not very # clear from the documentation, and you'd be surprised how long it took # us to figure this out. Anyway, we need to convert the point to be # relative to self.camera here; otherwise we'll get bizarre, # nonsensical, and hard-to-debug results. coord3d = self.camera.getRelativePoint(self.render, coord3d) screenCoord = Point2() if not self.camLens.project(coord3d, screenCoord): log.debug("Attempting 3d-to-screen conversion on point outside of " "camera's viewing frustum.") # Convert to a tuple to ensure no one else is keeping a reference # around. x, y = screenCoord return (x, y) def coordScreenTo3d(self, screenCoord): x, y = screenCoord screenPoint = Point2(x, y) # Do this calculation using simple geometry, rather than the absurd # collision-traversal nonsense we used to use. Thanks to # https://www.panda3d.org/forums/viewtopic.php?t=5409 # for pointing us at the right methods to make this work. # Get two points along the ray extending from the camera, in the # direction of the mouse click. nearPoint = Point3() farPoint = Point3() self.camLens.extrude(screenPoint, nearPoint, farPoint) # These points are relative to the camera, so need to be converted to # be relative to the render. Thanks to the example code (see link # above) for saving us probably some hours of debugging figuring that # one out again :) nearPoint = self.render.getRelativePoint(self.camera, nearPoint) farPoint = self.render.getRelativePoint(self.camera, farPoint) intersection = Point3() if self.groundPlane.intersectsLine(intersection, nearPoint, farPoint): # Convert to a tuple to ensure no one else is keeping a reference # around. x, y, z = intersection return (x, y, z) # The ray didn't intersect the ground. This is almost certainly going # to happen at some point; all you have to do is find a way to aim the # camera (or manipulate the screen coordinate) so that the ray points # horizontally. But we don't have code to handle it, so for now just # abort. thisIsNotHandled()
class WartsApp(ShowBase): """ The application running all the graphics. """ def __init__(self, graphicsInterface): ShowBase.__init__(self) # This is available as a global, but pylint gives an undefined-variable # warning if we use it that way. Looking at # https://www.panda3d.org/manual/index.php/ShowBase # I would have thought we could reference it as either # self.globalClock, direct.showbase.ShowBase.globalClock, or possibly # direct.showbase.globalClock, but none of those seems to work. To # avoid the pylint warnings, create self.globalClock manually. self.globalClock = ClockObject.getGlobalClock() self.graphicsInterface = graphicsInterface # Mapping from gids to entities. self.entities = {} # Set up event handling. self.mouseState = {} self.keys = {} self.setupEventHandlers() # Set up camera control. self.cameraHolder = self.render.attachNewNode('CameraHolder') self.cameraHolder.setPos(0, 0, 100) self.prevCameraHpr = (0, -80, 0) self.usingCustomCamera = True self.setCameraCustom() self.prevMousePos = None self.selectionBox = None self.selectionBoxNode = None self.selectionBoxOrigin = None # TODO[#3]: Magic numbers bad. self.resourceDisplay = OnscreenText(pos=(-0.98, .9), align=TextNode.ALeft, mayChange=True) # Define the ground plane by a normal (+z) and a point (the origin). self.groundPlane = core.Plane(core.Vec3(0, 0, 1), core.Point3(0, 0, 0)) self.graphicsInterface.graphicsReady(self) def cleanup(self): pass def interfaceMessage(self, data): # Messages from GraphicsInterface to Graphics are always internal # client messages, so no need to catch InvalidMessageError. message = deserializeMessage(data) if isinstance(message, messages.Tick): pass elif isinstance(message, cmessages.AddEntity): self.addEntity(message.gid, message.pos, message.modelPath, message.isExample, message.isUnit, message.goalSize) elif isinstance(message, cmessages.RemoveEntity): self.removeEntity(message.gid) elif isinstance(message, cmessages.MoveEntity): self.moveEntity(message.gid, message.pos) elif isinstance(message, cmessages.MarkEntitySelected): self.markSelected(message.gid, message.isSelected) elif isinstance(message, cmessages.DisplayResources): self.displayResources(message.resourceAmt) else: badIMessageCommand(message, log) def addEntity(self, gid, pos, modelPath, isExample, isUnit, goalSize): """ pos is given in graphics coordinates. goalSize, if specified, is a pair (width, height) -- the model will be scaled in the xy plane so that it's as large as possible while still fitting within that width and height. Don't pass 0 as the width or the height, because that's just not nice. """ if gid in self.entities: raise RuntimeError( "Already have entity with gid {gid}.".format(gid=gid)) log.debug("Adding graphical entity %s at %s", gid, pos) x, y = pos if isExample: # The example panda from the Panda3D "Hello world" tutorial. # TODO[#9]: Figure out a more general way of specifying animations. model = Actor(modelPath, {"walk": "models/panda-walk4"}) else: model = self.loader.loadModel(getModelPath(modelPath)) # Put the model in the scene, but don't position it yet. rootNode = self.render.attachNewNode("") model.reparentTo(rootNode) # Rescale the model about its origin. The x and y coordinates of the # model's origin should be chosen as wherever it looks like the model's # center of mass is, so that rotation about the origin (in the xy # plane) feels natural. goalWidthX, goalWidthY = goalSize bound1, bound2 = model.getTightBounds() modelWidthX = abs(bound2[0] - bound1[0]) modelWidthY = abs(bound2[1] - bound1[1]) # Scale it to the largest it can be while still fitting within the goal # rect. If the aspect ratio of the goal rect is different from that of # the model, then it'll only fill that rect in one dimension. # altScaleFactor is used for sanity checks below. scaleFactor, altScaleFactor = minmax(goalWidthX / modelWidthX, goalWidthY / modelWidthY) # Sanity check the scale factor. if scaleFactor <= 0.0: if scaleFactor == 0.0: log.warn("Graphical entity %s will be scaled negatively!", gid) else: log.warn("Graphical entity %s will be scaled to zero size.", gid) else: # TODO[#9]: Currently the example panda triggers this warning. # TODO[#3]: Magic numbers bad. if altScaleFactor / scaleFactor > 1.001: log.warn( "Graphical entity %s has different aspect ratio than " "its model: model of size %.3g x %.3g being scaled " "into %.3g x %.3g.", gid, modelWidthX, modelWidthY, goalWidthX, goalWidthY) model.setScale(scaleFactor) # Place the model at z=0. The model's origin should be placed so that # this looks natural -- for most units this means it should be right at # the bottom of the model, but if we add any units that are intended to # float above the ground, then this can be accomplished by just # positioning the model above its origin. rootNode.setPos(x, y, 0.0) entity = Entity(gid, model, rootNode, isExample) self.entities[gid] = entity if isUnit: # TODO[#52]: Sigh. This is a terrible hack. I guess we could pipe # through yet another bool for "is this my unit", but I don't want # to have a growing collection of bools that need to be passed into # the graphics for each unit. For now, "is this an example model?" # and "is this my unit" are equivalent, so I guess we'll just # piggyback off of isExample.... if isExample: entity.setIndicator( self.loader.loadModel( getModelPath("unit-indicator-mine.egg"))) else: entity.setIndicator( self.loader.loadModel( getModelPath("unit-indicator-notmine.egg"))) def removeEntity(self, gid): log.debug("Removing graphical entity %s", gid) entity = self.entities.pop(gid) entity.cleanup() def moveEntity(self, gid, newPos): log.debug("Moving graphical entity %s to %s", gid, newPos) entity = self.entities[gid] x, y = newPos oldX, oldY, oldZ = entity.rootNode.getPos() z = oldZ # Ensure the entity is facing the right direction. heading = math.atan2(y - oldY, x - oldX) heading *= 180.0 / math.pi # Magic angle adjustment needed to stop the panda always facing # sideways. # TODO[#9]: Establish a convention about which way _our_ models face; # figure out whether we need something like this. (Hopefully not?) heading += 90.0 entity.rootNode.setHpr(heading, 0, 0) moveInterval = entity.rootNode.posInterval(config.TICK_LENGTH, (x, y, z)) moveInterval.start() if entity.isActor and "walk" in entity.model.getAnimNames(): currFrame = entity.model.getCurrentFrame("walk") if currFrame is None: currFrame = 0 # Supposedly, it's possible to pass a startFrame and a duration to # actorInterval, instead of calculating the endFrame ourself. But # for some reason, that doesn't seem to work; if I do that, then # the animation just keeps jumping around the early frames and # never gets past frame 5 or so. I'm not sure why. For now at # least, just calculate the endFrame ourselves to work around this. log.debug("Animating entity %s from frame %s/%s", gid, currFrame, entity.model.getNumFrames("walk")) frameRate = entity.model.getAnimControl("walk").getFrameRate() endFrame = currFrame + int( math.ceil(frameRate * config.TICK_LENGTH)) animInterval = entity.model.actorInterval("walk", loop=1, startFrame=currFrame, endFrame=endFrame) animInterval.start() def markSelected(self, gid, isSelected): log.debug("Marking graphical entity %s as %sselected", gid, "" if isSelected else "not ") entity = self.entities[gid] if isSelected: entity.setIndicator( self.loader.loadModel( getModelPath("unit-indicator-selected.egg"))) else: # You can't currently select others' units, so if a unit is being # deselected it must be mine. entity.setIndicator( self.loader.loadModel(getModelPath("unit-indicator-mine.egg"))) def displayResources(self, resourceAmt): self.resourceDisplay.setText("Resource: {}".format(resourceAmt)) def createSelectionBox(self, corner1, corner2): """ Create a selection "box" given the coordinates of two opposite corners. The corners are given in world coordinates (well, 3d graphics coordinates). """ assert self.selectionBox is None p1, p2, p3, p4 = self.convert3dBoxToScreen(corner1, corner2) x1, y1 = p1 x2, y2 = p2 x3, y3 = p3 x4, y4 = p4 # TODO[#3]: Magic numbers bad. self.selectionBox = LineSegs("SelectionBox") self.selectionBox.setThickness(3.0) self.selectionBox.setColor(0.0, 1.0, 0.25, 1.0) self.selectionBox.move_to(x1, 0, y1) self.selectionBox.draw_to(x2, 0, y2) self.selectionBox.draw_to(x3, 0, y3) self.selectionBox.draw_to(x4, 0, y4) self.selectionBox.draw_to(x1, 0, y1) self.selectionBoxNode = self.render2d.attachNewNode( self.selectionBox.create()) def moveSelectionBox(self, corner1, corner2): assert self.selectionBox is not None p1, p2, p3, p4 = self.convert3dBoxToScreen(corner1, corner2) x1, y1 = p1 x2, y2 = p2 x3, y3 = p3 x4, y4 = p4 self.selectionBox.setVertex(0, x1, 0, y1) self.selectionBox.setVertex(1, x2, 0, y2) self.selectionBox.setVertex(2, x3, 0, y3) self.selectionBox.setVertex(3, x4, 0, y4) self.selectionBox.setVertex(4, x1, 0, y1) def removeSelectionBox(self): self.selectionBoxNode.removeNode() self.selectionBox = None self.selectionBoxNode = None def convert3dBoxToScreen(self, corner1, corner3): """ Return screen coordinates of the 4 corners of a box, given in 3d coordinates. The box is specified using 2 opposite corners. """ wx1, wy1, wz1 = corner1 wx3, wy3, wz3 = corner3 wx2, wy2 = (wx1, wy3) wx4, wy4 = (wx3, wy1) # Note: corner1 and corner2 could have nonzero z because floating-point # calculations, but they should at least be close. We'll just average # their z and not worry about it. wz2 = wz4 = 0.5 * (wz1 + wz3) p1 = self.coord3dToScreen((wx1, wy1, wz1)) p2 = self.coord3dToScreen((wx2, wy2, wz2)) p3 = self.coord3dToScreen((wx3, wy3, wz3)) p4 = self.coord3dToScreen((wx4, wy4, wz4)) return (p1, p2, p3, p4) def setCameraCustom(self): """ Change to using our custom task to control the camera. """ # Disable the default mouse-based camera control task, so we don't have # to fight with it for control of the camera. self.disableMouse() # Face the camera in the appropriate angle. self.camera.setHpr(self.prevCameraHpr) # Put it in the same location as the cameraHolder, and make it stay # put relative to the cameraHolder (so we can move the camera around by # changing the cameraHolder's position). self.camera.reparentTo(self.cameraHolder) self.camera.setPos(0, 0, 0) # Substitute our own camera control task. self.taskMgr.add(self.updateCameraTask, "UpdateCameraTask") self.usingCustomCamera = True # Need a task to handle mouse-dragging because there doesn't seem to be # a built-in mouseMove event. self.taskMgr.add(self.mouseMoveTask, "MouseMoveTask") def setCameraDefault(self): """ Change to using the default mouse-based camera controls. """ self.taskMgr.remove("UpdateCameraTask") # Save current location for when this control style is restored. self.prevCameraHpr = self.camera.getHpr() # Use the existing camera location, rather than jumping back to the one # from last time the default camera controller was active. # Copied from https://www.panda3d.org/manual/index.php/Mouse_Support mat = Mat4(self.camera.getMat()) mat.invertInPlace() self.mouseInterfaceNode.setMat(mat) self.enableMouse() self.usingCustomCamera = False def toggleCameraStyle(self): """ Switch to whichever style of camera control isn't currently active. """ if self.usingCustomCamera: self.setCameraDefault() else: self.setCameraCustom() # We don't use task, but we can't remove it because the function signature # is from Panda3D. def updateCameraTask(self, task): # pylint: disable=unused-argument """ Move the camera sensibly. """ dt = self.globalClock.getDt() translateSpeed = 30 * dt rotateSpeed = 50 * dt # Separately track whether the camera should translate in each of the 4 # directions. These 4 are initialized based on the various inputs that # might tell us to scroll, and different inputs saying the same thing # don't stack. That way if we get inputs saying both "left" and # "right", they can cancel and the camera just won't move along that # axis -- even if, say, there are two inputs saying "left" and only one # saying "right'. moveLeft = self.keys["arrow_left"] moveRight = self.keys["arrow_right"] moveUp = self.keys["arrow_up"] moveDown = self.keys["arrow_down"] # Check if the mouse is over the window. if self.mouseWatcherNode.hasMouse(): # Get the position. # Each coordinate is normalized to the interval [-1, 1]. mousePos = self.mouseWatcherNode.getMouse() xPos, yPos = mousePos.getX(), mousePos.getY() # Only move if the mouse is close to the edge, and actually within # the window. if (1.0 - EDGE_SCROLL_WIDTH) < xPos <= 1.0: moveRight = 1 if -(1.0 - EDGE_SCROLL_WIDTH) > xPos >= -1.0: moveLeft = 1 if (1.0 - EDGE_SCROLL_WIDTH) < yPos <= 1.0: moveUp = 1 if -(1.0 - EDGE_SCROLL_WIDTH) > yPos >= -1.0: moveDown = 1 forward = translateSpeed * (moveUp - moveDown) sideways = translateSpeed * (moveRight - moveLeft) self.cameraHolder.setPos(self.cameraHolder, sideways, forward, 0) if sideways != 0 or forward != 0: self.updateSelectionBox() rotate = rotateSpeed * (self.keys["a"] - self.keys["d"]) self.cameraHolder.setHpr(self.cameraHolder, rotate, 0, 0) return Task.cont def zoomCamera(self, inward): """ Zoom in or out. """ dt = self.globalClock.getDt() zoomSpeed = 100 * dt zoom = -zoomSpeed if inward else zoomSpeed self.cameraHolder.setPos(self.cameraHolder, 0, 0, zoom) def centerView(self): """ Center the view sensibly. """ message = cmessages.RequestCenter() self.graphicsInterface.graphicsMessage(message.serialize()) # We don't use task, but we can't remove it because the function signature # is from Panda3D. def mouseMoveTask(self, task): # pylint: disable=unused-argument """ Handle mouse movement. """ mousePos = self.getMousePos() # NOTE: We don't handle clicking and dragging at the same time. if mousePos is not None and mousePos != self.prevMousePos: for (buttonId, state) in self.mouseState.iteritems(): state.lastPos = mousePos if state.hasMoved: self.handleMouseDragMove(buttonId, state.modifiers, state.startPos, mousePos) else: startX, startY = state.startPos mouseX, mouseY = mousePos distance = math.hypot(mouseX - startX, mouseY - startY) # TODO[#3]: Magic numbers bad. # Check if the mouse has moved outside the dead zone. if distance > 0.0314: self.handleMouseDragStart(buttonId, state.modifiers, state.startPos, mousePos) state.hasMoved = True if mousePos != self.prevMousePos: self.prevMousePos = mousePos return Task.cont def pandaEventMouseDown(self, buttonId, modifiers): if buttonId in self.mouseState: # Call pandaEventMouseUp just to clear any state related to the # button being down, so we can handle this buttonDown event as if # it were a fresh press of the button. log.warn("Mouse button %s is already down.", buttonId) self.pandaEventMouseUp(buttonId) assert buttonId not in self.mouseState state = MouseButtonState(modifiers[:], self.getMousePos()) self.mouseState[buttonId] = state def pandaEventMouseUp(self, buttonId): if buttonId not in self.mouseState: # Drop the event, since there's nothing to do. log.warn("Mouse button %s is already up.", buttonId) return state = self.mouseState[buttonId] if state.hasMoved: endPos = self.getMousePos() if endPos is None: endPos = state.lastPos self.handleMouseDragEnd(buttonId, state.modifiers, state.startPos, endPos) else: self.handleMouseClick(buttonId, state.modifiers, state.startPos) del self.mouseState[buttonId] def handleMouseClick(self, button, modifiers, pos): # Make sure the mouse is inside the screen # TODO: Move this check to pandaEventMouseUp? if self.mouseWatcherNode.hasMouse() and self.usingCustomCamera: x, y, _z = self.coordScreenTo3d(pos) if modifiers == []: # TODO: This component should take care of decoding the # click as far as "left" or "right"; we shouldn't send a # numerical button id to the graphicsInterface. message = cmessages.Click(button, (x, y)) elif button == 1 and modifiers == ["shift"]: message = cmessages.ShiftLClick((x, y)) elif button == 1 and modifiers == ["control"]: message = cmessages.ControlLClick((x, y)) elif button == 3 and modifiers == ["shift"]: message = cmessages.ShiftRClick((x, y)) elif button == 3 and modifiers == ["control"]: message = cmessages.ControlRClick((x, y)) else: thisShouldNeverHappen( "Unhandled modifiers for click: {}".format(modifiers)) self.graphicsInterface.graphicsMessage(message.serialize()) def handleMouseDragStart(self, buttonId, modifiers, startPos, endPos): log.debug("Start dragging from %s to %s", startPos, endPos) if buttonId == 1 and modifiers == []: assert self.selectionBoxOrigin is None self.selectionBoxOrigin = self.coordScreenTo3d(startPos) endPos = self.coordScreenTo3d(endPos) self.createSelectionBox(self.selectionBoxOrigin, endPos) def handleMouseDragMove(self, buttonId, modifiers, startPos, endPos): log.debug("Continue dragging from %s to %s", startPos, endPos) if buttonId == 1 and modifiers == []: assert self.selectionBoxOrigin is not None endPos = self.coordScreenTo3d(endPos) self.moveSelectionBox(self.selectionBoxOrigin, endPos) def handleMouseDragEnd(self, buttonId, modifiers, startPos, endPos): log.debug("End dragging from %s to %s", startPos, endPos) if buttonId == 1 and modifiers == []: # Actually select the units. endPos = self.coordScreenTo3d(endPos) # TODO[#55]: Use 3d graphics coords in messages so we don't have to # remove the z coordinates everywhere. message = cmessages.DragBox(self.selectionBoxOrigin[:2], endPos[:2]) self.graphicsInterface.graphicsMessage(message.serialize()) # Clear the selection box; we're done dragging. self.selectionBoxOrigin = None self.removeSelectionBox() def updateSelectionBox(self): if self.selectionBoxOrigin is not None: mousePos = self.getMousePos() if mousePos is not None: endPos = self.coordScreenTo3d(mousePos) self.moveSelectionBox(self.selectionBoxOrigin, endPos) def getMousePos(self): # Check if the mouse is over the window. if self.mouseWatcherNode.hasMouse(): # Get the position. # Each coordinate is normalized to the interval [-1, 1]. mousePoint = self.mouseWatcherNode.getMouse() # Create a copy of mousePoint rather than returning a reference to # it, because mousePoint will be modified in place by Panda. return (mousePoint.getX(), mousePoint.getY()) else: return None def handleWindowClose(self): log.info("Window close requested -- shutting down client.") message = cmessages.RequestQuit() self.graphicsInterface.graphicsMessage(message.serialize()) def setupEventHandlers(self): def pushKey(key, value): self.keys[key] = value for key in [ "arrow_up", "arrow_left", "arrow_right", "arrow_down", "w", "a", "d", "s" ]: self.keys[key] = False self.accept(key, pushKey, [key, True]) self.accept("shift-%s" % key, pushKey, [key, True]) self.accept("%s-up" % key, pushKey, [key, False]) # Camera toggle. self.accept("f3", self.toggleCameraStyle, []) self.accept("shift-f3", self.toggleCameraStyle, []) # Center view. self.accept("space", self.centerView, []) # Handle mouse wheel. self.accept("wheel_up", self.zoomCamera, [True]) self.accept("wheel_down", self.zoomCamera, [False]) # Handle clicking. self.accept("mouse1", self.pandaEventMouseDown, [1, []]) self.accept("mouse1-up", self.pandaEventMouseUp, [1]) # TODO: Make sure this is always the right mouse button. self.accept("mouse3", self.pandaEventMouseDown, [3, []]) self.accept("mouse3-up", self.pandaEventMouseUp, [3]) # Handle clicking with modifier keys. self.accept("shift-mouse1", self.pandaEventMouseDown, [1, ["shift"]]) self.accept("control-mouse1", self.pandaEventMouseDown, [1, ["control"]]) self.accept("shift-mouse3", self.pandaEventMouseDown, [3, ["shift"]]) self.accept("control-mouse3", self.pandaEventMouseDown, [3, ["control"]]) # Handle window close request (clicking the X, Alt-F4, etc.) self.win.set_close_request_event("window-close") self.accept("window-close", self.handleWindowClose) def coord3dToScreen(self, coord3d): # Empirically, Lens.project takes coordinates in the *camera*'s # coordinate system, not its parent or the render. This was not very # clear from the documentation, and you'd be surprised how long it took # us to figure this out. Anyway, we need to convert the point to be # relative to self.camera here; otherwise we'll get bizarre, # nonsensical, and hard-to-debug results. coord3d = self.camera.getRelativePoint(self.render, coord3d) screenCoord = Point2() if not self.camLens.project(coord3d, screenCoord): log.debug("Attempting 3d-to-screen conversion on point outside of " "camera's viewing frustum.") # Convert to a tuple to ensure no one else is keeping a reference # around. x, y = screenCoord return (x, y) def coordScreenTo3d(self, screenCoord): x, y = screenCoord screenPoint = Point2(x, y) # Do this calculation using simple geometry, rather than the absurd # collision-traversal nonsense we used to use. Thanks to # https://www.panda3d.org/forums/viewtopic.php?t=5409 # for pointing us at the right methods to make this work. # Get two points along the ray extending from the camera, in the # direction of the mouse click. nearPoint = Point3() farPoint = Point3() self.camLens.extrude(screenPoint, nearPoint, farPoint) # These points are relative to the camera, so need to be converted to # be relative to the render. Thanks to the example code (see link # above) for saving us probably some hours of debugging figuring that # one out again :) nearPoint = self.render.getRelativePoint(self.camera, nearPoint) farPoint = self.render.getRelativePoint(self.camera, farPoint) intersection = Point3() if self.groundPlane.intersectsLine(intersection, nearPoint, farPoint): # Convert to a tuple to ensure no one else is keeping a reference # around. x, y, z = intersection return (x, y, z) # The ray didn't intersect the ground. This is almost certainly going # to happen at some point; all you have to do is find a way to aim the # camera (or manipulate the screen coordinate) so that the ray points # horizontally. But we don't have code to handle it, so for now just # abort. thisIsNotHandled()
def line_art(sd, stemlet_length, stemlet_diameter, rest_segments, style): segs = LineSegs() segs.set_thickness(2.0) if style.stem: segs.set_color(style.stem) segs.move_to(0, 0, 0) segs.draw_to(0, 0, stemlet_length) # Ring around base if style.ring: # Segment base ring segs.set_color(style.ring) for r in range(style.ring_segs): from_v = r / style.ring_segs * 2 * math.pi to_v = (r + 1) / style.ring_segs * 2 * math.pi segs.move_to( math.sin(from_v) * stemlet_diameter, math.cos(from_v) * stemlet_diameter, 0, ) segs.draw_to( math.sin(to_v) * stemlet_diameter, math.cos(to_v) * stemlet_diameter, 0, ) # Endcap ring if rest_segments == 1: for r in range(style.ring_segs): from_v = r / style.ring_segs * 2 * math.pi to_v = (r + 1) / style.ring_segs * 2 * math.pi segs.move_to( math.sin(from_v) * stemlet_diameter, math.cos(from_v) * stemlet_diameter, stemlet_length, ) segs.draw_to( math.sin(to_v) * stemlet_diameter, math.cos(to_v) * stemlet_diameter, stemlet_length, ) # Bark if style.bark: segs.set_color(style.bark) for r in range(style.ring_segs): lobing = 1 + math.sin(2 * math.pi * sd.lobes * r / style.ring_segs) v = r / style.ring_segs * 2 * math.pi segs.move_to( math.sin(v) * stemlet_diameter * lobing, math.cos(v) * stemlet_diameter * lobing, 0, ) segs.draw_to( math.sin(v) * stemlet_diameter * lobing, math.cos(v) * stemlet_diameter * lobing, stemlet_length, ) # x/y indicators if style.xyz_at_top: indicator_z = stemlet_length else: indicator_z = 0.0 if style.x: segs.set_color(style.x) segs.move_to(0, 0, indicator_z) segs.draw_to(stemlet_diameter, 0, indicator_z) if style.y: segs.set_color(style.y) segs.move_to(0, 0, indicator_z) segs.draw_to(0, stemlet_diameter, indicator_z) return segs.create()
def SetupModel(self, VUTProject): source = osr.SpatialReference() source.ImportFromEPSG(4326) target = osr.SpatialReference() target.ImportFromEPSG(int(self.OutEPSG)) transform = osr.CoordinateTransformation(source, target) BBxMin = float(self.VRTBoundingBox.split(':')[0].split(',')[0]) BByMin = float(self.VRTBoundingBox.split(':')[0].split(',')[1]) BBxMax = float(self.VRTBoundingBox.split(':')[1].split(',')[0]) BByMax = float(self.VRTBoundingBox.split(':')[1].split(',')[1]) XLenght = BBxMax - BBxMin YLenght = BByMax - BByMin NewBBxMax = BBxMax + XLenght / 2 NewBBxMin = BBxMin - XLenght / 2 NewBByMax = BByMax + YLenght / 2 NewBByMin = BByMin - YLenght / 2 pointMax = ogr.Geometry(ogr.wkbPoint) pointMax.AddPoint(NewBBxMax, NewBByMax) pointMax.Transform(transform) pointMin = ogr.Geometry(ogr.wkbPoint) pointMin.AddPoint(NewBBxMin, NewBByMin) pointMin.Transform(transform) self.BoundingBoxStr = '-te ' + str(pointMin.GetX()) + ' ' + str( pointMin.GetY()) + ' ' + str(pointMax.GetX()) + ' ' + str( pointMax.GetY()) + ' ' self.Moves = Sequence() Line = LineSegs('Path') with open(VUTProject, 'r') as File: Counter = 0 i = 0 PrevCourse = None PrevPos = None PrevHPr = None for line in File: if Counter < 6: pass else: line = line.split() lat = float(line[0]) lon = float(line[1]) ele = float(line[2]) course = float(line[4]) pitch = float(line[5]) roll = float(line[6]) if course < 180: course = -course elif course > 180: course = abs(course - 360) point = ogr.Geometry(ogr.wkbPoint) point.AddPoint(lon, lat) point.Transform(transform) if i == 0: FirstPos = (point.GetX() - self.Origin[0], point.GetY() - self.Origin[1], ele) FirstHpr = (course, pitch, roll) self.cam.setPos(FirstPos) self.cam.setHpr(FirstHpr) Line.move_to(point.GetX() - self.Origin[0], point.GetY() - self.Origin[1], ele) elif i == 1: self.Moves.append( LerpPosHprInterval( self.cam, 1, (point.GetX() - self.Origin[0], point.GetY() - self.Origin[1], ele), (fitDestAngle2Src(PrevCourse, course), pitch, roll), startPos=FirstPos, startHpr=FirstHpr, name='Interval', other=self.render)) Line.draw_to(point.GetX() - self.Origin[0], point.GetY() - self.Origin[1], ele) else: self.Moves.append( LerpPosHprInterval( self.cam, 1, (point.GetX() - self.Origin[0], point.GetY() - self.Origin[1], ele), (fitDestAngle2Src(PrevCourse, course), pitch, roll), startPos=PrevPos, startHpr=PrevHPr, name='Interval', other=self.render)) Line.draw_to(point.GetX() - self.Origin[0], point.GetY() - self.Origin[1], ele) i = i + 1 PrevCourse = course PrevPos = (point.GetX() - self.Origin[0], point.GetY() - self.Origin[1], ele) PrevHPr = (course, pitch, roll) Counter = Counter + 1 Line.setColor(1, 0.5, 0.5, 1) Line.setThickness(3) node = Line.create(False) nodePath = self.render.attachNewNode(node)
class WartsApp(ShowBase): def __init__(self, graphicsInterface, backend, gameState): ShowBase.__init__(self) self.graphicsInterface = graphicsInterface self.backend = backend self.gameState = gameState self.groundNodes = None self.firstTick = True # This is available as a global, but pylint gives an undefined-variable # warning if we use it that way. Looking at # https://www.panda3d.org/manual/index.php/ShowBase # I would have thought we could reference it as either # self.globalClock, direct.showbase.ShowBase.globalClock, or possibly # direct.showbase.globalClock, but none of those seems to work. To # avoid the pylint warnings, create self.globalClock manually. self.globalClock = ClockObject.getGlobalClock() # Set up event handling. self.mouseState = {} self.keys = {} self.setupEventHandlers() # Set up camera control. self.cameraHolder = self.render.attachNewNode('CameraHolder') self.cameraHolder.setPos(0, 0, 100) self.prevCameraHpr = (0, -80, 0) self.usingCustomCamera = True self.setCameraCustom() self.prevMousePos = None self.selectionBox = None self.selectionBoxNode = None self.selectionBoxOrigin = None # Define the ground plane by a normal (+z) and a point (the origin). self.groundPlane = core.Plane(core.Vec3(0, 0, 1), core.Point3(0, 0, 0)) graphicsInterface.graphicsReady(self) def cleanup(self): pass # For backward compatibility. # TODO[#84]: Remove when old graphics goes away; have backend just call # tick() directly. def interfaceMessage(self, data): message = deserializeMessage(data) if isinstance(message, messages.Tick): self.tick() # Ignore everything else. def tick(self): # TODO: Multiple levels of log.debug. For now, this is too spammy, so # skip it. # log.debug("Graphics: tick()") if self.firstTick: if not self.gameState.hasSize: log.error("GameState must be assigned a size before first " "tick().") return width, height = self.gameState.sizeInChunks self.groundNodes = [[None for _x in range(height)] for _y in range(width)] for cx in range(width): for cy in range(height): self.addGround((cx, cy), self.gameState.groundTypes[cx][cy]) self.firstTick = False # For now, just call this every tick. Optimize later. self.rescanUnits() def addGround(self, chunkIndex, terrainType): cx, cy = chunkIndex wPos = Coord.fromCBU(chunk=(chunkIndex)) if terrainType == 0: modelName = "green-ground.egg" else: modelName = "red-ground.egg" if terrainType != 1: log.warn("Unrecognized terrain type %d", terrainType) gPos1 = worldToGraphicsPos(wPos) gPos2 = worldToGraphicsPos(wPos + Distance.fromCBU(chunk=(1,1))) # Figure out where we want the tile. goalCenterX = 0.5 * (gPos2[0] + gPos1[0]) goalCenterY = 0.5 * (gPos2[1] + gPos1[1]) goalWidthX = abs(gPos2[0] - gPos1[0]) goalWidthY = abs(gPos2[1] - gPos1[1]) model = self.loader.loadModel(getModelPath(modelName)) # Put the model in the scene, but don't position it yet. rootNode = self.render.attachNewNode("") model.reparentTo(rootNode) # Rescale the model about its origin. The x and y coordinates of the # model's origin should be chosen as wherever it looks like the model's # center of mass is, so that rotation about the origin (in the xy # plane) feels natural. # TODO[#9]: Set a convention for model bounds so we don't have to do a # getTightBounds every time. This is dumb. # TODO[#3]: Or, as an alternative shorter-term solution, just define a # scale in the config files for the few models that aren't ours. bound1, bound2 = model.getTightBounds() modelWidthX = abs(bound2[0] - bound1[0]) modelWidthY = abs(bound2[1] - bound1[1]) # Scale it to the largest it can be while still fitting within the goal # rect. If the aspect ratio of the goal rect is different from that of # the model, then it'll only fill that rect in one dimension. # altScaleFactor is used for sanity checks below. scaleFactor, altScaleFactor = minmax(goalWidthX / modelWidthX, goalWidthY / modelWidthY) # Sanity check the scale factor. if scaleFactor <= 0.0: if scaleFactor == 0.0: log.warn("Ground %s will be scaled negatively!", chunkIndex) else: log.warn("Ground %s will be scaled to zero size.", chunkIndex) else: # TODO[#9]: Currently the example panda triggers this warning. # TODO[#3]: Magic numbers bad. if altScaleFactor / scaleFactor > 1.001: log.warn("Ground %s has different aspect ratio than " "its model: model of size %.3g x %.3g being scaled " "into %.3g x %.3g.", chunkIndex, modelWidthX, modelWidthY, goalWidthX, goalWidthY) model.setScale(scaleFactor) # Place the model at z=0. The model's origin should be placed so that # this looks natural -- for most units this means it should be right at # the bottom of the model, but if we add any units that are intended to # float above the ground, then this can be accomplished by just # positioning the model above its origin. rootNode.setPos(goalCenterX, goalCenterY, 0.0) self.groundNodes[cx][cy] = rootNode def rescanUnits(self): """ Check for units that have moved or been added/removed since the last scan. Update the display accordingly. """ # TODO: Actually write this. pass # Divide all unit ids from my own list of units and the gamestate's # current list into three parts: # - ids in both lists (potential moves) # - ids only in the gamestate's list (additions) # - ids only in my list (removals) # # Call: # moveUnit() for each potential move # addUnit() for each addition # removeUnit() for each removal def moveUnit(self, uid, newPos): # TODO: Actually write this. pass # Set unit's node to move to new position. # Play "walk" animation # ...whatever else we used to do? def addUnit(self, uid, pos): # TODO: Actually write this. pass # Create unit/model/node as we used to. # Also add it to our uid->unit mapping. def removeUnit(self, uid, pos): # TODO: Actually write this. pass # Remove+cleanup unit/model/node as we used to. # Also remove it from our uid->unit mapping. def createSelectionBox(self, corner1, corner2): """ Create a selection "box" given the coordinates of two opposite corners. The corners are given in world coordinates (well, 3d graphics coordinates). """ assert self.selectionBox is None p1, p2, p3, p4 = self.convert3dBoxToScreen(corner1, corner2) x1, y1 = p1 x2, y2 = p2 x3, y3 = p3 x4, y4 = p4 # TODO[#3]: Magic numbers bad. self.selectionBox = LineSegs("SelectionBox") self.selectionBox.setThickness(3.0) self.selectionBox.setColor(0.0, 1.0, 0.25, 1.0) self.selectionBox.move_to(x1, 0, y1) self.selectionBox.draw_to(x2, 0, y2) self.selectionBox.draw_to(x3, 0, y3) self.selectionBox.draw_to(x4, 0, y4) self.selectionBox.draw_to(x1, 0, y1) self.selectionBoxNode = self.render2d.attachNewNode( self.selectionBox.create()) def moveSelectionBox(self, corner1, corner2): assert self.selectionBox is not None p1, p2, p3, p4 = self.convert3dBoxToScreen(corner1, corner2) x1, y1 = p1 x2, y2 = p2 x3, y3 = p3 x4, y4 = p4 self.selectionBox.setVertex(0, x1, 0, y1) self.selectionBox.setVertex(1, x2, 0, y2) self.selectionBox.setVertex(2, x3, 0, y3) self.selectionBox.setVertex(3, x4, 0, y4) self.selectionBox.setVertex(4, x1, 0, y1) def removeSelectionBox(self): self.selectionBoxNode.removeNode() self.selectionBox = None self.selectionBoxNode = None def convert3dBoxToScreen(self, corner1, corner3): """ Return screen coordinates of the 4 corners of a box, given in 3d coordinates. The box is specified using 2 opposite corners. """ wx1, wy1, wz1 = corner1 wx3, wy3, wz3 = corner3 wx2, wy2 = (wx1, wy3) wx4, wy4 = (wx3, wy1) # Note: corner1 and corner2 could have nonzero z because floating-point # calculations, but they should at least be close. We'll just average # their z and not worry about it. wz2 = wz4 = 0.5 * (wz1 + wz3) p1 = self.coord3dToScreen((wx1, wy1, wz1)) p2 = self.coord3dToScreen((wx2, wy2, wz2)) p3 = self.coord3dToScreen((wx3, wy3, wz3)) p4 = self.coord3dToScreen((wx4, wy4, wz4)) return (p1, p2, p3, p4) def setCameraCustom(self): """ Change to using our custom task to control the camera. """ # Disable the default mouse-based camera control task, so we don't have # to fight with it for control of the camera. self.disableMouse() # Face the camera in the appropriate angle. self.camera.setHpr(self.prevCameraHpr) # Put it in the same location as the cameraHolder, and make it stay # put relative to the cameraHolder (so we can move the camera around by # changing the cameraHolder's position). self.camera.reparentTo(self.cameraHolder) self.camera.setPos(0, 0, 0) # Substitute our own camera control task. self.taskMgr.add(self.updateCameraTask, "UpdateCameraTask") self.usingCustomCamera = True # Need a task to handle mouse-dragging because there doesn't seem to be # a built-in mouseMove event. self.taskMgr.add(self.mouseMoveTask, "MouseMoveTask") def setCameraDefault(self): """ Change to using the default mouse-based camera controls. """ self.taskMgr.remove("UpdateCameraTask") # Save current location for when this control style is restored. self.prevCameraHpr = self.camera.getHpr() # Use the existing camera location, rather than jumping back to the one # from last time the default camera controller was active. # Copied from https://www.panda3d.org/manual/index.php/Mouse_Support mat = Mat4(self.camera.getMat()) mat.invertInPlace() self.mouseInterfaceNode.setMat(mat) self.enableMouse() self.usingCustomCamera = False def toggleCameraStyle(self): """ Switch to whichever style of camera control isn't currently active. """ if self.usingCustomCamera: self.setCameraDefault() else: self.setCameraCustom() # We don't use task, but we can't remove it because the function signature # is from Panda3D. def updateCameraTask(self, task): # pylint: disable=unused-argument """ Move the camera sensibly. """ dt = self.globalClock.getDt() translateSpeed = 30 * dt rotateSpeed = 50 * dt # Separately track whether the camera should translate in each of the 4 # directions. These 4 are initialized based on the various inputs that # might tell us to scroll, and different inputs saying the same thing # don't stack. That way if we get inputs saying both "left" and # "right", they can cancel and the camera just won't move along that # axis -- even if, say, there are two inputs saying "left" and only one # saying "right'. moveLeft = self.keys["arrow_left"] moveRight = self.keys["arrow_right"] moveUp = self.keys["arrow_up"] moveDown = self.keys["arrow_down"] # Check if the mouse is over the window. if self.mouseWatcherNode.hasMouse(): # Get the position. # Each coordinate is normalized to the interval [-1, 1]. mousePos = self.mouseWatcherNode.getMouse() xPos, yPos = mousePos.getX(), mousePos.getY() # Only move if the mouse is close to the edge, and actually within # the window. if (1.0 - EDGE_SCROLL_WIDTH) < xPos <= 1.0: moveRight = 1 if -(1.0 - EDGE_SCROLL_WIDTH) > xPos >= -1.0: moveLeft = 1 if (1.0 - EDGE_SCROLL_WIDTH) < yPos <= 1.0: moveUp = 1 if -(1.0 - EDGE_SCROLL_WIDTH) > yPos >= -1.0: moveDown = 1 forward = translateSpeed * (moveUp - moveDown) sideways = translateSpeed * (moveRight - moveLeft) self.cameraHolder.setPos(self.cameraHolder, sideways, forward, 0) # Selection box logic if sideways != 0 or forward != 0: self.updateSelectionBox() rotate = rotateSpeed * (self.keys["a"] - self.keys["d"]) self.cameraHolder.setHpr(self.cameraHolder, rotate, 0, 0) return Task.cont def zoomCamera(self, inward): """ Zoom in or out. """ dt = self.globalClock.getDt() zoomSpeed = 100 * dt zoom = -zoomSpeed if inward else zoomSpeed self.cameraHolder.setPos(self.cameraHolder, 0, 0, zoom) # We don't use task, but we can't remove it because the function signature # is from Panda3D. def mouseMoveTask(self, task): # pylint: disable=unused-argument """ Handle mouse movement. """ mousePos = self.getMousePos() # NOTE: We don't handle clicking and dragging at the same time. if mousePos is not None and mousePos != self.prevMousePos: for (buttonId, state) in self.mouseState.iteritems(): state.lastPos = mousePos if state.hasMoved: self.handleMouseDragMove(buttonId, state.modifiers, state.startPos, mousePos) else: startX, startY = state.startPos mouseX, mouseY = mousePos distance = math.hypot(mouseX - startX, mouseY - startY) # TODO[#3]: Magic numbers bad. # Check if the mouse has moved outside the dead zone. if distance > 0.0314: self.handleMouseDragStart(buttonId, state.modifiers, state.startPos, mousePos) state.hasMoved = True if mousePos != self.prevMousePos: self.prevMousePos = mousePos return Task.cont def pandaEventMouseDown(self, buttonId, modifiers): log.debug("Mouse down: button %s w/ mod %s", buttonId, modifiers) if buttonId in self.mouseState: # Call pandaEventMouseUp just to clear any state related to the # button being down, so we can handle this buttonDown event as if # it were a fresh press of the button. log.warn("Mouse button %s is already down.", buttonId) self.pandaEventMouseUp(buttonId) assert buttonId not in self.mouseState state = MouseButtonState(modifiers[:], self.getMousePos()) self.mouseState[buttonId] = state def pandaEventMouseUp(self, buttonId): log.debug("Mouse up: button %s", buttonId) if buttonId not in self.mouseState: # Drop the event, since there's nothing to do. log.warn("Mouse button %s is already up.", buttonId) return state = self.mouseState[buttonId] if state.hasMoved: endPos = self.getMousePos() if endPos is None: endPos = state.lastPos self.handleMouseDragEnd(buttonId, state.modifiers, state.startPos, endPos) else: self.handleMouseClick(buttonId, state.modifiers, state.startPos) del self.mouseState[buttonId] def handleMouseClick(self, button, modifiers, pos): # Make sure the mouse is inside the screen # TODO: Move this check to pandaEventMouseUp? if self.mouseWatcherNode.hasMouse() and self.usingCustomCamera: x, y, _z = self.coordScreenTo3d(pos) uPos = graphicsToWorldPos((x, y)) self.backend.worldClick(uPos, button, modifiers) def handleMouseDragStart(self, buttonId, modifiers, startPos, endPos): log.debug("Start dragging from %s to %s", startPos, endPos) if buttonId == 1 and modifiers == []: assert self.selectionBoxOrigin is None self.selectionBoxOrigin = self.coordScreenTo3d(startPos) endPos = self.coordScreenTo3d(endPos) self.createSelectionBox(self.selectionBoxOrigin, endPos) def handleMouseDragMove(self, buttonId, modifiers, startPos, endPos): log.debug("Continue dragging from %s to %s", startPos, endPos) if buttonId == 1 and modifiers == []: assert self.selectionBoxOrigin is not None endPos = self.coordScreenTo3d(endPos) self.moveSelectionBox(self.selectionBoxOrigin, endPos) def handleMouseDragEnd(self, buttonId, modifiers, startPos, endPos): log.debug("End dragging from %s to %s", startPos, endPos) # TODO: Do we need this check? What is the effect of only calling # removeSelectionBox() under this check? if buttonId == 1 and modifiers == []: # Actually select the units. startGPos = self.selectionBoxOrigin[:2] endGPos = self.coordScreenTo3d(endPos)[:2] startUPos = graphicsToWorldPos(startGPos) endUPos = graphicsToWorldPos(endGPos) self.backend.worldDrag(startUPos, endUPos, buttonId, modifiers) # Clear the selection box; we're done dragging. self.selectionBoxOrigin = None self.removeSelectionBox() def updateSelectionBox(self): if self.selectionBoxOrigin is not None: mousePos = self.getMousePos() if mousePos is not None: endPos = self.coordScreenTo3d(mousePos) self.moveSelectionBox(self.selectionBoxOrigin, endPos) def getMousePos(self): # Check if the mouse is over the window. if self.mouseWatcherNode.hasMouse(): # Get the position. # Each coordinate is normalized to the interval [-1, 1]. mousePoint = self.mouseWatcherNode.getMouse() # Create a copy of mousePoint rather than returning a reference to # it, because mousePoint will be modified in place by Panda. return (mousePoint.getX(), mousePoint.getY()) else: return None def handleWindowClose(self): log.info("Window close requested -- shutting down client.") # When in Rome, send messages like the Romans do, I guess. # TODO: Get rid of messages, I think. message = cmessages.RequestQuit() self.graphicsInterface.graphicsMessage(message.serialize()) def setupEventHandlers(self): def pushKey(key, value): self.keys[key] = value for key in ["arrow_up", "arrow_left", "arrow_right", "arrow_down", "w", "a", "d", "s"]: self.keys[key] = False self.accept(key, pushKey, [key, True]) self.accept("shift-%s" % key, pushKey, [key, True]) self.accept("%s-up" % key, pushKey, [key, False]) # Camera toggle. self.accept("f3", self.toggleCameraStyle, []) self.accept("shift-f3", self.toggleCameraStyle, []) # Center view. # self.accept("space", self.centerView, []) -- TODO # Handle mouse wheel. self.accept("wheel_up", self.zoomCamera, [True]) self.accept("wheel_down", self.zoomCamera, [False]) # Handle clicking. self.accept("mouse1", self.pandaEventMouseDown, [1, []]) self.accept("mouse1-up", self.pandaEventMouseUp, [1]) # TODO: Make sure this is always the right mouse button. self.accept("mouse3", self.pandaEventMouseDown, [3, []]) self.accept("mouse3-up", self.pandaEventMouseUp, [3]) # Handle clicking with modifier keys. self.accept("shift-mouse1", self.pandaEventMouseDown, [1, ["shift"]]) self.accept("control-mouse1", self.pandaEventMouseDown, [1, ["control"]]) self.accept("shift-mouse3", self.pandaEventMouseDown, [3, ["shift"]]) self.accept("control-mouse3", self.pandaEventMouseDown, [3, ["control"]]) # Handle window close request (clicking the X, Alt-F4, etc.) self.win.set_close_request_event("window-close") self.accept("window-close", self.handleWindowClose) def coord3dToScreen(self, coord3d): # Empirically, Lens.project takes coordinates in the *camera*'s # coordinate system, not its parent or the render. This was not very # clear from the documentation, and you'd be surprised how long it took # us to figure this out. Anyway, we need to convert the point to be # relative to self.camera here; otherwise we'll get bizarre, # nonsensical, and hard-to-debug results. coord3d = self.camera.getRelativePoint(self.render, coord3d) screenCoord = Point2() if not self.camLens.project(coord3d, screenCoord): log.debug("Attempting 3d-to-screen conversion on point outside of " "camera's viewing frustum.") # Convert to a tuple to ensure no one else is keeping a reference # around. x, y = screenCoord return (x, y) def coordScreenTo3d(self, screenCoord): x, y = screenCoord screenPoint = Point2(x, y) # Do this calculation using simple geometry, rather than the absurd # collision-traversal nonsense we used to use. Thanks to # https://www.panda3d.org/forums/viewtopic.php?t=5409 # for pointing us at the right methods to make this work. # Get two points along the ray extending from the camera, in the # direction of the mouse click. nearPoint = Point3() farPoint = Point3() self.camLens.extrude(screenPoint, nearPoint, farPoint) # These points are relative to the camera, so need to be converted to # be relative to the render. Thanks to the example code (see link # above) for saving us probably some hours of debugging figuring that # one out again :) nearPoint = self.render.getRelativePoint(self.camera, nearPoint) farPoint = self.render.getRelativePoint(self.camera, farPoint) intersection = Point3() if self.groundPlane.intersectsLine(intersection, nearPoint, farPoint): # Convert to a tuple to ensure no one else is keeping a reference # around. x, y, z = intersection return (x, y, z) # The ray didn't intersect the ground. This is almost certainly going # to happen at some point; all you have to do is find a way to aim the # camera (or manipulate the screen coordinate) so that the ray points # horizontally. But we don't have code to handle it, so for now just # abort. thisIsNotHandled()
class Tmx2Bam(): def __init__(self, input_file, output_file=None, prefabs=""): self.dir = os.path.dirname(input_file) self.depth = 0 self.cardmaker = CardMaker("image") self.cardmaker.set_frame(-0.5, 0.5, -0.5, 0.5) self.linesegs = LineSegs() self.textnode = TextNode("text") self.tilesheets = [] # Every tsx file loaded. self.tiles = {} # Every unique tile/card. self.node = NodePath("tmx_root") # load prefab models self.prefabs = {} if prefabs: loader = Loader.get_global_ptr() for prefab_node in loader.load_sync(prefabs).get_children(): prefab_node.clear_transform() self.prefabs[prefab_node.name] = NodePath(prefab_node) self.tmx = ET.parse(input_file).getroot() self.xscale = int(self.tmx.get("tilewidth")) self.yscale = int(self.tmx.get("tileheight")) self.size = [0, 0] self.load_group(self.tmx) if output_file: self.export_bam(output_file) def attributes_to_tags(self, node, element): if not element == None: for property in element: if property.get("name") and property.get("value"): node.set_tag(property.get("name"), property.get("value")) for key in element.keys(): node.set_tag(key, element.get(key)) def build_text(self, object): self.textnode.set_text(object[0].text) # TODO: set color # TODO: set wrap return self.textnode.generate() def build_polygon(self, object): self.linesegs.reset() points = object[0].get("points").split(" ") points = [tuple(map(float, i.split(","))) for i in points] startx = points[0][0] / self.xscale starty = points[0][1] / self.yscale self.linesegs.move_to(startx, -starty, 0) for point in points: x, y = point[0] / self.xscale, point[1] / self.yscale self.linesegs.draw_to(x, -y, 0) self.linesegs.draw_to(startx, -starty, 0) return self.linesegs.create() def build_rectangle(self, w, h): self.linesegs.reset() self.linesegs.move_to(0, 0, 0) self.linesegs.draw_to(w, 0, 0) self.linesegs.draw_to(w, -h, 0) self.linesegs.draw_to(0, -h, 0) self.linesegs.draw_to(0, 0, 0) return self.linesegs.create() def build_tile(self, tsx, id): tile = None # Cross-reference with self.prefabs in case there's a shape # corresponding with a tile's type use_prefab = False for tile in tsx.findall("tile"): if int(tile.get("id")) == id: type = tile.get("type") if type in self.prefabs: geometry_node = NodePath(str(id)) self.prefabs[type].copy_to(geometry_node) use_prefab = True break # Else we generate a card if not use_prefab: geometry = self.cardmaker.generate() geometry_node = NodePath(geometry) geometry_node.set_texture(tsx.get("texture"), 1) geometry_node.set_p(-90) geometry_node.set_transparency(True) # scale and offset UVs for single sprite columns = int(tsx.get("columns")) rows = int(tsx.get("rows")) w, h = 1 / columns, 1 / rows tile_x, tile_y = int(id % columns), int(id / (columns)) u, v = (tile_x * w), 1 - ((tile_y * h) + h) for stage in geometry_node.find_all_texture_stages(): geometry_node.set_texture(stage, tsx.get("texture"), 1) geometry_node.set_tex_scale(stage, w, h) geometry_node.set_tex_offset(stage, (u, v)) self.attributes_to_tags(geometry_node, tile) return geometry_node def animated_tile(self, tsx, tile): node = NodePath("animated tile") sequence = SequenceNode("animated tile") duration = int(tile[0][0].get("duration")) if duration >= 9000: sequence.set_frame_rate(0) else: sequence.set_frame_rate(1000 / duration) for frame in tile[0]: tileid = int(frame.get("tileid")) tile_node = self.build_tile(tsx, tileid) sequence.add_child(tile_node.node()) sequence.loop(True) node.attach_new_node(sequence) return node def get_tile(self, map_id): tileset, set_id = self.get_tileset(map_id) tsx = tileset.get("tsx") if map_id in self.tiles: # if card is already stored node = self.tiles[map_id] # use that one else: # else build and store it is_special = False node = self.build_tile(tsx, set_id) for element in tsx: if element.tag == "tile": if int(element.get("id")) == set_id: # if it contains an element, it's always an animation if len(element) > 0: node = self.animated_tile(tsx, element) self.attributes_to_tags(node, element) break self.tiles[map_id] = node return node def load_layer(self, layer): layer_node = NodePath(layer.get("name")) static_tiles = NodePath("static") # Static tiles to flatten flat_animated_tiles = NodePath("animated") # Animated tiles to flatten dynamic_tiles = NodePath( "dynamic") # All tiles unless otherwise specified (don't flatten) tile_groups = {} # should we flatten this layer store_data = flatten = False properties = layer.find("properties") if properties: for property in properties: if property.get("name") == "flatten": flatten = True if property.get("name") == "store_data": store_data = True # build all tiles in data as a grid of cards data = layer.find("data").text data = data.replace('\n', '') data = data.split(",") collumns = int(layer.get("width")) rows = int(layer.get("height")) self.size = [collumns, rows] for y in range(rows): for x in range(collumns): id = int(data[(y * collumns) + (x % collumns)]) data[(y * collumns) + (x % collumns)] = id if id > 0: tile = NodePath("tile") self.get_tile(id).copy_to(tile) if flatten: if tile.find("**/+SequenceNode"): tile.reparent_to(flat_animated_tiles) else: tile.reparent_to(static_tiles) else: tile.reparent_to(layer_node) tile.set_pos(x, -y, 0) if flatten: if static_tiles.get_num_children() > 0: clear_all_tags(static_tiles) static_tiles.flatten_strong() if flat_animated_tiles.get_num_children() > 0: clear_all_tags(flat_animated_tiles) flat_animated_tiles = self.flatten_animated_tiles( flat_animated_tiles) for t in (static_tiles, flat_animated_tiles): t.reparent_to(layer_node) if store_data: layer_node.set_python_tag("data", data) self.append_layer(layer_node, properties) def flatten_animated_tiles(self, group_node): # FIXME: hard to read: get_child() everywhere # Makes a new node for each frame using all its tiles # flatten the s*** out of the node and add to a new SequenceNode. tiles = group_node.get_children() flattened_sequence = SequenceNode(tiles[0].name) for a, animation in enumerate(tiles[0].node().get_children()): for f, frame in enumerate(animation.get_child(0).get_children()): combined_frame = NodePath("frame " + str(f)) for tile in tiles: new_np = NodePath("frame") new_np.set_pos(tile.get_pos()) animation = tile.node().get_child(a).get_child(0) new_np.attach_new_node(animation.get_child(f)) new_np.reparent_to(combined_frame) combined_frame.flattenStrong() flattened_sequence.add_child(combined_frame.node()) framerate = animation.get_frame_rate() flattened_sequence.set_frame_rate(framerate) flattened_sequence.loop(True) return NodePath(flattened_sequence) def load_objectgroup(self, objectgroup): layer_node = NodePath(objectgroup.get("name")) for object in objectgroup: name = object.get("name") if not name: name = "object" node = NodePath(name) if len(object) > 0: # Has a type, so it's either a polygon, text, point or ellipse # Points and ellipses are just an empty for now. kind = object[0].tag if kind == "polygon": node.attach_new_node(self.build_polygon(object)) elif kind == "text": node.attach_new_node(self.build_text(object)) node.set_p(-90) self.attributes_to_tags(node, object[0]) else: # Doesn't have a type, so it's either an image or a rectangle node = NodePath(name) w = float(object.get("width")) / self.xscale h = float(object.get("height")) / self.yscale if object.get("gid"): # Has a gid, so it's an image self.get_tile(int(object.get("gid"))).copy_to(node) node.set_scale(w, h, 1) else: # It's none of the above, so it's a rectangle node.attach_new_node(self.build_rectangle(w, h)) x = y = 0 if object.get("x"): x = float(object.get("x")) / self.xscale if object.get("y"): y = float(object.get("y")) / self.yscale node.set_pos(x, -y, 0) self.attributes_to_tags(node, object) node.reparent_to(layer_node) self.append_layer(layer_node, objectgroup.find("properties")) def load_imagelayer(self, imagelayer): # FIXME: A lot of this stuff is repeated in build_tilcard image = imagelayer[0] right = int(image.get("width")) / self.xscale down = int(image.get("height")) / self.yscale self.cardmaker.set_frame(0, right, -down, 0) node = NodePath(self.cardmaker.generate()) self.cardmaker.set_frame(0, 1, -1, 0) texture = Texture() texture.read(os.path.join(self.dir, image.get("source"))) texture.setMagfilter(SamplerState.FT_nearest) texture.setMinfilter(SamplerState.FT_nearest) node.set_texture(texture) node.set_transparency(True) node.reparent_to(self.node) ox = imagelayer.get("offsetx") x, y = 0, 0 if ox: x = float(ox) / self.xscale oy = imagelayer.get("offsety") if oy: y = float(oy) / self.yscale node.set_pos((x, -y, self.depth)) node.set_p(-90) def load_group(self, group): for layer in group: if layer.tag == "tileset": self.load_tsx(layer) elif layer.tag == "layer": self.load_layer(layer) elif layer.tag == "objectgroup": self.load_objectgroup(layer) elif layer.tag == "imagelayer": self.load_imagelayer(layer) elif layer.tag == "group": self.load_group(layer) def append_layer(self, node, properties): self.attributes_to_tags(node, properties) node.set_z(self.depth) self.depth += 1 if properties: for property in properties: if property.get("name") == "z": node.set_z(int(property.get("value"))) self.depth -= 1 break node.reparent_to(self.node) def get_tileset(self, id): for tilesheet in self.tilesheets: if int(tilesheet.get("firstgid")) > id: break else: last = tilesheet id_in_sheet = id - int(last.get("firstgid")) return last, id_in_sheet, def load_tsx(self, layer): tsx_filename = layer.get("source") tsx = ET.parse(os.path.join(self.dir, tsx_filename)).getroot() # Load texture and store in the element tree. img_filename = tsx[0].get("source") texture = Texture() dir = os.path.join(self.dir, tsx_filename) place = os.path.join(os.path.split(dir)[0], img_filename) texture.read(place) texture.setMagfilter(SamplerState.FT_nearest) texture.setMinfilter(SamplerState.FT_nearest) tsx.set("texture", texture) columns = int(tsx.get("columns")) rows = int(tsx.get("tilecount")) // columns tsx.set("rows", str(rows)) layer.set("tsx", tsx) self.tilesheets.append(layer) def export_bam(self, filename): print("Exporting as {}".format(filename)) self.node.writeBamFile("{}".format(filename))
class WartsApp(ShowBase): def __init__(self, graphicsInterface, backend, gameState): ShowBase.__init__(self) self.graphicsInterface = graphicsInterface self.backend = backend self.gameState = gameState self.groundNodes = None self.firstTick = True # This is available as a global, but pylint gives an undefined-variable # warning if we use it that way. Looking at # https://www.panda3d.org/manual/index.php/ShowBase # I would have thought we could reference it as either # self.globalClock, direct.showbase.ShowBase.globalClock, or possibly # direct.showbase.globalClock, but none of those seems to work. To # avoid the pylint warnings, create self.globalClock manually. self.globalClock = ClockObject.getGlobalClock() # Set up event handling. self.mouseState = {} self.keys = {} self.setupEventHandlers() # Set up camera control. self.cameraHolder = self.render.attachNewNode('CameraHolder') self.cameraHolder.setPos(0, 0, 100) self.prevCameraHpr = (0, -80, 0) self.usingCustomCamera = True self.setCameraCustom() self.prevMousePos = None self.selectionBox = None self.selectionBoxNode = None self.selectionBoxOrigin = None # Define the ground plane by a normal (+z) and a point (the origin). self.groundPlane = core.Plane(core.Vec3(0, 0, 1), core.Point3(0, 0, 0)) graphicsInterface.graphicsReady(self) def cleanup(self): pass # For backward compatibility. # TODO[#84]: Remove when old graphics goes away; have backend just call # tick() directly. def interfaceMessage(self, data): message = deserializeMessage(data) if isinstance(message, messages.Tick): self.tick() # Ignore everything else. def tick(self): # TODO: Multiple levels of log.debug. For now, this is too spammy, so # skip it. # log.debug("Graphics: tick()") if self.firstTick: if not self.gameState.hasSize: log.error("GameState must be assigned a size before first " "tick().") return width, height = self.gameState.sizeInChunks self.groundNodes = [[None for _x in range(height)] for _y in range(width)] for cx in range(width): for cy in range(height): self.addGround((cx, cy), self.gameState.groundTypes[cx][cy]) self.firstTick = False # For now, just call this every tick. Optimize later. self.rescanUnits() def addGround(self, chunkIndex, terrainType): cx, cy = chunkIndex wPos = Coord.fromCBU(chunk=(chunkIndex)) if terrainType == 0: modelName = "green-ground.egg" else: modelName = "red-ground.egg" if terrainType != 1: log.warn("Unrecognized terrain type %d", terrainType) gPos1 = worldToGraphicsPos(wPos) gPos2 = worldToGraphicsPos(wPos + Distance.fromCBU(chunk=(1, 1))) # Figure out where we want the tile. goalCenterX = 0.5 * (gPos2[0] + gPos1[0]) goalCenterY = 0.5 * (gPos2[1] + gPos1[1]) goalWidthX = abs(gPos2[0] - gPos1[0]) goalWidthY = abs(gPos2[1] - gPos1[1]) model = self.loader.loadModel(getModelPath(modelName)) # Put the model in the scene, but don't position it yet. rootNode = self.render.attachNewNode("") model.reparentTo(rootNode) # Rescale the model about its origin. The x and y coordinates of the # model's origin should be chosen as wherever it looks like the model's # center of mass is, so that rotation about the origin (in the xy # plane) feels natural. # TODO[#9]: Set a convention for model bounds so we don't have to do a # getTightBounds every time. This is dumb. # TODO[#3]: Or, as an alternative shorter-term solution, just define a # scale in the config files for the few models that aren't ours. bound1, bound2 = model.getTightBounds() modelWidthX = abs(bound2[0] - bound1[0]) modelWidthY = abs(bound2[1] - bound1[1]) # Scale it to the largest it can be while still fitting within the goal # rect. If the aspect ratio of the goal rect is different from that of # the model, then it'll only fill that rect in one dimension. # altScaleFactor is used for sanity checks below. scaleFactor, altScaleFactor = minmax(goalWidthX / modelWidthX, goalWidthY / modelWidthY) # Sanity check the scale factor. if scaleFactor <= 0.0: if scaleFactor == 0.0: log.warn("Ground %s will be scaled negatively!", chunkIndex) else: log.warn("Ground %s will be scaled to zero size.", chunkIndex) else: # TODO[#9]: Currently the example panda triggers this warning. # TODO[#3]: Magic numbers bad. if altScaleFactor / scaleFactor > 1.001: log.warn( "Ground %s has different aspect ratio than " "its model: model of size %.3g x %.3g being scaled " "into %.3g x %.3g.", chunkIndex, modelWidthX, modelWidthY, goalWidthX, goalWidthY) model.setScale(scaleFactor) # Place the model at z=0. The model's origin should be placed so that # this looks natural -- for most units this means it should be right at # the bottom of the model, but if we add any units that are intended to # float above the ground, then this can be accomplished by just # positioning the model above its origin. rootNode.setPos(goalCenterX, goalCenterY, 0.0) self.groundNodes[cx][cy] = rootNode def rescanUnits(self): """ Check for units that have moved or been added/removed since the last scan. Update the display accordingly. """ # TODO: Actually write this. pass # Divide all unit ids from my own list of units and the gamestate's # current list into three parts: # - ids in both lists (potential moves) # - ids only in the gamestate's list (additions) # - ids only in my list (removals) # # Call: # moveUnit() for each potential move # addUnit() for each addition # removeUnit() for each removal def moveUnit(self, uid, newPos): # TODO: Actually write this. pass # Set unit's node to move to new position. # Play "walk" animation # ...whatever else we used to do? def addUnit(self, uid, pos): # TODO: Actually write this. pass # Create unit/model/node as we used to. # Also add it to our uid->unit mapping. def removeUnit(self, uid, pos): # TODO: Actually write this. pass # Remove+cleanup unit/model/node as we used to. # Also remove it from our uid->unit mapping. def createSelectionBox(self, corner1, corner2): """ Create a selection "box" given the coordinates of two opposite corners. The corners are given in world coordinates (well, 3d graphics coordinates). """ assert self.selectionBox is None p1, p2, p3, p4 = self.convert3dBoxToScreen(corner1, corner2) x1, y1 = p1 x2, y2 = p2 x3, y3 = p3 x4, y4 = p4 # TODO[#3]: Magic numbers bad. self.selectionBox = LineSegs("SelectionBox") self.selectionBox.setThickness(3.0) self.selectionBox.setColor(0.0, 1.0, 0.25, 1.0) self.selectionBox.move_to(x1, 0, y1) self.selectionBox.draw_to(x2, 0, y2) self.selectionBox.draw_to(x3, 0, y3) self.selectionBox.draw_to(x4, 0, y4) self.selectionBox.draw_to(x1, 0, y1) self.selectionBoxNode = self.render2d.attachNewNode( self.selectionBox.create()) def moveSelectionBox(self, corner1, corner2): assert self.selectionBox is not None p1, p2, p3, p4 = self.convert3dBoxToScreen(corner1, corner2) x1, y1 = p1 x2, y2 = p2 x3, y3 = p3 x4, y4 = p4 self.selectionBox.setVertex(0, x1, 0, y1) self.selectionBox.setVertex(1, x2, 0, y2) self.selectionBox.setVertex(2, x3, 0, y3) self.selectionBox.setVertex(3, x4, 0, y4) self.selectionBox.setVertex(4, x1, 0, y1) def removeSelectionBox(self): self.selectionBoxNode.removeNode() self.selectionBox = None self.selectionBoxNode = None def convert3dBoxToScreen(self, corner1, corner3): """ Return screen coordinates of the 4 corners of a box, given in 3d coordinates. The box is specified using 2 opposite corners. """ wx1, wy1, wz1 = corner1 wx3, wy3, wz3 = corner3 wx2, wy2 = (wx1, wy3) wx4, wy4 = (wx3, wy1) # Note: corner1 and corner2 could have nonzero z because floating-point # calculations, but they should at least be close. We'll just average # their z and not worry about it. wz2 = wz4 = 0.5 * (wz1 + wz3) p1 = self.coord3dToScreen((wx1, wy1, wz1)) p2 = self.coord3dToScreen((wx2, wy2, wz2)) p3 = self.coord3dToScreen((wx3, wy3, wz3)) p4 = self.coord3dToScreen((wx4, wy4, wz4)) return (p1, p2, p3, p4) def setCameraCustom(self): """ Change to using our custom task to control the camera. """ # Disable the default mouse-based camera control task, so we don't have # to fight with it for control of the camera. self.disableMouse() # Face the camera in the appropriate angle. self.camera.setHpr(self.prevCameraHpr) # Put it in the same location as the cameraHolder, and make it stay # put relative to the cameraHolder (so we can move the camera around by # changing the cameraHolder's position). self.camera.reparentTo(self.cameraHolder) self.camera.setPos(0, 0, 0) # Substitute our own camera control task. self.taskMgr.add(self.updateCameraTask, "UpdateCameraTask") self.usingCustomCamera = True # Need a task to handle mouse-dragging because there doesn't seem to be # a built-in mouseMove event. self.taskMgr.add(self.mouseMoveTask, "MouseMoveTask") def setCameraDefault(self): """ Change to using the default mouse-based camera controls. """ self.taskMgr.remove("UpdateCameraTask") # Save current location for when this control style is restored. self.prevCameraHpr = self.camera.getHpr() # Use the existing camera location, rather than jumping back to the one # from last time the default camera controller was active. # Copied from https://www.panda3d.org/manual/index.php/Mouse_Support mat = Mat4(self.camera.getMat()) mat.invertInPlace() self.mouseInterfaceNode.setMat(mat) self.enableMouse() self.usingCustomCamera = False def toggleCameraStyle(self): """ Switch to whichever style of camera control isn't currently active. """ if self.usingCustomCamera: self.setCameraDefault() else: self.setCameraCustom() # We don't use task, but we can't remove it because the function signature # is from Panda3D. def updateCameraTask(self, task): # pylint: disable=unused-argument """ Move the camera sensibly. """ dt = self.globalClock.getDt() translateSpeed = 30 * dt rotateSpeed = 50 * dt # Separately track whether the camera should translate in each of the 4 # directions. These 4 are initialized based on the various inputs that # might tell us to scroll, and different inputs saying the same thing # don't stack. That way if we get inputs saying both "left" and # "right", they can cancel and the camera just won't move along that # axis -- even if, say, there are two inputs saying "left" and only one # saying "right'. moveLeft = self.keys["arrow_left"] moveRight = self.keys["arrow_right"] moveUp = self.keys["arrow_up"] moveDown = self.keys["arrow_down"] # Check if the mouse is over the window. if self.mouseWatcherNode.hasMouse(): # Get the position. # Each coordinate is normalized to the interval [-1, 1]. mousePos = self.mouseWatcherNode.getMouse() xPos, yPos = mousePos.getX(), mousePos.getY() # Only move if the mouse is close to the edge, and actually within # the window. if (1.0 - EDGE_SCROLL_WIDTH) < xPos <= 1.0: moveRight = 1 if -(1.0 - EDGE_SCROLL_WIDTH) > xPos >= -1.0: moveLeft = 1 if (1.0 - EDGE_SCROLL_WIDTH) < yPos <= 1.0: moveUp = 1 if -(1.0 - EDGE_SCROLL_WIDTH) > yPos >= -1.0: moveDown = 1 forward = translateSpeed * (moveUp - moveDown) sideways = translateSpeed * (moveRight - moveLeft) self.cameraHolder.setPos(self.cameraHolder, sideways, forward, 0) # Selection box logic if sideways != 0 or forward != 0: self.updateSelectionBox() rotate = rotateSpeed * (self.keys["a"] - self.keys["d"]) self.cameraHolder.setHpr(self.cameraHolder, rotate, 0, 0) return Task.cont def zoomCamera(self, inward): """ Zoom in or out. """ dt = self.globalClock.getDt() zoomSpeed = 100 * dt zoom = -zoomSpeed if inward else zoomSpeed self.cameraHolder.setPos(self.cameraHolder, 0, 0, zoom) # We don't use task, but we can't remove it because the function signature # is from Panda3D. def mouseMoveTask(self, task): # pylint: disable=unused-argument """ Handle mouse movement. """ mousePos = self.getMousePos() # NOTE: We don't handle clicking and dragging at the same time. if mousePos is not None and mousePos != self.prevMousePos: for (buttonId, state) in self.mouseState.iteritems(): state.lastPos = mousePos if state.hasMoved: self.handleMouseDragMove(buttonId, state.modifiers, state.startPos, mousePos) else: startX, startY = state.startPos mouseX, mouseY = mousePos distance = math.hypot(mouseX - startX, mouseY - startY) # TODO[#3]: Magic numbers bad. # Check if the mouse has moved outside the dead zone. if distance > 0.0314: self.handleMouseDragStart(buttonId, state.modifiers, state.startPos, mousePos) state.hasMoved = True if mousePos != self.prevMousePos: self.prevMousePos = mousePos return Task.cont def pandaEventMouseDown(self, buttonId, modifiers): log.debug("Mouse down: button %s w/ mod %s", buttonId, modifiers) if buttonId in self.mouseState: # Call pandaEventMouseUp just to clear any state related to the # button being down, so we can handle this buttonDown event as if # it were a fresh press of the button. log.warn("Mouse button %s is already down.", buttonId) self.pandaEventMouseUp(buttonId) assert buttonId not in self.mouseState state = MouseButtonState(modifiers[:], self.getMousePos()) self.mouseState[buttonId] = state def pandaEventMouseUp(self, buttonId): log.debug("Mouse up: button %s", buttonId) if buttonId not in self.mouseState: # Drop the event, since there's nothing to do. log.warn("Mouse button %s is already up.", buttonId) return state = self.mouseState[buttonId] if state.hasMoved: endPos = self.getMousePos() if endPos is None: endPos = state.lastPos self.handleMouseDragEnd(buttonId, state.modifiers, state.startPos, endPos) else: self.handleMouseClick(buttonId, state.modifiers, state.startPos) del self.mouseState[buttonId] def handleMouseClick(self, button, modifiers, pos): # Make sure the mouse is inside the screen # TODO: Move this check to pandaEventMouseUp? if self.mouseWatcherNode.hasMouse() and self.usingCustomCamera: x, y, _z = self.coordScreenTo3d(pos) uPos = graphicsToWorldPos((x, y)) self.backend.worldClick(uPos, button, modifiers) def handleMouseDragStart(self, buttonId, modifiers, startPos, endPos): log.debug("Start dragging from %s to %s", startPos, endPos) if buttonId == 1 and modifiers == []: assert self.selectionBoxOrigin is None self.selectionBoxOrigin = self.coordScreenTo3d(startPos) endPos = self.coordScreenTo3d(endPos) self.createSelectionBox(self.selectionBoxOrigin, endPos) def handleMouseDragMove(self, buttonId, modifiers, startPos, endPos): log.debug("Continue dragging from %s to %s", startPos, endPos) if buttonId == 1 and modifiers == []: assert self.selectionBoxOrigin is not None endPos = self.coordScreenTo3d(endPos) self.moveSelectionBox(self.selectionBoxOrigin, endPos) def handleMouseDragEnd(self, buttonId, modifiers, startPos, endPos): log.debug("End dragging from %s to %s", startPos, endPos) # TODO: Do we need this check? What is the effect of only calling # removeSelectionBox() under this check? if buttonId == 1 and modifiers == []: # Actually select the units. startGPos = self.selectionBoxOrigin[:2] endGPos = self.coordScreenTo3d(endPos)[:2] startUPos = graphicsToWorldPos(startGPos) endUPos = graphicsToWorldPos(endGPos) self.backend.worldDrag(startUPos, endUPos, buttonId, modifiers) # Clear the selection box; we're done dragging. self.selectionBoxOrigin = None self.removeSelectionBox() def updateSelectionBox(self): if self.selectionBoxOrigin is not None: mousePos = self.getMousePos() if mousePos is not None: endPos = self.coordScreenTo3d(mousePos) self.moveSelectionBox(self.selectionBoxOrigin, endPos) def getMousePos(self): # Check if the mouse is over the window. if self.mouseWatcherNode.hasMouse(): # Get the position. # Each coordinate is normalized to the interval [-1, 1]. mousePoint = self.mouseWatcherNode.getMouse() # Create a copy of mousePoint rather than returning a reference to # it, because mousePoint will be modified in place by Panda. return (mousePoint.getX(), mousePoint.getY()) else: return None def handleWindowClose(self): log.info("Window close requested -- shutting down client.") # When in Rome, send messages like the Romans do, I guess. # TODO: Get rid of messages, I think. message = cmessages.RequestQuit() self.graphicsInterface.graphicsMessage(message.serialize()) def setupEventHandlers(self): def pushKey(key, value): self.keys[key] = value for key in [ "arrow_up", "arrow_left", "arrow_right", "arrow_down", "w", "a", "d", "s" ]: self.keys[key] = False self.accept(key, pushKey, [key, True]) self.accept("shift-%s" % key, pushKey, [key, True]) self.accept("%s-up" % key, pushKey, [key, False]) # Camera toggle. self.accept("f3", self.toggleCameraStyle, []) self.accept("shift-f3", self.toggleCameraStyle, []) # Center view. # self.accept("space", self.centerView, []) -- TODO # Handle mouse wheel. self.accept("wheel_up", self.zoomCamera, [True]) self.accept("wheel_down", self.zoomCamera, [False]) # Handle clicking. self.accept("mouse1", self.pandaEventMouseDown, [1, []]) self.accept("mouse1-up", self.pandaEventMouseUp, [1]) # TODO: Make sure this is always the right mouse button. self.accept("mouse3", self.pandaEventMouseDown, [3, []]) self.accept("mouse3-up", self.pandaEventMouseUp, [3]) # Handle clicking with modifier keys. self.accept("shift-mouse1", self.pandaEventMouseDown, [1, ["shift"]]) self.accept("control-mouse1", self.pandaEventMouseDown, [1, ["control"]]) self.accept("shift-mouse3", self.pandaEventMouseDown, [3, ["shift"]]) self.accept("control-mouse3", self.pandaEventMouseDown, [3, ["control"]]) # Handle window close request (clicking the X, Alt-F4, etc.) self.win.set_close_request_event("window-close") self.accept("window-close", self.handleWindowClose) def coord3dToScreen(self, coord3d): # Empirically, Lens.project takes coordinates in the *camera*'s # coordinate system, not its parent or the render. This was not very # clear from the documentation, and you'd be surprised how long it took # us to figure this out. Anyway, we need to convert the point to be # relative to self.camera here; otherwise we'll get bizarre, # nonsensical, and hard-to-debug results. coord3d = self.camera.getRelativePoint(self.render, coord3d) screenCoord = Point2() if not self.camLens.project(coord3d, screenCoord): log.debug("Attempting 3d-to-screen conversion on point outside of " "camera's viewing frustum.") # Convert to a tuple to ensure no one else is keeping a reference # around. x, y = screenCoord return (x, y) def coordScreenTo3d(self, screenCoord): x, y = screenCoord screenPoint = Point2(x, y) # Do this calculation using simple geometry, rather than the absurd # collision-traversal nonsense we used to use. Thanks to # https://www.panda3d.org/forums/viewtopic.php?t=5409 # for pointing us at the right methods to make this work. # Get two points along the ray extending from the camera, in the # direction of the mouse click. nearPoint = Point3() farPoint = Point3() self.camLens.extrude(screenPoint, nearPoint, farPoint) # These points are relative to the camera, so need to be converted to # be relative to the render. Thanks to the example code (see link # above) for saving us probably some hours of debugging figuring that # one out again :) nearPoint = self.render.getRelativePoint(self.camera, nearPoint) farPoint = self.render.getRelativePoint(self.camera, farPoint) intersection = Point3() if self.groundPlane.intersectsLine(intersection, nearPoint, farPoint): # Convert to a tuple to ensure no one else is keeping a reference # around. x, y, z = intersection return (x, y, z) # The ray didn't intersect the ground. This is almost certainly going # to happen at some point; all you have to do is find a way to aim the # camera (or manipulate the screen coordinate) so that the ray points # horizontally. But we don't have code to handle it, so for now just # abort. thisIsNotHandled()