Skip to content

Instantly share code, notes, and snippets.

@extratone
Last active March 23, 2023 04:02
Show Gist options
  • Save extratone/8799e6f1f1a101cf72d25d89638448b7 to your computer and use it in GitHub Desktop.
Save extratone/8799e6f1f1a101cf72d25d89638448b7 to your computer and use it in GitHub Desktop.
# coding: utf-8
'''
Part 1 -- Getting Started 🏁
Welcome to the game tutorial for Pythonista!
We're going to build a simple, motion-controlled game in easily-digestible steps. Each part of the tutorial builds upon the previous one, so be sure to read/try them in order.
In each step, we'll add new features to the game, introducing concepts of the `scene` module and some useful features of Pythonista IDE the along the way. You should have some experience with Python already, but you don't need to be an expert.
In the final game, you'll be able to control a little alien by tilting your device, while coins (good) and meteors (bad) rain from the sky...
So let's get started. We first need a subclass of `Scene` -- it's simply called `Game` here. This basically defines everything that happens on the screen -- the objects that are drawn, how touches are handled, etc.
The `Scene` class defines several methods that are intended to be overridden to customize its behavior. One of them is `setup`, which is called automatically just before the scene becomes visible on screen. This is a good place to set up the initial state of the game.
All visible objects in a game are represented by `Node`s, most often `SpriteNode`, which basically render an image. Nodes have a position, rotation, etc., and they can have "children", so you can modify a whole group of nodes as one.
We'll start by adding two nodes to the scene -- one representing the player (a green alien), and one for the ground he or she stands on. The ground is made of multiple tiles, but they're all added to one group `Node`, in case we want to easily move the entire ground elsewhere, without having to worry about the individual tiles.
The comments in the code below explain more details. Feel free to make your own changes, experiment with the values, and see what happens when you tap the run button (▶️).
Open the next part to learn how to make things move.
'''
from scene import *
class Game (Scene):
def setup(self):
# First, set a background color for the entire scene.
# Tip: When you put the cursor inside the hex string, you can see a preview of the color directly in the editor. You can also tap the color swatch to select a new color visually.
self.background_color = '#004f82'
# Then create the ground node...
# Usually, nodes are added to their parent using the `add_child()` method, but for convenience, you can also pass the node's parent as a keyword argument for the same effect.
ground = Node(parent=self)
x = 0
# Increment x until we've reached the right edge of the screen...
while x <= self.size.w + 64:
tile = SpriteNode('plf:Ground_PlanetHalf_mid', position=(x, 0))
ground.add_child(tile)
# Each tile is 64 points wide.
x += 64
# Now create the player.
# A SpriteNode can be initialized from a `Texture` object or simply the name of a built-in image, which we're using here.
# Tip: When you put the cursor inside the name, you can see a small preview of the image -- you can also tap the preview image to select a different one.
self.player = SpriteNode('plf:AlienGreen_front')
# The `anchor_point` of a `SpriteNode` defines how its position/rotation is interpreted. By default, the position corresponds to the center of the sprite, but in this case, it's more convenient if the y coordinate corresponds to the bottom (feet) of the alien, so we can position it flush with the ground. The anchor point uses unit coordinates -- (0, 0) is the bottom-left corner, (1, 1) the top-right.
self.player.anchor_point = (0.5, 0)
# To center the player horizontally, we simply divide the width of the scene by two:
self.player.position = (self.size.w/2, 32)
self.add_child(self.player)
if __name__ == '__main__':
# To actually get the scene on screen, you have to create an instance of your subclass (`Game`), and pass it to the `run()` function.
# The `run()` function accepts a couple of different configuration arguments. In this case, we want the game to always run in portrait orientation, and we want to display a framerate counter. This can be useful during development -- if the framerate (fps) drops below 60 for an extended period of time, you'll probably want to start looking into performance optimizations.
run(Game(), PORTRAIT, show_fps=True)
# coding: utf-8
'''
Part 2 -- Motion Control 👋
The scene we created in the previous part already started to look like the static screenshot of a game, but nothing was moving! So let's change that.
After implementing the `setup()` method, we now override a second one: `update()`. Unlike `setup()`, which gets called exactly once, this gets called *very* often, usually 60 times per second. This means that you have to be somewhat careful not to do too much in there. If the method takes longer than about the 60th of a second (~0.0167) to execute, you'll see stuttery animations.
To move the player left and right, we're going to use the device's accelerometer.
To jump straight to the `update` method, and see how this is done, tap the filename in the toolbar at the top. You'll get a list of all classes and methods that you can use to navigate quickly in long programs.
'''
from scene import *
class Game (Scene):
def setup(self):
self.background_color = '#004f82'
ground = Node(parent=self)
x = 0
while x <= self.size.w + 64:
tile = SpriteNode('plf:Ground_PlanetHalf_mid', position=(x, 0))
ground.add_child(tile)
x += 64
self.player = SpriteNode('plf:AlienGreen_front')
self.player.anchor_point = (0.5, 0)
self.player.position = (self.size.w/2, 32)
self.add_child(self.player)
def update(self):
# The gravity() function returns an (x, y, z) vector that describes the current orientation of your device. We only use the x component here, to steer left and right.
g = gravity()
if abs(g.x) > 0.05:
x = self.player.position.x
# The components of the gravity vector are in the range 0.0 to 1.0, so we have to multiply it with some factor to move the player more quickly. 40 works pretty well, but feel free to experiment.
max_speed = 40
# We simply add the x component of the gravity vector to the current position, and clamp the value between 0 and the scene's width, so the alien doesn't move outside of the screen boundaries.
x = max(0, min(self.size.w, x + g.x * max_speed))
self.player.position = (x, 32)
if __name__ == '__main__':
run(Game(), PORTRAIT, show_fps=True)
# coding: utf-8
'''
Part 3 -- Walk Cycle and Sound Effects 🏃
In the previous part, our little alien moved left and right, but it all looked a bit unnatural. It might be different for aliens, but we usually don't just slide on the ground without moving our feet, while always looking in the same direction...
We're going to add a very simple walk cycle animation in this part -- and while we're at it, some footstep sounds too.
The changes in this part are numbered. You can navigate between them using the popup menu that appears when you tap the filename.
'''
from scene import *
import sound
def cmp(a, b):
return ((a > b) - (a < b))
# ---[1]
# We need a few different textures for the player now, so it's best to load them just once. The walk cycle consists of two textures, and the previous texture is used when the alien is standing still.
standing_texture = Texture('plf:AlienGreen_front')
walk_textures = [Texture('plf:AlienGreen_walk1'), Texture('plf:AlienGreen_walk2')]
class Game (Scene):
def setup(self):
self.background_color = '#004f82'
ground = Node(parent=self)
x = 0
while x <= self.size.w + 64:
tile = SpriteNode('plf:Ground_PlanetHalf_mid', position=(x, 0))
ground.add_child(tile)
x += 64
self.player = SpriteNode(standing_texture)
self.player.anchor_point = (0.5, 0)
self.player.position = (self.size.w/2, 32)
self.add_child(self.player)
# ---[2]
# This attribute simply keeps track of the current step in the walk cycle. When the alien is standing still, it's set to -1, otherwise it changes between 0 and 1, corresponding to an index into the `walk_textures` list.
self.walk_step = -1
def update(self):
g = gravity()
if abs(g.x) > 0.05:
#---[3]
# The alien should look in the direction it's walking. By simply setting the `x_scale` attribute to -1 (when moving left) or 1 (when moving right), we only need one image for both directions. Setting the `x_scale` to a negative value has the effect of flipping the image horizontally.
self.player.x_scale = cmp(g.x, 0)
x = self.player.position.x
max_speed = 40
x = max(0, min(self.size.w, x + g.x * max_speed))
self.player.position = x, 32
# ---[4]
# The current step in the walk cycle is simply derived from the current position. Every 40 points, the step changes from 0 to 1, and back again.
step = int(self.player.position.x / 40) % 2
if step != self.walk_step:
# If the step has just changed, switch to a different texture...
self.player.texture = walk_textures[step]
# ...and play a 'footstep' sound effect.
# The sound effect is always the same, but the pitch is modified by the current step, so it sounds like the two feet are making slightly different noises. The second parameter is the volume of the effect, which is set to a pretty low value here, so that the footsteps aren't too loud.
sound.play_effect('rpg:Footstep00', 0.05, 1.0 + 0.5 * step)
self.walk_step = step
else:
# If the alien is standing still (the device's tilt is below a certain threshold), use the 'standing' texture, and reset the walk cycle:
self.player.texture = standing_texture
self.walk_step = -1
if __name__ == '__main__':
run(Game(), PORTRAIT, show_fps=True)
# coding: utf-8
'''
Part 4 -- Coins! 💰
Now that our alien can run around, let's add a bit of an incentive to do so... What could be a better motivation than gold raining from the sky?
As before, you'll find detailed explanations about the changes in this part directly in the code. Use the popup menu to navigate to the numbered sections.
'''
from scene import *
import sound
import random
A = Action
def cmp(a, b):
return ((a > b) - (a < b))
standing_texture = Texture('plf:AlienGreen_front')
walk_textures = [Texture('plf:AlienGreen_walk1'), Texture('plf:AlienGreen_walk2')]
# ---[1]
# To represent the coins, we use a subclass of SpriteNode.
# This is not absolutely necessary (you could use plain SpriteNode objects as well), but it can be helpful if you have different kinds of items with unique attributes. For example, we might want to use different point values for different coins later.
class Coin (SpriteNode):
def __init__(self, **kwargs):
# Each coin uses the same built-in image/texture. The keyword arguments are simply passed on to the superclass's initializer, so that it's possible to pass a position, parent node etc. directly when initializing a Coin.
SpriteNode.__init__(self, 'plf:Item_CoinGold', **kwargs)
class Game (Scene):
def setup(self):
self.background_color = '#004f82'
self.ground = Node(parent=self)
x = 0
while x <= self.size.w + 64:
tile = SpriteNode('plf:Ground_PlanetHalf_mid', position=(x, 0))
self.ground.add_child(tile)
x += 64
self.player = SpriteNode(standing_texture)
self.player.anchor_point = (0.5, 0)
self.player.position = (self.size.w/2, 32)
self.add_child(self.player)
self.walk_step = -1
# ---[2]
# We need a new attribute to keep track of the coins that are in the game, so that we can check if any of them collides with the player:
self.items = []
def update(self):
# ---[3]
# To clean up the code a little, the player movement logic is now extracted to its own method.
self.update_player()
# This method checks for collisions between the player and all the coins:
self.check_item_collisions()
# In every frame, there's a 5% chance that a coin will appear at the top of the screen. Because `update()` is called 60 times per second, this corresponds to about 3 coins per second.
if random.random() < 0.05:
self.spawn_item()
def update_player(self):
g = gravity()
if abs(g.x) > 0.05:
self.player.x_scale = cmp(g.x, 0)
x = self.player.position.x
max_speed = 40
x = max(0, min(self.size.w, x + g.x * max_speed))
self.player.position = x, 32
step = int(self.player.position.x / 40) % 2
if step != self.walk_step:
self.player.texture = walk_textures[step]
sound.play_effect('rpg:Footstep00', 0.05, 1.0 + 0.5 * step)
self.walk_step = step
else:
self.player.texture = standing_texture
self.walk_step = -1
def check_item_collisions(self):
# ---[4]
# To check if the player has collected a coin, we simply check for intersections between each of the coins' frames, and the player's hitbox. The hitbox is a bit smaller than the actual player sprite because some of its image is transparent.
player_hitbox = Rect(self.player.position.x - 20, 32, 40, 65)
# Note: We iterate over a copy of the items list (created using list(...)), so that we can remove items from it while iterating.
for item in list(self.items):
if item.frame.intersects(player_hitbox):
self.collect_item(item)
# When a coin has finished its animation, it is automatically removed from the scene by its Action sequence. When that's the case, also remove it from the `items` list, so it isn't checked for collisions anymore:
elif not item.parent:
self.items.remove(item)
def spawn_item(self):
# ---[5]
# This gets called at random intervals from `update()` to create a new coin at the top of the screen.
coin = Coin(parent=self)
coin.position = (random.uniform(20, self.size.w-20), self.size.h + 30)
# The coin's fall duration is randomly chosen somewhere between 2 and 4 seconds:
d = random.uniform(2.0, 4.0)
# To let the coin fall down, we use an `Action`.
# Actions allow you to animate things without having to keep track of every frame of the animation yourself (as we did with the walking animation). Actions can be combined to groups and sequences. In this case, we create a sequence of moving the coin down, and then removing it from the scene.
actions = [A.move_by(0, -(self.size.h + 60), d), A.remove()]
coin.run_action(A.sequence(actions))
# Also add the coin to the `items` list (used for checking collisions):
self.items.append(coin)
def collect_item(self, item):
sound.play_effect('digital:PowerUp7')
item.remove_from_parent()
self.items.remove(item)
if __name__ == '__main__':
run(Game(), PORTRAIT, show_fps=True)
# coding: utf-8
'''
Part 5 -- Scores and Labels 💯
This part will be very simple, so you can relax a little -- you've come a long way already!
To show how many coins the player has collected, we're going to add a label that shows the current score. To use text in a scene, you can use a `LabelNode`, which is a simple subclass of `SpriteNode` that renders a string instead of an image.
'''
from scene import *
import sound
import random
A = Action
def cmp(a, b):
return ((a > b) - (a < b))
standing_texture = Texture('plf:AlienGreen_front')
walk_textures = [Texture('plf:AlienGreen_walk1'), Texture('plf:AlienGreen_walk2')]
class Coin (SpriteNode):
def __init__(self, **kwargs):
SpriteNode.__init__(self, 'plf:Item_CoinGold', **kwargs)
class Game (Scene):
def setup(self):
self.background_color = '#004f82'
self.ground = Node(parent=self)
x = 0
while x <= self.size.w + 64:
tile = SpriteNode('plf:Ground_PlanetHalf_mid', position=(x, 0))
self.ground.add_child(tile)
x += 64
self.player = SpriteNode(standing_texture)
self.player.anchor_point = (0.5, 0)
self.player.position = (self.size.w/2, 32)
self.add_child(self.player)
# ---[1]
# The font of a `LabelNode` is set using a tuple of font name and size.
score_font = ('Futura', 40)
self.score_label = LabelNode('0', score_font, parent=self)
# The label is centered horizontally near the top of the screen:
self.score_label.position = (self.size.w/2, self.size.h - 70)
# The score should appear on top of everything else, so we set the `z_position` attribute here. The default `z_position` is 0.0, so using 1.0 is enough to make it appear on top of the other objects.
self.score_label.z_position = 1
self.score = 0
self.walk_step = -1
self.items = []
def update(self):
self.update_player()
self.check_item_collisions()
if random.random() < 0.05:
self.spawn_item()
def update_player(self):
g = gravity()
if abs(g.x) > 0.05:
self.player.x_scale = cmp(g.x, 0)
x = self.player.position.x
max_speed = 40
x = max(0, min(self.size.w, x + g.x * max_speed))
self.player.position = x, 32
step = int(self.player.position.x / 40) % 2
if step != self.walk_step:
self.player.texture = walk_textures[step]
sound.play_effect('rpg:Footstep00', 0.05, 1.0 + 0.5 * step)
self.walk_step = step
else:
self.player.texture = standing_texture
self.walk_step = -1
def check_item_collisions(self):
player_hitbox = Rect(self.player.position.x - 20, 32, 40, 65)
for item in list(self.items):
if item.frame.intersects(player_hitbox):
self.collect_item(item)
elif not item.parent:
self.items.remove(item)
def spawn_item(self):
coin = Coin(parent=self)
coin.position = (random.uniform(20, self.size.w-20), self.size.h + 30)
d = random.uniform(2.0, 4.0)
actions = [A.move_by(0, -(self.size.h + 60), d), A.remove()]
coin.run_action(A.sequence(actions))
self.items.append(coin)
def collect_item(self, item, value=10):
sound.play_effect('digital:PowerUp7')
item.remove_from_parent()
self.items.remove(item)
# ---[2]
# Simply add 10 points to the score for every coin, then update the score label accordingly:
self.score += value
self.score_label.text = str(self.score)
if __name__ == '__main__':
run(Game(), PORTRAIT, show_fps=True)
# coding: utf-8
'''
Part 6 -- Meteors Incoming! ☄️
Collecting coins is fun, but did you notice the distinct lack of... challenge?
Let's change that now, and add some meteors to the mix. The mechanism is essentially the same as with the coins, but when the alien collides with a meteor, the game is over.
To make the game a bit harder, the speed at which coins and meteors fall to the ground now increases slightly over time.
'''
from scene import *
import sound
import random
A = Action
def cmp(a, b):
return ((a > b) - (a < b))
standing_texture = Texture('plf:AlienGreen_front')
walk_textures = [Texture('plf:AlienGreen_walk1'), Texture('plf:AlienGreen_walk2')]
# ---[1]
# Because the alien can be hit by a meteor, we need one additional texture for the unhappy alien:
hit_texture = Texture('plf:AlienGreen_hit')
class Coin (SpriteNode):
def __init__(self, **kwargs):
SpriteNode.__init__(self, 'plf:Item_CoinGold', **kwargs)
# ---[2]
# As with the coins, we use a custom subclass of SpriteNode to represent the meteors. For some variety, the texture of the meteor is chosen randomly.
class Meteor (SpriteNode):
def __init__(self, **kwargs):
img = random.choice(['spc:MeteorBrownBig1', 'spc:MeteorBrownBig2'])
SpriteNode.__init__(self, img, **kwargs)
class Game (Scene):
def setup(self):
self.background_color = '#004f82'
self.ground = Node(parent=self)
x = 0
while x <= self.size.w + 64:
tile = SpriteNode('plf:Ground_PlanetHalf_mid', position=(x, 0))
self.ground.add_child(tile)
x += 64
self.player = SpriteNode(standing_texture)
self.player.anchor_point = (0.5, 0)
self.add_child(self.player)
score_font = ('Futura', 40)
self.score_label = LabelNode('0', score_font, parent=self)
self.score_label.position = (self.size.w/2, self.size.h - 70)
self.score_label.z_position = 1
self.items = []
# ---[3]
# Because the game can end now, we need a method to restart it.
# Some of the initialization logic that was previously in `setup()` is now in `new_game()`, so it can be repeated without having to close the game first.
self.new_game()
def new_game(self):
# Reset everything to its initial state...
for item in self.items:
item.remove_from_parent()
self.items = []
self.score = 0
self.score_label.text = '0'
self.walk_step = -1
self.player.position = (self.size.w/2, 32)
self.player.texture = standing_texture
self.speed = 1.0
# ---[4]
# The game_over attribute is set to True when the alien gets hit by a meteor. We use this to stop player movement and collision checking (the update method simply does nothing when game_over is True).
self.game_over = False
def update(self):
if self.game_over:
return
self.update_player()
self.check_item_collisions()
if random.random() < 0.05 * self.speed:
self.spawn_item()
def update_player(self):
g = gravity()
if abs(g.x) > 0.05:
self.player.x_scale = cmp(g.x, 0)
x = self.player.position.x
max_speed = 40
x = max(0, min(self.size.w, x + g.x * max_speed))
self.player.position = x, 32
step = int(self.player.position.x / 40) % 2
if step != self.walk_step:
self.player.texture = walk_textures[step]
sound.play_effect('rpg:Footstep00', 0.05, 1.0 + 0.5 * step)
self.walk_step = step
else:
self.player.texture = standing_texture
self.walk_step = -1
def check_item_collisions(self):
# ---[5]
# The hit testing is essentially the same as before, but now distinguishes between coins and meteors (simply by checking the class of the item).
# When a meteor hits, the game is over (see the `player_hit()` method below).
player_hitbox = Rect(self.player.position.x - 20, 32, 40, 65)
for item in list(self.items):
if item.frame.intersects(player_hitbox):
if isinstance(item, Coin):
self.collect_item(item)
elif isinstance(item, Meteor):
self.player_hit()
elif not item.parent:
self.items.remove(item)
def player_hit(self):
# ---[6]
# This is. alled from `check_item_collisions()` when the alien collides with a meteor. The alien simply drops off the screen, and after 2 seconds, a new game is started.
self.game_over = True
sound.play_effect('arcade:Explosion_1')
self.player.texture = hit_texture
self.player.run_action(A.move_by(0, -150))
# Note: The duration of the `wait` action is multiplied by the current game speed, so that it always takes exactly 2 seconds, regardless of how fast the rest of the game is running.
self.run_action(A.sequence(A.wait(2*self.speed), A.call(self.new_game)))
def spawn_item(self):
if random.random() < 0.3:
# ---[7]
# Whenever a new item is created, there's now a 30% chance that it is a meteor instead of a coin.
# Their behavior is very similar to that of the coins, but instead of moving straight down, they may come in at an angle. To accomplish this, the x coordinate of the final position is simply chosen randomly.
meteor = Meteor(parent=self)
meteor.position = (random.uniform(20, self.size.w-20), self.size.h + 30)
d = random.uniform(2.0, 4.0)
actions = [A.move_to(random.uniform(0, self.size.w), -100, d), A.remove()]
meteor.run_action(A.sequence(actions))
self.items.append(meteor)
else:
coin = Coin(parent=self)
coin.position = (random.uniform(20, self.size.w-20), self.size.h + 30)
d = random.uniform(2.0, 4.0)
actions = [A.move_by(0, -(self.size.h + 60), d), A.remove()]
coin.run_action(A.sequence(actions))
self.items.append(coin)
# ---[8]
# To make things a bit more interesting, the entire game gets slightly faster whenever a new item is spawned. The `speed` attribute is essentially a multiplier for the duration of all actions in the scene. Note that this is actually an attribute of `Node`, so you could apply different speeds for different groups of nodes. Since all items are added directly to the scene in this example, we don't make use of that here though.
self.speed = min(3, self.speed + 0.005)
def collect_item(self, item, value=10):
sound.play_effect('digital:PowerUp7')
item.remove_from_parent()
self.items.remove(item)
self.score += value
self.score_label.text = str(self.score)
if __name__ == '__main__':
run(Game(), PORTRAIT, show_fps=True)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment