mirror of https://github.com/dirtbags/moth.git
538 lines
19 KiB
Python
538 lines
19 KiB
Python
import math
|
|
import random
|
|
from PIL import Image
|
|
from PIL import ImageDraw
|
|
|
|
import GameMath as gm
|
|
import Program
|
|
|
|
class Tank(object):
|
|
|
|
# How often, in turns, that we can fire.
|
|
FIRE_RATE = 20
|
|
# How far the laser shoots from the center of the tank
|
|
FIRE_RANGE = 45.0
|
|
# The radius of the tank, from the center of the turret.
|
|
# This is what is used for collision and hit detection.
|
|
RADIUS = 7.5
|
|
# Max speed, in pixels
|
|
SPEED = 7.0
|
|
# Max acceleration, as a fraction of speed.
|
|
ACCEL = 35
|
|
# Sensor range, in pixels
|
|
SENSOR_RANGE = 90.0
|
|
# Max turret turn rate, in radians
|
|
TURRET_TURN_RATE = math.pi/10
|
|
|
|
# The max number of sensors/timers/toggles
|
|
SENSOR_LIMIT = 10
|
|
|
|
def __init__(self, name, pos, color, boardSize, angle=None, tAngle=None,
|
|
testMode=True):
|
|
"""Create a new tank.
|
|
@param name: The name name of the tank. Stored in self.name.
|
|
@param pos: The starting position of the tank (x,y)
|
|
@param color: The color of the tank.
|
|
@param boardSize: The size of the board. (maxX, maxY)
|
|
@param angle: The starting angle of the tank, defaults to random.
|
|
@param tAngle: The starting turretAngle of the tank, defaults to random.
|
|
@param testMode: When True, extra debugging information is displayed. Namely,
|
|
arcs for each sensor are drawn, which turn white when
|
|
activated.
|
|
"""
|
|
|
|
# Keep track of what turn number it is for this tank.
|
|
self._turn = 0
|
|
|
|
self.name = name
|
|
self._testMode = testMode
|
|
|
|
assert len(pos) == 2 and pos[0] > 0 and pos[1] > 0, \
|
|
'Bad starting position: %s' % str(pos)
|
|
self.pos = pos
|
|
|
|
# The last speed of each tread (left, right)
|
|
self._lastSpeed = 0.0, 0.0
|
|
# The next speed that the tank should try to attain.
|
|
self._nextMove = 0,0
|
|
|
|
# When set, the led is drawn on the tank.
|
|
self.led = False
|
|
|
|
assert len(boardSize) == 2 and boardSize[0] > 0 and boardSize[1] > 0
|
|
# The limits of the playfield (maxX, maxY)
|
|
self._limits = boardSize
|
|
|
|
# The current angle of the tank.
|
|
if angle is None:
|
|
self._angle = random.random()*2*math.pi
|
|
else:
|
|
self._angle = angle
|
|
|
|
# The current angle of the turret
|
|
if tAngle is None:
|
|
self._tAngle = random.random()*2*math.pi
|
|
else:
|
|
self._tAngle = tAngle
|
|
|
|
self.color = color
|
|
|
|
# You can't fire until fireReady is 0.
|
|
self._fireReady = self.FIRE_RATE
|
|
# Means the tank will fire at it's next opportunity.
|
|
self._fireNow = False
|
|
# True when the tank has fired this turn (for drawing purposes)
|
|
self._fired = False
|
|
|
|
# What the desired turret angle should be (from the front of the tank).
|
|
# None means the turret should stay stationary.
|
|
self._tGoal = None
|
|
|
|
# Holds the properties of each sensor
|
|
self._sensors = []
|
|
# Holds the state of each sensor
|
|
self._sensorState = []
|
|
|
|
# The tanks toggle memory
|
|
self.toggles = []
|
|
|
|
# The tanks timers
|
|
self._timers = []
|
|
|
|
# Is this tank dead?
|
|
self.isDead = False
|
|
# The frame of the death animation.
|
|
self._deadFrame = 10
|
|
# Death reason
|
|
self.deathReason = 'survived'
|
|
|
|
def __repr__(self):
|
|
return '<tank: %s, (%d, %d)>' % (self.name, self.pos[0], self.pos[1])
|
|
|
|
def fire(self, near):
|
|
"""Shoots, if ordered to and able. Returns the set of tanks
|
|
destroyed."""
|
|
|
|
killed = set()
|
|
if self._fireReady > 0:
|
|
# Ignore the shoot order
|
|
self._fireNow = False
|
|
|
|
if self._fireNow:
|
|
self._fireNow = False
|
|
self._fireReady = self.FIRE_RATE
|
|
self._fired = True
|
|
|
|
|
|
firePoint = gm.polar2cart(self.FIRE_RANGE,
|
|
self._angle + self._tAngle)
|
|
for tank in near:
|
|
enemyPos = gm.minShift(self.pos, tank.pos, self._limits)
|
|
if gm.segmentCircleCollision(((0,0), firePoint), enemyPos,
|
|
self.RADIUS):
|
|
killed.add(tank)
|
|
|
|
return killed
|
|
|
|
def addSensor(self, range, angle, width, attachedTurret=False):
|
|
"""Add a sensor to this tank.
|
|
@param angle: The angle, from the tanks front and going clockwise,
|
|
of the center of the sensor. (radians)
|
|
@param width: The width of the sensor (percent).
|
|
@param range: The range of the sensor (percent)
|
|
@param attachedTurret: If set, the sensor moves with the turret.
|
|
"""
|
|
assert range >=0 and range <= 1, "Invalid range value."
|
|
|
|
if len(self._sensors) >= self.SENSOR_LIMIT:
|
|
raise ValueError("You can only have 10 sensors.")
|
|
|
|
range = range * self.SENSOR_RANGE
|
|
|
|
if attachedTurret:
|
|
attachedTurret = True
|
|
else:
|
|
attachedTurret = False
|
|
|
|
self._sensors.append((range, angle, width, attachedTurret))
|
|
self._sensorState.append(False)
|
|
|
|
def getSensorState(self, key):
|
|
return self._sensorState[key]
|
|
|
|
@property
|
|
def turn(self):
|
|
return self._turn
|
|
|
|
def setMove(self, left, right):
|
|
"""Parse the speed values given, and set them for the next move."""
|
|
|
|
self._nextMove = left, right
|
|
|
|
def setTurretAngle(self, angle=None):
|
|
"""Set the desired angle of the turret. No angle means the turret
|
|
should remain stationary."""
|
|
|
|
if angle is None:
|
|
self._tGoal = None
|
|
else:
|
|
self._tGoal = gm.reduceAngle(angle)
|
|
|
|
def setFire(self):
|
|
"""Set the tank to fire, next turn."""
|
|
self._fireNow = True
|
|
|
|
def fireReady(self):
|
|
"""Returns True if the tank can fire now."""
|
|
return self._fireReady == 0
|
|
|
|
def addTimer(self, period):
|
|
"""Add a timer with timeout period 'period'."""
|
|
|
|
if len(self._timers) >= self.SENSOR_LIMIT:
|
|
raise ValueError('You can only have 10 timers')
|
|
|
|
self._timers.append(None)
|
|
self._timerPeriods.append(period)
|
|
|
|
def resetTimer(self, key):
|
|
"""Reset, and start the given timer, but only if it is inactive.
|
|
If it is active, raise a ValueError."""
|
|
if self._timer[key] is None:
|
|
self._timer[key] = self._timerPeriods[key]
|
|
else:
|
|
raise ValueError("You can't reset an active timer (#%d)" % key)
|
|
|
|
def clearTimer(self, key):
|
|
"""Clear the timer."""
|
|
self._timer[key] = None
|
|
|
|
def checkTimer(self, key):
|
|
"""Returns True if the timer has expired."""
|
|
return self._timer[key] == 0
|
|
|
|
def _manageTimers(self):
|
|
"""Decrement each active timer."""
|
|
for i in range(len(self._timers)):
|
|
if self._timers[i] is not None and \
|
|
self._timers[i] > 0:
|
|
self._timers[i] = self._timers[i] - 1
|
|
|
|
def program(self, text):
|
|
"""Set the program for this tank."""
|
|
|
|
self._program = Program.Program(text)
|
|
self._program.setup(self)
|
|
|
|
def execute(self):
|
|
"""Execute this tanks program."""
|
|
|
|
# Decrement the active timers
|
|
self._manageTimers()
|
|
self.led = False
|
|
|
|
self._program(self)
|
|
|
|
self._move(self._nextMove[0], self._nextMove[1])
|
|
self._moveTurret()
|
|
if self._fireReady > 0:
|
|
self._fireReady = self._fireReady - 1
|
|
|
|
self._turn = self._turn + 1
|
|
|
|
def sense(self, near):
|
|
"""Detect collisions and trigger sensors. Returns the set of
|
|
tanks collided with, plus this one. We do both these steps at once
|
|
mainly because all the data is available."""
|
|
|
|
near = list(near)
|
|
polar = []
|
|
for tank in near:
|
|
polar.append(gm.relativePolar(self.pos, tank.pos, self._limits))
|
|
|
|
for sensorId in range(len(self._sensors)):
|
|
# Reset the sensor
|
|
self._sensorState[sensorId] = False
|
|
|
|
dist, sensorAngle, width, tSens = self._sensors[sensorId]
|
|
|
|
# Adjust the sensor angles according to the tanks angles.
|
|
sensorAngle = sensorAngle + self._angle
|
|
# If the angle is tied to the turret, add that too.
|
|
if tSens:
|
|
sensorAngle = sensorAngle + self._tAngle
|
|
|
|
while sensorAngle >= 2*math.pi:
|
|
sensorAngle = sensorAngle - 2*math.pi
|
|
|
|
for i in range(len(near)):
|
|
r, theta = polar[i]
|
|
# Find the difference between the object angle and the sensor.
|
|
# The max this can be is pi, so adjust for that.
|
|
dAngle = gm.angleDiff(theta, sensorAngle)
|
|
|
|
rCoord = gm.polar2cart(dist, sensorAngle - width/2)
|
|
lCoord = gm.polar2cart(dist, sensorAngle + width/2)
|
|
rightLine = ((0,0), rCoord)
|
|
leftLine = ((0,0), lCoord)
|
|
tankRelPos = gm.minShift(self.pos, near[i].pos, self._limits)
|
|
if r < (dist + self.RADIUS):
|
|
if abs(dAngle) <= (width/2) or \
|
|
gm.segmentCircleCollision(rightLine, tankRelPos,
|
|
self.RADIUS) or \
|
|
gm.segmentCircleCollision(leftLine, tankRelPos,
|
|
self.RADIUS):
|
|
|
|
self._sensorState[sensorId] = True
|
|
break
|
|
|
|
# Check for collisions here, since we already have all the data.
|
|
collided = set()
|
|
for i in range(len(near)):
|
|
r, theta = polar[i]
|
|
if r < (self.RADIUS + near[i].RADIUS):
|
|
collided.add(near[i])
|
|
|
|
# Add this tank (a collision kills both, after all
|
|
if collided:
|
|
collided.add(self)
|
|
|
|
return collided
|
|
|
|
def die(self, reason):
|
|
self.isDead = True
|
|
self.deathReason = reason
|
|
|
|
def _moveTurret(self):
|
|
if self._tGoal is None or self._tAngle == self._tGoal:
|
|
return
|
|
|
|
diff = gm.angleDiff(self._tGoal, self._tAngle)
|
|
|
|
if abs(diff) < self.TURRET_TURN_RATE:
|
|
self._tAngle = self._tGoal
|
|
elif diff > 0:
|
|
self._tAngle = gm.reduceAngle(self._tAngle - self.TURRET_TURN_RATE)
|
|
else:
|
|
self._tAngle = gm.reduceAngle(self._tAngle + self.TURRET_TURN_RATE)
|
|
|
|
def _move(self, lSpeed, rSpeed):
|
|
|
|
assert abs(lSpeed) <= 100, "Bad speed value: %s" % lSpeed
|
|
assert abs(rSpeed) <= 100, "Bad speed value: %s" % rSpeed
|
|
|
|
# Handle acceleration
|
|
if self._lastSpeed[0] < lSpeed and \
|
|
self._lastSpeed[0] + self.ACCEL < lSpeed:
|
|
lSpeed = self._lastSpeed[0] + self.ACCEL
|
|
elif self._lastSpeed[0] > lSpeed and \
|
|
self._lastSpeed[0] - self.ACCEL > lSpeed:
|
|
lSpeed = self._lastSpeed[0] - self.ACCEL
|
|
|
|
if self._lastSpeed[1] < rSpeed and \
|
|
self._lastSpeed[1] + self.ACCEL < rSpeed:
|
|
rSpeed = self._lastSpeed[1] + self.ACCEL
|
|
elif self._lastSpeed[1] > rSpeed and \
|
|
self._lastSpeed[1] - self.ACCEL > rSpeed:
|
|
rSpeed = self._lastSpeed[1] - self.ACCEL
|
|
|
|
self._lastSpeed = lSpeed, rSpeed
|
|
|
|
# The simple case
|
|
if lSpeed == rSpeed:
|
|
fSpeed = self.SPEED*lSpeed/100
|
|
x = fSpeed*math.cos(self._angle)
|
|
y = fSpeed*math.sin(self._angle)
|
|
# Adjust our position
|
|
self._reposition((x,y), 0)
|
|
return
|
|
|
|
# The works as follows:
|
|
# The tank drives around in a circle of radius r, which is some
|
|
# offset on a line perpendicular to the tank. The distance it travels
|
|
# around the circle varies with the speed of each tread, and is
|
|
# such that each side of the tank moves an equal angle around the
|
|
# circle.
|
|
L = self.SPEED * lSpeed/100.0
|
|
R = self.SPEED * rSpeed/100.0
|
|
friction = .75 * abs(L-R)/(2.0*self.SPEED)
|
|
L = L * (1 - friction)
|
|
R = R * (1 - friction)
|
|
|
|
# Si is the speed of the tread on the inside of the turn,
|
|
# So is the speed on the outside of the turn.
|
|
# dir is to note the direction of rotation.
|
|
if abs(L) > abs(R):
|
|
Si = R; So = L
|
|
dir = 1
|
|
else:
|
|
Si = L; So = R
|
|
dir = -1
|
|
|
|
# The width of the tank...
|
|
w = self.RADIUS * 2
|
|
|
|
# This is the angle that will determine the circle the tank travels
|
|
# around.
|
|
# theta = math.atan((So - Sl)/w)
|
|
# This is the distance from the outer tread to the center of the
|
|
# circle formed by it's movement.
|
|
r = w*So/(So - Si)
|
|
|
|
# The fraction of the circle traveled is equal to the speed of
|
|
# the outer tread over the circumference of the circle.
|
|
# Ft = So/(2*pi*r)
|
|
# The angle traveled is equal to the Fraction traveled * 2 * pi
|
|
# This reduces to a simple: So/r
|
|
# We multiply it by dir to adjust for the direction of rotation
|
|
theta = So/r * dir
|
|
|
|
# These are the offsets from the center of the circle, given that
|
|
# the tank is turned in some direction. The tank is facing
|
|
# perpendicular to the circle
|
|
# So far everything has been relative to the outer tread. At this
|
|
# point, however, we need to move relative to the center of the
|
|
# tank. Hence the adjustment in r.
|
|
x = -math.cos( self._angle + math.pi/2*dir ) * (r - w/2.0)
|
|
y = -math.sin( self._angle + math.pi/2*dir ) * (r - w/2.0)
|
|
|
|
# Now we just rotate the tank's position around the center of the
|
|
# circle to get the change in coordinates.
|
|
mx, my = gm.rotatePoint((x,y), theta)
|
|
mx = mx - x
|
|
my = my - y
|
|
|
|
# Finally, we shift the tank relative to the playing field, and
|
|
# rotate it by theta.
|
|
self._reposition((mx, my), theta)
|
|
|
|
def _reposition(self, move, angleChange):
|
|
"""Move the tank by x,y = move, and change it's angle by angle.
|
|
I assume the tanks move slower than the boardSize."""
|
|
|
|
x = self.pos[0] + move[0]
|
|
y = self.pos[1] + move[1]
|
|
self._angle = self._angle + angleChange
|
|
|
|
if x < 0:
|
|
x = self._limits[0] + x
|
|
elif x > self._limits[0]:
|
|
x = x - self._limits[0]
|
|
|
|
if y < 0:
|
|
y = self._limits[1] + y
|
|
elif y > self._limits[1]:
|
|
y = y - self._limits[1]
|
|
|
|
self.pos = round(x), round(y)
|
|
|
|
while self._angle < 0:
|
|
self._angle = self._angle + math.pi * 2
|
|
|
|
while self._angle > math.pi * 2:
|
|
self._angle = self._angle - math.pi * 2
|
|
|
|
# A rectangle starting 2 pixels past the tank front, and two pixels to the
|
|
# side. width = 4, height = 18.
|
|
tread1 = [(9,-9), (9, -5), (-8, -5), (-8, -9)]
|
|
# Same as tread one, except to the right.
|
|
tread2 = [(9, 9), (9,5), (-8,5), (-8,9)]
|
|
# A circle of radius 7.5 centered on center
|
|
body = [(-6,-6), (6,6)]
|
|
gun = [(0, -2), (0,2), (12,1), (12,-1)]
|
|
ledPoly = [(0, -2), (0,2), (-2,2), (-2,-2)]
|
|
hood = [(8, -6), (8, 6), (-7, 6), (-7, -6)]
|
|
laser = [(12,2), (FIRE_RANGE, 2), (FIRE_RANGE,-2), (12, -2)]
|
|
treadColor = '#777777'
|
|
def draw(self, image):
|
|
|
|
d = ImageDraw.Draw(image)
|
|
|
|
if self.isDead:
|
|
if self._deadFrame > 0:
|
|
# Draw the explosion instead of the normal art
|
|
self._drawExplosion(d)
|
|
self._drawLaser(d)
|
|
return image
|
|
|
|
if self._testMode:
|
|
self._drawSensors(d)
|
|
|
|
hood = gm.rotatePoly(self.hood, self._angle)
|
|
tread1 = gm.rotatePoly(self.tread1, self._angle)
|
|
tread2 = gm.rotatePoly(self.tread2, self._angle)
|
|
gun = gm.rotatePoly( self.gun, self._angle + self._tAngle )
|
|
led = gm.rotatePoly( self.ledPoly, self._angle + self._tAngle )
|
|
|
|
# The base body rectangle.
|
|
for poly in gm.displacePoly(hood, self.pos, self._limits):
|
|
d.polygon( poly, fill=self.color )
|
|
|
|
# The treads
|
|
for poly in gm.displacePoly(tread1, self.pos, self._limits) + \
|
|
gm.displacePoly(tread2, self.pos, self._limits):
|
|
d.polygon( poly, fill=self.treadColor )
|
|
|
|
# The turret circle
|
|
for poly in gm.displacePoly(self.body, self.pos, self._limits):
|
|
d.ellipse( poly, fill=self.color, outline='black')
|
|
|
|
self._drawLaser(d)
|
|
|
|
for poly in gm.displacePoly(gun, self.pos, self._limits):
|
|
d.polygon( poly, fill='#000000')
|
|
|
|
if self.led:
|
|
for poly in gm.displacePoly(led, self.pos, self._limits):
|
|
d.polygon( poly, fill='#ffffff')
|
|
|
|
def _drawLaser(self, drawing):
|
|
"""Draw the laser line if we shot this turn."""
|
|
if self._fired:
|
|
laser = gm.rotatePoly( self.laser, self._angle + self._tAngle )
|
|
for poly in gm.displacePoly(laser, self.pos, self._limits):
|
|
drawing.polygon(poly, fill=self.color)
|
|
|
|
self._fired = False
|
|
|
|
def _drawExplosion(self, drawing):
|
|
"""Draw the tank exploding."""
|
|
|
|
self._deadFrame = self._deadFrame - 1
|
|
innerBoom = []
|
|
outerBoom = []
|
|
points = 20
|
|
for i in range(points):
|
|
if i%2 == 1:
|
|
radius = 1.5 * self.RADIUS / 2
|
|
else:
|
|
radius = 1.5 * self.RADIUS
|
|
innerBoom.append(gm.polar2cart(radius/2, i*2*math.pi/points))
|
|
outerBoom.append(gm.polar2cart(radius, i*2*math.pi/points))
|
|
|
|
for poly in gm.displacePoly(outerBoom, self.pos, self._limits):
|
|
drawing.polygon(poly, fill='red')
|
|
for poly in gm.displacePoly(innerBoom, self.pos, self._limits):
|
|
drawing.polygon(poly, fill='orange')
|
|
|
|
def _drawSensors(self, drawing):
|
|
"""Draw sensor arcs for all of the defined sensors."""
|
|
|
|
for i in range( len( self._sensors) ):
|
|
if self._sensorState[i]:
|
|
color = '#000000'
|
|
else:
|
|
color = self.color
|
|
|
|
r, angle, width, tAttached = self._sensors[i]
|
|
r = int(r)
|
|
sensorCircle = [(-r, -r), (r, r)]
|
|
angle = angle + self._angle
|
|
if tAttached:
|
|
angle = angle + self._tAngle
|
|
min = int( math.degrees( angle - width/2 ) )
|
|
max = int( math.degrees( angle + width/2 ) )
|
|
for poly in gm.displacePoly(sensorCircle, self.pos, self._limits,
|
|
coordSequence=True):
|
|
drawing.chord(poly, min, max, outline=color)
|