1"""
2Graphical user interface for PySpaceWar
3"""
4
5import os
6import sys
7import glob
8import time
9import math
10import random
11import itertools
12
13try:
14    from configparser import ConfigParser
15except ImportError:
16    from ConfigParser import RawConfigParser as ConfigParser
17
18try:
19    unicode
20except NameError:
21    unicode = str
22
23import pygame
24from pygame.locals import (
25    FULLSCREEN,
26    KEYDOWN,
27    KMOD_ALT,
28    K_1,
29    K_2,
30    K_BACKSPACE,
31    K_CAPSLOCK,
32    K_DELETE,
33    K_DOWN,
34    K_EQUALS,
35    K_ESCAPE,
36    K_F1,
37    K_F12,
38    K_KP_ENTER,
39    K_KP_PERIOD,
40    K_LALT,
41    K_LCTRL,
42    K_LEFT,
43    K_LMETA,
44    K_LSHIFT,
45    K_LSUPER,
46    K_MINUS,
47    K_MODE,
48    K_NUMLOCK,
49    K_PAGEDOWN,
50    K_PAGEUP,
51    K_PAUSE,
52    K_RALT,
53    K_RCTRL,
54    K_RETURN,
55    K_RIGHT,
56    K_RMETA,
57    K_RSHIFT,
58    K_RSUPER,
59    K_SCROLLOCK,
60    K_SPACE,
61    K_UP,
62    K_a,
63    K_d,
64    K_f,
65    K_h,
66    K_o,
67    K_q,
68    K_s,
69    K_w,
70    MOUSEBUTTONDOWN,
71    MOUSEBUTTONUP,
72    MOUSEMOTION,
73    QUIT,
74    RESIZABLE,
75    Rect,
76    VIDEORESIZE,
77)
78
79from .world import Vector, Missile
80from .game import Game
81from .ai import AIController
82from .version import version
83
84
85MODIFIER_KEYS = {
86    K_NUMLOCK, K_CAPSLOCK, K_SCROLLOCK,
87    K_RSHIFT, K_LSHIFT, K_RCTRL, K_LCTRL, K_RALT, K_LALT,
88    K_RMETA, K_LMETA, K_LSUPER, K_RSUPER, K_MODE,
89}
90
91
92DEFAULT_CONTROLS = {
93    # Player 1
94    'P1_TOGGLE_AI': K_1,
95    'P1_LEFT': K_LEFT,
96    'P1_RIGHT': K_RIGHT,
97    'P1_FORWARD': K_UP,
98    'P1_BACKWARD': K_DOWN,
99    'P1_BRAKE': K_RALT,
100    'P1_FIRE': K_RCTRL,
101    # Player 2
102    'P2_TOGGLE_AI': K_2,
103    'P2_LEFT': K_a,
104    'P2_RIGHT': K_d,
105    'P2_FORWARD': K_w,
106    'P2_BACKWARD': K_s,
107    'P2_BRAKE': K_LALT,
108    'P2_FIRE': K_LCTRL,
109}
110
111
112HELP_TEXT = u"""\
113=PySpaceWar=
114
115Two ships duel in a gravity field.   Gravity doesn't affect the ships
116themselves (which have spanking new anti-gravity devices), but it affects
117missiles launced by the ships.  The law of inertia applies to the ships \u2014
118if you accelerate in one direction, you will continue to move in that direction
119until you accelerate in another direction.
120
121The two player mode is good for target practice, and to get the feel of your
122ship.
123
124=Player 1 Controls=
125
126  P1_LEFT, P1_RIGHT \u2014 rotate
127  P1_FORWARD      \u2014 accelerate in the direction you're facing
128  P1_BACKWARD     \u2014 accelerate in the opposite direction
129  P1_FIRE         \u2014 launch a missile
130  P1_BRAKE        \u2014 brake (lose 5% speed)
131  P1_TOGGLE_AI    \u2014 enable/disable computer control
132
133=Player 2 Controls=
134
135  P2_LEFT, P2_RIGHT \u2014 rotate
136  P2_FORWARD      \u2014 accelerate in the direction you're facing
137  P2_BACKWARD     \u2014 accelerate in the opposite direction
138  P2_FIRE         \u2014 launch a missile
139  P2_BRAKE        \u2014 brake (lose 5% speed)
140  P2_TOGGLE_AI    \u2014 enable/disable computer control
141
142=Other Controls=
143
144  F1              \u2014 help
145  ESC             \u2014 game menu
146  PAUSE           \u2014 pause the game
147  O               \u2014 hide/show missile orbits
148  F, ALT+ENTER    \u2014 toggle full-screen mode
149  +, -            \u2014 zoom in/out
150  mouse wheel     \u2014 zoom in/out
151  left click      \u2014 game menu
152  right drag      \u2014 drag the viewport around
153
154=Credits=
155
156  Developer       \u2014 Marius Gedminas
157  AI              \u2014 Ignas Mikalaj\u016bnas
158  Graphics        \u2014 IGE - Outer Space (planet images)
159                  \u2014 Hubble Space Telescope (background)
160                  \u2014 Marius Gedminas (everything else)
161
162PySpaceWar is powered by PyGame, Python and SDL.
163
164This program is free software; you can redistribute it and/or modify it under
165the terms of the GNU General Public License as published by the Free Software
166Foundation; either version 2 of the License, or (at your option) any later
167version.
168"""
169
170
171def key_name(key):
172    """Return the name of the key.
173
174        >>> key_name(K_RCTRL)
175        'RIGHT CTRL'
176        >>> key_name(None)
177        '(unset)'
178
179    """
180    if not key:
181        return '(unset)'
182    return pygame.key.name(key).upper()
183
184
185def fixup_keys_in_text(text, controls):
186    """Replace action names with key names in help text.
187
188        >>> fixup_keys_in_text('Press FIRE to start', {'FIRE': [K_RCTRL]})
189        'Press RIGHT CTRL to start'
190
191    """
192    for action, keys in controls.items():
193        text = text.replace(action, key_name(keys[0]))
194    return text
195
196
197def is_modifier_key(key):
198    """Is this key a modifier?"""
199    return key in MODIFIER_KEYS
200
201
202def find(*filespec):
203    """Construct a pathname relative to the location of this module."""
204    basedir = os.path.dirname(__file__)
205    return os.path.join(basedir, *filespec)
206
207
208def colorblend(col1, col2, alpha=0.5):
209    """Blend two colors.
210
211    For example, let's blend 25% black and 75% white
212
213        >>> colorblend((0, 0, 0), (255, 255, 255), 0.25)
214        (191, 191, 191)
215
216    """
217    r1, g1, b1 = col1
218    r2, g2, b2 = col2
219    beta = 1-alpha
220    return (
221        int(alpha*r1+beta*r2),
222        int(alpha*g1+beta*g2),
223        int(alpha*b1+beta*b2),
224    )
225
226
227def linear(x, xmax, y1, y2):
228    """Calculate a linear transition from y1 to y2 as x moves from 0 to xmax.
229
230        >>> for x in range(10):
231        ...     print('*' * int(linear(x, 9, 1, 10)))
232        *
233        **
234        ***
235        ****
236        *****
237        ******
238        *******
239        ********
240        *********
241        **********
242
243    """
244    return y1 + (y2 - y1) * float(x) / xmax
245
246
247def smooth(x, xmax, y1, y2):
248    """Calculate a smooth transition from y1 to y2 as x moves from 0 to xmax.
249
250        >>> for x in range(10):
251        ...     print('*' * int(smooth(x, 9, 1, 10)))
252        *
253        *
254        *
255        **
256        ****
257        ******
258        ********
259        *********
260        *********
261        *********
262
263    """
264    t = -5 + 10 * (float(x) / xmax)
265    value = 1 / (1 + math.exp(-t))
266    return y1 + (y2 - y1) * value
267
268
269class Viewport(object):
270    """A viewport to the universe.
271
272    The responsibility of this class is to provide a mapping from screen
273    coordinates to world coordinates and back.
274
275    Attributes and properties:
276
277        ``origin`` -- point in the universe corresponding to the center of
278        the screen.
279
280        ``scale`` -- ratio of pixels to world coordinate units.
281
282    """
283
284    AUTOSCALE_FACTOR = 1.001
285
286    def __init__(self, surface):
287        self.surface = surface
288        self._origin = Vector(0, 0)
289        self._scale = 1.0
290        self._recalc()
291
292    def _set_origin(self, new_origin):
293        self._origin = new_origin
294        self._recalc()
295
296    origin = property(lambda self: self._origin, _set_origin)
297
298    def _set_scale(self, new_scale):
299        self._scale = new_scale
300        self._recalc()
301
302    scale = property(lambda self: self._scale, _set_scale)
303
304    def _recalc(self):
305        """Recalculate everything when origin/scale/screen size changes."""
306        surface_w, surface_h = self.surface.get_size()
307        # We want self.screen_pos(self.origin) == (surface_w/2, surface_h/2)
308        self._screen_x = surface_w * 0.5 - self.origin.x * self.scale
309        self._screen_y = surface_h * 0.5 + self.origin.y * self.scale
310        # Let's cache world_bounds
311        x1, y1 = self.world_pos((0, 0))
312        x2, y2 = self.world_pos((surface_w, surface_h))
313        self.world_bounds = min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)
314
315    def surface_size_changed(self):
316        """Notify that surface size has changed."""
317        self._recalc()
318
319    def screen_len(self, world_len):
320        """Convert a length in world coordinate units to pixels."""
321        return int(world_len * self.scale)
322
323    def screen_pos(self, world_pos):
324        """Convert world coordinates to screen coordinates."""
325        return (int(self._screen_x + world_pos[0] * self._scale),
326                int(self._screen_y - world_pos[1] * self._scale))
327
328    def draw_trail(self, list_of_world_pos, gradient, set_at):
329        """Draw a trail.
330
331        Optimization to avoid function calls and construction of lists.
332        """
333        sx = self._screen_x
334        sy = self._screen_y
335        scale = self._scale
336        for (x, y), color in zip(list_of_world_pos, gradient):
337            set_at((int(sx + x * scale), int(sy - y * scale)), color)
338
339    def world_pos(self, screen_pos):
340        """Convert screen coordinates into world coordinates."""
341        x = (screen_pos[0] - self._screen_x) / self._scale
342        y = -(screen_pos[1] - self._screen_y) / self._scale
343        return (x, y)
344
345    def in_screen(self, world_pos):
346        """Is a position visible on screen?"""
347        xmin, ymin, xmax, ymax = self.world_bounds
348        return xmin <= world_pos[0] <= xmax and ymin <= world_pos[1] <= ymax
349
350    def shift_by_pixels(self, delta):
351        """Shift the origin by a given number of screen pixels."""
352        delta_x, delta_y = delta
353        self.origin += Vector(delta_x / self.scale, -delta_y / self.scale)
354
355    def keep_visible(self, points, margin):
356        """Adjust origin and scale to keep all specified points visible.
357
358        Postcondition:
359
360            margin <= x <= screen_w - margin
361
362              and
363
364            margin <= y <= screen_h - margin
365
366              for x, y in [self.screen_pos(pt) for pt in points]
367
368        """
369        if len(points) > 1:
370            xs = [pt.x for pt in points]
371            ys = [pt.y for pt in points]
372            w = max(xs) - min(xs)
373            h = max(ys) - min(ys)
374            xmin, ymin, xmax, ymax = self.world_inner_bounds(margin)
375            seen_w = xmax - xmin
376            seen_h = ymax - ymin
377            factor = 1.0
378            while seen_w < w or seen_h < h:
379                factor *= self.AUTOSCALE_FACTOR
380                seen_w *= self.AUTOSCALE_FACTOR
381                seen_h *= self.AUTOSCALE_FACTOR
382            if factor != 1.0:
383                self.scale /= factor
384
385        for pt in points:
386            xmin, ymin, xmax, ymax = self.world_inner_bounds(margin)
387            if pt.x < xmin:
388                self.origin -= Vector(xmin - pt.x, 0)
389            elif pt.x > xmax:
390                self.origin -= Vector(xmax - pt.x, 0)
391            if pt.y < ymin:
392                self.origin -= Vector(0, ymin - pt.y)
393            elif pt.y > ymax:
394                self.origin -= Vector(0, ymax - pt.y)
395
396    def world_inner_bounds(self, margin):
397        """Calculate the rectange in world coordinates that fits inside a
398        given margin in the screen.
399
400        Returns (xmin, ymin, xmax, ymax).
401
402        For all points (x, y) where (xmin <= x <= xmax and ymin <= y <= ymax)
403        it is true, that margin <= sx <= screen_w - margin and
404        margin <= sy <= screen_h - margin; here sx, sy == screen_pos(x, y)
405        """
406        surface_w, surface_h = self.surface.get_size()
407        x1, y1 = self.world_pos((margin, margin))
408        x2, y2 = self.world_pos((surface_w - margin, surface_h - margin))
409        return min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)
410
411
412class FrameRateCounter(object):
413    """Frame rate counter."""
414
415    avg_last_n_frames = 10  # calculate average FPS for 10 frames
416
417    get_ticks = staticmethod(pygame.time.get_ticks)
418
419    def __init__(self):
420        self.frames = []
421
422    def frame(self):
423        """Tell the counter that a new frame has just been drawn."""
424        self.frames.append(self.get_ticks())
425        if len(self.frames) > self.avg_last_n_frames:
426            del self.frames[0]
427
428    def reset(self):
429        """Tell the counter that we stopped drawing frames for a while.
430
431        Call this method if you pause the game for a time.
432        """
433        self.frames = []
434
435    def fps(self):
436        """Calculate the frame rate.
437
438        Returns 0 if not enough frames have been drawn yet.
439        """
440        if len(self.frames) < 2:
441            return 0
442        ms = self.frames[-1] - self.frames[0]
443        frames = len(self.frames) - 1
444        return frames * 1000.0 / ms
445
446    def notional_fps(self):
447        """Calculate the frame rate assuming that I'm about to draw a frame.
448
449        Returns 0 if not enough frames have been drawn yet.
450        """
451        if len(self.frames) < 1:
452            return 0.0
453        ms = self.get_ticks() - self.frames[0]
454        frames = len(self.frames)
455        return frames * 1000.0 / ms
456
457
458class HUDCollection(object):
459    """A collection of heads up display widgets."""
460
461    def __init__(self, widgets=()):
462        self.widgets = list(widgets)
463
464    def draw(self, surface):
465        """Draw all the elements."""
466        for w in self.widgets:
467            w.draw(surface)
468
469
470class HUDElement(object):
471    """Heads-up status display widget."""
472
473    def __init__(self, width, height, xalign, yalign):
474        self.width = width
475        self.height = height
476        self.xalign = xalign
477        self.yalign = yalign
478
479    def position(self, surface, margin=10):
480        """Calculate screen position for the widget."""
481        surface_w, surface_h = surface.get_size()
482        x = margin + self.xalign * (surface_w - self.width - 2 * margin)
483        y = margin + self.yalign * (surface_h - self.height - 2 * margin)
484        return int(x), int(y)
485
486    def draw(self, surface):
487        """Draw the element."""
488        pass
489
490
491class HUDLabel(HUDElement):
492    """A static text label."""
493
494    DEFAULT_COLOR = (250, 250, 255)
495
496    def __init__(self, font, text, xalign=0, yalign=0, color=DEFAULT_COLOR):
497        self.font = font
498        self.width, self.height = self.font.size(text)
499        self.xalign = xalign
500        self.yalign = yalign
501        self.color = color
502        self.rendered_text = font.render(text, True, self.color)
503
504    def draw(self, surface):
505        """Draw the element."""
506        x, y = self.position(surface)
507        surface.blit(self.rendered_text, (x, y))
508
509
510class HUDFormattedText(HUDElement):
511    """A static text screen."""
512
513    bgcolor = (0x01, 0x02, 0x08)
514    color = (0xff, 0xff, 0xff)
515    page_number_color = (0x80, 0xcc, 0xff)
516    alpha = int(0.95 * 255)
517
518    xpadding = 40
519    ypadding = 40
520
521    indent = 20
522    tabstop = 140
523
524    def __init__(self, font, bold_font, text, xalign=0.5, yalign=0.5,
525                 xsize=1.0, ysize=1.0, small_font=None):
526        self.font = font
527        self.bold_font = bold_font
528        self.small_font = small_font or font
529        self.text = text
530        self.xsize = xsize
531        self.ysize = ysize
532        self.xalign = xalign
533        self.yalign = yalign
534        self.page = 0
535        self.n_pages = -1
536
537    def position(self, surface, margin=30):
538        """Calculate screen position for the widget."""
539        self.width = int((surface.get_width() - 2 * margin) * self.xsize)
540        self.height = int((surface.get_height() - 2 * margin) * self.ysize)
541        return HUDElement.position(self, surface, margin)
542
543    def draw(self, surface):
544        """Draw the element."""
545        x, y = self.position(surface)  # calculates self.width/height as well
546        rect = Rect(x, y, self.width, self.height)
547        buffer = pygame.Surface(rect.size)
548        buffer.set_alpha(self.alpha)
549        buffer.set_colorkey((1, 1, 1))
550        buffer.fill(self.bgcolor)
551        for ax in (0, rect.width-1):
552            for ay in (0, rect.height-1):
553                buffer.set_at((ax, ay), (1, 1, 1))
554        surface.blit(buffer, rect.topleft)
555        rect.inflate_ip(-self.xpadding*2, -self.ypadding*2)
556        self.render_text(surface, rect)
557
558    def split_to_paragraphs(self, text):
559        """Split text into paragraphs."""
560        paragraphs = []
561        for paragraph in self.text.split('\n\n'):
562            if not paragraph.startswith(' '):
563                # Intented blocks preserve line breaks, all others are joined
564                paragraph = paragraph.replace('\n', ' ')
565            paragraphs.append(paragraph)
566        return paragraphs
567
568    def split_items_into_groups(self, items, size, spacing):
569        """Split a list of tuples (item_size, item) into groups such that
570        the sum of sizes + spacing * (group size - 1) in each group is <= size.
571
572        Think "word wrapping".
573        """
574        groups = []
575        cur_group_size = size + 1
576        for item_size, item in items:
577            if cur_group_size > 0 and cur_group_size + item_size > size:
578                cur_group = []
579                cur_group_size = 0
580                groups.append(cur_group)
581            cur_group_size += item_size + spacing
582            cur_group.append((item_size, item))
583        return groups
584
585    def layout_paragraph(self, paragraph, width):
586        """Render and lay out a single paragraph.
587
588        Returns (height, bits, keep_with_next) where bits is a list
589        of images (one for each word) with relative coordinates.
590        """
591        font = self.font
592        leftindent = 0
593        tabstop = 0
594        keep_with_next = False
595        justify = False
596        if paragraph.startswith('=') and paragraph.endswith('='):
597            # =Title=
598            paragraph = paragraph[1:-1]
599            font = self.bold_font
600            keep_with_next = True
601        elif paragraph.startswith(' '):
602            # Indented block
603            leftindent += self.indent
604            width -= self.indent
605            tabstop = self.tabstop
606        else:
607            # Regular text
608            justify = True
609        word_spacing = font.size(' ')[0]
610        line_spacing = font.get_linesize()
611        bits = []
612        y = 0
613        for line in paragraph.splitlines():
614            if tabstop and u'\u2014' in line:
615                prefix, line = line.split(u'\u2014', 1)
616                prefix_img = font.render(prefix.strip(), True, self.color)
617                bits.append((prefix_img, (leftindent, y)))
618                wrapwidth = width - tabstop
619                cur_tabstop = tabstop
620            else:
621                wrapwidth = width
622                cur_tabstop = 0
623            words = [font.render(word, True, self.color)
624                     for word in line.split()]
625            items = [(img.get_width(), img) for img in words]
626            groups = self.split_items_into_groups(items, wrapwidth,
627                                                  word_spacing)
628            for group in groups:
629                x = leftindent + cur_tabstop
630                extra_spacing = 0
631                if justify and len(group) > 1 and group is not groups[-1]:
632                    extra_spacing = wrapwidth
633                    for img_width, img in group:
634                        extra_spacing -= img_width
635                    extra_spacing -= word_spacing * (len(group) - 1)
636                    extra_spacing = float(extra_spacing) / (len(group) - 1)
637                for img_width, img in group:
638                    bits.append((img, (int(x), y)))
639                    x += img_width + word_spacing + extra_spacing
640                y += line_spacing
641        return y, bits, keep_with_next
642
643    def layout_pages(self, text, page_size):
644        """Render and lay out text into pages.
645
646        Returns a list of pages, where each page is a list of of images (one
647        for each word) with relative coordinates.
648
649        Currently the page layout engine doesn't try to split paragraphs.
650        """
651        width, height = page_size
652        paragraph_spacing = self.font.get_linesize()
653        last_item_size = 0
654        last_item_bits = []
655        items = []
656        for paragraph in self.split_to_paragraphs(self.text):
657            size, bits, keep_with_next = self.layout_paragraph(paragraph,
658                                                               width)
659            if last_item_bits:  # join with previous
660                dy = last_item_size + paragraph_spacing
661                bits = last_item_bits + [(img, (x, y+dy))
662                                         for (img, (x, y)) in bits]
663                size += dy
664            if keep_with_next:
665                last_item_size = size
666                last_item_bits = bits
667            else:
668                items.append((size, bits))
669                last_item_size = 0
670                last_item_bits = []
671        if last_item_bits:  # last paragraph had "keep with next" set
672            items.append((last_item_size, last_item_bits))
673        pages = self.split_items_into_groups(items, height, paragraph_spacing)
674        return pages
675
676    def render_text(self, surface, page_rect):
677        """Render the text onto surface."""
678        paragraph_spacing = self.font.get_linesize()
679        width, height = page_rect.size
680        height -= self.small_font.get_linesize() * 2
681        pages = self.layout_pages(self.text, (width, height))
682        self.n_pages = len(pages)
683        if not pages:
684            return
685        self.page = max(0, min(self.page, len(pages)-1))
686        left = page_rect.left
687        top = page_rect.top
688        for para_size, para in pages[self.page]:
689            for img, (x, y) in para:
690                surface.blit(img, (left + x, top + y))
691            top += para_size + paragraph_spacing
692        page_text = 'Page %d of %d' % (self.page + 1, self.n_pages)
693        img = self.small_font.render(page_text, True, self.page_number_color)
694        r = img.get_rect()
695        r.bottomright = page_rect.bottomright
696        surface.blit(img, r.topleft)
697
698
699class HUDInfoPanel(HUDElement):
700    """Heads-up status display base class."""
701
702    STD_COLORS = [(0xff, 0xff, 0xff), (0xcc, 0xff, 0xff)]
703    GREEN_COLORS = [(0x7f, 0xff, 0x00), (0xcc, 0xff, 0xff)]
704
705    def __init__(self, font, ncols, nrows=None, xalign=0, yalign=0,
706                 colors=STD_COLORS, content=None):
707        self.font = font
708        self.width = int(self.font.size('x')[0] * ncols)
709        self.row_height = self.font.get_linesize()
710        if nrows is None:
711            nrows = len(content)
712        self.height = int(nrows * self.row_height)
713        self.xalign = xalign
714        self.yalign = yalign
715        self.color1, self.color2 = colors
716        self.surface = pygame.Surface((self.width, self.height))
717        self.surface.set_alpha(255 * 0.8)
718        self.surface.set_colorkey((1, 1, 1))
719        self.surface.fill((8, 8, 8))
720        for x in (0, self.width-1):
721            for y in (0, self.height-1):
722                self.surface.set_at((x, y), (1, 1, 1))
723        self.content = content or []
724
725    def draw_rows(self, surface, *rows):
726        """Draw some information.
727
728        ``rows`` is a list of 2-tuples.
729        """
730        x, y = self.position(surface)
731        surface.blit(self.surface, (x, y))
732        x += 1
733        y += 1
734        for a, b in rows:
735            img = self.font.render(str(a), True, self.color1)
736            surface.blit(img, (x, y))
737            img = self.font.render(str(b), True, self.color2)
738            surface.blit(img, (x + self.width - 2 - img.get_width(), y))
739            y += self.row_height
740
741    def draw(self, surface):
742        """Draw the panel."""
743        rows = []
744        for content_row in self.content:
745            row = []
746            for value in content_row:
747                if callable(value):
748                    row.append(value())
749                else:
750                    row.append(unicode(value))
751            rows.append(row)
752        self.draw_rows(surface, *rows)
753
754
755class HUDShipInfo(HUDInfoPanel):
756    """Heads-up ship status display."""
757
758    def __init__(self, ship, font, xalign=0, yalign=0,
759                 colors=HUDInfoPanel.STD_COLORS):
760        HUDInfoPanel.__init__(self, font, 12, 4.75, xalign, yalign, colors)
761        self.ship = ship
762
763    def draw(self, surface):
764        self.draw_rows(
765            surface,
766            ('direction', '%d' % self.ship.direction),
767            ('heading', '%d' % self.ship.velocity.direction()),
768            ('speed', '%.1f' % self.ship.velocity.length()),
769            ('frags', '%d' % self.ship.frags),
770        )
771        x, y = self.position(surface)
772        x += 1
773        y += self.height - 5
774        w = max(0, int((self.width - 4) * self.ship.health))
775        pygame.draw.rect(surface, self.color2, (x, y, self.width-2, 4), 1)
776        surface.fill(self.color1, (x+1, y+1, w, 2))
777
778
779class HUDCompass(HUDElement):
780    """Heads-up ship compass display.
781
782    Shows two vectors: direction of the ship, and the current velocity.
783    """
784
785    alpha = int(0.9*255)
786
787    BLUE_COLORS = (
788        (0x00, 0x11, 0x22),
789        (0x99, 0xaa, 0xff),
790        (0x44, 0x55, 0x66),
791        (0xaa, 0x77, 0x66),
792    )
793
794    GREEN_COLORS = (
795        (0x00, 0x22, 0x11),
796        (0x99, 0xff, 0xaa),
797        (0x44, 0x66, 0x55),
798        (0xaa, 0x66, 0x77),
799    )
800
801    radius = 50
802    radar_scale = 0.05
803    velocity_scale = 50
804
805    def __init__(self, world, ship, viewport, xalign=0, yalign=1,
806                 colors=BLUE_COLORS):
807        self.world = world
808        self.ship = ship
809        self.viewport = viewport
810        self.width = self.height = 2*self.radius
811        self.surface = pygame.Surface((self.width, self.height))
812        self.bgcolor, self.fgcolor1, self.fgcolor2, self.fgcolor3 = colors
813        self.xalign = xalign
814        self.yalign = yalign
815
816    def draw(self, surface):
817        if surface.get_bitsize() >= 24:
818            # Only 24 and 32 bpp modes support aaline
819            draw_line = pygame.draw.aaline
820        else:
821            draw_line = pygame.draw.line
822        x = y = self.radius
823        self.surface.set_colorkey((1, 1, 1))
824        self.surface.fill((1, 1, 1))
825        self.surface.set_alpha(self.alpha)
826
827        pygame.draw.circle(self.surface, self.bgcolor, (x, y), self.radius)
828        self.surface.set_at((x, y), self.fgcolor1)
829
830        scale = self.radar_scale * self.viewport.scale
831        for body in self.world.objects:
832            if body.mass == 0:
833                continue
834            pos = (body.position - self.ship.position) * scale
835            if pos.length() > self.radius:
836                continue
837            radius = max(0, int(body.radius * scale))
838            px = x + int(pos.x)
839            py = y - int(pos.y)
840            if radius < 1:
841                self.surface.set_at((px, py), self.fgcolor3)
842            elif radius == 1:
843                self.surface.fill(self.fgcolor3, (px, py, 2, 2))
844            else:
845                pygame.draw.circle(self.surface, self.fgcolor3, (px, py),
846                                   radius)
847
848        d = self.ship.direction_vector
849        d = d.scaled(self.radius * 0.9)
850        x2 = x + int(d.x)
851        y2 = y - int(d.y)
852        draw_line(self.surface, self.fgcolor2, (x, y), (x2, y2))
853
854        v = self.ship.velocity * self.velocity_scale
855        if v.length() > self.radius * 0.9:
856            v = v.scaled(self.radius * 0.9)
857        x2 = x + int(v.x)
858        y2 = y - int(v.y)
859        draw_line(self.surface, self.fgcolor1, (x, y), (x2, y2))
860
861        surface.blit(self.surface, self.position(surface))
862
863
864class FadingImage(object):
865    """An image that can smoothly fade away.
866
867    Uses a color key and surface alpha, as an approximation of a smooth fade
868    out.  Drops the alpha information in the source image, so instead of
869    smooth anti-aliased text being faded out the users will see ragged text
870    being faded out.
871
872    This happens quickly enough so that nobody will likely notice -- it took me
873    a good ten minutes to remember why I even had the more advanced fading
874    methods ;)
875    """
876
877    def __init__(self, image):
878        self.image = image.convert()  # drop the alpha channel
879        self.image.set_colorkey((0, 0, 0))
880
881    def draw(self, surface, x, y, alpha):
882        """Draw the image.
883
884        ``alpha`` is a floating point value between 0 and 255.
885        """
886        self.image.set_alpha(alpha)
887        surface.blit(self.image, (x, y))
888
889
890class NumPyFadingImage(object):
891    """An image that can smoothly fade away.
892
893    Implemented using NumPy arrays to scale the alpha channel on the fly.
894    """
895
896    def __init__(self, image):
897        import numpy  # noqa
898        self.image = image
899        self.mask = pygame.surfarray.array_alpha(image)
900        if hasattr(pygame.surfarray, 'use_arraytype'):
901            # This is a global switch, which breaks the abstraction a bit. :(
902            pygame.surfarray.use_arraytype('numpy')
903
904    def draw(self, surface, x, y, alpha):
905        """Draw the image.
906
907        ``alpha`` is a floating point value between 0 and 255.
908        """
909        import numpy
910        numpy.multiply(self.mask, alpha / 255,
911                       pygame.surfarray.pixels_alpha(self.image),
912                       casting='unsafe')
913        surface.blit(self.image, (x, y))
914
915
916class HUDTitle(HUDElement):
917    """Fading out title."""
918
919    paused = False
920
921    def __init__(self, image, xalign=0.5, yalign=0.25):
922        HUDElement.__init__(self, image.get_width(), image.get_height(),
923                            xalign, yalign)
924        self.alpha = 255
925        for cls in NumPyFadingImage, FadingImage:
926            try:
927                self.image = cls(image)
928            except ImportError:
929                pass
930            else:
931                break
932
933    def draw(self, surface):
934        """Draw the element."""
935        if self.alpha < 1:
936            return
937        x, y = self.position(surface)
938        self.image.draw(surface, x, y, self.alpha)
939        if not self.paused:
940            self.alpha *= 0.95
941
942
943class HUDMenu(HUDElement):
944    """A menu."""
945
946    normal_fg_color = (220, 255, 64)
947    normal_bg_color = (120, 24, 24)
948    selected_fg_color = (255, 255, 220)
949    selected_bg_color = (210, 48, 48)
950
951    def __init__(self, font, items, xalign=0.5, yalign=0.5,
952                 xpadding=32, ypadding=8, yspacing=16):
953        width, item_height = self.itemsize(font, items, xpadding, ypadding)
954        height = max(0, (item_height + yspacing) * len(items) - yspacing)
955        HUDElement.__init__(self, width, height, xalign, yalign)
956        self.full_height = height
957        self.font = font
958        self.items = items
959        self.yspacing = yspacing
960        self.xpadding = xpadding
961        self.ypadding = ypadding
962        self.selected_item = 0
963        self.top = 0
964        self.item_height = item_height
965        self.resize()
966
967    def position(self, surface, margin=10):
968        """Calculate screen position for the widget."""
969        max_height = surface.get_height() - 2 * margin
970        item_spacing = self.item_height + self.yspacing
971        self.height = self.full_height
972        while self.height > max_height:
973            self.height -= item_spacing
974        if self.selected_item * item_spacing < self.top:
975            self.top = self.selected_item * item_spacing
976        while (self.selected_item * item_spacing + self.item_height >
977               self.top + self.height):
978            self.top += item_spacing
979        return HUDElement.position(self, surface, margin)
980
981    def resize(self):
982        self.surface = pygame.Surface((self.width, self.full_height))
983        self.surface.set_alpha(255 * 0.9)
984        self.surface.set_colorkey((1, 1, 1))
985        self.invalidate()
986
987    def invalidate(self):
988        """Indicate that the menu needs to be redrawn."""
989        self._drawn_with = None
990
991    def itemsize(self, font, items, xpadding, ypadding):
992        """Calculate the size of the largest item."""
993        width = 0
994        height = 0
995        for item in items:
996            size = font.size(item)
997            if '\t' in item:
998                size = (size[0] + xpadding * 2, size[1])
999            width = max(width, size[0])
1000            height = max(height, size[1])
1001        return width + 2 * xpadding, height + 2 * ypadding
1002
1003    def find(self, surface, pos):
1004        """Find the item at given coordinates."""
1005        x, y = pos
1006        ix, iy = self.position(surface)
1007        iy -= self.top
1008        for idx, item in enumerate(self.items):
1009            if ix <= x < ix + self.width and iy <= y < iy + self.item_height:
1010                return idx
1011            iy += self.item_height + self.yspacing
1012        return -1
1013
1014    def _draw(self):
1015        """Draw the menu on self.surface."""
1016        self._drawn_with = self.selected_item
1017        self.surface.fill((1, 1, 1))
1018        x = 0
1019        y = 0
1020        for idx, item in enumerate(self.items):
1021            if idx == self.selected_item:
1022                fg_color = self.selected_fg_color
1023                bg_color = self.selected_bg_color
1024            else:
1025                fg_color = self.normal_fg_color
1026                bg_color = self.normal_bg_color
1027            self.surface.fill(bg_color, (x, y, self.width, self.item_height))
1028            if '\t' in item:
1029                # align left and right
1030                parts = item.split('\t', 1)
1031                img = self.font.render(parts[0], True, fg_color)
1032                margin = (self.item_height - img.get_height()) // 2
1033                self.surface.blit(img, (x + self.xpadding, y + margin))
1034                img = self.font.render(parts[1], True, fg_color)
1035                self.surface.blit(
1036                    img,
1037                    (x + self.width - img.get_width() - self.xpadding,
1038                     y + margin))
1039            else:
1040                # center
1041                img = self.font.render(item, True, fg_color)
1042                margin = (self.item_height - img.get_height()) // 2
1043                self.surface.blit(img,
1044                                  (x + (self.width - img.get_width()) // 2,
1045                                   y + margin))
1046            for ax in (0, self.width-1):
1047                for ay in (0, self.item_height-1):
1048                    self.surface.set_at((x+ax, y+ay), (1, 1, 1))
1049            y += self.item_height + self.yspacing
1050
1051    def draw(self, surface):
1052        """Draw the element."""
1053        # NB: self.position() might call self.resize() so we must
1054        # call it before _draw()
1055        x, y = self.position(surface)
1056        if self.selected_item != self._drawn_with:
1057            self._draw()
1058        surface.blit(self.surface, (x, y),
1059                     (0, self.top, self.width, self.height))
1060
1061
1062class HUDControlsMenu(HUDMenu):
1063    """A scrolling menu for keyboard controls."""
1064
1065    def __init__(self, font, items, xalign=0.5, yalign=0.5,
1066                 xpadding=8, ypadding=4, yspacing=2):
1067        HUDMenu.__init__(self, font, items, xalign, yalign, xpadding,
1068                         ypadding, yspacing)
1069
1070    def position(self, surface, margin=20):
1071        """Calculate screen position for the widget."""
1072        width = surface.get_width() - 2 * margin - 2 * self.xpadding
1073        if width != self.width:
1074            self.width = width
1075            self.resize()
1076        return HUDMenu.position(self, surface, margin)
1077
1078
1079class HUDInput(HUDElement):
1080    """An input box."""
1081
1082    bgcolor = (0x01, 0x02, 0x08)
1083    color1 = (0x80, 0xcc, 0xff)
1084    color2 = (0xee, 0xee, 0xee)
1085    alpha = int(0.8 * 255)
1086
1087    def __init__(self, font, prompt, text='', xmargin=20, ymargin=120,
1088                 xpadding=8, ypadding=8):
1089        self.font = font
1090        self.prompt = prompt
1091        self.text = text
1092        self.xmargin = xmargin
1093        self.ymargin = ymargin
1094        self.xpadding = xpadding
1095        self.ypadding = ypadding
1096
1097    def draw(self, surface):
1098        """Draw the element."""
1099        surface_w, surface_h = surface.get_size()
1100        width = surface_w - 2*self.xmargin
1101        height = self.font.get_linesize() + 2*self.ypadding
1102        buffer = pygame.Surface((width, height))
1103        buffer.set_alpha(self.alpha)
1104        buffer.set_colorkey((1, 1, 1))
1105        buffer.fill(self.bgcolor)
1106        img1 = self.font.render(self.prompt, True, self.color1)
1107        buffer.blit(img1, (self.xpadding, self.ypadding))
1108        img2 = self.font.render(self.text, True, self.color2)
1109        buffer.blit(img2, (self.xpadding + img1.get_width(), self.ypadding))
1110        for x in (0, width-1):
1111            for y in (0, height-1):
1112                buffer.set_at((x, y), (1, 1, 1))
1113        surface.blit(buffer, (self.xmargin,
1114                              surface_h - self.ymargin - buffer.get_height()))
1115
1116
1117class HUDMessage(HUDElement):
1118    """An message box."""
1119
1120    fg_color = (220, 255, 255)
1121    bg_color = (24, 120, 14)
1122    alpha = int(255 * 0.9)
1123
1124    def __init__(self, font, text, xpadding=16, ypadding=16, xalign=0.5,
1125                 yalign=0.5):
1126        width, height = font.size(text)
1127        width += 2*xpadding
1128        height += 2*ypadding
1129        HUDElement.__init__(self, width, height, xalign, yalign)
1130        self.xpadding = xpadding
1131        self.ypadding = ypadding
1132        self.font = font
1133        self.text = text
1134        self.surface = pygame.Surface((self.width, self.height))
1135        self.surface.set_colorkey((1, 1, 1))
1136        self.surface.fill(self.bg_color)
1137        img = self.font.render(text, True, self.fg_color)
1138        x = (self.width - img.get_width()) // 2
1139        y = (self.height - img.get_height()) // 2
1140        self.surface.blit(img, (x, y))
1141        for dx, dy in (0, 0), (1, 0), (0, 1):
1142            self.surface.set_at((dx, dy), (1, 1, 1))
1143            self.surface.set_at((self.width-1-dx, dy), (1, 1, 1))
1144            self.surface.set_at((dx, self.height-1-dy), (1, 1, 1))
1145            self.surface.set_at((self.width-1-dx, self.height-1-dy), (1, 1, 1))
1146
1147    def draw(self, surface):
1148        """Draw the element."""
1149        x, y = self.position(surface)
1150        self.surface.set_alpha(self.alpha)
1151        surface.blit(self.surface, (x, y))
1152
1153
1154class UIMode(object):
1155    """Mode of user interface.
1156
1157    The mode determines several things:
1158      - what is displayed on screen
1159      - whether the game progresses
1160      - how keystrokes are interpreted
1161
1162    Examples of modes: game play, paused, navigating a menu.
1163    """
1164
1165    paused = False
1166    mouse_visible = False
1167    keys_repeat = False
1168    music = None
1169
1170    inherit_pause_from_prev_mode = False
1171
1172    def __init__(self, ui):
1173        self.ui = ui
1174        self.prev_mode = None
1175        self.clear_keymap()
1176        self.init()
1177
1178    def init(self):
1179        """Initialize the mode."""
1180        pass
1181
1182    def enter(self, prev_mode):
1183        """Enter the mode."""
1184        if self.prev_mode is None:
1185            # Only do this once, otherwise two modes might get in a loop
1186            self.prev_mode = prev_mode
1187            if self.inherit_pause_from_prev_mode and prev_mode is not None:
1188                self.paused = prev_mode.paused
1189        pygame.mouse.set_visible(self.mouse_visible)
1190        if self.keys_repeat:
1191            pygame.key.set_repeat(250, 30)
1192        else:
1193            pygame.key.set_repeat()
1194        if self.music:
1195            self.ui.play_music(self.music)
1196
1197    def leave(self, next_mode=None):
1198        """Leave the mode."""
1199        pass
1200
1201    def return_to_previous_mode(self):
1202        """Return to the previous game mode."""
1203        if self.prev_mode is not None:
1204            self.ui.ui_mode = self.prev_mode
1205
1206    def draw(self, screen):
1207        """Draw extra things pertaining to the mode."""
1208        pass
1209
1210    def clear_keymap(self):
1211        """Clear all key mappings."""
1212        self._keymap_once = {}
1213        self._keymap_repeat = {}
1214
1215    def on_key(self, key, handler, *args):
1216        """Install a handler to be called once when a key is pressed."""
1217        self._keymap_once[key] = handler, args
1218
1219    def while_key(self, key, handler, *args):
1220        """Install a handler to be called repeatedly while a key is pressed."""
1221        self._keymap_repeat[key] = handler, args
1222
1223    def handle_key_press(self, event):
1224        """Handle a KEYDOWN event."""
1225        key = event.key
1226        if key in self.ui.rev_controls:
1227            action = self.ui.rev_controls[key]
1228            if action in self._keymap_once or action in self._keymap_repeat:
1229                key = action
1230        handler_and_args = self._keymap_once.get(key)
1231        if handler_and_args:
1232            handler, args = handler_and_args
1233            handler(*args)
1234        elif key not in self._keymap_repeat:
1235            self.handle_any_other_key(event)
1236
1237    def handle_any_other_key(self, event):
1238        """Handle a KEYDOWN event for unknown keys."""
1239        pass
1240
1241    def handle_held_keys(self, pressed):
1242        """Handle any keys that are pressed."""
1243        for key, (handler, args) in self._keymap_repeat.items():
1244            for key in self.ui.controls.get(key, [key]):
1245                if key is not None and pressed[key]:
1246                    handler(*args)
1247
1248    def handle_mouse_press(self, event):
1249        """Handle a MOUSEBUTTONDOWN event."""
1250        if event.button == 4:
1251            self.ui.zoom_in()
1252        if event.button == 5:
1253            self.ui.zoom_out()
1254
1255    def handle_mouse_release(self, event):
1256        """Handle a MOUSEBUTTONUP event."""
1257        pass
1258
1259    def handle_mouse_motion(self, event):
1260        """Handle a MOUSEMOTION event."""
1261        if event.buttons[1] or event.buttons[2]:
1262            self.ui.viewport.shift_by_pixels(event.rel)
1263
1264
1265class PauseMode(UIMode):
1266    """Mode: paused."""
1267
1268    paused = True
1269
1270    show_message_after = 1  # seconds
1271    fade_in_time = 5  # seconds
1272
1273    clock = staticmethod(time.time)
1274
1275    def enter(self, prev_mode):
1276        """Enter the mode."""
1277        UIMode.enter(self, prev_mode)
1278        self.message = None
1279        self.pause_entered = self.clock()
1280        self.animate = self.wait_for_fade
1281
1282    def draw(self, screen):
1283        """Draw extra things pertaining to the mode."""
1284        self.prev_mode.draw(screen)
1285        if self.animate:
1286            self.animate()
1287        if self.message:
1288            self.message.draw(screen)
1289
1290    def wait_for_fade(self):
1291        if self.clock() >= self.pause_entered + self.show_message_after:
1292            self.message = HUDMessage(self.ui.menu_font, "Paused")
1293            self.message.alpha = 0
1294            self.animate = self.fade_in
1295
1296    def fade_in(self):
1297        t = self.clock() - self.pause_entered - self.show_message_after
1298        if t > self.fade_in_time:
1299            self.message.alpha = int(255 * 0.9)
1300            self.animate = None
1301        else:
1302            self.message.alpha = int(smooth(t, self.fade_in_time, 0, 255*0.9))
1303
1304    def handle_any_other_key(self, event):
1305        """Handle a KEYDOWN event for unknown keys."""
1306        if not is_modifier_key(event.key):
1307            self.return_to_previous_mode()
1308
1309    def handle_mouse_release(self, event):
1310        """Handle a MOUSEBUTTONUP event."""
1311        self.return_to_previous_mode()
1312
1313
1314class DemoMode(UIMode):
1315    """Mode: demo."""
1316
1317    paused = False
1318    music = 'demo'
1319
1320    def init(self):
1321        """Initialize the mode."""
1322        self.on_key(K_PAUSE, self.ui.pause)
1323        self.while_key(K_EQUALS, self.ui.zoom_in)
1324        self.while_key(K_MINUS, self.ui.zoom_out)
1325        self.on_key(K_o, self.ui.toggle_missile_orbits)
1326        self.on_key(K_f, self.ui.toggle_fullscreen)
1327
1328    def handle_mouse_release(self, event):
1329        """Handle a MOUSEBUTTONDOWN event."""
1330        if event.button == 1:
1331            self.ui.main_menu()
1332        else:
1333            UIMode.handle_mouse_press(self, event)
1334
1335    def handle_any_other_key(self, event):
1336        """Handle a KEYDOWN event for unknown keys."""
1337        if not is_modifier_key(event.key):
1338            self.ui.main_menu()
1339
1340
1341class TitleMode(DemoMode):
1342    """Mode: fading out title."""
1343
1344    def init(self):
1345        """Initialize the mode."""
1346        DemoMode.init(self)
1347        title_image = pygame.image.load(find('images', 'title.png'))
1348        self.title = HUDTitle(title_image)
1349        self.version = HUDLabel(self.ui.hud_font, self.ui.version_text,
1350                                0.5, 1)
1351
1352    def draw(self, screen):
1353        """Draw extra things pertaining to the mode."""
1354        self.version.draw(screen)
1355        self.title.paused = self.ui.ui_mode.paused
1356        self.title.draw(screen)
1357        if self.title.alpha < 1:
1358            self.ui.watch_demo()
1359
1360
1361class MenuMode(UIMode):
1362    """Abstract base class for menu modes."""
1363
1364    mouse_visible = True
1365    keys_repeat = True
1366    inherit_pause_from_prev_mode = True
1367
1368    def init(self):
1369        """Initialize the mode."""
1370        self.init_menu()
1371        self.menu = self.create_menu()
1372        if self.has_no_action(self.menu.selected_item):
1373            self.select_next_item()
1374        self.on_key(K_UP, self.select_prev_item)
1375        self.on_key(K_DOWN, self.select_next_item)
1376        self.on_key(K_RETURN, self.activate_item)
1377        self.on_key(K_KP_ENTER, self.activate_item)
1378        self.on_key(K_ESCAPE, self.close_menu)
1379        self.menu.invalidate()
1380        # These might be overkill
1381        self.while_key(K_EQUALS, self.ui.zoom_in)
1382        self.while_key(K_MINUS, self.ui.zoom_out)
1383        self.on_key(K_o, self.ui.toggle_missile_orbits)
1384        self.on_key(K_f, self.ui.toggle_fullscreen)
1385        self.version = HUDLabel(self.ui.hud_font, self.ui.version_text, 0.5, 1)
1386
1387    def init_menu(self):
1388        """Initialize the menu."""
1389        self.menu_items = [
1390            ('Quit',            self.ui.quit),
1391        ]
1392
1393    def create_menu(self):
1394        """Create the menu control for display."""
1395        return HUDMenu(self.ui.menu_font,
1396                       [item[0] for item in self.menu_items])
1397
1398    def has_no_action(self, item_idx):
1399        """Is this menu item just an unselectable label?"""
1400        return len(self.menu_items[item_idx]) == 1
1401
1402    def reinit_menu(self):
1403        """Reinitialize the menu."""
1404        self.init_menu()
1405        assert len(self.menu_items) == len(self.menu.items)
1406        self.menu.items = [item[0] for item in self.menu_items]
1407        self.menu.invalidate()
1408
1409    def _select_menu_item(self, pos):
1410        """Select menu item under cursor."""
1411        which = self.menu.find(self.ui.screen, pos)
1412        if which != -1 and not self.has_no_action(which):
1413            self.menu.selected_item = which
1414        return which
1415
1416    def handle_mouse_press(self, event):
1417        """Handle a MOUSEBUTTONDOWN event."""
1418        if event.button == 1:
1419            self._select_menu_item(event.pos)
1420        else:
1421            UIMode.handle_mouse_press(self, event)
1422
1423    def handle_mouse_motion(self, event):
1424        """Handle a MOUSEMOTION event."""
1425        if event.buttons[0]:
1426            self._select_menu_item(event.pos)
1427        UIMode.handle_mouse_motion(self, event)
1428
1429    def handle_mouse_release(self, event):
1430        """Handle a MOUSEBUTTONUP event."""
1431        if event.button == 1:
1432            which = self._select_menu_item(event.pos)
1433            if which != -1:
1434                self.activate_item()
1435        else:
1436            UIMode.handle_mouse_release(self, event)
1437
1438    def draw(self, screen):
1439        """Draw extra things pertaining to the mode."""
1440        self.version.draw(screen)
1441        self.menu.draw(screen)
1442
1443    def select_prev_item(self):
1444        """Select the previous menu item."""
1445        if self.menu.selected_item == 0:
1446            self.menu.selected_item = len(self.menu.items)
1447        self.menu.selected_item -= 1
1448        if self.has_no_action(self.menu.selected_item):
1449            self.select_prev_item()
1450
1451    def select_next_item(self):
1452        """Select the next menu item."""
1453        self.menu.selected_item += 1
1454        if self.menu.selected_item == len(self.menu.items):
1455            self.menu.selected_item = 0
1456            self.menu.top = 0
1457        if self.has_no_action(self.menu.selected_item):
1458            self.select_next_item()
1459
1460    def activate_item(self):
1461        """Activate the selected menu item."""
1462        action = self.menu_items[self.menu.selected_item][1:]
1463        if action:
1464            self.ui.play_sound('menu')
1465            handler = action[0]
1466            args = action[1:]
1467            handler(*args)
1468
1469    def close_menu(self):
1470        """Close the menu and return to the previous game mode."""
1471        self.return_to_previous_mode()
1472
1473
1474class MainMenuMode(MenuMode):
1475    """Mode: main menu."""
1476
1477    def init_menu(self):
1478        """Initialize the mode."""
1479        self.menu_items = [
1480            ('New Game',        self.ui.new_game_menu),
1481            ('Options',         self.ui.options_menu),
1482            ('Help',            self.ui.help),
1483            ('Watch Demo',      self.ui.watch_demo),
1484            ('Quit',            self.ui.quit),
1485        ]
1486        self.on_key(K_PAUSE, self.ui.pause)
1487        self.on_key(K_q, self.ui.quit)  # hidden shortcut
1488        self.on_key(K_h, self.ui.help)  # hidden shortcut
1489        self.on_key(K_F1, self.ui.help)  # hidden shortcut
1490
1491
1492class NewGameMenuMode(MenuMode):
1493    """Mode: new game menu."""
1494
1495    def init_menu(self):
1496        """Initialize the mode."""
1497        self.menu_items = [
1498            ('One Player Game', self.ui.start_single_player_game),
1499            ('Two Player Game', self.ui.start_two_player_game),
1500            ('Gravity Wars',    self.ui.start_gravity_wars),
1501            ('No, thanks',      self.close_menu),
1502        ]
1503
1504
1505class OptionsMenuMode(MenuMode):
1506    """Mode: options menu."""
1507
1508    def init_menu(self):
1509        """Initialize the mode."""
1510        self.menu_items = [
1511            ('Video', self.ui.video_options_menu),
1512            ('Sound', self.ui.sound_options_menu),
1513            ('Controls', self.ui.controls_menu),
1514            ('Return to main menu', self.close_menu),
1515        ]
1516
1517
1518class VideoOptionsMenuMode(MenuMode):
1519    """Mode: video options menu."""
1520
1521    def init_menu(self):
1522        """Initialize the mode."""
1523        def title(label, on):
1524            return label + '\t' + (on and 'on' or 'off')
1525        self.menu_items = [
1526            ('Screen size\t%dx%d' % self.ui.fullscreen_mode,
1527             self.ui.screen_resolution_menu),
1528            (title('Full screen mode', self.ui.fullscreen),
1529             self.toggle_fullscreen),
1530            (title('Missile orbits', self.ui.show_missile_trails),
1531             self.toggle_missile_orbits),
1532            ('Return to options menu', self.close_menu),
1533        ]
1534
1535    def enter(self, prev_mode):
1536        """Enter the mode."""
1537        MenuMode.enter(self, prev_mode)
1538        # If we're coming back from the screen resolution menu, we need
1539        # to update the current resolution
1540        self.reinit_menu()
1541
1542    def toggle_fullscreen(self):
1543        """Toggle full-screen mode and reflect the setting in the menu."""
1544        self.ui.toggle_fullscreen()
1545        self.reinit_menu()
1546
1547    def toggle_missile_orbits(self):
1548        """Toggle missile orbits and reflect the setting in the menu."""
1549        self.ui.toggle_missile_orbits()
1550        self.reinit_menu()
1551
1552
1553class ScreenResolutionMenuMode(MenuMode):
1554    """Mode: screen resolution menu."""
1555
1556    def init_menu(self):
1557        """Initialize the mode."""
1558        self.menu_items = [
1559            ('%dx%d' % mode, lambda mode=mode: self.switch_to_mode(mode))
1560            for mode in pygame.display.list_modes()
1561        ] + [
1562            ('Return to options menu', self.close_menu),
1563        ]
1564
1565    def switch_to_mode(self, mode):
1566        """Switch to a specified video mode."""
1567        self.ui.switch_to_mode(mode)
1568        self.reinit_menu()
1569
1570
1571class SoundOptionsMenuMode(MenuMode):
1572    """Mode: sound options menu."""
1573
1574    def init_menu(self):
1575        """Initialize the mode."""
1576        def title(label, on):
1577            return label + '\t' + (on and 'on' or 'off')
1578        if self.ui.sound_available:
1579            extra = ''
1580        else:
1581            extra = ' (not available)'
1582        self.menu_items = [
1583            (title('Music' + extra, self.ui.music),
1584             self.toggle_music),
1585            (title('Sound' + extra, self.ui.sound),
1586             self.toggle_sound),
1587            (title('Sound in vacuum', self.ui.sound_in_vacuum),
1588             self.toggle_sound_in_vacuum),
1589            ('Return to options menu', self.close_menu),
1590        ]
1591
1592    def toggle_music(self):
1593        """Toggle music and reflect the setting in the menu."""
1594        self.ui.toggle_music()
1595        self.reinit_menu()
1596
1597    def toggle_sound(self):
1598        """Toggle sound effects and reflect the setting in the menu."""
1599        self.ui.toggle_sound()
1600        self.reinit_menu()
1601
1602    def toggle_sound_in_vacuum(self):
1603        """Toggle sound in vacuum and reflect the setting in the menu."""
1604        self.ui.toggle_sound_in_vacuum()
1605        self.reinit_menu()
1606
1607
1608class ControlsMenuMode(MenuMode):
1609    """Mode: controls menu."""
1610
1611    def init(self):
1612        MenuMode.init(self)
1613        self.on_key(K_BACKSPACE, self.clear_item)
1614        self.on_key(K_DELETE, self.clear_item)
1615        self.on_key(K_KP_PERIOD, self.clear_item)
1616        self.version = HUDLabel(self.ui.hud_font,
1617                                "Press ENTER to change a binding,"
1618                                " BACKSPACE to clear it",
1619                                0.5, 1)
1620
1621    def items(self, label, items):
1622        return ([(label, )] +
1623                [(title + '\t' + ', '.join(map(key_name,
1624                                               self.ui.controls[action])),
1625                  self.set_control, action)
1626                 for title, action in items])
1627
1628    def init_menu(self):
1629        self.menu_items = self.items('Player 1', [
1630                ('Turn left', 'P1_LEFT'),
1631                ('Turn right', 'P1_RIGHT'),
1632                ('Accelerate', 'P1_FORWARD'),
1633                ('Decelerate', 'P1_BACKWARD'),
1634                ('Launch missile', 'P1_FIRE'),
1635                ('Brake', 'P1_BRAKE'),
1636                ('Toggle computer control', 'P1_TOGGLE_AI'),
1637        ]) + self.items('Player 2', [
1638                ('Turn left', 'P2_LEFT'),
1639                ('Turn right', 'P2_RIGHT'),
1640                ('Accelerate', 'P2_FORWARD'),
1641                ('Decelerate', 'P2_BACKWARD'),
1642                ('Launch missile', 'P2_FIRE'),
1643                ('Brake', 'P2_BRAKE'),
1644                ('Toggle computer control', 'P2_TOGGLE_AI'),
1645        ]) + [
1646            ('Return to options menu', self.close_menu),
1647        ]
1648
1649    def create_menu(self):
1650        """Create the menu control for display."""
1651        return HUDControlsMenu(self.ui.input_font,
1652                               [item[0] for item in self.menu_items])
1653
1654    def set_control(self, action):
1655        """Change a control"""
1656        self.ui.ui_mode = WaitingForControlMode(self.ui, action)
1657
1658    def clear_item(self):
1659        """Clear the selected menu item."""
1660        action = self.menu_items[self.menu.selected_item][1:]
1661        if action:
1662            handler = action[0]
1663            args = action[1:]
1664            if handler == self.set_control:
1665                action = args[0]
1666                self.ui.set_control(action, None)
1667                self.reinit_menu()
1668
1669
1670class WaitingForControlMode(UIMode):
1671    """Mode: controls menu, waiting for a key press."""
1672
1673    inherit_pause_from_prev_mode = True
1674
1675    def __init__(self, ui, action):
1676        self.action = action
1677        UIMode.__init__(self, ui)
1678
1679    def init(self):
1680        self.prompt = HUDMessage(self.ui.menu_font,
1681                                 "Press a key or ESC to cancel")
1682        self.on_key(K_PAUSE, self.ui.pause)
1683        self.on_key(K_ESCAPE, self.return_to_previous_mode)
1684
1685    def draw(self, screen):
1686        """Draw extra things pertaining to the mode."""
1687        self.prev_mode.draw(screen)
1688        self.prompt.draw(screen)
1689
1690    def handle_any_other_key(self, event):
1691        """Handle a KEYDOWN event for unknown keys."""
1692        self.ui.set_control(self.action, event.key)
1693        self.prev_mode.reinit_menu()
1694        self.return_to_previous_mode()
1695
1696    def handle_mouse_release(self, event):
1697        """Handle a MOUSEBUTTONUP event."""
1698        self.return_to_previous_mode()
1699
1700
1701class GameMenuMode(MenuMode):
1702    """Mode: in-game menu."""
1703
1704    paused = True
1705    inherit_pause_from_prev_mode = False
1706
1707    def init_menu(self):
1708        """Initialize the mode."""
1709        self.menu_items = [
1710            ('Resume game',     self.close_menu),
1711            ('Options',         self.ui.options_menu),
1712            ('Help',            self.ui.help),
1713            ('End Game',        self.ui.end_game),
1714        ]
1715
1716
1717class PlayMode(UIMode):
1718    """Mode: play the game."""
1719
1720    paused = False
1721    music = 'game'
1722
1723    def init(self):
1724        """Initialize the mode."""
1725        self.on_key(K_PAUSE, self.ui.pause)
1726        self.on_key(K_ESCAPE, self.ui.game_menu)
1727        self.on_key(K_F1, self.ui.help)
1728        self.on_key(K_o, self.ui.toggle_missile_orbits)
1729        self.on_key(K_f, self.ui.toggle_fullscreen)
1730        self.while_key(K_EQUALS, self.ui.zoom_in)
1731        self.while_key(K_MINUS, self.ui.zoom_out)
1732        # Player 1
1733        self.on_key('P1_TOGGLE_AI', self.ui.toggle_ai, 0)
1734        self.while_key('P1_LEFT', self.ui.turn_left, 0)
1735        self.while_key('P1_RIGHT', self.ui.turn_right, 0)
1736        self.while_key('P1_FORWARD', self.ui.accelerate, 0)
1737        self.while_key('P1_BACKWARD', self.ui.backwards, 0)
1738        self.while_key('P1_BRAKE', self.ui.brake, 0)
1739        self.on_key('P1_FIRE', self.ui.launch_missile, 0)
1740        # Player 2
1741        self.on_key('P2_TOGGLE_AI', self.ui.toggle_ai, 1)
1742        self.while_key('P2_LEFT', self.ui.turn_left, 1)
1743        self.while_key('P2_RIGHT', self.ui.turn_right, 1)
1744        self.while_key('P2_FORWARD', self.ui.accelerate, 1)
1745        self.while_key('P2_BACKWARD', self.ui.backwards, 1)
1746        self.while_key('P2_BRAKE', self.ui.brake, 1)
1747        self.on_key('P2_FIRE', self.ui.launch_missile, 1)
1748
1749    def handle_mouse_release(self, event):
1750        """Handle a MOUSEBUTTONUP event."""
1751        if event.button == 1:
1752            self.ui.game_menu()
1753        else:
1754            UIMode.handle_mouse_release(self, event)
1755
1756
1757class GravityWarsMode(UIMode):
1758    """Mode: play gravity wars."""
1759
1760    paused = False
1761    music = 'gravitywars'
1762
1763    def init(self):
1764        """Initialize the mode."""
1765        self.on_key(K_PAUSE, self.ui.pause)
1766        self.on_key(K_ESCAPE, self.ui.game_menu)
1767        self.on_key(K_F1, self.ui.help)
1768        self.on_key(K_o, self.ui.toggle_missile_orbits)
1769        self.on_key(K_f, self.ui.toggle_fullscreen)
1770        self.while_key(K_EQUALS, self.ui.zoom_in)
1771        self.while_key(K_MINUS, self.ui.zoom_out)
1772        self.prompt = None
1773        self.state = self.logic()
1774        next(self.state)
1775
1776    def wait_for_input(self, prompt, value):
1777        """Ask the user to enter a value."""
1778        self.prompt = HUDInput(self.ui.input_font,
1779                               "%s (%s): " % (prompt, value))
1780        while True:
1781            yield None
1782            if not self.prompt.text:
1783                break
1784            try:
1785                yield float(self.prompt.text)
1786            except (ValueError):
1787                pass
1788
1789    def logic(self):
1790        """Game logic."""
1791        num_players = len(self.ui.ships)
1792        for ship in self.ui.ships:
1793            ship.missile_recoil = 0
1794        for player in itertools.cycle(range(num_players)):
1795            ship = self.ui.ships[player]
1796            for value in self.wait_for_input("Player %d, launch angle"
1797                                             % (player + 1),
1798                                             ship.direction):
1799                if value is None:
1800                    yield None
1801                else:
1802                    ship.direction = value
1803                    break
1804            for value in self.wait_for_input("Player %d, launch speed"
1805                                             % (player + 1),
1806                                             ship.launch_speed):
1807                if value is None:
1808                    yield None
1809                else:
1810                    ship.launch_speed = value
1811                    break
1812            ship.launch()
1813
1814    def draw(self, screen):
1815        """Draw extra things pertaining to the mode."""
1816        if self.prompt is not None:
1817            self.prompt.draw(screen)
1818
1819    def handle_mouse_release(self, event):
1820        """Handle a MOUSEBUTTONUP event."""
1821        if event.button == 1:
1822            self.ui.game_menu()
1823        else:
1824            UIMode.handle_mouse_press(self, event)
1825
1826    def handle_any_other_key(self, event):
1827        """Handle a KEYDOWN event for unknown keys."""
1828        if self.prompt is not None:
1829            if event.key == K_RETURN:
1830                next(self.state)
1831            elif event.key == K_BACKSPACE:
1832                self.prompt.text = self.prompt.text[:-1]
1833            elif event.unicode.isdigit() or event.unicode == '.':
1834                self.prompt.text += event.unicode
1835
1836
1837class HelpMode(UIMode):
1838    """Mode: show on-line help."""
1839
1840    paused = True
1841    mouse_visible = True
1842
1843    def init(self):
1844        """Initialize the mode."""
1845        self.on_key(K_f, self.ui.toggle_fullscreen)
1846        self.on_key(K_ESCAPE, self.return_to_previous_mode)
1847        self.on_key(K_RETURN, self.next_page)
1848        self.on_key(K_KP_ENTER, self.next_page)
1849        self.on_key(K_SPACE, self.next_page)
1850        self.on_key(K_PAGEDOWN, self.next_page)
1851        self.on_key(K_PAGEUP, self.prev_page)
1852        self.help_text = HUDFormattedText(self.ui.help_font,
1853                                          self.ui.help_bold_font,
1854                                          fixup_keys_in_text(HELP_TEXT,
1855                                                             self.ui.controls),
1856                                          small_font=self.ui.hud_font)
1857
1858    def draw(self, screen):
1859        """Draw extra things pertaining to the mode."""
1860        self.help_text.draw(screen)
1861
1862    def handle_mouse_release(self, event):
1863        """Handle a MOUSEBUTTONUP event."""
1864        if self.help_text.page + 1 == self.help_text.n_pages:
1865            self.return_to_previous_mode()
1866        else:
1867            self.next_page()
1868
1869    def prev_page(self):
1870        """Turn to next page"""
1871        self.help_text.page -= 1
1872
1873    def next_page(self):
1874        """Turn to next page"""
1875        self.help_text.page += 1
1876
1877
1878class GameUI(object):
1879    """User interface for the game."""
1880
1881    ZOOM_FACTOR = 1.25              # Keyboard zoom factor
1882
1883    MAX_TRAIL = 100                 # Maximum missile trail length
1884
1885    fullscreen = False              # Start in windowed mode
1886    fullscreen_mode = None          # Desired video mode (w, h)
1887    show_missile_trails = True      # Show missile trails by default
1888    music = True                    # Do we have background music?
1889    sound = True                    # Do we have sound effects?
1890    sound_in_vacuum = True          # Can you hear what happens to AI ships?
1891    show_debug_info = False         # Hide debug info by default
1892    desired_zoom_level = 1.0        # The desired zoom level
1893
1894    min_fps = 10                    # Minimum FPS
1895
1896    ship_colors = [
1897        (255, 255, 255),            # Player 1 has a white ship
1898        (127, 255, 0),              # Player 2 has a green ship
1899    ]
1900
1901    visibility_margin = 120         # Keep ships >=120px from screen edges
1902
1903    respawn_animation = 100         # Duration (ticks) of respawn animation
1904
1905    _ui_mode = None                 # Previous user interface mode
1906
1907    now_playing = None              # Filename of the current music track
1908
1909    # Some debug information
1910    time_to_draw = 0                # Time to draw everything
1911    time_to_draw_trails = 0         # Time to draw missile trails
1912    flip_time = 0                   # Time to draw debug info & flip
1913    total_time = 0                  # Time to process a frame
1914    last_time = None                # Timestamp of last frame
1915
1916    def __init__(self):
1917        self.rng = random.Random()
1918        self.controls = {}
1919        for action in DEFAULT_CONTROLS:
1920            self.controls[action] = [None]
1921        self.rev_controls = {}
1922        for action, key in DEFAULT_CONTROLS.items():
1923            self.set_control(action, key)
1924
1925    def load_settings(self, filename=None):
1926        """Load settings from a configuration file."""
1927        if not filename:
1928            filename = os.path.expanduser('~/.pyspacewarrc')
1929        config = self.get_config_parser()
1930        config.read([filename])
1931        self.fullscreen = config.getboolean('video', 'fullscreen')
1932        mode = config.get('video', 'mode')
1933        try:
1934            w, h = mode.split('x')
1935            self.fullscreen_mode = int(w), int(h)
1936        except ValueError:
1937            self.fullscreen_mode = None
1938        self.show_missile_trails = config.getboolean('video',
1939                                                     'show_missile_trails')
1940        self.music = config.getboolean('sound', 'music')
1941        self.sound = config.getboolean('sound', 'sound')
1942        self.sound_in_vacuum = config.getboolean('sound', 'sound_in_vacuum')
1943        for action in self.controls:
1944            key = config.get('controls', action)
1945            if key:
1946                # clear all current keys first
1947                self.set_control(action, None)
1948            for key in key.split():
1949                try:
1950                    key = int(key)
1951                except ValueError:
1952                    key = None
1953                self.set_control(action, key)
1954
1955    def save_settings(self, filename=None):
1956        """Save settings to a configuration file."""
1957        if not filename:
1958            filename = os.path.expanduser('~/.pyspacewarrc')
1959        config = self.get_config_parser()
1960        with open(filename, 'w') as f:
1961            config.write(f)
1962
1963    def get_config_parser(self):
1964        """Create a ConfigParser initialized with current settings."""
1965        config = ConfigParser()
1966        config.add_section('video')
1967        config.set('video', 'fullscreen', str(self.fullscreen))
1968        if self.fullscreen_mode:
1969            config.set('video', 'mode', '%dx%d' % self.fullscreen_mode)
1970        else:
1971            config.set('video', 'mode', '')
1972        config.set('video', 'show_missile_trails',
1973                   str(self.show_missile_trails))
1974        config.add_section('sound')
1975        config.set('sound', 'music', str(self.music))
1976        config.set('sound', 'sound', str(self.sound))
1977        config.set('sound', 'sound_in_vacuum', str(self.sound_in_vacuum))
1978        config.add_section('controls')
1979        for action, keys in self.controls.items():
1980            config.set('controls', action, ' '.join(map(str, keys)))
1981        return config
1982
1983    def init(self):
1984        """Initialize the user interface."""
1985        self.version = version
1986        self.version_text = 'PySpaceWar version %s' % self.version
1987        self._init_pygame()
1988        self._init_trail_colors()
1989        self._load_sounds()
1990        self._load_music()
1991        self._load_planet_images()
1992        self._load_background()
1993        self._init_fonts()
1994        self._set_display_mode()
1995        self._optimize_images()
1996        self.viewport = Viewport(self.screen)
1997        self.frame_counter = FrameRateCounter()
1998        self.framedrop_needed = False
1999        self.ui_mode = TitleMode(self)
2000        self._new_game(0)
2001
2002    def _set_ui_mode(self, new_ui_mode):
2003        prev_mode = self._ui_mode
2004        if prev_mode is not None:
2005            prev_mode.leave(new_ui_mode)
2006        self._ui_mode = new_ui_mode
2007        self._ui_mode.enter(prev_mode)
2008
2009    ui_mode = property(lambda self: self._ui_mode, _set_ui_mode)
2010
2011    def _init_trail_colors(self):
2012        """Precalculate missile trail gradients."""
2013        self.trail_colors = {}
2014        for appearance, color in enumerate(self.ship_colors):
2015            self.trail_colors[appearance] = [[], ]
2016            r, g, b = color
2017            r1, g1, b1 = r*.1, g*.1, b*.1
2018            r2, g2, b2 = r*.5, g*.5, b*.5
2019            for n in range(1, self.MAX_TRAIL+1):
2020                dr, dg, db = (r2-r1) / n, (g2-g1) / n, (b2-b1) / n
2021                colors_for_length_n = [
2022                    (int(r1+dr*i), int(g1+dg*i), int(b1+db*i))
2023                    for i in range(n)]
2024                self.trail_colors[appearance].append(colors_for_length_n)
2025
2026    def _init_pygame(self):
2027        """Initialize pygame, but don't create an output window just yet."""
2028        pygame.init()
2029        pygame.display.set_caption('PySpaceWar')
2030        icon = pygame.image.load(find('icons', 'pyspacewar48.png'))
2031        pygame.display.set_icon(icon)
2032        pygame.mouse.set_visible(False)
2033        if not self.fullscreen_mode:
2034            self.fullscreen_mode = self._choose_best_mode()
2035        self.sound_available = bool(pygame.mixer.get_init())
2036        if not self.sound_available:
2037            # Try again, at least we'll get an error message, maybe?
2038            try:
2039                pygame.mixer.init()
2040            except pygame.error as e:
2041                print("pyspacewar: disabling sound: %s" % e)
2042            else:
2043                self.sound_available = True
2044
2045    def _choose_best_mode(self):
2046        """Choose a suitable display mode."""
2047        # Previously this function used to pick the largest sane video mode
2048        # Sadly, my laptop is not fast enough to sustain 20 fps at 1024x768
2049        # when there are too many missiles around.
2050        return (800, 600)
2051
2052    def _set_display_mode(self):
2053        """Set display mode."""
2054        if self.fullscreen:
2055            # Consider using DOUBLEBUF and HWSURFACE flags here
2056            # http://aspn.activestate.com/ASPN/Mail/Message/pygame-users/2793695
2057            # On the other hand, alpha-blended blits are reportedly slow on
2058            # hardware surfaces, and there are other sorts of problems too:
2059            # http://aspn.activestate.com/ASPN/Mail/Message/pygame-users/1825852
2060            # According to my measurements, using HWSURFACE|DOUBLEBUF had no
2061            # impact on pygame.display.flip() time.
2062            self.screen = pygame.display.set_mode(self.fullscreen_mode,
2063                                                  FULLSCREEN)
2064        else:
2065            w, h = self.fullscreen_mode
2066            windowed_mode = (int(w * 0.8), int(h * 0.8))
2067            self.screen = pygame.display.set_mode(windowed_mode, RESIZABLE)
2068        self._prepare_background()
2069        if self.screen.get_bitsize() >= 24:
2070            # Only 24 and 32 bpp modes support aaline
2071            self.draw_line = pygame.draw.aaline
2072        else:
2073            self.draw_line = pygame.draw.line
2074
2075    def _resize_window(self, size):
2076        """Resize the PyGame window as requested."""
2077        self.screen = pygame.display.set_mode(size, RESIZABLE)
2078        self._prepare_background()
2079
2080    def _optimize_images(self):
2081        """Convert loaded images to native format for faster blitting.
2082
2083        Must be called after _set_display_mode, and, of course, after
2084        _load_planet_images.
2085        """
2086        self.planet_images = [img.convert_alpha()
2087                              for img in self.planet_images]
2088
2089    def _load_planet_images(self):
2090        """Load bitmaps of planets."""
2091        self.planet_images = [
2092            pygame.image.load(img)
2093            for img in glob.glob(find('images', 'planet*.png'))
2094        ]
2095        if not self.planet_images:  # pragma: nocover
2096            raise RuntimeError("Could not find planet bitmaps")
2097
2098    def _load_background(self):
2099        """Load background bitmap."""
2100        self.background = pygame.image.load(find('images',
2101                                                 'background.jpg'))
2102        self.background_surface = None
2103
2104    def _prepare_background(self):
2105        """Prepare a background surface."""
2106        if self.background_surface is None:
2107            self.background_surface = self.background.convert()
2108        w, h = self.background_surface.get_size()
2109        screen_w, screen_h = self.screen.get_size()
2110        if w != screen_w or h != screen_h:
2111            scaled = pygame.transform.scale(self.background,
2112                                            (screen_w, screen_h))
2113            # The call to surface.convert dramatically affects performance
2114            # of subsequent blits
2115            self.background_surface = scaled.convert()
2116
2117    def _load_sounds(self):
2118        """Load sound effects."""
2119        self.sounds = {}
2120        self.sound_looping = set()
2121        if not self.sound_available:
2122            return
2123        config = ConfigParser()
2124        config.add_section('sounds')
2125        config.read([find('sounds', 'sounds.ini')])
2126        for name in ['thruster', 'fire', 'bounce', 'hit', 'explode', 'respawn',
2127                     'menu']:
2128            if config.has_option('sounds', name):
2129                filename = config.get('sounds', name)
2130                if filename:
2131                    try:
2132                        sound = pygame.mixer.Sound(find('sounds', filename))
2133                        self.sounds[name] = sound
2134                    except pygame.error:
2135                        print("pyspacewar: could not load %s" % filename)
2136        if 'thruster' in self.sounds:
2137            self.sounds['thruster'].set_volume(0.5)
2138
2139    def _load_music(self):
2140        """Load music files."""
2141        self.music_files = {}
2142        if not self.sound_available:
2143            return
2144        config = ConfigParser()
2145        config.add_section('music')
2146        config.read([find('music', 'music.ini')])
2147        for what in ['demo', 'game', 'gravitywars']:
2148            if config.has_option('music', what):
2149                filename = config.get('music', what)
2150                if filename:
2151                    self.music_files[what] = find('music', filename)
2152
2153    def play_music(self, which, restart=False):
2154        """Loop the music file for a certain mode."""
2155        if not self.sound_available:
2156            return
2157        if which == self.now_playing and not restart:
2158            return
2159        self.now_playing = which
2160        if not self.music:
2161            return
2162        filename = self.music_files.get(which)
2163        if not filename:
2164            pygame.mixer.music.stop()
2165        else:
2166            try:
2167                pygame.mixer.music.load(filename)
2168                pygame.mixer.music.play(-1)
2169            except pygame.error:
2170                print("pyspacewar: could not load %s" % filename)
2171                pygame.mixer.music.stop()
2172
2173    def play_sound(self, which):
2174        """Play a certain sound effect."""
2175        if which in self.sounds and self.sound:
2176            self.sounds[which].play()
2177
2178    def start_sound(self, which):
2179        """Start looping a certain sound effect."""
2180        if which not in self.sound_looping and which in self.sounds:
2181            if self.sound:
2182                self.sounds[which].play(-1)
2183            self.sound_looping.add(which)
2184
2185    def stop_sound(self, which):
2186        """Stop playing a certain sound effect."""
2187        if which in self.sound_looping:
2188            self.sounds[which].stop()
2189            self.sound_looping.remove(which)
2190
2191    def _init_fonts(self):
2192        """Load fonts."""
2193        # Work around another bug in pygame:
2194        # http://aspn.activestate.com/ASPN/Mail/Message/pygame-users/3415468
2195        verdana = '/usr/share/fonts/truetype/msttcorefonts/verdana.ttf'
2196        verdana_bold = '/usr/share/fonts/truetype/msttcorefonts/verdanab.ttf'
2197        if not os.path.exists(verdana):
2198            verdana = pygame.font.match_font('Verdana')
2199        if not os.path.exists(verdana_bold):
2200            verdana_bold = pygame.font.match_font('Verdana', bold=True)
2201        # Work around a bug in pygame:
2202        # http://aspn.activestate.com/ASPN/Mail/Message/pygame-users/2970161
2203        if verdana and os.path.basename(verdana).lower() == 'verdanaz.ttf':
2204            fontdir = os.path.dirname(verdana)
2205            verdana = os.path.join(fontdir, 'verdana.ttf')
2206            verdana_bold = os.path.join(fontdir, 'verdanab.ttf')
2207            if not os.path.exists(verdana):
2208                verdana = None
2209            if not os.path.exists(verdana_bold):
2210                verdana_bold = verdana
2211        self.hud_font = pygame.font.Font(verdana, 14)
2212        self.help_font = pygame.font.Font(verdana, 16)
2213        self.help_bold_font = pygame.font.Font(verdana_bold, 16)
2214        self.input_font = pygame.font.Font(verdana, 24)
2215        self.menu_font = pygame.font.Font(verdana_bold, 30)
2216
2217    def _new_game(self, players=1):
2218        """Start a new game."""
2219        self.game = Game.new(ships=2,
2220                             planet_kinds=len(self.planet_images),
2221                             rng=self.rng)
2222        self.ships = self.game.ships
2223        for ship in self.ships:
2224            ship.launch_effect = self.launch_effect_Ship
2225            ship.bounce_effect = self.bounce_effect_Ship
2226            ship.hit_effect = self.hit_effect_Ship
2227            ship.explode_effect = self.explode_effect_Ship
2228            ship.respawn_effect = self.respawn_effect_Ship
2229        self.ai = [AIController(ship) for ship in self.ships]
2230        self.ai_controlled = [False] * len(self.ships)
2231        self.missile_trails = {}
2232        self.angular_momentum = {}
2233        self.viewport.origin = (self.ships[0].position +
2234                                self.ships[1].position) / 2
2235        self.viewport.scale = 1
2236        self.desired_zoom_level = 1
2237        self._init_hud()
2238        if players == 0:  # demo mode
2239            self.toggle_ai(0)
2240            self.toggle_ai(1)
2241        elif players == 1:  # player vs computer
2242            self.toggle_ai(1)
2243        else:  # player vs player
2244            pass
2245
2246    def _count_trails(self):
2247        """Count the number of pixels in missile trails."""
2248        return sum(len(trail) for trail in self.missile_trails.values())
2249
2250    def _init_hud(self):
2251        """Initialize the heads-up display."""
2252        time_format = '%.f ms'
2253        self.fps_hud1 = HUDInfoPanel(
2254            self.hud_font, 16, xalign=0.5, yalign=0,
2255            content=[
2256                ('objects', lambda: len(self.game.world.objects)),
2257                ('missile trails', self._count_trails),
2258                ('fps', lambda: '%.0f' % self.frame_counter.fps()),
2259            ])
2260        self.fps_hud2 = HUDCollection([
2261            HUDInfoPanel(
2262                self.hud_font, 16, xalign=0.25, yalign=0.95,
2263                content=[
2264                    ('update', lambda: time_format %
2265                     (self.game.time_to_update * 1000)),
2266                    ('  gravity', lambda: time_format %
2267                     (self.game.world.time_for_gravitation * 1000)),
2268                    ('  collisions', lambda: time_format %
2269                     (self.game.world.time_for_collisions * 1000)),
2270                    ('  other', lambda: time_format %
2271                     ((self.game.time_to_update
2272                       - self.game.world.time_for_gravitation
2273                       - self.game.world.time_for_collisions)
2274                      * 1000)),
2275                ]),
2276            HUDInfoPanel(
2277                self.hud_font, 16, xalign=0.5, yalign=0.95,
2278                content=[
2279                    ('draw', lambda: time_format %
2280                     (self.time_to_draw * 1000)),
2281                    ('  trails', lambda: time_format %
2282                     (self.time_to_draw_trails * 1000)),
2283                    ('  other', lambda: time_format %
2284                     ((self.time_to_draw - self.time_to_draw_trails) * 1000)),
2285                    ('    flip', lambda: time_format %
2286                     (self.flip_time * 1000)),
2287                ]),
2288            HUDInfoPanel(
2289                self.hud_font, 16, xalign=0.75, yalign=0.95,
2290                content=[
2291                    ('other', lambda: time_format %
2292                     ((self.total_time - self.game.time_to_update
2293                       - self.time_to_draw - self.game.time_waiting)
2294                      * 1000)),
2295                    ('idle', lambda: time_format %
2296                     (self.game.time_waiting * 1000)),
2297                    ('total', lambda: time_format %
2298                     (self.total_time * 1000)),
2299                ]),
2300        ])
2301        self.hud = HUDCollection([
2302            HUDShipInfo(self.ships[0], self.hud_font, 1, 0),
2303            HUDShipInfo(self.ships[1], self.hud_font, 0, 0,
2304                        HUDShipInfo.GREEN_COLORS),
2305            HUDCompass(self.game.world, self.ships[0], self.viewport, 1, 1,
2306                       HUDCompass.BLUE_COLORS),
2307            HUDCompass(self.game.world, self.ships[1], self.viewport, 0, 1,
2308                       HUDCompass.GREEN_COLORS),
2309        ])
2310
2311    def _keep_ships_visible(self):
2312        """Update viewport origin/scale so that all ships are on screen."""
2313        self.viewport.scale = self.desired_zoom_level
2314        self.viewport.keep_visible([s.position for s in self.ships],
2315                                   self.visibility_margin)
2316
2317    def interact(self):
2318        """Process pending keyboard/mouse events."""
2319        for event in pygame.event.get():
2320            if event.type == QUIT:
2321                self.quit()
2322            elif event.type == VIDEORESIZE:
2323                self._resize_window(event.size)
2324            elif event.type == KEYDOWN:
2325                if event.key == K_F12:
2326                    self.toggle_debug_info()
2327                elif (event.key in (K_RETURN, K_KP_ENTER) and
2328                      event.mod & KMOD_ALT):
2329                    self.toggle_fullscreen()
2330                else:
2331                    self.ui_mode.handle_key_press(event)
2332            elif event.type == MOUSEBUTTONDOWN:
2333                self.ui_mode.handle_mouse_press(event)
2334            elif event.type == MOUSEBUTTONUP:
2335                self.ui_mode.handle_mouse_release(event)
2336            elif event.type == MOUSEMOTION:
2337                self.ui_mode.handle_mouse_motion(event)
2338        pressed = pygame.key.get_pressed()
2339        self.ui_mode.handle_held_keys(pressed)
2340        self.update_continuous_sounds()
2341
2342    def quit(self):
2343        """Exit the game."""
2344        sys.exit(0)
2345
2346    def pause(self):
2347        """Pause whatever is happening (so I can take a screenshot)."""
2348        self.ui_mode = PauseMode(self)
2349
2350    def main_menu(self):
2351        """Enter the main menu."""
2352        self.play_sound('menu')
2353        self.ui_mode = MainMenuMode(self)
2354
2355    def new_game_menu(self):
2356        """Enter the new game menu."""
2357        self.ui_mode = NewGameMenuMode(self)
2358
2359    def options_menu(self):
2360        """Enter the options menu."""
2361        self.ui_mode = OptionsMenuMode(self)
2362
2363    def video_options_menu(self):
2364        """Enter the video options menu."""
2365        self.ui_mode = VideoOptionsMenuMode(self)
2366
2367    def sound_options_menu(self):
2368        """Enter the sound options menu."""
2369        self.ui_mode = SoundOptionsMenuMode(self)
2370
2371    def screen_resolution_menu(self):
2372        """Enter the screen resolution menu."""
2373        self.ui_mode = ScreenResolutionMenuMode(self)
2374
2375    def controls_menu(self):
2376        """Enter the controls menu."""
2377        self.ui_mode = ControlsMenuMode(self)
2378
2379    def watch_demo(self):
2380        """Go back to demo mode."""
2381        self.ui_mode = DemoMode(self)
2382
2383    def start_single_player_game(self):
2384        """Start a new single-player game."""
2385        self._new_game(1)
2386        self.ui_mode = PlayMode(self)
2387
2388    def start_two_player_game(self):
2389        """Start a new two-player game."""
2390        self._new_game(2)
2391        self.ui_mode = PlayMode(self)
2392
2393    def start_gravity_wars(self):
2394        """Start a new two-player gravity wars game."""
2395        self._new_game(2)
2396        self.ui_mode = GravityWarsMode(self)
2397
2398    def help(self):
2399        """Show the help screen."""
2400        self.ui_mode = HelpMode(self)
2401
2402    def game_menu(self):
2403        """Enter the game menu."""
2404        self.play_sound('menu')
2405        self.ui_mode = GameMenuMode(self)
2406
2407    def resume_game(self):
2408        """Resume a game in progress."""
2409        self.ui_mode = PlayMode(self)
2410
2411    def end_game(self):
2412        """End the game in progress."""
2413        self._new_game(0)
2414        self.watch_demo()
2415        self.ui_mode = MainMenuMode(self)
2416
2417    def toggle_fullscreen(self):
2418        """Toggle fullscreen mode."""
2419        self.fullscreen = not self.fullscreen
2420        self._set_display_mode()
2421
2422    def switch_to_mode(self, mode):
2423        """Toggle fullscreen mode."""
2424        self.fullscreen_mode = mode
2425        self._set_display_mode()
2426
2427    def set_control(self, action, key):
2428        """Change a key mapping"""
2429        if key in self.rev_controls:
2430            old_action = self.rev_controls[key]
2431            self.controls[old_action].remove(key)
2432            if not self.controls[old_action]:
2433                self.controls[old_action] = [None]
2434        keys = self.controls[action]
2435        if len(keys) > 1 or key is None or keys == [None]:
2436            for old_key in keys:
2437                if old_key is not None:
2438                    del self.rev_controls[old_key]
2439            self.controls[action] = []
2440        self.controls[action].append(key)
2441        if key is not None:
2442            self.rev_controls[key] = action
2443
2444    def zoom_in(self):
2445        """Zoom in."""
2446        self.desired_zoom_level = self.viewport.scale * self.ZOOM_FACTOR
2447
2448    def zoom_out(self):
2449        """Zoom in."""
2450        self.desired_zoom_level = self.viewport.scale / self.ZOOM_FACTOR
2451
2452    def toggle_debug_info(self):
2453        """Show/hide debug info."""
2454        self.show_debug_info = not self.show_debug_info
2455
2456    def toggle_missile_orbits(self):
2457        """Show/hide missile trails."""
2458        self.show_missile_trails = not self.show_missile_trails
2459
2460    def toggle_music(self):
2461        """Toggle music."""
2462        self.music = not self.music
2463        if not self.sound_available:
2464            return
2465        if self.music:
2466            self.play_music(self.now_playing, restart=True)
2467        else:
2468            pygame.mixer.music.stop()
2469
2470    def toggle_sound(self):
2471        """Toggle sound effects."""
2472        self.sound = not self.sound
2473        for sound in self.sound_looping:
2474            if self.sound:
2475                self.sounds[sound].play(-1)
2476            else:
2477                self.sounds[sound].stop()
2478
2479    def toggle_sound_in_vacuum(self):
2480        """Toggle sound in vacuum."""
2481        self.sound_in_vacuum = not self.sound_in_vacuum
2482
2483    def toggle_ai(self, player_id):
2484        """Toggle AI control for player."""
2485        self.ai_controlled[player_id] = not self.ai_controlled[player_id]
2486        if self.ai_controlled[player_id]:
2487            self.game.controllers.append(self.ai[player_id])
2488        else:
2489            self.game.controllers.remove(self.ai[player_id])
2490
2491    def turn_left(self, player_id):
2492        """Manual ship control: turn left."""
2493        if not self.ai_controlled[player_id]:
2494            self.ships[player_id].turn_left()
2495
2496    def turn_right(self, player_id):
2497        """Manual ship control: turn right."""
2498        if not self.ai_controlled[player_id]:
2499            self.ships[player_id].turn_right()
2500
2501    def accelerate(self, player_id):
2502        """Manual ship control: accelerate."""
2503        if not self.ai_controlled[player_id]:
2504            self.ships[player_id].accelerate()
2505
2506    def backwards(self, player_id):
2507        """Manual ship control: accelerate backwards."""
2508        if not self.ai_controlled[player_id]:
2509            self.ships[player_id].backwards()
2510
2511    def brake(self, player_id):
2512        """Manual ship control: brake."""
2513        if not self.ai_controlled[player_id]:
2514            self.ships[player_id].brake()
2515
2516    def launch_missile(self, player_id):
2517        """Manual ship control: launch a missile."""
2518        if not self.ai_controlled[player_id]:
2519            self.ships[player_id].launch()
2520
2521    def launch_effect_Ship(self, ship, obstacle):
2522        """Play a sound effect when the player's ship bounces off something."""
2523        player_id = self.ships.index(ship)
2524        if not self.ai_controlled[player_id] or self.sound_in_vacuum:
2525            self.play_sound('fire')
2526
2527    def bounce_effect_Ship(self, ship, obstacle):
2528        """Play a sound effect when the player's ship bounces off something."""
2529        player_id = self.ships.index(ship)
2530        if not ship.dead:
2531            # It sounds weird to hear that sound when dead ships bounce
2532            if not self.ai_controlled[player_id] or self.sound_in_vacuum:
2533                self.play_sound('bounce')
2534
2535    def hit_effect_Ship(self, ship, missile):
2536        """Play a sound effect when the player's ship is hit."""
2537        player_id = self.ships.index(ship)
2538        if not self.ai_controlled[player_id] or self.sound_in_vacuum:
2539            self.play_sound('hit')
2540
2541    def explode_effect_Ship(self, ship, killer):
2542        """Play a sound effect when the player's ship explodes."""
2543        player_id = self.ships.index(ship)
2544        if not self.ai_controlled[player_id] or self.sound_in_vacuum:
2545            self.play_sound('explode')
2546
2547    def respawn_effect_Ship(self, ship):
2548        """Play a sound effect when the player's ship respawns."""
2549        self.play_sound('respawn')
2550
2551    def update_continuous_sounds(self):
2552        """Loop certain sound effects while certain conditions hold true."""
2553        makes_noise = False
2554        for player_id, ship in enumerate(self.ships):
2555            if not self.ai_controlled[player_id] or self.sound_in_vacuum:
2556                makes_noise = (ship.forward_thrust or ship.rear_thrust or
2557                               ship.left_thrust or ship.right_thrust or
2558                               ship.engage_brakes) or makes_noise
2559        if makes_noise:
2560            self.start_sound('thruster')
2561        else:
2562            self.stop_sound('thruster')
2563
2564    def draw(self):
2565        """Draw the state of the game"""
2566        self.time_to_draw = 0
2567        self.time_to_draw_trails = 0
2568        drop_this_frame = (self.framedrop_needed and
2569                           self.frame_counter.notional_fps() >= self.min_fps)
2570        if not drop_this_frame:
2571            start = time.time()
2572            self._keep_ships_visible()
2573            self.screen.blit(self.background_surface, (0, 0))
2574            if self.show_missile_trails:
2575                self.draw_missile_trails()
2576            for obj in self.game.world.objects:
2577                getattr(self, 'draw_' + obj.__class__.__name__)(obj)
2578            self.hud.draw(self.screen)
2579            self.ui_mode.draw(self.screen)
2580            self.time_to_draw = time.time() - start
2581            self.frame_counter.frame()
2582        now = time.time()
2583        if self.last_time is not None:
2584            self.total_time = now - self.last_time
2585        self.last_time = now
2586        if self.show_debug_info:
2587            self.fps_hud1.draw(self.screen)
2588            if not drop_this_frame:
2589                self.fps_hud2.draw(self.screen)
2590        now = time.time()
2591        pygame.display.flip()
2592        self.flip_time = time.time() - now
2593
2594    def draw_Planet(self, planet):
2595        """Draw a planet."""
2596        pos = self.viewport.screen_pos(planet.position)
2597        size = self.viewport.screen_len(planet.radius * 2)
2598        unscaled_img = self.planet_images[planet.appearance]
2599        img = pygame.transform.scale(unscaled_img, (size, size))
2600        self.screen.blit(img, (pos[0] - size/2, pos[1] - size/2))
2601
2602    def draw_Ship(self, ship):
2603        """Draw a ship."""
2604        color = self.ship_colors[ship.appearance]
2605        if ship.dead:
2606            ratio = self.game.time_to_respawn(ship) / self.game.respawn_time
2607            color = colorblend(color, (0x20, 0x20, 0x20), 0.2)
2608            color = colorblend(color, (0, 0, 0), ratio)
2609        elif self.game.world.time - ship.spawn_time < self.respawn_animation:
2610            self.draw_Ship_spawn_animation(ship)
2611        direction_vector = ship.direction_vector * ship.size
2612        side_vector = direction_vector.perpendicular()
2613        sp = self.viewport.screen_pos
2614        pt1 = sp(ship.position - direction_vector + side_vector * 0.5)
2615        pt2 = sp(ship.position + direction_vector)
2616        pt3 = sp(ship.position - direction_vector - side_vector * 0.5)
2617        self.draw_line(self.screen, color, pt1, pt2)
2618        self.draw_line(self.screen, color, pt2, pt3)
2619        (front, back, left_front, left_back,
2620         right_front, right_back) = self.calc_Ship_thrusters(ship)
2621        thrust_lines = []
2622        if back:
2623            thrust_lines.append(((-0.1, -0.9), (-0.1, -0.9-back)))
2624            thrust_lines.append(((+0.1, -0.9), (+0.1, -0.9-back)))
2625        if front:
2626            thrust_lines.append(((-0.6, -0.2), (-0.6, -0.2+front)))
2627            thrust_lines.append(((+0.6, -0.2), (+0.6, -0.2+front)))
2628        if left_front:
2629            thrust_lines.append(((-0.2, +0.8), (-0.2-left_front, +0.8)))
2630        if right_front:
2631            thrust_lines.append(((+0.2, +0.8), (+0.2+right_front, +0.8)))
2632        if left_back:
2633            thrust_lines.append(((-0.6, -0.8), (-0.6-left_back, -0.8)))
2634        if right_back:
2635            thrust_lines.append(((+0.6, -0.8), (+0.6+right_back, -0.8)))
2636        for (s1, d1), (s2, d2) in thrust_lines:
2637            pt1 = sp(ship.position + direction_vector * d1 + side_vector * s1)
2638            pt2 = sp(ship.position + direction_vector * d2 + side_vector * s2)
2639            self.draw_line(self.screen, (255, 120, 20), pt1, pt2)
2640
2641    def calc_Ship_thrusters(self, ship):
2642        """Calculate the output of the ship's thrusters.
2643
2644        Returns (front, back, left_front, left_back, right_front, right_back)
2645        where each value is the ratio of world units to the ship size.
2646
2647        Keeps track of the ship's rotation and only shows turn thrusters firing
2648        if there was any change.  Updates self.angular_momentum as a side
2649        effect.
2650        """
2651        front = back = left_front = left_back = right_front = right_back = 0
2652        if ship.forward_thrust:
2653            back += ship.forward_thrust * 0.3 / ship.forward_power
2654        if ship.rear_thrust:
2655            front += ship.rear_thrust * 0.15 / ship.backward_power
2656        rotation = ship.left_thrust - ship.right_thrust
2657        prev_rotation = self.angular_momentum.get(ship, 0)
2658        self.angular_momentum[ship] = rotation
2659        if rotation > prev_rotation:
2660            amount = (rotation - prev_rotation) * 0.15 / ship.rotation_speed
2661            left_back += amount
2662            right_front += amount
2663        elif rotation < prev_rotation:
2664            amount = (prev_rotation - rotation) * 0.15 / ship.rotation_speed
2665            left_front += amount
2666            right_back += amount
2667        if ship.engage_brakes:
2668            delta_v = ship.velocity * (ship.brake_factor - 1)
2669            front_back_proj = delta_v.dot_product(ship.direction_vector)
2670            front_back_proj *= 0.45 / (ship.forward_power+ship.backward_power)
2671            if front_back_proj > 0:
2672                back += front_back_proj
2673            elif front_back_proj < 0:
2674                front -= front_back_proj
2675            left_right_proj = delta_v.dot_product(
2676                                        ship.direction_vector.perpendicular())
2677            left_right_proj *= 0.45 / (ship.forward_power+ship.backward_power)
2678            if left_right_proj > 0:
2679                left_front += left_right_proj
2680                left_back += left_right_proj
2681            elif left_right_proj < 0:
2682                right_front -= left_right_proj
2683                right_back -= left_right_proj
2684        # Very high accelerations (caused by braking or the AI code) look
2685        # slightly ridiculous.  Clamp all the values
2686        front = min(front, 0.2)
2687        back = min(back, 0.4)
2688        left_front = min(left_front, 0.2)
2689        left_back = min(left_back, 0.2)
2690        right_front = min(right_front, 0.2)
2691        right_back = min(right_back, 0.2)
2692        return (front, back, left_front, left_back, right_front, right_back)
2693
2694    def draw_Ship_spawn_animation(self, ship):
2695        sp = self.viewport.screen_pos(ship.position)
2696        color = self.ship_colors[ship.appearance]
2697        t = math.sqrt((self.game.world.time - ship.spawn_time)
2698                      / self.respawn_animation)
2699        radius = linear(t, 1, 1, 100)
2700        color = colorblend((0, 0, 0), color, linear(t, 1, 0.2, 0.9))
2701        pygame.draw.circle(self.screen, color, sp, int(radius), 1)
2702
2703    def update_missile_trails(self):
2704        """Update missile trails."""
2705        for missile, trail in list(self.missile_trails.items()):
2706            if missile.world is None:
2707                del trail[:2]
2708                if not trail:
2709                    del self.missile_trails[missile]
2710            else:
2711                trail.append(missile.position)
2712                if len(trail) > self.MAX_TRAIL:
2713                    del trail[0]
2714        for obj in self.game.world.objects:
2715            if isinstance(obj, Missile) and obj not in self.missile_trails:
2716                self.missile_trails[obj] = [obj.position]
2717
2718    def draw_missile_trails(self):
2719        """Draw missile trails."""
2720        start = time.time()
2721        for missile, trail in self.missile_trails.items():
2722            self.draw_missile_trail(missile, trail)
2723        self.time_to_draw_trails = time.time() - start
2724
2725    def draw_missile_trail(self, missile, trail):
2726        """Draw a missile orbit trail."""
2727        r, g, b = self.ship_colors[missile.appearance]
2728        gradient = self.trail_colors[missile.appearance][len(trail)]
2729        self.viewport.draw_trail(trail, gradient, self.screen.set_at)
2730
2731    def draw_Missile(self, missile):
2732        """Draw a missile."""
2733        color = self.ship_colors[missile.appearance]
2734        x, y = self.viewport.screen_pos(missile.position)
2735        self.screen.set_at((x, y), color)
2736        if self.viewport.scale > 0.5:
2737            color = colorblend(color, (0, 0, 0), 0.4)
2738            self.screen.set_at((x+1, y), color)
2739            self.screen.set_at((x, y+1), color)
2740            self.screen.set_at((x-1, y), color)
2741            self.screen.set_at((x, y-1), color)
2742
2743    def draw_Debris(self, debris):
2744        """Draw debris."""
2745        self.screen.set_at(self.viewport.screen_pos(debris.position),
2746                           debris.appearance)
2747
2748    def wait_for_tick(self):
2749        """Wait for the next game time tick.  World moves during this time."""
2750        if self.ui_mode.paused:
2751            self.game.skip_a_tick()
2752            self.framedrop_needed = False
2753        else:
2754            self.update_missile_trails()
2755            self.framedrop_needed = not self.game.wait_for_tick()
2756