import curses
import logging
import random
import re
import textwrap
import time
from sqlalchemy import Column
from sqlalchemy import create_engine
from sqlalchemy import ForeignKey
from sqlalchemy import func
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.hybrid import hybrid_method
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import relationship
from sqlalchemy.orm import Session
logging.basicConfig(
filename="space_invaders.log",
format="%(asctime)s,%(msecs)03d %(levelname)-5.5s %(message)s",
)
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
Base = declarative_base()
WINDOW_LEFT = 10
WINDOW_TOP = 2
WINDOW_WIDTH = 70
WINDOW_HEIGHT = 34
VERT_PADDING = 2
HORIZ_PADDING = 5
ENEMY_VERT_SPACING = 4
MAX_X = WINDOW_WIDTH - HORIZ_PADDING
MAX_Y = WINDOW_HEIGHT - VERT_PADDING
LEFT_KEY = ord("j")
RIGHT_KEY = ord("l")
FIRE_KEY = ord(" ")
PAUSE_KEY = ord("p")
COLOR_MAP = {
"K": curses.COLOR_BLACK,
"B": curses.COLOR_BLUE,
"C": curses.COLOR_CYAN,
"G": curses.COLOR_GREEN,
"M": curses.COLOR_MAGENTA,
"R": curses.COLOR_RED,
"W": curses.COLOR_WHITE,
"Y": curses.COLOR_YELLOW,
}
class Glyph(Base):
"""Describe a "glyph", a graphical element
to be painted on the screen.
"""
__tablename__ = "glyph"
id = Column(Integer, primary_key=True)
name = Column(String)
type = Column(String)
width = Column(Integer)
height = Column(Integer)
data = Column(String)
alt_data = Column(String)
__mapper_args__ = {"polymorphic_on": type}
def __init__(self, name, img, alt=None):
self.name = name
self.data, self.width, self.height = self._encode_glyph(img)
if alt is not None:
self.alt_data, alt_w, alt_h = self._encode_glyph(alt)
def _encode_glyph(self, img):
"""Receive a textual description of the glyph and
encode into a format understood by
GlyphCoordinate.render().
"""
img = re.sub(r"^\n", "", textwrap.dedent(img))
color = "W"
lines = [line.rstrip() for line in img.split("\n")]
data = []
for line in lines:
render_line = []
line = list(line)
while line:
char = line.pop(0)
if char == "#":
color = line.pop(0)
continue
render_line.append((color, char))
data.append(render_line)
width = max([len(rl) for rl in data])
data = "".join(
"".join("%s%s" % (color, char) for color, char in render_line)
+ ("W " * (width - len(render_line)))
for render_line in data
)
return data, width, len(lines)
def glyph_for_state(self, coord, state):
"""Return the appropriate data representation
for this Glyph, based on the current coordinates
and state.
Subclasses may override this to provide animations.
"""
return self.data
class GlyphCoordinate(Base):
"""Describe a glyph rendered at a certain x, y coordinate.
The GlyphCoordinate may also include optional values
such as the tick at time of render, a label, and a
score value.
"""
__tablename__ = "glyph_coordinate"
id = Column(Integer, primary_key=True)
glyph_id = Column(Integer, ForeignKey("glyph.id"))
x = Column(Integer)
y = Column(Integer)
tick = Column(Integer)
label = Column(String)
score = Column(Integer)
glyph = relationship(Glyph, innerjoin=True)
def __init__(
self, session, glyph_name, x, y, tick=None, label=None, score=None
):
self.glyph = session.query(Glyph).filter_by(name=glyph_name).one()
self.x = x
self.y = y
self.tick = tick
self.label = label
self.score = score
session.add(self)
def render(self, window, state):
"""Render the Glyph at this position."""
col = 0
row = 0
glyph = self.glyph
data = glyph.glyph_for_state(self, state)
for color, char in [
(data[i], data[i + 1]) for i in range(0, len(data), 2)
]:
x = self.x + col
y = self.y + row
if 0 <= x <= MAX_X and 0 <= y <= MAX_Y:
window.addstr(
y + VERT_PADDING,
x + HORIZ_PADDING,
char,
_COLOR_PAIRS[color],
)
col += 1
if col == glyph.width:
col = 0
row += 1
if self.label:
self._render_label(window, False)
def _render_label(self, window, blank):
label = self.label if not blank else " " * len(self.label)
if self.x + self.width + len(self.label) < MAX_X:
window.addstr(self.y, self.x + self.width, label)
else:
window.addstr(self.y, self.x - len(self.label), label)
def blank(self, window):
"""Render a blank box for this glyph's position and size."""
glyph = self.glyph
x = min(max(self.x, 0), MAX_X)
width = min(glyph.width, MAX_X - x) or 1
for y_a in range(self.y, self.y + glyph.height):
y = y_a
window.addstr(y + VERT_PADDING, x + HORIZ_PADDING, " " * width)
if self.label:
self._render_label(window, True)
@hybrid_property
def width(self):
return self.glyph.width
@width.expression
def width(cls):
return Glyph.width
@hybrid_property
def height(self):
return self.glyph.height
@height.expression
def height(cls):
return Glyph.height
@hybrid_property
def bottom_bound(self):
return self.y + self.height >= MAX_Y
@hybrid_property
def top_bound(self):
return self.y <= 0
@hybrid_property
def left_bound(self):
return self.x <= 0
@hybrid_property
def right_bound(self):
return self.x + self.width >= MAX_X
@hybrid_property
def right_edge_bound(self):
return self.x > MAX_X
@hybrid_method
def intersects(self, other):
"""Return True if this GlyphCoordinate intersects with
the given GlyphCoordinate."""
return ~(
(self.x + self.width < other.x) | (self.x > other.x + other.width)
) & ~(
(self.y + self.height < other.y)
| (self.y > other.y + other.height)
)
class EnemyGlyph(Glyph):
"""Describe an enemy."""
__mapper_args__ = {"polymorphic_identity": "enemy"}
class ArmyGlyph(EnemyGlyph):
"""Describe an enemy that's part of the "army"."""
__mapper_args__ = {"polymorphic_identity": "army"}
def glyph_for_state(self, coord, state):
if state["flip"]:
return self.alt_data
else:
return self.data
class SaucerGlyph(EnemyGlyph):
"""Describe the enemy saucer flying overhead."""
__mapper_args__ = {"polymorphic_identity": "saucer"}
def glyph_for_state(self, coord, state):
if state["flip"] == 0:
return self.alt_data
else:
return self.data
class MessageGlyph(Glyph):
"""Describe a glyph for displaying a message."""
__mapper_args__ = {"polymorphic_identity": "message"}
class PlayerGlyph(Glyph):
"""Describe a glyph representing the player."""
__mapper_args__ = {"polymorphic_identity": "player"}
class MissileGlyph(Glyph):
"""Describe a glyph representing a missile."""
__mapper_args__ = {"polymorphic_identity": "missile"}
class SplatGlyph(Glyph):
"""Describe a glyph representing a "splat"."""
__mapper_args__ = {"polymorphic_identity": "splat"}
def glyph_for_state(self, coord, state):
age = state["tick"] - coord.tick
if age > 5:
return self.alt_data
else:
return self.data
def init_glyph(session):
"""Create the glyphs used during play."""
enemy1 = ArmyGlyph(
"enemy1",
"""
#W-#B^#R-#B^#W-
#G| |
""",
"""
#W>#B^#R-#B^#W<
#G^ ^
""",
)
enemy2 = ArmyGlyph(
"enemy2",
"""
#W***
#R<#C~~~#R>
""",
"""
#W@@@
#R<#C---#R>
""",
)
enemy3 = ArmyGlyph(
"enemy3",
"""
#Y((--))
#M-~-~-~
""",
"""
#Y[[--]]
#M~-~-~-
""",
)
saucer = SaucerGlyph(
"saucer",
"""#R~#Y^#R~#G<<((=#WOO#G=))>>""",
"""#Y^#R~#Y^#G<<((=#WOO#G=))>>""",
)
splat1 = SplatGlyph(
"splat1",
"""
#WVVVVV
#W> #R*** #W<
#W^^^^^
""",
"""
#M|
#M- #Y+++ #M-
#M|
""",
)
ship = PlayerGlyph(
"ship",
"""
#Y^
#G=====
""",
)
missile = MissileGlyph(
"missile",
"""
|
""",
)
start = MessageGlyph(
"start_message",
"J = move left; L = move right; SPACE = fire\n"
" #GPress any key to start",
)
lose = MessageGlyph("lose_message", "#YY O U L O S E ! ! !")
win = MessageGlyph("win_message", "#RL E V E L C L E A R E D ! ! !")
paused = MessageGlyph(
"pause_message", "#WP A U S E D\n#GPress P to continue"
)
session.add_all(
[
enemy1,
enemy2,
enemy3,
ship,
saucer,
missile,
start,
lose,
win,
paused,
splat1,
]
)
def setup_curses():
"""Setup terminal/curses state."""
window = curses.initscr()
curses.noecho()
window = curses.newwin(
WINDOW_HEIGHT + (VERT_PADDING * 2),
WINDOW_WIDTH + (HORIZ_PADDING * 2),
WINDOW_TOP - VERT_PADDING,
WINDOW_LEFT - HORIZ_PADDING,
)
curses.start_color()
global _COLOR_PAIRS
_COLOR_PAIRS = {}
for i, (k, v) in enumerate(COLOR_MAP.items(), 1):
curses.init_pair(i, v, curses.COLOR_BLACK)
_COLOR_PAIRS[k] = curses.color_pair(i)
return window
def init_positions(session):
"""Establish a new field of play.
This generates GlyphCoordinate objects
and persists them to the database.
"""
# delete all existing coordinates
session.query(GlyphCoordinate).delete()
session.add(
GlyphCoordinate(
session, "ship", WINDOW_WIDTH // 2 - 2, WINDOW_HEIGHT - 4
)
)
arrangement = (
("enemy3", 50),
("enemy2", 25),
("enemy1", 10),
("enemy2", 25),
("enemy1", 10),
)
for ship_vert, (etype, score) in zip(
range(5, 30, ENEMY_VERT_SPACING), arrangement
):
for ship_horiz in range(0, 50, 10):
session.add(
GlyphCoordinate(
session, etype, ship_horiz, ship_vert, score=score
)
)
def draw(session, window, state):
"""Load all current GlyphCoordinate objects from the
database and render.
"""
for gcoord in session.query(GlyphCoordinate).options(
joinedload(GlyphCoordinate.glyph)
):
gcoord.render(window, state)
window.addstr(1, WINDOW_WIDTH - 5, "Score: %.4d" % state["score"])
window.move(0, 0)
window.refresh()
def check_win(session, state):
"""Return the number of army glyphs remaining -
the player wins if this is zero."""
return (
session.query(func.count(GlyphCoordinate.id))
.join(GlyphCoordinate.glyph.of_type(ArmyGlyph))
.scalar()
)
def check_lose(session, state):
"""Return the number of army glyphs either colliding
with the player or hitting the bottom of the screen.
The player loses if this is non-zero."""
player = state["player"]
return (
session.query(GlyphCoordinate)
.join(GlyphCoordinate.glyph.of_type(ArmyGlyph))
.filter(
GlyphCoordinate.intersects(player) | GlyphCoordinate.bottom_bound
)
.count()
)
def render_message(session, window, msg, x, y):
"""Render a message glyph.
Clears the area beneath the message first
and assumes the display will be paused
afterwards.
"""
# create message box
msg = GlyphCoordinate(session, msg, x, y)
# clear existing glyphs which intersect
for gly in (
session.query(GlyphCoordinate)
.join(GlyphCoordinate.glyph)
.filter(GlyphCoordinate.intersects(msg))
):
gly.blank(window)
# render
msg.render(window, {})
window.refresh()
return msg
def win(session, window, state):
"""Handle the win case."""
render_message(session, window, "win_message", 15, 15)
time.sleep(2)
start(session, window, state, True)
def lose(session, window, state):
"""Handle the lose case."""
render_message(session, window, "lose_message", 15, 15)
time.sleep(2)
start(session, window, state)
def pause(session, window, state):
"""Pause the game."""
msg = render_message(session, window, "pause_message", 15, 15)
prompt(window)
msg.blank(window)
session.delete(msg)
def prompt(window):
"""Display a prompt, quashing any keystrokes
which might have remained."""
window.move(0, 0)
window.nodelay(1)
window.getch()
window.nodelay(0)
window.getch()
window.nodelay(1)
def move_army(session, window, state):
"""Update the army position based on the current
size of the field."""
speed = 30 // 25 * state["num_enemies"]
flip = (state["tick"] % speed) == 0
if not flip:
return
else:
state["flip"] = not state["flip"]
x_slide = 1
# get the lower/upper boundaries of the army
# along the X axis.
min_x, max_x = (
session.query(
func.min(GlyphCoordinate.x),
func.max(GlyphCoordinate.x + GlyphCoordinate.width),
)
.join(GlyphCoordinate.glyph.of_type(ArmyGlyph))
.first()
)
if min_x is None or max_x is None:
# no enemies
return
direction = state["army_direction"]
move_y = False
if direction == 0 and max_x + x_slide >= MAX_X:
direction = state["army_direction"] = 1
move_y = True
elif direction == 1 and min_x - x_slide <= 0:
direction = state["army_direction"] = 0
move_y = True
for enemy_g in session.query(GlyphCoordinate).join(
GlyphCoordinate.glyph.of_type(ArmyGlyph)
):
enemy_g.blank(window)
if move_y:
enemy_g.y += 1
elif direction == 0:
enemy_g.x += x_slide
elif direction == 1:
enemy_g.x -= x_slide
def move_player(session, window, state):
"""Receive player input and adjust state."""
ch = window.getch()
if ch not in (LEFT_KEY, RIGHT_KEY, FIRE_KEY, PAUSE_KEY):
return
elif ch == PAUSE_KEY:
pause(session, window, state)
return
player = state["player"]
if ch == RIGHT_KEY and not player.right_bound:
player.blank(window)
player.x += 1
elif ch == LEFT_KEY and not player.left_bound:
player.blank(window)
player.x -= 1
elif ch == FIRE_KEY and state["missile"] is None:
state["missile"] = GlyphCoordinate(
session, "missile", player.x + 3, player.y - 1
)
def move_missile(session, window, state):
"""Update the status of the current missile, if any."""
if state["missile"] is None or state["tick"] % 2 != 0:
return
missile = state["missile"]
# locate enemy glyphs which intersect with the
# missile's current position; i.e. a hit
glyph = (
session.query(GlyphCoordinate)
.join(GlyphCoordinate.glyph.of_type(EnemyGlyph))
.filter(GlyphCoordinate.intersects(missile))
.first()
)
missile.blank(window)
if glyph or missile.top_bound:
# missile is done
session.delete(missile)
state["missile"] = None
if glyph:
# score!
score(session, window, state, glyph)
else:
# move missile up one character.
missile.y -= 1
def move_saucer(session, window, state):
"""Update the status of the saucer."""
saucer_interval = 500
saucer_speed_interval = 4
if state["saucer"] is None and state["tick"] % saucer_interval != 0:
return
if state["saucer"] is None:
state["saucer"] = saucer = GlyphCoordinate(
session, "saucer", -6, 1, score=random.randrange(100, 600, 100)
)
elif state["tick"] % saucer_speed_interval == 0:
saucer = state["saucer"]
saucer.blank(window)
saucer.x += 1
if saucer.right_edge_bound:
session.delete(saucer)
state["saucer"] = None
def update_splat(session, window, state):
"""Render splat animations."""
for splat in session.query(GlyphCoordinate).join(
GlyphCoordinate.glyph.of_type(SplatGlyph)
):
age = state["tick"] - splat.tick
if age > 10:
splat.blank(window)
session.delete(splat)
else:
splat.render(window, state)
def score(session, window, state, glyph):
"""Process a glyph intersecting with a missile."""
glyph.blank(window)
session.delete(glyph)
if state["saucer"] is glyph:
state["saucer"] = None
state["score"] += glyph.score
# render a splat !
GlyphCoordinate(
session,
"splat1",
glyph.x,
glyph.y,
tick=state["tick"],
label=str(glyph.score),
)
def update_state(session, window, state):
"""Update all state for each game tick."""
num_enemies = state["num_enemies"] = check_win(session, state)
if num_enemies == 0:
win(session, window, state)
elif check_lose(session, state):
lose(session, window, state)
else:
# update the tick counter.
state["tick"] += 1
move_player(session, window, state)
move_missile(session, window, state)
move_army(session, window, state)
move_saucer(session, window, state)
update_splat(session, window, state)
def start(session, window, state, continue_=False):
"""Start a new field of play."""
render_message(session, window, "start_message", 15, 20)
prompt(window)
init_positions(session)
player = (
session.query(GlyphCoordinate)
.join(GlyphCoordinate.glyph.of_type(PlayerGlyph))
.one()
)
state.update(
{
"field_pos": 0,
"alt": False,
"tick": 0,
"missile": None,
"saucer": None,
"player": player,
"army_direction": 0,
"flip": False,
}
)
if not continue_:
state["score"] = 0
window.clear()
window.box()
draw(session, window, state)
def main():
"""Initialize the database and establish the game loop."""
e = create_engine("sqlite://")
Base.metadata.create_all(e)
session = Session(e)
init_glyph(session)
session.commit()
window = setup_curses()
state = {}
start(session, window, state)
while True:
update_state(session, window, state)
draw(session, window, state)
time.sleep(0.01)
if __name__ == "__main__":
main()