Building Knight's Hall
Play it first → Monsters come through the doors of a hall, and a knight holds it with a sword. On a phone you play with your thumbs: a d-pad on the left, two buttons on the right. On a computer you use the arrow keys, space and z.
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.
The one big idea: the buttons pretend to be keys
The d-pad and the buttons take one word, on the very first line:
kaypy(width=576, height=864, joystick=True)
That draws a d-pad in the bottom-left corner and two buttons in the
bottom-right, and they pretend to be the keyboard. A thumb on the
left of the d-pad is exactly the same as holding the left arrow. The two
buttons are space and z. So nothing else in the
game knows the buttons are there. It asks isKeyDown("left") and
onKeyPress("space") like any other game, and a thumb answers.
Three things come with it for free:
- Diagonals. A thumb between up and right holds both keys, so the game sees up and right together.
- Two thumbs at once. Each finger is tracked on its own, so you can hold the d-pad and tap a button without letting go of the d-pad.
- The mouse counts as a finger, so you can try it all on a computer by clicking on the d-pad.
The one thing you have to think about is where your thumbs go. They cover the bottom corners of the screen, so a monster down there is a monster you can't see. This game is tall, like a phone held upright: the hall is at the top, and underneath it is a strip that holds only the controls.
Step 1 — a hall, a knight and a d-pad
from kaypy import *
TILE = 48 # a tile is 48 pixels: the 16-pixel art, times 3
# 12 tiles across. 18 down: 12 for the hall, 6 under it for the controls.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
joystick=True)
IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True}}
loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")
SPEED = 220 # pixels per second
HALL = [
"^D^^^D^^^D^^",
"#dd#BddB#dd#",
"#..,....:..#",
"#.:...,....#",
"#....,...,.#",
"#,.:.......#",
"#.....:..,.#",
"#..,.......#",
"#.....,.:..#",
"#.:.,......#",
"#.......,..#",
"############",
]
TILES = {
"^": lambda: [sprite("wall_top"), scale(3)],
"#": lambda: [sprite("wall"), scale(3)],
"B": lambda: [sprite("banner"), scale(3)],
"D": lambda: [sprite("door"), scale(3)],
".": lambda: [sprite("floor_1"), scale(3)],
",": lambda: [sprite("floor_2"), scale(3)],
":": lambda: [sprite("floor_3"), scale(3)],
}
# The floor, in pixels: everything inside the walls.
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11
@scene("hall")
def hall():
addLevel(HALL, {"tileWidth": TILE, "tileHeight": TILE,
"pos": vec2(0, 0), "tiles": TILES})
knight = add([
sprite("knight", anim="idle"),
pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
anchor("center"),
scale(2.5),
area(),
z(10),
"knight",
])
@onUpdate
def walk():
dx = 0
dy = 0
if isKeyDown("left"):
dx = dx - 1
if isKeyDown("right"):
dx = dx + 1
if isKeyDown("up"):
dy = dy - 1
if isKeyDown("down"):
dy = dy + 1
if dx != 0 or dy != 0:
way = vec2(dx, dy).unit()
knight.move(way.x * SPEED, way.y * SPEED)
if dx != 0:
knight.flipX = dx < 0
if knight.curAnim() != "run":
knight.play("run")
elif knight.curAnim() != "idle":
knight.play("idle")
# Stay on the floor.
knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)
go("hall")
A knight in a hall, walking in eight directions. On a phone, put your thumb on the d-pad. On a computer, use the arrow keys, or click and drag on the d-pad.
The window. A tile is 48 pixels, the 16-pixel dungeon art drawn three times as big. The window is 12 tiles across and 18 down: 12 for the hall and 6 under it for the controls.
The hall is a picture. Each letter in HALL
is one tile, and TILES says what each letter turns into.
addLevel walks the picture and builds them. There are three
kinds of floor, . , and :, so it
doesn't look like wallpaper. A door picture is two tiles wide and two tall,
drawn from the top-left of the D's tile, so it covers three
more tiles than its own. Those are the ds. They aren't in
TILES, so nothing is drawn there. Put a # there
instead and a piece of wall is drawn over a corner of the door.
Why a scene already? Everything goes inside
hall(), and go("hall") at the bottom starts it.
That doesn't do anything useful yet. In step 4 the knight can die, and
go("hall") again is how the game starts over.
Walking. Every frame, walk asks which arrows
are held and adds them up into dx (left is −1, right is +1) and
dy (up is −1, down is +1). Up and right together make
(1, −1), a diagonal.
Why .unit()? The arrow (1, 1) is
longer than (1, 0), about 1.4 times longer. Multiply it by
SPEED and the knight walks 1.4 times faster on a diagonal. Try
it: change those two lines to
knight.move(dx * SPEED, dy * SPEED). There's no error message,
and in a test the knight went 140 pixels diagonally in the time it went
99 straight. .unit() keeps the direction but makes the length
1, so every direction is the same speed.
Staying in. There are no walls to bump into. Every frame
clamp just puts the knight back inside the floor:
LEFT, RIGHT, TOP and
BOTTOM are its edges, in pixels.
Step 2 — facing, and the sword
from kaypy import *
TILE = 48 # a tile is 48 pixels: the 16-pixel art, times 3
# 12 tiles across. 18 down: 12 for the hall, 6 under it for the controls.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
joystick=True)
IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True}}
loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("sword", "dungeon/weapon_regular_sword.png")
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")
SPEED = 220 # pixels per second
SWORD_REACH = 42 # how far in front of the knight the blade appears
SWORD_TIME = 0.15 # how long a swing lasts, in seconds
HALL = [
"^D^^^D^^^D^^",
"#dd#BddB#dd#",
"#..,....:..#",
"#.:...,....#",
"#....,...,.#",
"#,.:.......#",
"#.....:..,.#",
"#..,.......#",
"#.....,.:..#",
"#.:.,......#",
"#.......,..#",
"############",
]
TILES = {
"^": lambda: [sprite("wall_top"), scale(3)],
"#": lambda: [sprite("wall"), scale(3)],
"B": lambda: [sprite("banner"), scale(3)],
"D": lambda: [sprite("door"), scale(3)],
".": lambda: [sprite("floor_1"), scale(3)],
",": lambda: [sprite("floor_2"), scale(3)],
":": lambda: [sprite("floor_3"), scale(3)],
}
# The floor, in pixels: everything inside the walls.
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11
facing = (0, 1) # which way the knight faces, as (x, y): (0, 1) is down
# The sword picture points up, so up is 0 degrees, and the rest go round
# clockwise.
SWORD_ANGLE = {
(0, -1): 0, (1, -1): 45, (1, 0): 90, (1, 1): 135,
(0, 1): 180, (-1, 1): 225, (-1, 0): 270, (-1, -1): 315,
}
@scene("hall")
def hall():
global facing
facing = (0, 1)
addLevel(HALL, {"tileWidth": TILE, "tileHeight": TILE,
"pos": vec2(0, 0), "tiles": TILES})
knight = add([
sprite("knight", anim="idle"),
pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
anchor("center"),
scale(2.5),
area(),
z(10),
"knight",
])
@onUpdate
def walk():
global facing
dx = 0
dy = 0
if isKeyDown("left"):
dx = dx - 1
if isKeyDown("right"):
dx = dx + 1
if isKeyDown("up"):
dy = dy - 1
if isKeyDown("down"):
dy = dy + 1
if dx != 0 or dy != 0:
facing = (dx, dy)
way = vec2(dx, dy).unit()
knight.move(way.x * SPEED, way.y * SPEED)
if dx != 0:
knight.flipX = dx < 0
if knight.curAnim() != "run":
knight.play("run")
elif knight.curAnim() != "idle":
knight.play("idle")
# Stay on the floor.
knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)
@onKeyPress("space")
def swing():
if get("sword"): # one swing at a time
return
add([
sprite("sword"),
pos(sword_spot()),
anchor("center"),
scale(2.5),
rotate(SWORD_ANGLE[facing]),
area(),
lifespan(SWORD_TIME),
z(11),
"sword",
])
def sword_spot():
way = vec2(facing[0], facing[1]).unit()
return knight.pos + way * SWORD_REACH
@onUpdate("sword")
def follow_knight(sword):
sword.pos = sword_spot()
go("hall")
Press S (or space) and the sword flashes out the way you're facing, in any of the eight directions. Hold the d-pad with one thumb and tap S with the other. The knight keeps walking while it swings.
Which way you face. facing is a pair of
numbers, (dx, dy), saved whenever you walk. It isn't changed
when you stop, so the knight still faces the way it was last going.
Pointing the sword. The sword picture points straight up.
SWORD_ANGLE is a dictionary that turns a direction into how far
to turn the picture: up is 0, right is 90, and so on round the clock.
rotate() turns it. A pair of numbers like (1, 1)
can be a dictionary key because it's a tuple, which can't change.
Where the sword goes. sword_spot() is the
knight's position plus SWORD_REACH pixels the way it faces.
follow_knight puts the sword back there every frame, so it moves
with you. lifespan(SWORD_TIME) destroys it after 0.15 seconds,
and get("sword") stops a second swing until then: an empty list
counts as False.
Break it on purpose. Take global facing out
of walk. There's no error message. But now
facing = (dx, dy) makes a new variable that only lives inside
walk, and the one at the top of the file stays
(0, 1). The sword always points down, whichever way you walk.
Put it back, then delete (1, 1): 135, from
SWORD_ANGLE and swing while walking down and right. The game
stops with KeyError: (1, 1), pointing at
rotate(SWORD_ANGLE[facing]). It means the dictionary has
nothing for (1, 1).
Step 3 — goblins through the doors
from kaypy import *
TILE = 48 # a tile is 48 pixels: the 16-pixel art, times 3
# 12 tiles across. 18 down: 12 for the hall, 6 under it for the controls.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
joystick=True)
IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True}}
loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("goblin", "dungeon/goblin.png", sliceX=8, anims=IDLE_RUN)
loadSprite("sword", "dungeon/weapon_regular_sword.png")
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")
SPEED = 220 # pixels per second
SWORD_REACH = 42 # how far in front of the knight the blade appears
SWORD_TIME = 0.15 # how long a swing lasts, in seconds
KNOCKBACK = 40 # how far a monster is shoved when the sword hits it
GOBLIN_SPEED = 80
SPAWN_GAP = 2 # seconds between goblins
HALL = [
"^D^^^D^^^D^^",
"#dd#BddB#dd#",
"#..,....:..#",
"#.:...,....#",
"#....,...,.#",
"#,.:.......#",
"#.....:..,.#",
"#..,.......#",
"#.....,.:..#",
"#.:.,......#",
"#.......,..#",
"############",
]
TILES = {
"^": lambda: [sprite("wall_top"), scale(3)],
"#": lambda: [sprite("wall"), scale(3)],
"B": lambda: [sprite("banner"), scale(3)],
"D": lambda: [sprite("door"), scale(3)],
".": lambda: [sprite("floor_1"), scale(3)],
",": lambda: [sprite("floor_2"), scale(3)],
":": lambda: [sprite("floor_3"), scale(3)],
}
# The floor, in pixels: everything inside the walls.
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11
# Where the monsters come in: just inside each door in the picture.
DOORS = []
for col in range(len(HALL[0])):
if HALL[0][col] == "D":
DOORS.append(vec2(col * TILE + TILE, TOP + 10))
facing = (0, 1) # which way the knight faces, as (x, y): (0, 1) is down
# The sword picture points up, so up is 0 degrees, and the rest go round
# clockwise.
SWORD_ANGLE = {
(0, -1): 0, (1, -1): 45, (1, 0): 90, (1, 1): 135,
(0, 1): 180, (-1, 1): 225, (-1, 0): 270, (-1, -1): 315,
}
@scene("hall")
def hall():
global facing
facing = (0, 1)
addLevel(HALL, {"tileWidth": TILE, "tileHeight": TILE,
"pos": vec2(0, 0), "tiles": TILES})
knight = add([
sprite("knight", anim="idle"),
pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
anchor("center"),
scale(2.5),
area(),
z(10),
"knight",
])
@onUpdate
def walk():
global facing
dx = 0
dy = 0
if isKeyDown("left"):
dx = dx - 1
if isKeyDown("right"):
dx = dx + 1
if isKeyDown("up"):
dy = dy - 1
if isKeyDown("down"):
dy = dy + 1
if dx != 0 or dy != 0:
facing = (dx, dy)
way = vec2(dx, dy).unit()
knight.move(way.x * SPEED, way.y * SPEED)
if dx != 0:
knight.flipX = dx < 0
if knight.curAnim() != "run":
knight.play("run")
elif knight.curAnim() != "idle":
knight.play("idle")
# Stay on the floor.
knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)
@onKeyPress("space")
def swing():
if get("sword"): # one swing at a time
return
add([
sprite("sword"),
pos(sword_spot()),
anchor("center"),
scale(2.5),
rotate(SWORD_ANGLE[facing]),
area(),
lifespan(SWORD_TIME),
z(11),
"sword",
])
def sword_spot():
way = vec2(facing[0], facing[1]).unit()
return knight.pos + way * SWORD_REACH
@onUpdate("sword")
def follow_knight(sword):
sword.pos = sword_spot()
@onCollide("sword", "enemy")
def sword_hits(sword, enemy):
enemy.hurt(1)
away = (enemy.pos - knight.pos).unit()
enemy.pos = enemy.pos + away * KNOCKBACK
def add_goblin():
door = choose(DOORS)
monster = add([
sprite("goblin", anim="run"),
pos(door.x, door.y),
anchor("center"),
scale(2.5),
area(),
health(1),
z(5),
"enemy",
])
@monster.onUpdate
def chase():
toward = (knight.pos - monster.pos).unit()
monster.move(toward.x * GOBLIN_SPEED, toward.y * GOBLIN_SPEED)
monster.flipX = toward.x < 0
monster.pos.x = clamp(monster.pos.x, LEFT + 16, RIGHT - 16)
monster.pos.y = clamp(monster.pos.y, TOP, BOTTOM - 20)
@monster.onDeath
def beaten():
addKaboom(monster.pos, scale=0.5)
monster.destroy()
loop(SPAWN_GAP, add_goblin)
go("hall")
Every two seconds a goblin comes in through one of the doors and heads straight for you. One hit of the sword and it's gone. They can't hurt you yet. That's step 4.
Where the doors are. The loop near the top looks along
the first row of the picture for each D. A door is two tiles
wide and starts at that column, so its middle is col * TILE +
TILE. Move the Ds in the picture and the goblins come
in through the new doors. Nothing else needs changing.
One every two seconds. loop(SPAWN_GAP,
add_goblin) calls add_goblin every
SPAWN_GAP seconds, forever. choose(DOORS) picks
one of the doors at random.
Chasing. knight.pos - monster.pos is an
arrow from the goblin to the knight. .unit() makes it one long,
the same trick as step 1, so every goblin walks at the same speed however
far away it is. Every goblin gets its own chase, made inside
its own call of add_goblin. That's why monster
inside it always means that goblin, even with ten of them on the
floor.
The hit. onCollide("sword", "enemy") runs
once, at the moment a sword starts touching a monster, so one swing is one
hit. enemy.hurt(1) takes away one hit point. The goblin then
gets shoved KNOCKBACK pixels straight away from the knight.
Break it on purpose. Take health(1), out of
the goblin. The game stops when the first goblin is made, with
AttributeError: GameObj has no attribute 'onDeath' — did you forget a
component that provides it? onDeath, hurt
and hp all come from the health() component.
Without it, the goblin has none of them.
Step 4 — getting hurt
from kaypy import *
TILE = 48 # a tile is 48 pixels: the 16-pixel art, times 3
# 12 tiles across. 18 down: 12 for the hall, 6 under it for the controls.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
joystick=True)
IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True}}
loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("goblin", "dungeon/goblin.png", sliceX=8, anims=IDLE_RUN)
loadSprite("heart_full", "dungeon/ui_heart_full.png")
loadSprite("heart_empty", "dungeon/ui_heart_empty.png")
loadSprite("sword", "dungeon/weapon_regular_sword.png")
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")
SPEED = 220 # pixels per second
HEARTS = 3
HURT_TIME = 1.2 # after a hit, how long before you can be hurt again
SWORD_REACH = 42 # how far in front of the knight the blade appears
SWORD_TIME = 0.15 # how long a swing lasts, in seconds
KNOCKBACK = 40 # how far a monster is shoved when the sword hits it
GOBLIN_SPEED = 80
SPAWN_GAP = 2 # seconds between goblins
HALL = [
"^D^^^D^^^D^^",
"#dd#BddB#dd#",
"#..,....:..#",
"#.:...,....#",
"#....,...,.#",
"#,.:.......#",
"#.....:..,.#",
"#..,.......#",
"#.....,.:..#",
"#.:.,......#",
"#.......,..#",
"############",
]
TILES = {
"^": lambda: [sprite("wall_top"), scale(3)],
"#": lambda: [sprite("wall"), scale(3)],
"B": lambda: [sprite("banner"), scale(3)],
"D": lambda: [sprite("door"), scale(3)],
".": lambda: [sprite("floor_1"), scale(3)],
",": lambda: [sprite("floor_2"), scale(3)],
":": lambda: [sprite("floor_3"), scale(3)],
}
# The floor, in pixels: everything inside the walls.
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11
# Where the monsters come in: just inside each door in the picture.
DOORS = []
for col in range(len(HALL[0])):
if HALL[0][col] == "D":
DOORS.append(vec2(col * TILE + TILE, TOP + 10))
facing = (0, 1) # which way the knight faces, as (x, y): (0, 1) is down
hurt_left = 0 # seconds of safety left after being hit
game_over = False
# The sword picture points up, so up is 0 degrees, and the rest go round
# clockwise.
SWORD_ANGLE = {
(0, -1): 0, (1, -1): 45, (1, 0): 90, (1, 1): 135,
(0, 1): 180, (-1, 1): 225, (-1, 0): 270, (-1, -1): 315,
}
@scene("hall")
def hall():
global facing, hurt_left, game_over
facing = (0, 1)
hurt_left = 0
game_over = False
addLevel(HALL, {"tileWidth": TILE, "tileHeight": TILE,
"pos": vec2(0, 0), "tiles": TILES})
knight = add([
sprite("knight", anim="idle"),
pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
anchor("center"),
scale(2.5),
area(),
opacity(1),
health(HEARTS),
z(10),
"knight",
])
@onUpdate
def walk():
global facing, hurt_left
if game_over:
return
if hurt_left > 0:
hurt_left = hurt_left - dt()
dx = 0
dy = 0
if isKeyDown("left"):
dx = dx - 1
if isKeyDown("right"):
dx = dx + 1
if isKeyDown("up"):
dy = dy - 1
if isKeyDown("down"):
dy = dy + 1
if dx != 0 or dy != 0:
facing = (dx, dy)
way = vec2(dx, dy).unit()
knight.move(way.x * SPEED, way.y * SPEED)
if dx != 0:
knight.flipX = dx < 0
if knight.curAnim() != "run":
knight.play("run")
elif knight.curAnim() != "idle":
knight.play("idle")
# Stay on the floor.
knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)
# Flicker while you are safe after a hit.
if hurt_left > 0:
knight.opacity = wave(0.2, 1, time() * 20)
else:
knight.opacity = 1
@onKeyPress("space")
def swing():
if game_over:
return
if get("sword"): # one swing at a time
return
add([
sprite("sword"),
pos(sword_spot()),
anchor("center"),
scale(2.5),
rotate(SWORD_ANGLE[facing]),
area(),
lifespan(SWORD_TIME),
z(11),
"sword",
])
def sword_spot():
way = vec2(facing[0], facing[1]).unit()
return knight.pos + way * SWORD_REACH
@onUpdate("sword")
def follow_knight(sword):
sword.pos = sword_spot()
@onCollide("sword", "enemy")
def sword_hits(sword, enemy):
enemy.hurt(1)
away = (enemy.pos - knight.pos).unit()
enemy.pos = enemy.pos + away * KNOCKBACK
def add_goblin():
door = choose(DOORS)
monster = add([
sprite("goblin", anim="run"),
pos(door.x, door.y),
anchor("center"),
scale(2.5),
area(),
health(1),
z(5),
"enemy",
])
@monster.onUpdate
def chase():
if game_over:
return
toward = (knight.pos - monster.pos).unit()
monster.move(toward.x * GOBLIN_SPEED, toward.y * GOBLIN_SPEED)
monster.flipX = toward.x < 0
monster.pos.x = clamp(monster.pos.x, LEFT + 16, RIGHT - 16)
monster.pos.y = clamp(monster.pos.y, TOP, BOTTOM - 20)
@monster.onDeath
def beaten():
addKaboom(monster.pos, scale=0.5)
monster.destroy()
loop(SPAWN_GAP, add_goblin)
@knight.onCollideUpdate("enemy")
def touched(enemy):
global hurt_left
if game_over or hurt_left > 0:
return
knight.hurt(1)
hurt_left = HURT_TIME
shake(8)
@knight.onDeath
def knight_died():
global game_over
game_over = True
addKaboom(knight.pos)
knight.destroy()
@say("The hall has fallen.", button="Play again")
def again():
go("hall")
@onDraw
def hearts():
hp = 0
if knight.exists():
hp = knight.hp
i = 0
while i < HEARTS:
if i < hp:
name = "heart_full"
else:
name = "heart_empty"
drawSprite(sprite=name, pos=vec2(width() - 150 + i * 46,
TILE * 12 + 8),
width=39, height=36)
i = i + 1
go("hall")
Now the goblins bite. You have three hearts, drawn under the hall. After a hit the knight flickers for a moment and can't be hurt again until it stops. Lose all three and the hall falls. Press Play again and it starts over.
Every frame, not just once.
knight.onCollideUpdate("enemy") runs every frame that a monster
is touching the knight. onCollide would only run at the moment
they start touching, so a goblin that walked into you while you
were flickering and then stayed there would never hurt you again. Try it:
change it to knight.onCollide("enemy") and stand still. In a
test, the knight standing still lasted almost twice as long.
A countdown. hurt_left is how many seconds
of safety are left. A hit sets it to HURT_TIME, and every frame
walk takes away dt(), how long that frame took.
While it's above zero, touched does nothing, and
wave() swings the knight's opacity up and down to make it
flicker.
Starting over. When the knight's hit points reach zero,
onDeath runs. game_over stops everything moving,
and say shows a message. When it's closed,
go("hall") runs hall() again from the top. That's
why the start of hall() sets every variable back: the ones at
the top of the file aren't run again, so without those lines the new game
would start with the old game's game_over still
True. The old scene's loop stops by itself, so
the goblins don't start coming twice as fast.
The hearts are drawn every frame with
drawSprite, full or empty, under the hall. A heart is a mark on
the screen, not a thing in the hall, so it doesn't need to be a game
object.
Step 5 — waves
from kaypy import *
TILE = 48 # a tile is 48 pixels: the 16-pixel art, times 3
# 12 tiles across. 18 down: 12 for the hall, 6 under it for the controls.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
joystick=True)
IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True}}
loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("goblin", "dungeon/goblin.png", sliceX=8, anims=IDLE_RUN)
loadSprite("imp", "dungeon/imp.png", sliceX=8, anims=IDLE_RUN)
loadSprite("orc", "dungeon/orc_warrior.png", sliceX=8, anims=IDLE_RUN)
loadSprite("heart_full", "dungeon/ui_heart_full.png")
loadSprite("heart_empty", "dungeon/ui_heart_empty.png")
loadSprite("sword", "dungeon/weapon_regular_sword.png")
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")
SPEED = 220 # pixels per second
HEARTS = 3
HURT_TIME = 1.2 # after a hit, how long before you can be hurt again
SWORD_REACH = 42 # how far in front of the knight the blade appears
SWORD_TIME = 0.15 # how long a swing lasts, in seconds
KNOCKBACK = 40 # how far a monster is shoved when the sword hits it
SPAWN_GAP = 0.8 # seconds between monsters coming through a door
WAVE_PAUSE = 2.0 # seconds of quiet between waves
MONSTERS = {
"goblin": {"hp": 1, "speed": 80},
"imp": {"hp": 1, "speed": 140},
"orc": {"hp": 3, "speed": 60},
}
HALL = [
"^D^^^D^^^D^^",
"#dd#BddB#dd#",
"#..,....:..#",
"#.:...,....#",
"#....,...,.#",
"#,.:.......#",
"#.....:..,.#",
"#..,.......#",
"#.....,.:..#",
"#.:.,......#",
"#.......,..#",
"############",
]
TILES = {
"^": lambda: [sprite("wall_top"), scale(3)],
"#": lambda: [sprite("wall"), scale(3)],
"B": lambda: [sprite("banner"), scale(3)],
"D": lambda: [sprite("door"), scale(3)],
".": lambda: [sprite("floor_1"), scale(3)],
",": lambda: [sprite("floor_2"), scale(3)],
":": lambda: [sprite("floor_3"), scale(3)],
}
# The floor, in pixels: everything inside the walls.
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11
# Where the monsters come in: just inside each door in the picture.
DOORS = []
for col in range(len(HALL[0])):
if HALL[0][col] == "D":
DOORS.append(vec2(col * TILE + TILE, TOP + 10))
facing = (0, 1) # which way the knight faces, as (x, y): (0, 1) is down
hurt_left = 0 # seconds of safety left after being hit
wave_number = 0
score = 0
queue = [] # the monsters still to come in this wave
spawn_wait = 0 # seconds until the next one comes through a door
game_over = False
# The sword picture points up, so up is 0 degrees, and the rest go round
# clockwise.
SWORD_ANGLE = {
(0, -1): 0, (1, -1): 45, (1, 0): 90, (1, 1): 135,
(0, 1): 180, (-1, 1): 225, (-1, 0): 270, (-1, -1): 315,
}
@scene("hall")
def hall():
global facing, hurt_left, game_over
global wave_number, score, queue, spawn_wait
facing = (0, 1)
hurt_left = 0
wave_number = 0
score = 0
queue = []
spawn_wait = WAVE_PAUSE
game_over = False
addLevel(HALL, {"tileWidth": TILE, "tileHeight": TILE,
"pos": vec2(0, 0), "tiles": TILES})
knight = add([
sprite("knight", anim="idle"),
pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
anchor("center"),
scale(2.5),
area(),
opacity(1),
health(HEARTS),
z(10),
"knight",
])
@onUpdate
def walk():
global facing, hurt_left
if game_over:
return
if hurt_left > 0:
hurt_left = hurt_left - dt()
dx = 0
dy = 0
if isKeyDown("left"):
dx = dx - 1
if isKeyDown("right"):
dx = dx + 1
if isKeyDown("up"):
dy = dy - 1
if isKeyDown("down"):
dy = dy + 1
if dx != 0 or dy != 0:
facing = (dx, dy)
way = vec2(dx, dy).unit()
knight.move(way.x * SPEED, way.y * SPEED)
if dx != 0:
knight.flipX = dx < 0
if knight.curAnim() != "run":
knight.play("run")
elif knight.curAnim() != "idle":
knight.play("idle")
# Stay on the floor.
knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)
# Flicker while you are safe after a hit.
if hurt_left > 0:
knight.opacity = wave(0.2, 1, time() * 20)
else:
knight.opacity = 1
@onKeyPress("space")
def swing():
if game_over:
return
if get("sword"): # one swing at a time
return
add([
sprite("sword"),
pos(sword_spot()),
anchor("center"),
scale(2.5),
rotate(SWORD_ANGLE[facing]),
area(),
lifespan(SWORD_TIME),
z(11),
"sword",
])
def sword_spot():
way = vec2(facing[0], facing[1]).unit()
return knight.pos + way * SWORD_REACH
@onUpdate("sword")
def follow_knight(sword):
sword.pos = sword_spot()
@onCollide("sword", "enemy")
def sword_hits(sword, enemy):
enemy.hurt(1)
away = (enemy.pos - knight.pos).unit()
enemy.pos = enemy.pos + away * KNOCKBACK
def add_monster(kind):
details = MONSTERS[kind]
door = choose(DOORS)
monster = add([
sprite(kind, anim="run"),
pos(door.x, door.y),
anchor("center"),
scale(2.5),
area(),
health(details["hp"]),
z(5),
"enemy",
])
speed = details["speed"] * rand(0.85, 1.15)
@monster.onUpdate
def chase():
if game_over:
return
toward = (knight.pos - monster.pos).unit()
monster.move(toward.x * speed, toward.y * speed)
monster.flipX = toward.x < 0
monster.pos.x = clamp(monster.pos.x, LEFT + 16, RIGHT - 16)
monster.pos.y = clamp(monster.pos.y, TOP, BOTTOM - 20)
@monster.onDeath
def beaten():
global score
score = score + 1
addKaboom(monster.pos, scale=0.5)
monster.destroy()
def next_wave():
global wave_number, queue
wave_number = wave_number + 1
queue = []
for i in range(wave_number + 2):
queue.append("goblin")
for i in range(wave_number - 1):
queue.append("imp")
for i in range(wave_number // 3):
queue.append("orc")
@onUpdate
def waves():
global spawn_wait
if game_over:
return
spawn_wait = spawn_wait - dt()
if spawn_wait > 0:
return
if len(queue) > 0:
kind = choose(queue)
queue.remove(kind)
add_monster(kind)
spawn_wait = SPAWN_GAP
elif len(get("enemy")) == 0:
next_wave()
spawn_wait = WAVE_PAUSE
@knight.onCollideUpdate("enemy")
def touched(enemy):
global hurt_left
if game_over or hurt_left > 0:
return
knight.hurt(1)
hurt_left = HURT_TIME
shake(8)
@knight.onDeath
def knight_died():
global game_over
game_over = True
addKaboom(knight.pos)
knight.destroy()
@say("The hall has fallen.\n\nYou reached wave "
+ str(wave_number) + " and beat " + str(score) + " monsters.",
button="Play again")
def again():
go("hall")
@onDraw
def panel():
drawText(text="WAVE " + str(wave_number),
pos=vec2(310, TILE * 12 + 10), size=24,
color=(240, 220, 160))
drawText(text="SCORE " + str(score),
pos=vec2(310, TILE * 12 + 54), size=16,
color=(200, 200, 210))
if len(queue) == 0 and len(get("enemy")) == 0 and not game_over:
drawText(text="Wave " + str(wave_number + 1) + " is coming...",
pos=vec2(width() / 2 - 120, TOP + 190), size=24,
color=(255, 255, 255))
hp = 0
if knight.exists():
hp = knight.hp
i = 0
while i < HEARTS:
if i < hp:
name = "heart_full"
else:
name = "heart_empty"
drawSprite(sprite=name, pos=vec2(width() - 150 + i * 46,
TILE * 12 + 8),
width=39, height=36)
i = i + 1
go("hall")
Now the monsters come in waves, and each wave is bigger than the last. Imps arrive from wave 2: they're fast. Orcs arrive from wave 3, one more every three waves: they're slow, but each takes three hits. The wave number and the score are drawn above the buttons.
Three kinds, one dictionary. MONSTERS holds
each kind's hit points and speed. add_monster(kind) looks them
up, and the sprite has the same name as the kind. Each monster also gets a
speed a little above or below the usual (rand(0.85, 1.15)), so
a wave spreads out instead of walking in as one lump.
The wave is a list. next_wave fills
queue with the names of every monster in the wave: wave 1 is
three goblins, wave 2 is four goblins and an imp, wave 3 adds an orc. Every
SPAWN_GAP seconds, waves picks one at random with
choose, takes it out of the list with remove, and
lets it in. When the list is empty and the floor is clear, the next
wave starts after a short pause.
No more loop. A loop goes on forever, and
here the gap depends on what's happening: short between monsters, longer
between waves. So spawn_wait is a countdown, like
hurt_left.
Break it on purpose. Take global spawn_wait
out of waves. The game stops at once, with
UnboundLocalError: cannot access local variable 'spawn_wait' where it
is not associated with a value. Python sees spawn_wait = ... in the function and decides
spawn_wait must be a new variable of its own. Then the right
side asks for its value before it has one. It's the same mistake as in step
2, but this time there's an error message, because here the variable is
read before it's set.
Step 6 — the whole game
This is the game exactly as it is on the games page. It adds:
- The dash, on the second button. For 0.18 seconds the knight shoots forward the way it's facing and can't be hurt, so you can dash through a monster. Then it has to wait a second before it can dash again, and the word DASH goes grey until it can. The dash speed was 700 at first, and in a test a dash through a goblin ended with the knight still on top of it, and it got hurt anyway. At 900 the knight comes out the other side.
- Healing flasks. Now and then a monster leaves one
behind. Walk over it for a heart back (never more than three). It fades
away after eight seconds. Fading needs
opacity(1)on the flask. Without it, kaypy prints
note: lifespan(fade=...) needs an opacity() component to fade — add opacity(1) to this object, or drop the fade argument.
and the flask just vanishes. - Your best score, kept with
setDataand read back withgetData, so it's still there next time. - Sounds for swinging, hitting, dashing, getting hurt, drinking and a new wave.
- Labels for the buttons. kaypy labels each button with the first letter of its key, S and Z. The game writes SWORD and DASH under them, so you know what they're for.
- A message at the start saying how to play.
It also has every number worth changing in one settings block near the top, and comments that explain why each part is there.
"""Knight's Hall -- hold the hall against wave after wave of monsters.
d-pad / arrow keys walk, in eight directions
S button / space swing your sword the way you are facing
Z button / z dash -- you cannot be hurt while you dash
Made for a phone held upright, like a handheld game: the hall at the top,
the d-pad and the two buttons underneath it, where your thumbs are. On a
computer the arrow keys, space and z do exactly the same thing.
HOW THE BUTTONS GET ON THE SCREEN
One word, on the first line of the program:
kaypy(width=576, height=864, joystick=True)
joystick=True draws a d-pad and two buttons, and they PRETEND TO BE KEYS.
Holding the left of the d-pad is the left arrow being held down; the two
buttons are space and z. So nothing else in this file knows about the
buttons at all. It asks isKeyDown("left") and onKeyPress("space"), the same
as any game, and a thumb answers.
HOW THIS FILE IS PUT TOGETHER
1. the window and the pictures
2. the settings
3. the hall, drawn as a picture made of letters
4. what the game has to remember
5. the knight: walking, the sword, the dash, getting hurt
6. the monsters, and the waves they come in
7. the score, the hearts and the button labels
8. start the game
"""
from kaypy import *
# --------------------------------------------------------------- 1. setup
TILE = 48 # a tile is 48 pixels square: the 16-pixel art, times 3
# The hall is 12 tiles square, and the window is taller than that. The
# extra 6 tiles at the bottom are where the d-pad and the buttons go, so a
# thumb on the controls never covers a monster.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
joystick=True)
# Sprites. Every path is written out in full, on purpose: the playground
# reads this file for the names of the pictures it needs, and it only finds
# them if they are plain text.
IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True}}
loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("goblin", "dungeon/goblin.png", sliceX=8, anims=IDLE_RUN)
loadSprite("imp", "dungeon/imp.png", sliceX=8, anims=IDLE_RUN)
loadSprite("orc", "dungeon/orc_warrior.png", sliceX=8, anims=IDLE_RUN)
loadSprite("sword", "dungeon/weapon_regular_sword.png")
loadSprite("flask", "dungeon/flask_red.png")
loadSprite("heart_full", "dungeon/ui_heart_full.png")
loadSprite("heart_empty", "dungeon/ui_heart_empty.png")
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")
loadSound("swing", "sounds/swing.wav")
loadSound("hit", "sounds/thump.wav")
loadSound("dash", "sounds/swoosh.wav")
loadSound("hurt", "sounds/lazer_punch.wav")
loadSound("drink", "sounds/small_beep.wav")
loadSound("wave", "sounds/rising_beep.wav")
loadSound("boom", "sounds/small_boom.wav")
# --------------------------------------------------------- 2. the settings
SPEED = 220 # how fast the knight walks, pixels per second
HEARTS = 3
SWORD_REACH = 42 # how far in front of the knight the blade appears
SWORD_TIME = 0.15 # how long a swing lasts, in seconds
KNOCKBACK = 40 # how far a monster is shoved when the sword hits it
DASH_SPEED = 900
DASH_TIME = 0.18 # how long a dash lasts
DASH_RECHARGE = 1.0 # how long until you can dash again
HURT_TIME = 1.2 # after a hit, how long before you can be hurt again
SPAWN_GAP = 0.8 # seconds between monsters coming through a door
WAVE_PAUSE = 2.0 # seconds of quiet between waves
FLASK_CHANCE = 0.1 # how likely a monster is to drop a healing flask
# The three kinds of monster. Each one is a dictionary, so adding a fourth
# is one more line here (and a loadSprite above).
MONSTERS = {
"goblin": {"hp": 1, "speed": 80},
"imp": {"hp": 1, "speed": 140},
"orc": {"hp": 3, "speed": 60},
}
BEST_KEY = "knights_hall_best"
# ----------------------------------------------------------- 3. the hall
#
# One letter is one tile.
#
# ^ top of the wall # wall B wall with a banner
# D a doorway, two tiles wide and two tall. The 'd's beside and under
# it are the rest of the same door, so they draw nothing.
# . , : three kinds of floor, so it does not look like wallpaper
#
# The monsters come in through the doors, so a door can be moved by moving
# its letters -- nothing in the code knows where they are.
HALL = [
"^D^^^D^^^D^^",
"#dd#BddB#dd#",
"#..,....:..#",
"#.:...,....#",
"#....,...,.#",
"#,.:.......#",
"#.....:..,.#",
"#..,.......#",
"#.....,.:..#",
"#.:.,......#",
"#.......,..#",
"############",
]
# A door is drawn from the top-left of its tile, and the picture is two
# tiles wide and two tall, so it covers three more tiles than its own. Those
# are the 'd's. Put a '#' there instead and that wall gets drawn over a
# corner of the door.
TILES = {
"^": lambda: [sprite("wall_top"), scale(3)],
"#": lambda: [sprite("wall"), scale(3)],
"B": lambda: [sprite("banner"), scale(3)],
"D": lambda: [sprite("door"), scale(3)],
".": lambda: [sprite("floor_1"), scale(3)],
",": lambda: [sprite("floor_2"), scale(3)],
":": lambda: [sprite("floor_3"), scale(3)],
}
# The floor, in pixels: everything inside the walls. Nothing that walks may
# leave it. There are no walls to bump into -- each frame the knight and the
# monsters are simply put back inside this box with clamp().
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11
# Where the monsters appear: just inside each door. Worked out from the
# picture, so they move with the doors.
DOORS = []
for col in range(len(HALL[0])):
if HALL[0][col] == "D":
DOORS.append(vec2(col * TILE + TILE, TOP + 10))
# --------------------------------------------- 4. what the game remembers
#
# These change while you play, so any function that CHANGES one has to say
# `global` first. They all go back to the start in the scene below.
facing = (0, 1) # which way the knight faces, as (x, y): (0, 1) is down
dash_left = 0 # seconds of dash still to go
dash_wait = 0 # seconds until the next dash is allowed
hurt_left = 0 # seconds of safety left after being hit
wave_number = 0 # not called `wave`: that would hide kaypy's wave()
score = 0
queue = [] # the monsters still to come in this wave
spawn_wait = 0 # seconds until the next one comes through a door
game_over = False
best = getData(BEST_KEY, 0)
# The way the sword points for each of the eight ways the knight can face.
# The picture of the sword points straight up, so up is 0 degrees and the
# rest go round clockwise.
SWORD_ANGLE = {
(0, -1): 0, (1, -1): 45, (1, 0): 90, (1, 1): 135,
(0, 1): 180, (-1, 1): 225, (-1, 0): 270, (-1, -1): 315,
}
@scene("hall")
def hall():
global facing, dash_left, dash_wait, hurt_left
global wave_number, score, queue, spawn_wait, game_over
facing = (0, 1)
dash_left = 0
dash_wait = 0
hurt_left = 0
wave_number = 0
score = 0
queue = []
spawn_wait = WAVE_PAUSE
game_over = False
addLevel(HALL, {"tileWidth": TILE, "tileHeight": TILE,
"pos": vec2(0, 0), "tiles": TILES})
# ------------------------------------------------------- 5. the knight
knight = add([
sprite("knight", anim="idle"),
pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
anchor("center"),
scale(2.5),
area(),
opacity(1),
health(HEARTS),
z(10),
"knight",
])
@onUpdate
def walk():
global facing, dash_left, dash_wait, hurt_left
if game_over:
return
# Count the timers down. dt() is how long this frame took, so
# taking it off every frame counts real seconds.
if dash_left > 0:
dash_left = dash_left - dt()
if dash_wait > 0:
dash_wait = dash_wait - dt()
if hurt_left > 0:
hurt_left = hurt_left - dt()
# Which arrows are held. The d-pad holds these too, and a thumb
# between two arrows holds both -- that is a diagonal.
dx = 0
dy = 0
if isKeyDown("left"):
dx = dx - 1
if isKeyDown("right"):
dx = dx + 1
if isKeyDown("up"):
dy = dy - 1
if isKeyDown("down"):
dy = dy + 1
if dash_left > 0:
# A dash goes the way you were facing when it started, fast,
# whatever you do with the d-pad meanwhile.
way = vec2(facing[0], facing[1]).unit()
knight.move(way.x * DASH_SPEED, way.y * DASH_SPEED)
elif dx != 0 or dy != 0:
facing = (dx, dy)
# unit() makes the arrow one long whichever way it points.
# Without it, (1, 1) is about 1.4 long and the knight would
# walk faster diagonally than straight.
way = vec2(dx, dy).unit()
knight.move(way.x * SPEED, way.y * SPEED)
if dx != 0:
knight.flipX = dx < 0
if knight.curAnim() != "run":
knight.play("run")
elif knight.curAnim() != "idle":
knight.play("idle")
# Stay on the floor.
knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)
# See-through while dashing, flickering while safe after a hit, and
# solid the rest of the time.
if dash_left > 0:
knight.opacity = 0.4
elif hurt_left > 0:
knight.opacity = wave(0.2, 1, time() * 20)
else:
knight.opacity = 1
# ---- the sword
@onKeyPress("space")
def swing():
if game_over or isShowing():
return
# One swing at a time. get() gives back a list, and an empty list
# counts as False, so this asks "is a sword out already?"
if get("sword"):
return
add([
sprite("sword"),
pos(sword_spot()),
anchor("center"),
scale(2.5),
rotate(SWORD_ANGLE[facing]),
area(),
lifespan(SWORD_TIME),
z(11),
"sword",
])
play("swing")
def sword_spot():
way = vec2(facing[0], facing[1]).unit()
return knight.pos + way * SWORD_REACH
@onUpdate("sword")
def follow_knight(sword):
# The sword stays in the knight's hand as the knight moves.
sword.pos = sword_spot()
@onCollide("sword", "enemy")
def sword_hits(sword, enemy):
enemy.hurt(1)
play("hit")
# Shove it away from the knight, so one monster cannot stand on you.
away = (enemy.pos - knight.pos).unit()
enemy.pos = enemy.pos + away * KNOCKBACK
# ---- the dash
@onKeyPress("z")
def dash():
global dash_left, dash_wait
if game_over or isShowing():
return
if dash_wait > 0:
return
dash_left = DASH_TIME
dash_wait = DASH_RECHARGE
play("dash")
# ---- getting hurt
# onCollideUpdate, not onCollide: onCollide only happens at the moment
# two things START touching. A goblin that walks into you while you are
# safe after a hit, and then just stays there, would never hurt you
# again. This one asks every frame they are touching.
@knight.onCollideUpdate("enemy")
def touched(enemy):
global hurt_left
if game_over or dash_left > 0 or hurt_left > 0:
return
knight.hurt(1)
hurt_left = HURT_TIME
shake(8)
play("hurt")
@knight.onDeath
def knight_died():
global game_over, best
game_over = True
play("boom")
addKaboom(knight.pos)
knight.destroy()
if score > best:
best = score
setData(BEST_KEY, best)
@say("The hall has fallen.\n\nYou reached wave "
+ str(wave_number) + " and beat " + str(score) + " monsters.",
button="Play again")
def again():
go("hall")
@knight.onCollide("flask")
def drink(flask):
flask.destroy()
knight.heal(1)
play("drink")
# ------------------------------------------------------ 6. the monsters
def add_monster(kind):
details = MONSTERS[kind]
door = choose(DOORS)
monster = add([
sprite(kind, anim="run"),
pos(door.x, door.y),
anchor("center"),
scale(2.5),
area(),
health(details["hp"]),
z(5),
"enemy",
])
# Not quite all the same speed, so they spread out instead of
# walking in one lump.
speed = details["speed"] * rand(0.85, 1.15)
# Every monster gets its own chase(), made fresh inside this call of
# add_monster(). So `monster` and `speed` in it mean THIS monster and
# THIS speed, even when there are ten of them.
@monster.onUpdate
def chase():
if game_over:
return
toward = (knight.pos - monster.pos).unit()
monster.move(toward.x * speed, toward.y * speed)
monster.flipX = toward.x < 0
monster.pos.x = clamp(monster.pos.x, LEFT + 16, RIGHT - 16)
monster.pos.y = clamp(monster.pos.y, TOP, BOTTOM - 20)
@monster.onDeath
def beaten():
global score
score = score + 1
addKaboom(monster.pos, scale=0.5)
if chance(FLASK_CHANCE):
# opacity(1) is what lets lifespan() fade it out. Without
# it kaypy prints a note and the flask just vanishes.
add([sprite("flask"), pos(monster.pos), anchor("center"),
scale(2.5), area(), opacity(1), lifespan(8, fade=1),
z(4), "flask"])
monster.destroy()
def next_wave():
global wave_number, queue
wave_number = wave_number + 1
# More goblins every wave, imps from wave 2, an orc every third.
queue = []
for i in range(wave_number + 2):
queue.append("goblin")
for i in range(wave_number - 1):
queue.append("imp")
for i in range(wave_number // 3):
queue.append("orc")
play("wave")
@onUpdate
def waves():
global spawn_wait
if game_over:
return
spawn_wait = spawn_wait - dt()
if spawn_wait > 0:
return
if len(queue) > 0:
# Let the next one in, chosen at random from those left.
kind = choose(queue)
queue.remove(kind)
add_monster(kind)
spawn_wait = SPAWN_GAP
elif len(get("enemy")) == 0:
# Everyone beaten and nobody waiting: the next wave.
next_wave()
spawn_wait = WAVE_PAUSE
# ----------------------------------------- 7. the score, hearts, labels
#
# The bottom of the window, under the hall, is the control panel. The
# d-pad and buttons are drawn there by kaypy, on top of everything; all
# this draws is the writing around them.
PANEL = TILE * 12
@onDraw
def panel():
# Everything is on the right, above the buttons. The d-pad takes up
# the whole of the left.
drawText(text="WAVE " + str(wave_number), pos=vec2(310, PANEL + 10),
size=24, color=(240, 220, 160))
drawText(text="SCORE " + str(score) + " BEST " + str(best),
pos=vec2(310, PANEL + 54), size=16, color=(200, 200, 210))
# A full heart for every hit you can still take.
hp = 0
if knight.exists():
hp = knight.hp
i = 0
while i < HEARTS:
if i < hp:
name = "heart_full"
else:
name = "heart_empty"
drawSprite(sprite=name, pos=vec2(width() - 150 + i * 46,
PANEL + 8),
width=39, height=36)
i = i + 1
# What the two buttons do. kaypy labels them with their keys, S and
# Z, so the game says what they are FOR.
drawText(text="SWORD", pos=vec2(width() - 130, PANEL + 242),
size=16, color=(200, 200, 210))
if dash_wait > 0:
dash_colour = (110, 110, 120)
else:
dash_colour = (200, 200, 210)
drawText(text="DASH", pos=vec2(width() - 243, PANEL + 186),
size=16, color=dash_colour)
# A note about the next wave, while it is waiting to start.
if len(queue) == 0 and len(get("enemy")) == 0 and not game_over:
drawText(text="Wave " + str(wave_number + 1) + " is coming...",
pos=vec2(width() / 2 - 120, TOP + 190), size=24,
color=(255, 255, 255))
# ------------------------------------------------------- 8. start the game
go("hall")
say("Monsters are coming through the doors.\n\n"
"Walk with the d-pad. S swings your sword the way you are facing. "
"Z dashes, and you can't be hurt while you dash.\n\n"
"On a computer: arrow keys, space and z.", button="Play")
Make it yours
Open the whole game in the playground → It goes straight into the editor, ready to change and run.
- A new monster. Add a line to
MONSTERS, say"skelet": {"hp": 2, "speed": 100},, load its picture withloadSprite("skelet", "dungeon/skelet.png", sliceX=8, anims=IDLE_RUN), and put some in the wave innext_wave. Forget theloadSpriteand the first skeleton stops the game with
KeyError: 'no sprite called \'skelet\' — loadSprite("skelet", ...) must run before it is used' - A different hero. The elves, wizards, dwarves and the
other knight all have the same nine frames as the knight, so changing
"dungeon/knight_m.png"to"dungeon/elf_f.png"is all it takes. - A different button.
joystick=["space", "x"]makes the second button sendxinstead ofz, and it says X. ChangeonKeyPress("z")toonKeyPress("x")as well, or the button does nothing. - Move the doors. Move the
Ds andds inHALL. The monsters follow. - Harder or easier.
HEARTS,HURT_TIME,SPAWN_GAPand the speeds inMONSTERSare where to start.