Lesson 7 — Decorators
By the end of this you can: read the @ lines that every kaypy game is
built out of, write your own, tell the two shapes apart, and recognise the
three ways they go wrong without saying anything.
This is the last piece of Python you need before the games in the tutorials read as ordinary code rather than magic.
The line you keep writing
Lesson 6 ended with this pattern: define a function, then hand it to the engine by name.
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
def announce():
print("Tick")
loop(1.0, announce)
It works, and the same shape covers everything a game reacts to:
onKeyPress("space", jump)
onUpdate(move_everything)
onCollide("bullet", "enemy", explode)
There is one thing wrong with it, and it is not the typing. It is that the
function and the fact that it is a handler are in two different places.
You read def announce():, get to the bottom of it, and only then find out
what calls it. In a game with fifteen handlers, the list of hand-overs at the
end is a table of contents nobody keeps in order.
The same thing, written above it
An @ line moves the hand-over to the top:
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
@loop(1.0)
def announce():
print("Tick")
That is a decorator, and it means exactly what the previous version meant. Python reads it in this order:
- Define the function
announce, the normal way. - Hand it to
loop(1.0). - Leave the name
announcepointing at your function.
So the whole of the @ syntax is: define this, then give it to that. The
work still happens when the engine decides — a second from now, when a key
goes down, when two things touch — not when Python reads the line.
Step 3 is worth saying out loud, because people assume the function has been swallowed. It has not:
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
@onKeyPress("space")
def jump():
print("Jumped!")
print("jump is still called", jump.__name__)
jump()
jump is still called jump
Jumped!
It is registered with the engine and it is still an ordinary function you can call yourself. That matters when you want the same thing to happen for two reasons — a key press and a collision — without writing it twice.
Two shapes
Some decorators have brackets and some do not, and the difference is not style:
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
@onUpdate
def every_frame():
pass
@onKeyPress("space")
def on_space():
pass
@loop(0.5)
def twice_a_second():
pass
print("three handlers registered")
three handlers registered
The rule: brackets when it needs to know something else.
@onUpdateneeds nothing. Every frame is every frame, so there is nothing to tell it and no brackets.@onKeyPress("space")needs the key.@loop(0.5)needs the interval.@onCollide("player", "spike")needs both tags.
Read @onKeyPress("space") as two steps: onKeyPress("space") runs first and
produces something that is waiting for a function, and the @ hands your
function to it. That is why the brackets are there — they are an ordinary
call, made before your function exists.
@onUpdate also takes a tag, and then it has brackets like the rest:
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
add([rect(30, 30), pos(100, 100), color(200, 60, 60), "enemy"])
add([rect(30, 30), pos(200, 100), color(200, 60, 60), "enemy"])
@onUpdate("enemy")
def drift(enemy):
enemy.pos.x = enemy.pos.x + 60 * dt()
@onUpdate("enemy") runs once per frame for each tagged object, and hands
you the object it is talking about. Two enemies means drift is called twice
a frame, with a different enemy each time. The "enemy" in the component
list is what puts an object in that group — a plain string among the
components is a tag.
Forgetting the brackets
This is the mistake, and it is a bad one because nothing complains:
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
@onKeyPress
def jump():
print("Jumped!")
print("jump is now called", jump.__name__)
jump is now called decorator
No error. The program runs. Space does nothing, forever.
Here is what happened. onKeyPress expects the key first, so it took your
function as the key — and then, having been given no function, it did what it
does when it is used with brackets: it handed back the thing that waits for
one. That thing is now sitting under the name jump, and your function is
gone. Nothing was ever registered, so nothing was ever going to fire.
print(jump.__name__) is the quickest way to catch it. If it says anything
other than the name you wrote, the decorator above it is the wrong shape.
When a handler does nothing at all, check this line first. The instinct is to suspect the collision, the key name, the sprite — all of which are harder to check and none of which will be the problem when the
@line is missing its brackets.
What the engine hands you
Each kind of handler is called with different things, and the tutorials' examples are the quickest reference:
@onUpdate,@onKeyPress("x"),@loop(1)— nothing.@onUpdate("enemy")— the tagged object.@onCollide("bullet", "enemy")— both objects, in the order the tags were named.@player.onCollide("coin")— the other object, since you already have the player.
kaypy is relaxed about this in one direction: your function may take fewer parameters than it is offered, and the extras are dropped. Both of these are fine, and you write the second when you do not care which coin it was:
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
loadSprite("coin", "images/coin.png")
player = add([rect(30, 30), pos(100, 100), area()])
add([sprite("coin"), pos(100, 100), area(), "coin"])
coins = 0
@player.onCollide("coin")
def picked_up(coin):
global coins
coins = coins + 1
destroy(coin)
@player.onCollide("coin")
def play_a_noise():
print("ding")
ding
Taking more than it is offered is not forgiven — that is a TypeError
naming the parameter nothing was supplied for, which is at least loud.
And note what the first one needed: global coins, from
lesson 6. Every decorated handler is a function, so every
decorated handler that changes a score needs it.
Decorating something that belongs to an object
@player.onCollide("coin") is worth a second look, because it is a decorator
whose name has a dot in it:
@player.onCollide("coin")
def picked_up(coin):
...
Nothing new is going on. The @ line is still just an expression that
produces something waiting for a function — this time it comes from an object
you made rather than from the engine as a whole. The difference in behaviour
is scope: onCollide("player", "coin") watches every player-tagged object,
while player.onCollide("coin") watches that one.
Use the object form when you are holding the object, which is most of the time. Use the tag form for pairs that come and go while the game runs — bullets and enemies, where neither one is a variable you kept.
Two handlers, one name
One more thing that behaves unlike you would guess:
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
log = []
@loop(0.01)
def tick():
log.append("first")
@loop(0.01)
def tick():
log.append("second")
@onUpdate
def check():
if len(log) >= 2:
print(sorted(set(log)))
quit()
['first', 'second']
Both of them run. Re-using the name did not replace the first handler,
because the engine was handed the function itself and does not care what it
was called — the name is only how you refer to it. So the second def
rebinds the name and leaves the first function registered and running, with
nothing left to call it by.
That is a nuisance when it is an accident, which is usually: two @onUpdate
handlers both called update, and a bug that behaves like the old code never
went away. Give every handler a name that says what it does and the problem
disappears.
A game is a stack of these
Put together, this is what a whole small game looks like — and by now there is nothing in it you have not met:
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
score = 0
label = add([text("Score: 0", size=28), pos(20, 20)])
player = add([sprite("bean"), pos(400, 300), area()])
def add_point():
global score
score = score + 1
label.text = "Score: " + str(score)
@onKeyDown("right")
def go_right():
player.pos.x = player.pos.x + 240 * dt()
@onKeyDown("left")
def go_left():
player.pos.x = player.pos.x - 240 * dt()
@loop(1.5)
def drop_a_coin():
add([sprite("coin"), pos(rand(40, 760), rand(40, 560)), area(), "coin"])
@player.onCollide("coin")
def collect(coin):
destroy(coin)
add_point()
Four handlers and one ordinary function. The @ lines say when; the
functions under them say what; add_point is shared by anything that ever
needs to score, and is called by name because nothing in the engine calls it.
That is the whole pattern. Every game in the games section is this, with more of it.
Watch for
@ means "define this, then hand it over". The function does not run
when Python reads it.
Brackets when the decorator needs to know something. @onUpdate bare,
@onKeyPress("space") with.
A missing pair of brackets fails silently. print(your_handler.__name__)
tells you within seconds.
Handlers are functions, so changing a score in one needs global.
Two handlers with the same name both run. Name them for what they do.
Your function may take fewer parameters than it is offered, never more.
Classwork 1 — Rewrite It With @
This program is written entirely in the hand-over style. Run it first, so you know what it does:
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
loadSprite("bean", "images/bean.png")
player = add([sprite("bean"), pos(400, 300), area()])
def go_up():
player.pos.y = player.pos.y - 200 * dt()
def go_down():
player.pos.y = player.pos.y + 200 * dt()
def spin():
print("spin")
onKeyDown("up", go_up)
onKeyDown("down", go_down)
loop(2.0, spin)
- Rewrite all three as decorators. Delete the three hand-over lines at the bottom. Run it — it should behave identically.
- Add left and right the same way.
- Add an
@onKeyPress("space")that prints the player's position. Notice thatonKeyDownfires every frame the key is held andonKeyPressfires once — hold both down and watch the difference in the output pane. - Deliberately break one: take the brackets off
@onKeyPress("space")and run it again. Then print its__name__and confirm what it says.
Stretch: make one function handle both up and down by giving it a parameter, then register it twice — once by hand for each key, since a decorator cannot pass an argument of your choosing. Which reads better?
Classwork 2 — The Collector
Build the small game from the section above, one handler at a time. Start here:
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
score = 0
label = add([text("Score: 0", size=28), pos(20, 20)])
player = add([sprite("bean"), pos(400, 300), area()])
- Add left and right movement with two
@onKeyDownhandlers. - Add an
add_point()function — a plain one, no decorator — that raises the score and updates the label. It needsglobal. - Drop a coin every second and a half with
@loop(1.5), atpos(rand(40, 760), rand(40, 560)), tagged"coin". - Collect them with
@player.onCollide("coin"):destroythe coin and calladd_point(). - Add a timer. A
seconds_leftvariable, a second label, and an@loop(1.0)that counts it down — and when it reaches 0, stops dropping coins. (Making the drop stop is the interesting bit: a flag the drop handler checks with anifis the simplest way.)
Stretch: make coins worth more when there are fewer of them. get("coin")
hands back a list of every object with that tag, so len(get("coin")) is how
many are on screen — lesson 5, doing real work.
Check yourself
Run this and press space. What happens, and why?
from kaypy import *
kaypy(width=800, height=600, background=[20, 24, 36])
score = 0
@onKeyPress("space")
def add_point():
score = score + 1
print("Score:", score)
It crashes — and the traceback appears a long way from the mistake
Pressing space raises UnboundLocalError: local variable 'score' referenced
before assignment, because score = score + 1 assigns to score, which
makes it local for the whole function, which means the score on the
right-hand side is the local one — and nothing has ever been put in it.
global score at the top of the function is the fix.
The part worth noticing is when it happens. The program starts fine. The window opens, the handler registers, everything looks right — and the error arrives whenever somebody first presses space, which might be a minute later or in front of a class. A decorated function is code you have written and not yet run.
That is the real cost of the @ style, and the reason a game's handlers are
worth testing one at a time as you add them rather than all at the end.
← Lesson 6 — Functions · All nine lessons · Lesson 8 — Dictionaries →