Skip to content

Instantly share code, notes, and snippets.

@selenologist
Last active October 28, 2017 00:04
Show Gist options
  • Save selenologist/1811aeb650e758a0edabaab7a9e1e2a4 to your computer and use it in GitHub Desktop.
Save selenologist/1811aeb650e758a0edabaab7a9e1e2a4 to your computer and use it in GitHub Desktop.
##### All rights reserved #####
# crappy sound implementation
# no more pymunk dependency now, pure pygame
import time, math, queue, ctypes, random, pygame, operator, numpy as np, sys
from pygame import gfxdraw
from pyaudio import PyAudio, paFloat32,paContinue
pyaudio = PyAudio()
# Vec2 isn't with the other classes because a global depends on it
class Vec2(object):
__slots__ = ("x", "y")
def __init__(self, x=None, y=None):
if x is None:
self.x, self.y = 0,0
elif y is None:
self.x, self.y = x[0], x[1]
else:
self.x, self.y = x, y
def __iter__(self):
yield self.x
yield self.y
def __repr__(self):
return "Vec2({:f},{:f})".format(self.x, self.y)
def __getitem__(self, key):
if key == 0:
return self.x
elif key == 1:
return self.y
raise IndexError()
def __setitem__(self, key, value):
if key == 0:
self.x = value
elif key == 1:
self.y = value
else:
raise IndexError()
def __len__(self):
return 2
def _opcopy(self, other, f):
if isinstance(other, Vec2):
return Vec2(f(self.x, other.x), f(self.y, other.y))
else:
return Vec2(f(self.x, other), f(self.y, other))
def _inplace(self, other, f):
if isinstance(other, Vec2):
self.x = f(self.x, other.x)
self.y = f(self.y, other.y)
else:
self.x = f(self.x, other)
self.y = f(self.y, other)
return self
__add__ = lambda self, other: self._opcopy(other, operator.add)
__sub__ = lambda self, other: self._opcopy(other, operator.sub)
__mul__ = lambda self, other: self._opcopy(other, operator.mul)
__floordiv__ = lambda self, other: self._opcopy(other, operator.floordiv)
__truediv__ = lambda self, other: self._opcopy(other, operator.truediv)
__radd__ = __add__
__rmul__ = __mul__
__rfloordiv__ = lambda self, other: Vec2._opcopy(other, self, operator.floordiv)
__rtruediv__ = lambda self, other: Vec2._opcopy(other, self, operator.floordiv)
__iadd__ = lambda self, other: self._inplace(other, operator.add)
__isub__ = lambda self, other: self._inplace(other, operator.sub)
__imul__ = lambda self, other: self._inplace(other, operator.mul)
__idiv__ = lambda self, other: self._inplace(other, operator.div)
__neg__ = lambda self: Vec2(operator.neg(self.x), operator.neg(self.y))
__abs__ = lambda self: Vec2(operator.abs(self.x), operator.abs(self.y))
def length_sqrd(self):
return self.x**2 + self.y**2
def _get_length(self):
return math.sqrt(self.length_sqrd())
def _set_length(self, value):
length = self.get_length()
scale = value/length
self.x *= scale
self.y *= scale
length = property(_get_length, _set_length)
def dist_sqrd(self, other):
return (self.x - other.x)**2 + (self.y - other.y)**2
def dist(self, other):
return math.sqrt(self.dist_sqrd(other))
def normalized(self):
length = self.length
if length != 0:
return self/length
else:
return Vec2(self)
def __lt__(self, other):
return self.length < other.length
### static helpers
def angle2vec(angle):
return Vec2(math.cos(angle), math.sin(angle))
def offset2angle(offset_or_x, y=None):
if y is None:
return math.atan2(offset_or_x.y, offset_or_x.x)
else:
return math.atan2(y, offset)
### constants
screen_size = Vec2(1280,1024)
G = 6.67408 # gravitational constant
epsilon = sys.float_info.epsilon
chart_height = 64
chart_ratio = 3 # ratio of chart length to height
chart_gap = 10
fps = 60
dt = 1/fps # no fancy integrator, so keep the step is always the same
font_size = 16
bigfont_size = 48
fuel_height = 18
# minimum and maximum number of segments to use to shape asteroids
asteroid_minmax_segments = (7, 17)
asteroid_scale = screen_size.y/2
# thruster fuel rate in kg/N
thruster_fuel_rate = 0.0036
# thruster control slew rate in seconds
thruster_lag = 1
### globals
font = None
bigfont = None
draw_options = None
clock = pygame.time.Clock()
### classes
class ColorConfig():
background = (0 ,0 ,0 )
asteroid = (160,160,160)
person = (0 ,255,0 )
accelchart = (0 ,0 ,255)
gravchart = (255,0 ,255)
distchart = (255,0 ,0 )
surfchart = (192,192,0 )
velocitychart = (0 ,255,64 )
momentumchart = (128,255,0 )
impact_stats = (255,64 ,64 )
score_text = (192,72 ,72 )
center_of_gravity= (255,255,0 )
eva_accel = (255,255,0 )
grav_accel = (255,0 ,255)
total_accel = (64 ,64 ,255)
velocity_vis = (0 ,255,64 )
ready_text = (255,0 ,0 )
grav_text = (128,0 ,128)
thrust_text = (128,128,0 )
velocity_text = (0 ,192,64 )
cooldown_text = (0 ,195,255)
info_text = (255,255,255)
fuel_empty = (128,96 ,0 )
fuel_full = (192,128,0 )
score_background = (0 ,0 ,0 ,200)
colorconfig = ColorConfig()
class Container(): # like a dict but you can use dot notation
pass
class CrappyChart:
def __init__(self, height, x, y, color, label=None, override_min=None):
self.height = height
self.x, self.y = x, y
self.color = color
self.width = math.floor(chart_ratio * height)
self.data = []
self.color = color
self.center_color = tuple(math.floor(x/2) for x in color)
if label:
self.label = font.render(label, True, self.center_color)
else:
self.label = None
self.override_min = override_min
def log(self,point):
self.data += [point]
self.data = self.data[-self.width:] # keep only self.width points
def draw(self,surf):
if self.label:
screen.blit(self.label, (self.x, self.y+self.height/2))
min_ = min(self.data) if not self.override_min else self.override_min
range_ = max(max(self.data)-min_, 1)
points = [(self.x + x, self.y - ((y-min_)/range_ - 0.5)*self.height)
for x,y in enumerate(self.data)]
if len(points) < self.width:
points += [(self.x+self.width, points[-1] if len(self.data) > 0 else self.y)]
pygame.draw.lines(surf, self.color, False, points)
pygame.draw.line(surf, self.center_color,
[self.x,self.y],
[self.x+self.width,self.y])
class AudioThread:
class Params:
def __init__(self, n):
self.last = [0.0 for _ in range(0,n)]
def update(self, _new, dt):
current = [new*dt + last*(1-dt) for new,last in zip(_new,self.last)]
self.last = current
return current
class Lpf:
def __init__(self, sample_rate):
self.sample_rate = sample_rate
self.w0 = math.pi * 1/sample_rate
self.z1 = 0
self.z2 = 0
def calculate(self, sample, freq, res):
K = math.tan(freq*self.w0)
norm = 1 / (1 + K / res + K * K)
a0 = K * K * norm
a1 = 2 * a0
a2 = a0
b1 = 2 * (K * K - 1) * norm
b2 = (1 - K / res + K * K) * norm
out = sample * a0 + self.z1
self.z1 = sample * a1 + self.z2 - b1 * out
self.z2 = sample * a2 - b2 * out
return out
def __init__(self):
self.queue = queue.Queue(maxsize=16) # even though there are less audio updates than game updates per second this queue should not grow more than 3, 4
self.creation = time.time()
self.lasttime = None
self.params = self.Params(1)
self.lpf = self.Lpf(44100)
callback = lambda in_data, frame_count, time_info, flag: self.update(in_data, frame_count, time_info, flag)
self.stream = pyaudio.open(
format = paFloat32,
channels = 1,
rate = 44100,
output = True,
frames_per_buffer = 1024,
stream_callback = callback)
self.lastphase = 0
def update(self, in_data, frame_count, time_info, status):
params=self.params
lpf=self.lpf
now = time.time()
if not self.lasttime:
deltatime = now - self.creation
self.lasttime = now
else:
deltatime = now - self.lasttime
updates = []
try:
while True:
update = self.queue.get(block=False)
updates.append(update)
except queue.Empty:
pass
if len(updates) == 0:
updates = [[0]]
output = (ctypes.c_float * frame_count)()
for frame in range(0,frame_count):
update = updates[math.floor(frame/(frame_count/len(updates)))]
param = params.update(update, deltatime/frame_count)
freq = param[0]
vol = min(max(0.8, freq*16), freq)
inc = (440+440*freq)*math.tau/44100
self.lastphase += inc
output[frame] =\
lpf.calculate(random.random() * 0.5 - 0.25,
110+freq*3000,
0.8) * vol
return output, paContinue
class Game:
ready_text = None
def __init__(s):
asteroid = Container()
asteroid.mass = 16000
asteroid.segments = random.randint(*asteroid_minmax_segments)
asteroid.segment_increment = math.tau / asteroid.segments
asteroid.points = [random.random() * 1/5 + 1/3 for _ in np.arange(0, math.tau, asteroid.segment_increment)]
# this isn't quite right because flat sections are sometimes lower than a point itself but it will do
lowest_height = min(asteroid.points)*asteroid_scale
asteroid.lowest_height = lowest_height
asteroid.surface_grav = G * asteroid.mass / lowest_height**2
asteroid.rotational_velocity = random.random() - 0.5 # range [-0.5,+0.5]
asteroid.angle = 0
asteroid.position = screen_size/2
s.asteroid = asteroid
person = Container()
person.propellant = 20
person.selfmass = 80
person.mass = person.propellant + person.selfmass
orbit_phase = random.random()*math.pi*2
orbit_height = lowest_height*2
person.position = screen_size/2 + orbit_height * angle2vec(orbit_phase)
person.velocity = math.sqrt((G * asteroid.mass)/orbit_height) * angle2vec(orbit_phase + math.pi/2)
person.stuck = False
person.thruster = 0
s.person = person
s.thruster_newtons = (person.mass*asteroid.surface_grav)*2.9
s.starfield = [
((random.random() * 2 * math.pi, random.random()), # angle, mag
(random.randint(16,255),random.randint(16,255),random.randint(16,255))) # color
for points in range(0,random.randint(140,360))]
s.star_rot = 0.0
s.star_rot_speed = 0.1
s.star_zoom = 0
s.star_zoom_speed = 0.034
num_charts = 6
ch = chart_height # abbreviation to keep line length down
m = chart_ratio * chart_height + chart_gap # multiplier for each chart
xo = (screen_size.x - m*num_charts) / 2 # x offset
y = screen_size.y - chart_height # y position
c = 0 # chart counter for multiplier
s.accelchart = CrappyChart(ch, c*m+xo, y, colorconfig.accelchart, "Acceleration"); c+=1
s.gravchart = CrappyChart(ch, c*m+xo, y, colorconfig.gravchart, "Gravity"); c+=1
s.distchart = CrappyChart(ch, c*m+xo, y, colorconfig.distchart, "COG Dist"); c+=1
s.surfchart = CrappyChart(ch, c*m+xo, y, colorconfig.surfchart, "Surface Dist", 0.01); c+=1
s.velochart = CrappyChart(ch, c*m+xo, y, colorconfig.velocitychart, "Velocity"); c+=1
s.momechart = CrappyChart(ch, c*m+xo, y, colorconfig.momentumchart, "Momentum")
# Generate the "READY?" text just once
if not Game.ready_text:
Game.ready_text = bigfont.render("READY?", True, colorconfig.ready_text)
s.cooldown = 0
def score(self):
person = self.person
momentum = person.velocity.length * person.mass
propleft = person.mass - person.selfmass
propratio = propleft/person.propellant
if propratio >= 1.0:
return "Were you asleep?"
elif momentum > 1800:
return "Splat!"
elif momentum > 800:
return "That's gotta hurt!"
elif momentum > 400:
return "A bit rough..."
elif momentum > 160:
return "Could be a bit softer."
elif momentum < 20:
return "Feather touch!"
elif propratio > 0.9:
return "Very fuel efficient!"
elif propratio > 0.6:
return "Great landing!"
elif propratio > 0.4:
return "Not bad!"
elif propratio > 0.25:
return "The important thing is, you survived"
elif propratio > 0.1:
return "Propellant is scarce, you know..."
else:
return "Only just made it!"
def update_cooldown(s, screen=None):
if s.cooldown:
if screen:
text = bigfont.render(str(round(s.cooldown)), True, colorconfig.cooldown_text)
screen.blit(text, (screen_size.x - text.get_width(),0))
s.cooldown -= dt
if s.cooldown <= 0:
s.cooldown = 0
def draw_starfield(self, screen):
screen.lock()
pixel = gfxdraw.pixel
zoom = (math.cos(self.star_zoom) + 1.75) * screen_size.x
rot = self.star_rot
for star in self.starfield:
pos = star[0]
col = star[1]
ang = angle2vec(pos[0]+rot)
mag = pos[1] * zoom
pos = screen_size/2 + ang * mag
pixel(screen, int(pos[0]), int(pos[1]), col)
screen.unlock()
self.star_rot += self.star_rot_speed * dt
self.star_zoom += self.star_zoom_speed * dt
def draw_objects(self, screen):
asteroid = self.asteroid
person = self.person
flip_y = lambda z: (int(z.x), int(screen_size.y-z.y))
screen.fill(colorconfig.background)
self.draw_starfield(screen)
# display asteroid
poly = [flip_y(asteroid.position + angle2vec(asteroid.segment_increment * i + asteroid.angle) * p * asteroid_scale)
for (i,p) in enumerate(asteroid.points)]
pygame.draw.polygon(screen, colorconfig.asteroid, poly)
# display person
pygame.draw.circle(screen, colorconfig.person, flip_y(person.position), 2)
# display circle for center of gravity
pygame.draw.circle(screen, colorconfig.center_of_gravity, flip_y(asteroid.position), 5)
def draw_info(self, screen,
dist, surf_dist,
accel, gaccel, taccel):
person = self.person
asteroid = self.asteroid
propleft = person.mass - person.selfmass
propratio = propleft/person.propellant
# draw remaining fuel left across top bar
pygame.draw.rect(screen, colorconfig.fuel_empty, [0, 0, screen_size.x, fuel_height])
pygame.draw.rect(screen, colorconfig.fuel_full , [0, 0, screen_size.x * propratio, fuel_height])
# pygame and pymunk use different y polarity
# keep a copy of the positions before we change anything
ppos = Vec2(person.position)
cogpos = Vec2(asteroid.position)
# generate visualisation lines for the gravity and thrust vectors
accel_vis = ppos + accel*48
gaccel_vis = ppos + gaccel*48
taccel_vis = ppos + taccel*48
velocity_vis = ppos + person.velocity*32
# now flip all the y axes so pygame can use them
for z in [ppos, cogpos, accel_vis, gaccel_vis, taccel_vis, velocity_vis]:
z.y = screen_size.y-z.y
# display info at top of screen
surf_accel = (accel * gaccel.normalized()).length
impulse = propleft / thruster_fuel_rate
momentum = person.mass * person.velocity.length
Δv = impulse / person.mass
burntime = impulse / self.thruster_newtons
impact = math.sqrt(surf_dist/max(surf_accel,epsilon))
max_a = self.thruster_newtons/person.mass
text = font.render(
"surf={:05.1f} fuel={:06.3f}kg p={:06.3f} Δv={:05.3f} in {:05.2f}s impact in {:05.2f}s max accel={:05.2f}"
.format(surf_dist, propleft, momentum, Δv, burntime, impact, max_a),
True, colorconfig.info_text)
screen.blit(text, [0,0])
# display visualisation of velocity
pygame.draw.aaline(screen, colorconfig.velocity_vis, ppos, velocity_vis)
text = font.render("v = {:3.3f}".format(person.velocity.length), True, colorconfig.velocity_text)
midpoint = ppos + person.velocity/2
screen.blit(text, midpoint)
# display visualisation of total acceleration on person
pygame.draw.aaline(screen, colorconfig.total_accel, ppos, accel_vis)
# display visualisation of gravity acceleration on person
pygame.draw.aaline(screen, colorconfig.grav_accel, ppos, gaccel_vis)
text = font.render("ga = {:3.3f}".format(gaccel.length), True, colorconfig.grav_text)
midpoint = ppos+gaccel/2 - Vec2(text.get_width()/2, text.get_height())
screen.blit(text, midpoint)
# display visualisation of EVA thruster acceleration on person
pygame.draw.aaline(screen, colorconfig.eva_accel, ppos, taccel_vis)
text = font.render("ta = {:3.3f}".format(taccel.length), True, colorconfig.thrust_text)
midpoint = ppos+taccel/2 - Vec2(text.get_width()/2, -text.get_height()/2)
screen.blit(text, midpoint)
if person.stuck:
screen.blit(person.impact_stats,
(screen_size.x/2 - person.impact_stats.get_width()/2,
screen_size.y * 0.3))
def draw_charts(s, screen,
dist, surf_dist,
accel, gaccel):
velocity = s.person.velocity.length
momentum = s.person.mass * velocity
# log to and display charts
s.accelchart.log(accel.length)
s.gravchart.log (gaccel.length)
s.distchart.log (dist)
s.surfchart.log (surf_dist)
s.velochart.log (velocity)
s.momechart.log (momentum)
s.accelchart.draw(screen)
s.gravchart.draw (screen)
s.distchart.draw (screen)
s.surfchart.draw (screen)
s.velochart.draw (screen)
s.momechart.draw (screen)
def wait_until_ready(self, screen, audio):
pressed = False
while (not pressed) or self.cooldown:
for event in pygame.event.get(): # this also makes .get_pressed actually work
if event.type == pygame.QUIT:
return 'quit'
if pygame.key.get_pressed()[pygame.K_SPACE]:
pressed = True
# start game after 3 seconds
self.cooldown = 3
self.draw_objects(screen)
dist, gaccel = self.calculate_gravity()
self.draw_info(screen, dist, 0, gaccel, gaccel, Vec2(0,0))
offset = Vec2(0,0)
if self.cooldown:
invert = (3 - self.cooldown)
offset = Vec2(math.sin(invert*math.pi) * invert, invert*2) * screen_size.y/10
screen.blit(self.ready_text,
screen_size/2 -
Vec2(self.ready_text.get_size())/2 -
offset)
self.update_cooldown(screen)
audio.queue.put([0.0], block=False)
pygame.display.flip()
clock.tick(fps)
def rotate_asteroid(s):
person = s.person
asteroid = s.asteroid
asteroid.angle = asteroid.angle + asteroid.rotational_velocity*dt
if person.stuck:
# make the person rotate with the asteroid
person.position = asteroid.position + person.old_length * angle2vec(person.old_angle)
person.old_angle = person.old_angle + asteroid.rotational_velocity*dt
def calculate_surf_dist(s):
asteroid = s.asteroid
person = s.person
inc = asteroid.segment_increment
# get person direction from asteroid
person_dir = offset2angle(person.position - asteroid.position)
relative_rotation = asteroid.angle - person_dir
# make sure rotation in range [0,2pi]
rotation = relative_rotation - math.tau * math.floor(relative_rotation/math.tau)
segment = rotation/inc
idx = 1 - math.ceil(segment)
a1 = inc * idx
a2 = inc * (idx-1)
p1 = angle2vec(a1+rotation) * asteroid.points[idx]
p2 = angle2vec(a2+rotation) * asteroid.points[idx-1]
# surf height = x coordinate of intersection of line p1,p2 and line outer_edge, 0
surf_height = (p1.x*p2.y - p1.y*p2.x)/(p1.y - p2.y)*asteroid_scale
surf_point = asteroid.position - surf_height * angle2vec(person_dir)
surf_sign = 1
if (person.position - asteroid.position).length < (surf_point-asteroid.position).length:
surf_sign = -1
return person.position.dist(surf_point) * surf_sign, surf_point
def check_surface(s, gaccel):
person = s.person
asteroid = s.asteroid
# get surface distance
surf_dist, surf_point = s.calculate_surf_dist()
if surf_dist <= 0: # collided with asteroid
impact_speed = (person.velocity*gaccel.normalized()).length
impact_stats = font.render(
"Impacted at {:3.3f}m/s with {:3.3f}kg propellant left"
.format(impact_speed, person.mass-person.selfmass),
True, colorconfig.impact_stats)
width, height = impact_stats.get_size()
border_w = 10
border_h = 5
width += border_w * 2
height += border_h * 2 + height
scoretext = s.score()
scoretext = font.render(scoretext, True, colorconfig.score_text)
person.impact_stats = pygame.Surface((width, height), pygame.SRCALPHA)
person.impact_stats.fill(colorconfig.score_background)
person.impact_stats.blit(impact_stats, (border_w,border_h))
person.impact_stats.blit(scoretext,(width/2-scoretext.get_width()/2,height/2))
person.position = surf_point # put person on surface
person.velocity = Vec2(0,0)
person.stuck = True
offset = person.position - asteroid.position
person.old_length = offset.length
person.old_angle = offset2angle(offset)
# prevent starting a new game for three seconds
s.cooldown = 3
return 0.0, person.position
else:
return surf_dist, surf_point
def calculate_gravity(s):
person = s.person
asteroid = s.asteroid
# get angle to center of gravity
angle_to_cog = offset2angle(asteroid.position - person.position)
gravity_dir = angle2vec(angle_to_cog)
# get squared distance to center of gravity
distsq = person.position.dist_sqrd(asteroid.position)
gravity_force = (G * person.mass * asteroid.mass)/distsq
gaccel = (gravity_force / person.mass) * gravity_dir
return math.sqrt(distsq), gaccel
def update(s, surf_dist, surf_point, gaccel, audio):
person = s.person
asteroid = s.asteroid
propleft = person.mass - person.selfmass
if propleft > 0 and not person.stuck:
button_pressed = (pygame.key.get_pressed()[pygame.K_SPACE] != 0)
old = person.thruster
current = 1.0 if button_pressed else -1.0
lag = dt/thruster_lag
person.thruster = max(current*lag + old*(1.0-lag), 0)
else:
person.thruster = 0
audio.queue.put([person.thruster], block=False)
thruster = person.thruster * s.thruster_newtons
taccel = (thruster / person.mass) * -person.velocity.normalized()
accel = gaccel+taccel
person.mass = max(person.mass - thruster*thruster_fuel_rate*dt, person.selfmass)
if not person.stuck:
person.velocity += accel * dt
person.position += person.velocity * dt
return accel, taccel
def loop(s, screen, audio):
person = s.person
status = None
for event in pygame.event.get():
if event.type == pygame.QUIT:
status = 'quit'
s.draw_objects(screen)
s.rotate_asteroid()
dist, gaccel = s.calculate_gravity()
surf_dist, surf_point = 0.0, 0.0
if person.stuck:
surf_point = person.position
button_pressed = (pygame.key.get_pressed()[pygame.K_SPACE] != 0)
if button_pressed and not s.cooldown:
status = 'new_game'
else:
surf_dist, surf_point = s.check_surface(gaccel)
accel, taccel = s.update(surf_dist, surf_point, gaccel, audio)
s.draw_info(screen,
dist, surf_dist,
accel, gaccel, taccel)
s.draw_charts(screen, dist, surf_dist, accel, gaccel)
s.update_cooldown(screen)
pygame.display.flip()
return status
if __name__ == "__main__":
pygame.init()
pygame.font.init()
# Find a monospaced font or default to the... default
# this is pretty gross but it does work and I'm not sure there's really a better way
fontname = next((x for x in pygame.font.get_fonts() if 'mono' in x),
pygame.font.get_default_font())
font = pygame.font.SysFont(fontname, font_size)
bigfont = pygame.font.SysFont(fontname, bigfont_size)
screen = pygame.display.set_mode(tuple(screen_size))
audio = AudioThread()
while True:
game = Game()
if game.wait_until_ready(screen, audio) == 'quit':
sys.exit(0)
while True:
status = game.loop(screen, audio)
if status == 'new_game':
break
elif status == 'quit':
sys.exit(0)
clock.tick(fps)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment