Building Spiral Storm
Play it first → The Gazer swings along the top of the arena and fills the sky with bullets. First it throws single shots aimed straight at Bean, then rings of bullets, then a spiral that never stops turning. Every second you last is a point. One hit and it's over.
This is how it was built, a step at a time. Every numbered step below is a whole program: copy it into the playground, press Run, and it works. Each step is the one before it with something added, so you can build the game the way it was built. Then break something on purpose and see what changes. That teaches you more than reading does.
There is nothing clever in it. No list comprehensions, no tricks, and every kaypy function it uses is on the API page. There are only two pictures, Bean and the Gazer. The bullets are circles.
Build it after Paddle Ball and before
Pong. It is put together the way
Bomb Dodge is — one score, one life,
global, a scene to play again in — and it adds two new things:
a camera that zooms out, and bullets that fly off in any direction.
The one big idea: a bullet knows which way it is going
In Paddle Ball, the ball moved because every frame your own code added
ball.dx and ball.dy to its position. That's fine for
one ball. Spiral Storm has a hundred bullets on the screen at once, and none of
them ever turns round, so there's an easier way.
Put move(direction, speed) in a bullet's list, and kaypy moves
it for you, every frame, for ever, in a straight line. You never write an
onUpdate to move it. All you have to say is which way.
There are two ways to say which way:
- Towards something.
bean.pos - gazer.posis the way from the Gazer to Bean. Take where you start away from where you want to get to, and what's left is the way there. - At an angle.
Vec2.fromAngle(angle)turns an angle, in degrees, into a direction.0is right,90is down,180is left and270is up. Down is 90, not up, because on a screenygets bigger going down.
Once you have angles, the patterns are easy. A ring is a
for loop that fires one bullet every 15 degrees, all the way
round. A spiral fires a few bullets at an angle that turns a little further
every time. That's all a bullet hell is.
Step 1 — zoom out, and move
Bean sits near the bottom of a dark arena. The arrow keys move it in four directions, and holding the mouse down somewhere moves Bean there. It stops at the edges. Everything looks a little smaller than in the other games, because the camera has zoomed out.
from kaypy import *
ZOOM = 0.7 # 1 is normal; 0.7 shows more of the world
BEAN_SPEED = 360 # how fast Bean moves, in pixels per second
kaypy(width=800, height=600, background=[24, 18, 40])
loadSprite("bean", "images/bean.png")
# Zoom out. The window now holds a bigger piece of the world: arena_w by
# arena_h, which is 1143 by 857. Point the camera at the middle of that,
# and the arena runs from (0, 0) to (arena_w, arena_h).
arena_w = width() / ZOOM
arena_h = height() / ZOOM
setCamScale(ZOOM)
setCamPos(vec2(arena_w / 2, arena_h / 2))
bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
anchor("center")])
@onKeyDown("left")
def go_left():
bean.move(-BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("right")
def go_right():
bean.move(BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("up")
def go_up():
bean.move(0, -BEAN_SPEED)
keep_on_screen()
@onKeyDown("down")
def go_down():
bean.move(0, BEAN_SPEED)
keep_on_screen()
# Holding the mouse down -- or a finger, on a phone -- moves Bean towards
# it. mousePos() is where the mouse is in the WINDOW; with the camera
# zoomed out, toWorld() turns that into where it is in the arena.
@onMouseDown
def go_to_mouse():
bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
keep_on_screen()
def keep_on_screen():
# 32 and 27 are half of Bean's width and height.
bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)
Zooming out. setCamScale(ZOOM) tells the
camera to draw everything at ZOOM times its size. At 0.7, Bean,
64 pixels wide, is drawn about 45 pixels wide, so the 800-by-600 window
now has room for a bigger piece of the world: 800 / 0.7 by 600 / 0.7, which is
about 1143 by 857. That's arena_w and
arena_h. More room means more bullets on the screen and more
space to dodge them.
The camera always shows whatever is around the spot it's pointing at.
setCamPos(vec2(arena_w / 2, arena_h / 2)) points it at the
middle of the arena, so the arena runs from (0, 0) at the top left to
(arena_w, arena_h) at the bottom right, the way the
window does in a game with no zoom.
- From here on, every position is a position in the arena.
pos(arena_w / 2, arena_h - 100)is the middle of the arena, 100 pixels up from the bottom. - Four
@onKeyDownfunctions, one per arrow, the way Bean ran in Bomb Dodge.bean.move(0, -BEAN_SPEED)is up, because up is minus. bean.moveTo(place, BEAN_SPEED)moves Bean towardsplaceat that speed, a little each frame, and stops when it gets there. It does in one line what Bomb Dodge did with twoifs.mousePos()is where the mouse is in the window.toWorld()turns that into where it is in the arena. With no zoom they are the same. With the camera zoomed out they are not, and Bean needs the arena one.keep_on_screen()usesclamptwice now, once across and once down. 32 and 27 are half Bean's width and height.
Two things to break.
Take out the setCamPos(...) line. There's no error message,
and no Bean either. The camera is still pointing where it started, at
(400, 300), so it is looking at the wrong part of the arena, and Bean is just
below the bottom of the window. Hold up for a moment and Bean
climbs into view. Then hold right: Bean walks off the
right-hand side of the window, because the arena's edge is past the
window's.
Put the line back, then change toWorld(mousePos()) to just
mousePos(). Hold the mouse near the top-left corner, at
(100, 100), and Bean stops at (70, 70), a little past the pointer. Hold it
near the bottom right, at (700, 500), and Bean stops at (490, 350), nowhere
near it. Bean is going to the right numbers, but in the wrong place: a window
position used as an arena position.
Step 2 — the Gazer, and shots aimed at Bean
The Gazer appears at the top and swings from side to side. A second in, it starts shooting yellow bullets straight at Bean, one every 0.7 seconds. Move and they miss. Stand still and they go straight through you, because nothing checks yet whether a bullet has hit.
from kaypy import *
ZOOM = 0.7 # 1 is normal; 0.7 shows more of the world
BEAN_SPEED = 360 # how fast Bean moves, in pixels per second
BULLET_SIZE = 9 # how big a bullet is (its radius)
BULLET_SPEED = 130 # how fast bullets fly
AIM_WAIT = 0.7 # seconds between shots aimed straight at Bean
kaypy(width=800, height=600, background=[24, 18, 40])
loadSprite("bean", "images/bean.png")
loadSprite("gazer", "images/gazer.png")
# Zoom out. The window now holds a bigger piece of the world: arena_w by
# arena_h, which is 1143 by 857. Point the camera at the middle of that,
# and the arena runs from (0, 0) to (arena_w, arena_h).
arena_w = width() / ZOOM
arena_h = height() / ZOOM
setCamScale(ZOOM)
setCamPos(vec2(arena_w / 2, arena_h / 2))
# The Gazer, along the top. anchor("center") means pos() is the middle.
gazer = add([sprite("gazer"), pos(arena_w / 2, 110), anchor("center"),
z(5)])
bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
anchor("center"), z(5)])
@onKeyDown("left")
def go_left():
bean.move(-BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("right")
def go_right():
bean.move(BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("up")
def go_up():
bean.move(0, -BEAN_SPEED)
keep_on_screen()
@onKeyDown("down")
def go_down():
bean.move(0, BEAN_SPEED)
keep_on_screen()
# Holding the mouse down -- or a finger, on a phone -- moves Bean towards
# it. mousePos() is where the mouse is in the WINDOW; with the camera
# zoomed out, toWorld() turns that into where it is in the arena.
@onMouseDown
def go_to_mouse():
bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
keep_on_screen()
def keep_on_screen():
# 32 and 27 are half of Bean's width and height.
bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)
# The Gazer swings from side to side. wave() goes smoothly back and forth
# between the two numbers as time() goes up.
@onUpdate
def swing():
gazer.pos.x = wave(150, arena_w - 150, time() * 0.6)
# One bullet, from the Gazer, flying in `direction`. move() keeps it going
# by itself; it only needs to be told which way and how fast.
def shoot(direction, colour):
add([circle(BULLET_SIZE), color(colour), pos(gazer.pos),
anchor("center"), move(direction, BULLET_SPEED), "bullet"])
# A shot aimed straight at Bean. Bean's position take away the Gazer's is
# the way from the Gazer to Bean.
def aim():
shoot(bean.pos - gazer.pos, YELLOW)
wait(AIM_WAIT, aim)
wait(1, aim)
wave(150, arena_w - 150, time() * 0.6)is a number that swings smoothly between 150 andarena_w - 150astime()goes up. Setting the Gazer'sxto it every frame swings the Gazer. Make the 0.6 bigger and it swings faster.shoot(direction, colour)makes one bullet. It starts where the Gazer is,pos(gazer.pos), andmove(direction, BULLET_SPEED)sends it off. There's nothing else to write: the bullet keeps going by itself. The"bullet"at the end is a tag, so the next step can find all of them.color(colour)takes a colour kaypy already has a name for:YELLOWhere, andMAGENTAandCYANin step 5, so you can tell the kinds of shot apart.bean.pos - gazer.posis the way from the Gazer to Bean.move()only cares about the way, not the length, so it doesn't matter how far apart they are.aimfires a shot and then askswaitto runaimagain inAIM_WAITseconds. That's how bombs kept dropping in Bomb Dodge: a function that books its own next turn.
Break it. Turn the subtraction round:
shoot(gazer.pos - bean.pos, YELLOW). There's no error message.
Every shot flies up and away from Bean, off the top of the window.
gazer.pos - bean.pos is the way from Bean to the
Gazer, so the bullet sets off from the Gazer going the way that points:
further away from Bean.
Step 3 — getting hit, and cleaning up
Now a bullet that reaches Bean is a hit: a bang, a shake, and Bean is gone. The Gazer stops swinging and stops shooting.
from kaypy import *
ZOOM = 0.7 # 1 is normal; 0.7 shows more of the world
BEAN_SPEED = 360 # how fast Bean moves, in pixels per second
HIT_DISTANCE = 16 # how close a bullet's middle has to get to Bean's
BULLET_SIZE = 9 # how big a bullet is (its radius)
BULLET_SPEED = 130 # how fast bullets fly
AIM_WAIT = 0.7 # seconds between shots aimed straight at Bean
kaypy(width=800, height=600, background=[24, 18, 40])
loadSprite("bean", "images/bean.png")
loadSprite("gazer", "images/gazer.png")
loadSound("boom", "sounds/small_boom.wav")
alive = True
# Zoom out. The window now holds a bigger piece of the world: arena_w by
# arena_h, which is 1143 by 857. Point the camera at the middle of that,
# and the arena runs from (0, 0) to (arena_w, arena_h).
arena_w = width() / ZOOM
arena_h = height() / ZOOM
setCamScale(ZOOM)
setCamPos(vec2(arena_w / 2, arena_h / 2))
# The Gazer, along the top. anchor("center") means pos() is the middle.
gazer = add([sprite("gazer"), pos(arena_w / 2, 110), anchor("center"),
z(5)])
bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
anchor("center"), z(5)])
@onKeyDown("left")
def go_left():
bean.move(-BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("right")
def go_right():
bean.move(BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("up")
def go_up():
bean.move(0, -BEAN_SPEED)
keep_on_screen()
@onKeyDown("down")
def go_down():
bean.move(0, BEAN_SPEED)
keep_on_screen()
# Holding the mouse down -- or a finger, on a phone -- moves Bean towards
# it. mousePos() is where the mouse is in the WINDOW; with the camera
# zoomed out, toWorld() turns that into where it is in the arena.
@onMouseDown
def go_to_mouse():
bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
keep_on_screen()
def keep_on_screen():
# 32 and 27 are half of Bean's width and height.
bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)
# The Gazer swings from side to side. wave() goes smoothly back and forth
# between the two numbers as time() goes up.
@onUpdate
def swing():
if alive:
gazer.pos.x = wave(150, arena_w - 150, time() * 0.6)
# One bullet, from the Gazer, flying in `direction`. move() keeps it going
# by itself; it only needs to be told which way and how fast.
def shoot(direction, colour):
add([circle(BULLET_SIZE), color(colour), pos(gazer.pos),
anchor("center"), move(direction, BULLET_SPEED), "bullet"])
# A shot aimed straight at Bean. Bean's position take away the Gazer's is
# the way from the Gazer to Bean.
def aim():
if not alive:
return
shoot(bean.pos - gazer.pos, YELLOW)
wait(AIM_WAIT, aim)
wait(1, aim)
# Every bullet runs this, every frame.
@onUpdate("bullet")
def check(bullet):
if not alive:
return
if bullet.pos.dist(bean.pos) < HIT_DISTANCE:
hit()
return
# Gone out of the arena? Then it is finished with.
x = bullet.pos.x
y = bullet.pos.y
if x < -20 or x > arena_w + 20 or y < -20 or y > arena_h + 20:
bullet.destroy()
def hit():
global alive
alive = False
play("boom")
shake(12)
addKaboom(bean.pos)
# Bean goes, and so does every bullet. move() would keep them flying.
bean.destroy()
destroyAll("bullet")
@onUpdate("bullet")runscheckevery frame for every bullet, with that bullet asbullet.bullet.pos.dist(bean.pos)is how far the bullet's middle is from Bean's middle. UnderHIT_DISTANCEis a hit.- The hit is much smaller than Bean. Bean is 64 pixels
wide, and a bullet is 18 across. Measured from middle to middle, a bullet
first touches the picture of Bean at about 40.
HIT_DISTANCEis 16, so a bullet has to get right into the middle of Bean to count. That's on purpose, and every game like this does it: a bullet that only clips your edge misses, and you can squeeze through gaps that look too small. It feels fair. A hit the size of the picture feels like being hit by something that missed. aliveis how the rest of the game knows it's over.hitchanges it, sohitsaysglobal alivefirst.aim,swingandcheckonly read it, so they don't.- A bullet more than 20 pixels outside the arena is destroyed.
Two things to break.
Take out the last two lines of check, the ones that destroy a
bullet that has left the arena. There's no error message, and the game looks
just the same. But every bullet the Gazer has ever fired is still out there,
flying away, and kaypy moves every one of them every frame. Keep Bean
alive for a minute and there are 82 bullets, with only about 9 of them on the
screen. The whole game fires a lot more: three minutes in there were 4,683,
each frame took about 28 milliseconds instead of less than one, and it kept
getting slower the longer it ran.
Put them back, and take out destroyAll("bullet") instead. Stand
still and get hit. The bullets that were already in the air keep flying after
Bean has gone, straight off the edge of the window.
move() doesn't know the game is over. Only your own code
knows that, so your own code has to stop them.
Step 4 — points, and playing again
A point for every second you last, a best score that's remembered, and
space or a click to play again. This step is Bomb Dodge's
step 4 all over again, so it is put together the same way: the whole game
goes inside a scene, and again() starts the scene over.
from kaypy import *
ZOOM = 0.7 # 1 is normal; 0.7 shows more of the world
BEAN_SPEED = 360 # how fast Bean moves, in pixels per second
HIT_DISTANCE = 16 # how close a bullet's middle has to get to Bean's
BULLET_SIZE = 9 # how big a bullet is (its radius)
BULLET_SPEED = 130 # how fast bullets fly
AIM_WAIT = 0.7 # seconds between shots aimed straight at Bean
BEST_KEY = "spiral_storm_best"
kaypy(width=800, height=600, background=[24, 18, 40])
loadSprite("bean", "images/bean.png")
loadSprite("gazer", "images/gazer.png")
loadSound("boom", "sounds/small_boom.wav")
score = 0
alive = True
@scene("game")
def game():
global score, alive
# A new game, so everything starts again.
score = 0
alive = True
best = getData(BEST_KEY, 0)
# Zoom out. go() puts the camera back to normal, which is why this is
# in the scene now.
arena_w = width() / ZOOM
arena_h = height() / ZOOM
setCamScale(ZOOM)
setCamPos(vec2(arena_w / 2, arena_h / 2))
gazer = add([sprite("gazer"), pos(arena_w / 2, 110), anchor("center"),
z(5)])
bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
anchor("center"), z(5)])
# fixed() keeps the writing where it is on the screen, at its normal
# size, whatever the camera does. So these positions are in the window,
# not the arena.
score_label = add([text("0", size=48), pos(width() / 2, 40),
anchor("center"), fixed(), z(10)])
best_label = add([text("best " + str(best), size=18), pos(12, 12),
opacity(0.75), fixed(), z(10)])
message = add([text("Arrow keys to move, or hold the mouse where you "
"want to go. Don't get hit!", size=26, width=560),
pos(width() / 2, height() / 2), anchor("center"),
fixed(), z(10)])
@onKeyDown("left")
def go_left():
bean.move(-BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("right")
def go_right():
bean.move(BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("up")
def go_up():
bean.move(0, -BEAN_SPEED)
keep_on_screen()
@onKeyDown("down")
def go_down():
bean.move(0, BEAN_SPEED)
keep_on_screen()
@onMouseDown
def go_to_mouse():
bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
keep_on_screen()
def keep_on_screen():
bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)
@onUpdate
def swing():
if alive:
gazer.pos.x = wave(150, arena_w - 150, time() * 0.6)
def shoot(direction, colour):
add([circle(BULLET_SIZE), color(colour), pos(gazer.pos),
anchor("center"), move(direction, BULLET_SPEED), "bullet"])
def aim():
if not alive:
return
shoot(bean.pos - gazer.pos, YELLOW)
wait(AIM_WAIT, aim)
wait(1, aim)
@onUpdate("bullet")
def check(bullet):
if not alive:
return
if bullet.pos.dist(bean.pos) < HIT_DISTANCE:
hit()
return
x = bullet.pos.x
y = bullet.pos.y
if x < -20 or x > arena_w + 20 or y < -20 or y > arena_h + 20:
bullet.destroy()
# A point for every second you are still here.
def tick():
global score
if not alive:
return
score = score + 1
score_label.text = str(score)
if score == 1:
message.text = ""
wait(1, tick)
wait(1, tick)
def hit():
global alive
alive = False
play("boom")
shake(12)
addKaboom(bean.pos)
bean.destroy()
destroyAll("bullet")
if score > best:
setData(BEST_KEY, score)
best_label.text = "best " + str(score)
if score == 1:
message.text = "1 point -- press SPACE or click to play again"
else:
message.text = (str(score)
+ " points -- press SPACE or click to play again")
# Once the game is over, space or a click starts a new one.
def again():
if not alive:
go("game")
onKeyPress("space", again)
onClick(again)
go("game")
tickadds a point and books itself again in a second, the same trick asaim. It stops booking oncealiveisFalse, so the points stop too.score,aliveandbestwork as they did in Bomb Dodge.getDataandsetDatakeep the best score after the page is closed.- The zoom moved into the scene.
go("game")throws away everything from the last game before it starts the new one, and that includes the camera: it goes back to no zoom, pointing at the middle of the window. Setting it in the scene sets it again every game. fixed()is on the score, the best score and the message. It means "this belongs to the window, not the arena": the camera leaves it alone, so it stays where it was put, at its own size. Itspos()is a window position, which is why the score is atwidth() / 2and notarena_w / 2.
Two things to break.
Move the four zoom lines out of the scene and up to the top of the program,
under alive = True. The first game looks fine. Get hit and press
space: the second game is zoomed back in, and Bean is
nowhere. It's still at (571, 757) in the arena, and with no zoom the window
only shows down to 600.
Put them back, and take fixed() off the score. There's no error
message. The score shrinks and slides up and to the left, to about (280, 28)
in the window, because now it's a thing in the arena at (400, 40), and the
camera zooms it like everything else.
Step 5 — rings, and a spiral
At 8 points the Gazer starts throwing a ring of pink bullets every 2.5 seconds. At 20 it adds a blue spiral with four arms that turns and turns and never stops. With everything going, there are about a hundred bullets on the screen.
from kaypy import *
ZOOM = 0.7 # 1 is normal; 0.7 shows more of the world
BEAN_SPEED = 360 # how fast Bean moves, in pixels per second
HIT_DISTANCE = 16 # how close a bullet's middle has to get to Bean's
BULLET_SIZE = 9 # how big a bullet is (its radius)
BULLET_SPEED = 130 # how fast bullets fly
AIM_WAIT = 0.7 # seconds between shots aimed straight at Bean
RING_FROM = 8 # the score the rings start at
RING_GAP = 15 # degrees between the bullets in a ring
RING_WAIT = 2.5 # seconds between rings
SPIRAL_FROM = 20 # the score the spiral starts at
SPIRAL_ARMS = 4 # how many arms the spiral has
SPIRAL_WAIT = 0.25 # seconds between one turn of the spiral and the next
SPIRAL_TURN = 11 # how many degrees it turns each time
BEST_KEY = "spiral_storm_best"
kaypy(width=800, height=600, background=[24, 18, 40])
loadSprite("bean", "images/bean.png")
loadSprite("gazer", "images/gazer.png")
loadSound("shoot", "sounds/small_beep.wav")
loadSound("boom", "sounds/small_boom.wav")
score = 0
alive = True
spiral_angle = 0
@scene("game")
def game():
global score, alive, spiral_angle
# A new game, so everything starts again.
score = 0
alive = True
spiral_angle = 0
best = getData(BEST_KEY, 0)
# Zoom out. go() puts the camera back to normal, which is why this is
# in the scene now.
arena_w = width() / ZOOM
arena_h = height() / ZOOM
setCamScale(ZOOM)
setCamPos(vec2(arena_w / 2, arena_h / 2))
gazer = add([sprite("gazer"), pos(arena_w / 2, 110), anchor("center"),
z(5)])
bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
anchor("center"), z(5)])
# fixed() keeps the writing where it is on the screen, at its normal
# size, whatever the camera does. So these positions are in the window,
# not the arena.
score_label = add([text("0", size=48), pos(width() / 2, 40),
anchor("center"), fixed(), z(10)])
best_label = add([text("best " + str(best), size=18), pos(12, 12),
opacity(0.75), fixed(), z(10)])
message = add([text("Arrow keys to move, or hold the mouse where you "
"want to go. Don't get hit!", size=26, width=560),
pos(width() / 2, height() / 2), anchor("center"),
fixed(), z(10)])
@onKeyDown("left")
def go_left():
bean.move(-BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("right")
def go_right():
bean.move(BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("up")
def go_up():
bean.move(0, -BEAN_SPEED)
keep_on_screen()
@onKeyDown("down")
def go_down():
bean.move(0, BEAN_SPEED)
keep_on_screen()
@onMouseDown
def go_to_mouse():
bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
keep_on_screen()
def keep_on_screen():
bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)
@onUpdate
def swing():
if alive:
gazer.pos.x = wave(150, arena_w - 150, time() * 0.6)
def shoot(direction, colour):
add([circle(BULLET_SIZE), color(colour), pos(gazer.pos),
anchor("center"), move(direction, BULLET_SPEED), "bullet"])
def aim():
if not alive:
return
shoot(bean.pos - gazer.pos, YELLOW)
wait(AIM_WAIT, aim)
# A ring: one bullet every RING_GAP degrees, all the way round.
def ring():
if not alive:
return
if score >= RING_FROM:
for angle in range(0, 360, RING_GAP):
shoot(Vec2.fromAngle(angle), MAGENTA)
play("shoot")
wait(RING_WAIT, ring)
# A spiral: a bullet for each arm, spread evenly round, at an angle
# that turns a little further every time.
def spiral():
global spiral_angle
if not alive:
return
if score >= SPIRAL_FROM:
for arm in range(0, 360, 360 // SPIRAL_ARMS):
shoot(Vec2.fromAngle(spiral_angle + arm), CYAN)
spiral_angle = spiral_angle + SPIRAL_TURN
wait(SPIRAL_WAIT, spiral)
wait(1, aim)
wait(1, ring)
wait(1, spiral)
@onUpdate("bullet")
def check(bullet):
if not alive:
return
if bullet.pos.dist(bean.pos) < HIT_DISTANCE:
hit()
return
x = bullet.pos.x
y = bullet.pos.y
if x < -20 or x > arena_w + 20 or y < -20 or y > arena_h + 20:
bullet.destroy()
# A point for every second you are still here.
def tick():
global score
if not alive:
return
score = score + 1
score_label.text = str(score)
if score == 1:
message.text = ""
wait(1, tick)
wait(1, tick)
def hit():
global alive
alive = False
play("boom")
shake(12)
addKaboom(bean.pos)
bean.destroy()
destroyAll("bullet")
if score > best:
setData(BEST_KEY, score)
best_label.text = "best " + str(score)
if score == 1:
message.text = "1 point -- press SPACE or click to play again"
else:
message.text = (str(score)
+ " points -- press SPACE or click to play again")
# Once the game is over, space or a click starts a new one.
def again():
if not alive:
go("game")
onKeyPress("space", again)
onClick(again)
go("game")
The ring. range(0, 360, RING_GAP) counts
from 0 up to 360 in steps of 15: 0, 15, 30, and so on up to 345. That's 24
angles. The loop fires one bullet at each angle, so 24 bullets leave the Gazer
at once, spread evenly all the way round. (360 itself is left out, which is
right: it's the same as 0.)
The spiral. 360 // SPIRAL_ARMS is 90, so
range(0, 360, 90) is 0, 90, 180 and 270: four bullets, a
quarter of a turn apart, one for each arm. spiral_angle is added
to every one of them. Then spiral_angle goes up by 11, so a
quarter of a second later, the next four go out a little further round. Each
arm curls a bit more every time, and that is the spiral.
spiral_anglehas to last from one call ofspiralto the next, so it lives up at the top withscoreandalive.spiralchanges it, sospiralsaysglobal spiral_angle. The scene sets it back to 0 for each new game, so the scene says it too.ringandspiralstart running a second in, likeaim, but they don't shoot until the score is high enough. They still book their next turn either way, so they're ready the moment it is.play("shoot")is outside theforloop, so it beeps once for each ring, not once for each of its 24 bullets.
Two things to break.
Make RING_GAP 0. The game runs fine until 8 points, when the
first ring is due, and then stops with
for angle in range(0, 360, RING_GAP):
ValueError: range() arg 3 must not be zero
Counting from 0 to 360 in steps of 0 would never get there, so Python refuses. "arg 3" is the third thing in the brackets.
Make SPIRAL_ARMS 7. There's no error, but count the bullets in
one turn: there are 8, and two of them are almost on top of each other.
360 // 7 is 51, because // throws away what's left
over. Counting up in 51s gives 0, 51, 102, 153, 204, 255, 306 — and then 357,
which is still under 360, so it's in. The eighth bullet is 3 degrees from the
first. Arms that divide 360 exactly — 2, 3, 4, 5, 6, 8 — come out
even.
Step 6 — the whole game
The last thing: it gets harder as you go. The bullets were all
BULLET_SPEED, and the rings came every RING_WAIT.
Now each is worked out from the score by a small function, the way Bomb Dodge
works out how fast its bombs fall. bullet_speed() starts at 130
and adds 1 for every point, up to 200. ring_wait() starts at 2.5
seconds and takes off 0.02 for every point, down to 2. Both stop somewhere, so
the game ends up hard but never impossible.
Here it is, the whole game, exactly as it is on the Games page. It has the settings gathered at the top, and comments saying what every part is for.
"""Spiral Storm -- the Gazer fills the sky with bullets. Don't get hit.
arrow keys move
or hold the mouse (or a finger) where you want Bean to go
Every second you last is a point. The longer you last, the more the Gazer
throws at you: first shots aimed straight at Bean, then rings of bullets,
then a spiral that never stops turning. One hit and it is game over. Your
best score is remembered.
HOW THIS FILE IS PUT TOGETHER
1. the window, the pictures and the sounds
2. the settings
3. how hard the game is
4. the game scene: zooming out, Bean, the Gazer, the bullets
5. start the game
Every bullet is a game object with move() on it, which sends it off in a
straight line by itself. All the Gazer has to do is say which way.
Vec2.fromAngle(angle) turns an angle into a direction: 0 is right, 90 is
down, 180 is left, 270 is up. A ring is a for loop over the angles, and a
spiral is one angle that keeps turning. After this, try Pong.
"""
from kaypy import *
# ----------------------------------------------------------------- 1. setup
kaypy(width=800, height=600, background=[24, 18, 40])
loadSprite("bean", "images/bean.png")
loadSprite("gazer", "images/gazer.png")
loadSound("shoot", "sounds/small_beep.wav")
loadSound("boom", "sounds/small_boom.wav")
# -------------------------------------------------------------- 2. settings
#
# Everything you would want to change is here. Change one at a time and
# play it.
# How far the camera is zoomed out. 1 is normal; 0.7 shows more of the
# world, so everything looks smaller and there is more room to dodge.
ZOOM = 0.7
BEAN_SPEED = 360 # how fast Bean moves, in pixels per second
# How close a bullet's middle has to get to Bean's middle to hit. Bean is
# 64 pixels wide, so this is a lot smaller than Bean, on purpose: a bullet
# that only clips the edge of Bean misses, and you can squeeze through gaps
# that look too small.
HIT_DISTANCE = 16
BULLET_SIZE = 9 # how big a bullet is (its radius)
SPEED_START = 130 # how fast bullets fly, to begin with
SPEED_FASTEST = 200 # ...the fastest they ever fly
SPEED_GAIN = 1 # ...and how much faster each point makes them
AIM_WAIT = 0.7 # seconds between shots aimed straight at Bean
RING_FROM = 8 # the score the rings start at
RING_GAP = 15 # degrees between the bullets in a ring
RING_WAIT_START = 2.5 # seconds between rings, to begin with
RING_WAIT_SHORTEST = 2 # ...the shortest wait there ever is
RING_WAIT_DROP = 0.02 # ...and how much shorter each point makes it
SPIRAL_FROM = 20 # the score the spiral starts at
SPIRAL_ARMS = 4 # how many arms the spiral has
SPIRAL_WAIT = 0.25 # seconds between one turn of the spiral and the next
SPIRAL_TURN = 11 # how many degrees it turns each time
# The name the best score is saved under. Every game on a website shares
# the same storage, so the game's own name goes in it.
BEST_KEY = "spiral_storm_best"
# -------------------------------------------------- 3. how hard the game is
#
# Each of these works out a number from the score. They get harder as the
# score goes up, and they stop somewhere, so the game ends up hard but never
# impossible.
score = 0
def bullet_speed():
speed = SPEED_START + score * SPEED_GAIN
if speed > SPEED_FASTEST:
speed = SPEED_FASTEST
return speed
def ring_wait():
seconds = RING_WAIT_START - score * RING_WAIT_DROP
if seconds < RING_WAIT_SHORTEST:
seconds = RING_WAIT_SHORTEST
return seconds
# ------------------------------------------------------- 4. the game scene
#
# These change while you play. A function that CHANGES one has to say
# `global` first, which means "the one up here, not a new one of my own".
alive = True
spiral_angle = 0
@scene("game")
def game():
global score, alive, spiral_angle
# A new game, so everything starts again.
score = 0
alive = True
spiral_angle = 0
best = getData(BEST_KEY, 0)
# ----------------------------------------------------- zooming out
#
# The camera shows the world at ZOOM times its size, so the window
# now holds a bigger piece of the world: arena_w by arena_h, which is
# 1143 by 857 when ZOOM is 0.7. Point the camera at the middle of that
# and the arena runs from (0, 0) to (arena_w, arena_h), the way the
# window did before. go() puts the camera back to normal, which is why
# this is in the scene.
arena_w = width() / ZOOM
arena_h = height() / ZOOM
setCamScale(ZOOM)
setCamPos(vec2(arena_w / 2, arena_h / 2))
# The Gazer, along the top. anchor("center") means pos() is the middle.
gazer = add([sprite("gazer"), pos(arena_w / 2, 110), anchor("center"),
z(5)])
bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
anchor("center"), z(5)])
# fixed() keeps the writing where it is on the screen, at its normal
# size, whatever the camera does. So these positions are in the window,
# not the arena.
score_label = add([text("0", size=48), pos(width() / 2, 40),
anchor("center"), fixed(), z(10)])
best_label = add([text("best " + str(best), size=18), pos(12, 12),
opacity(0.75), fixed(), z(10)])
# width=560 wraps a long message onto more than one line.
message = add([text("Arrow keys to move, or hold the mouse where you "
"want to go. Don't get hit!", size=26, width=560),
pos(width() / 2, height() / 2), anchor("center"),
fixed(), z(10)])
# ------------------------------------------------------------ moving
@onKeyDown("left")
def go_left():
bean.move(-BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("right")
def go_right():
bean.move(BEAN_SPEED, 0)
keep_on_screen()
@onKeyDown("up")
def go_up():
bean.move(0, -BEAN_SPEED)
keep_on_screen()
@onKeyDown("down")
def go_down():
bean.move(0, BEAN_SPEED)
keep_on_screen()
# Holding the mouse down -- or a finger, on a phone -- moves Bean
# towards it. mousePos() is where the mouse is in the WINDOW; with the
# camera zoomed out, toWorld() turns that into where it is in the arena.
@onMouseDown
def go_to_mouse():
bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
keep_on_screen()
def keep_on_screen():
# 32 and 27 are half of Bean's width and height.
bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)
# ------------------------------------------------------------- the Gazer
#
# It swings from side to side. wave() goes smoothly back and forth
# between the two numbers as time() goes up.
@onUpdate
def swing():
if alive:
gazer.pos.x = wave(150, arena_w - 150, time() * 0.6)
# ------------------------------------------------------------ bullets
#
# One bullet, from the Gazer, flying in `direction`. move() keeps it
# going by itself; it only needs to be told which way and how fast.
# Each kind of shot has its own colour, so you can tell them apart.
def shoot(direction, colour):
add([circle(BULLET_SIZE), color(colour), pos(gazer.pos),
anchor("center"), move(direction, bullet_speed()), "bullet"])
# A shot aimed straight at Bean. Bean's position take away the Gazer's
# is the way from the Gazer to Bean.
def aim():
if not alive:
return
shoot(bean.pos - gazer.pos, YELLOW)
wait(AIM_WAIT, aim)
# A ring: one bullet every RING_GAP degrees, all the way round.
def ring():
if not alive:
return
if score >= RING_FROM:
for angle in range(0, 360, RING_GAP):
shoot(Vec2.fromAngle(angle), MAGENTA)
play("shoot")
wait(ring_wait(), ring)
# A spiral: a bullet for each arm, spread evenly round, at an angle
# that turns a little further every time.
def spiral():
global spiral_angle
if not alive:
return
if score >= SPIRAL_FROM:
for arm in range(0, 360, 360 // SPIRAL_ARMS):
shoot(Vec2.fromAngle(spiral_angle + arm), CYAN)
spiral_angle = spiral_angle + SPIRAL_TURN
wait(SPIRAL_WAIT, spiral)
wait(1, aim)
wait(1, ring)
wait(1, spiral)
# Every bullet runs this, every frame.
@onUpdate("bullet")
def check(bullet):
if not alive:
return
if bullet.pos.dist(bean.pos) < HIT_DISTANCE:
hit(bullet)
return
# Gone out of the arena? Then it is finished with. Left alone, it
# would fly on for ever, and thousands of them would slow the game
# down to a crawl.
x = bullet.pos.x
y = bullet.pos.y
if x < -20 or x > arena_w + 20 or y < -20 or y > arena_h + 20:
bullet.destroy()
# ------------------------------------------------------------- points
#
# A point for every second you are still here.
def tick():
global score
if not alive:
return
score = score + 1
score_label.text = str(score)
if score == 1:
message.text = ""
wait(1, tick)
wait(1, tick)
# -------------------------------------------------------- getting hit
def hit(bullet):
global alive
alive = False
play("boom")
shake(12)
addKaboom(bean.pos)
# Bean goes, and so does every bullet. move() would keep them
# flying, over the top of the message below.
bean.destroy()
destroyAll("bullet")
if score > best:
setData(BEST_KEY, score)
best_label.text = "best " + str(score)
if score == 1:
message.text = "1 point -- press SPACE or click to play again"
else:
message.text = (str(score)
+ " points -- press SPACE or click to play again")
# Once the game is over, space or a click starts a new one.
def again():
if not alive:
go("game")
onKeyPress("space", again)
onClick(again)
# ------------------------------------------------------- 5. start the game
go("game")
Make it yours
Open the whole game in the playground →
- Zoom out further. Make
ZOOM0.5. The arena is now 1600 by 1200, everything is drawn half size, and there's room for a lot more bullets. Nothing else needs changing, because everything is worked out fromarena_wandarena_h. - A ring with a way out. In
ring, put theshootinside anif:if angle < 75 or angle > 105:. The angles 75, 90 and 105 are skipped, and every ring has a gap pointing straight down, where Bean usually is. - A spiral that turns the other way. Make
SPIRAL_TURN-11. Then try 45: each turn lands exactly halfway between the arms of the one before, and the spiral turns into eight straight lines. - Now play Pong. Pong goes back to a ball that carries its own speed, the way Paddle Ball's did, and adds a second paddle that the computer moves.