Tutorial 3 — Quest
A Zelda-ish overworld: walk a scrolling map, collect coins, talk to an NPC, fight slimes, and complete a quest. Assumes you’ve done 01-bounce and 02-starship. This is where the engine’s camera and tilemap-as-world come in.
The full source for this tutorial lives on GitHub: tutorials/03-quest.
Run any step:
python3 sim/run.py tutorials/03-quest/stepN_name.py --hold DOWN --shot /tmp/out.pngTip: the talk/quest steps (6–8) are best seen live with --backend pygame — a headless
--shot can catch a dialog box mid-draw (the overlay paints in several passes), so the box
may screenshot without its text even though it renders fine on device and in the live window.
step 1 — step1_world.py · a world bigger than the screen
Section titled “step 1 — step1_world.py · a world bigger than the screen”
An ASCII map becomes a Tilemap (30×20 tiles = 480×320 px, larger than the 320×240
screen). shp.tileset_colors builds the tileset (grass/path/water/tree/wall/door/goal).
scene.set_view(ox, oy) chooses the visible window — that’s the camera. The hero lives in
world coordinates; the view offset decides where it lands on screen. You see: a
patch of world with the hero centred. Try it: edit the MAP strings.
15 collapsed lines
# Quest -- step 1: a world bigger than the screen.## This third tutorial builds a top-down RPG. It teaches what Bounce and Starship# couldn't: a scrolling world larger than the display, a camera that follows the# hero, tile-based wall collision, a walk animation, items, an NPC you talk to, and# light combat. Do 01-bounce and 02-starship first.## What you learn here: a Tilemap can be a whole WORLD (30x20 tiles = 480x320 px,# bigger than the 320x240 screen). scene.set_view(ox, oy) chooses which part of the# world is on screen -- that's the camera. We centre it on the hero. The hero lives# in WORLD coordinates; the view offset decides where that lands on screen.## New: a large Tilemap from an ASCII map, shp.tileset_colors, scene.set_view.## Run: python3 sim/run.py tutorials/03-quest/step1_world.py --shot /tmp/q1.png
import arrayimport picogame as pgimport picogame_gameimport picogame_clockimport picogame_shapes as shp
W, H = 320, 240TILE = 16
# . grass : path ~ water(solid) # tree(solid) W wall(solid) D door G goal# P player N npc * coin E enemyMAP = [ "##############################", "#.....:......................#", "#.....:........*.............#", "#..##.:.............~~~~~~...#", "#..##.:.............~~~~~~...#", "#.....N.............~~~~~~...#", "#.....:.......E.....~~~~~~...#", "#.....:......................#", "#.....:.....*.........*......#", "#.....:......................#", "#:::::::P:::::::::*::::::::::#", "#.....:...................*..#", "#.....:.............E........#", "#.....:...WWWWWWW............#", "#.....:...W.....W......##....#", "#.....:.*.W..G..W......##....#", "#.....:...W.....W........E...#", "#.....:...WWWDWWW............#", "#.....:......................#", "##############################",]MCOLS, MROWS = 30, 20# map char -> tile value (entities sit on grass; the tile under them is grass/path)CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4, "W": 5, "D": 6, "G": 7}# tile colours for values 1..7 (value 0 is unused)TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30), (120, 120, 130), (150, 90, 40), (240, 210, 60)]
scene, bufA, bufB = picogame_game.setup(background=pg.rgb565(0, 0, 0))clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])world = pg.Tilemap(tileset, MCOLS, MROWS)hero_x, hero_y = TILE, TILE # world pixel position of the herofor ty in range(MROWS): row = MAP[ty] for tx in range(MCOLS): ch = row[tx] if tx < len(row) else "." world.tile(tx, ty, CH2TILE.get(ch, 1)) if ch == "P": hero_x, hero_y = tx * TILE, ty * TILEscene.add(world)
def hero_bitmap(): """A simple hero: a 16x16 body with a lighter 'face' nub on the facing edge. frame 0=down, 1=up, 2=left, 3=right.""" pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170)]) stride = TILE * 4 data = bytearray(stride * TILE) for f in range(4): for y in range(TILE): for x in range(TILE): face = ((f == 0 and y >= TILE - 4) or (f == 1 and y < 4) or (f == 2 and x < 4) or (f == 3 and x >= TILE - 4)) data[y * stride + f * TILE + x] = 2 if face else 1 return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=4, stride=stride)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=0)scene.add(hero)
def follow(): # centre the camera on the hero, clamped so we never show past the world edges ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2))) oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2))) scene.set_view(int(ox), int(oy))
follow()while True: scene.refresh() clock.tick()step 2 — step2_walk.py · walk, camera follows
Section titled “step 2 — step2_walk.py · walk, camera follows”
4-direction movement; after each move follow() re-centres the camera, clamped to the
world so you never see past the edges (near an edge the hero walks toward the screen edge
instead). The hero faces its movement direction (sprite.frame). No wall collision yet —
you can walk over water. Try it: change SPEED.
52 collapsed lines
# Quest -- step 2: walk around, camera follows.## What you learn: world vs screen coordinates. The hero moves in WORLD space; after# each move we call follow() to re-aim the camera. Near the world edges the clamp in# follow() stops the camera and the hero walks toward the screen edge instead -- the# classic top-down feel. The hero also FACES the way it moves (frame = facing).# There's no wall collision yet, so you can walk over water and trees (step 3 fixes# that).## New vs step 1: 4-direction input, facing (sprite.frame), calling follow() on move.## Run: python3 sim/run.py tutorials/03-quest/step2_walk.py --hold DOWN --shot /tmp/q2.png
import arrayimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_shapes as shp
W, H = 320, 240TILE = 16SPEED = 2MAP = [ "##############################", "#.....:......................#", "#.....:........*.............#", "#..##.:.............~~~~~~...#", "#..##.:.............~~~~~~...#", "#.....N.............~~~~~~...#", "#.....:.......E.....~~~~~~...#", "#.....:......................#", "#.....:.....*.........*......#", "#.....:......................#", "#:::::::P:::::::::*::::::::::#", "#.....:...................*..#", "#.....:.............E........#", "#.....:...WWWWWWW............#", "#.....:...W.....W......##....#", "#.....:.*.W..G..W......##....#", "#.....:...W.....W........E...#", "#.....:...WWWDWWW............#", "#.....:......................#", "##############################",]MCOLS, MROWS = 30, 20CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4, "W": 5, "D": 6, "G": 7}TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30), (120, 120, 130), (150, 90, 40), (240, 210, 60)]DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3 # facing -> frame
scene, bufA, bufB = picogame_game.setup(background=pg.rgb565(0, 0, 0))btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])world = pg.Tilemap(tileset, MCOLS, MROWS)hero_x, hero_y = TILE, TILEfor ty in range(MROWS): for tx in range(MCOLS): ch = MAP[ty][tx] if tx < len(MAP[ty]) else "." world.tile(tx, ty, CH2TILE.get(ch, 1)) if ch == "P": hero_x, hero_y = tx * TILE, ty * TILEscene.add(world)
def hero_bitmap(): pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170)]) stride = TILE * 4 data = bytearray(stride * TILE) for f in range(4): for y in range(TILE): for x in range(TILE): face = ((f == 0 and y >= TILE - 4) or (f == 1 and y < 4) or (f == 2 and x < 4) or (f == 3 and x >= TILE - 4)) data[y * stride + f * TILE + x] = 2 if face else 1 return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=4, stride=stride)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=DOWN)scene.add(hero)
def follow(): ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2))) oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2))) scene.set_view(int(ox), int(oy))
follow()while True: btn.poll() dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT) dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP) if dx: hero.frame = RIGHT if dx > 0 else LEFT # face horizontally elif dy: hero.frame = DOWN if dy > 0 else UP # face vertically if dx or dy: hero.move(hero.x + dx * SPEED, hero.y + dy * SPEED) follow()
scene.refresh() clock.tick()step 3 — step3_walls.py · tile collision
Section titled “step 3 — step3_walls.py · tile collision”
solid_at(px, py) maps a world pixel to a tile and checks a SOLID set; can_walk()
probes the hero’s four corners. We test the X and Y moves separately, so you slide
along a wall instead of sticking when pushing diagonally into it. You see: water, trees
and walls now block you. Try it: add a tile value to SOLID (or remove one).
52 collapsed lines
# Quest -- step 3: walls (tile-based collision).## What you learn: stop the hero walking through water/trees/walls. Convert a world# pixel to a tile (tx = px // TILE), look the tile up, and treat some values as# SOLID. can_walk() probes the hero's body (its four corners, inset a little) so it# can't clip into a wall. We test the X and Y moves SEPARATELY, so the hero slides# along a wall instead of sticking when you push diagonally into it.## New vs step 2: solid_at()/can_walk() pixel->tile collision, per-axis movement.## Run: python3 sim/run.py tutorials/03-quest/step3_walls.py --hold RIGHT --shot /tmp/q3.png
import arrayimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_shapes as shp
W, H = 320, 240TILE = 16SPEED = 2MAP = [ "##############################", "#.....:......................#", "#.....:........*.............#", "#..##.:.............~~~~~~...#", "#..##.:.............~~~~~~...#", "#.....N.............~~~~~~...#", "#.....:.......E.....~~~~~~...#", "#.....:......................#", "#.....:.....*.........*......#", "#.....:......................#", "#:::::::P:::::::::*::::::::::#", "#.....:...................*..#", "#.....:.............E........#", "#.....:...WWWWWWW............#", "#.....:...W.....W......##....#", "#.....:.*.W..G..W......##....#", "#.....:...W.....W........E...#", "#.....:...WWWDWWW............#", "#.....:......................#", "##############################",]MCOLS, MROWS = 30, 20CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4, "W": 5, "D": 6, "G": 7}TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30), (120, 120, 130), (150, 90, 40), (240, 210, 60)]SOLID = (3, 4, 5, 6) # water, tree, wall, door block movementDOWN, UP, LEFT, RIGHT = 0, 1, 2, 3
scene, bufA, bufB = picogame_game.setup(background=pg.rgb565(0, 0, 0))btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])world = pg.Tilemap(tileset, MCOLS, MROWS)hero_x, hero_y = TILE, TILEfor ty in range(MROWS): for tx in range(MCOLS): ch = MAP[ty][tx] if tx < len(MAP[ty]) else "." world.tile(tx, ty, CH2TILE.get(ch, 1)) if ch == "P": hero_x, hero_y = tx * TILE, ty * TILEscene.add(world)
def hero_bitmap(): pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170)]) stride = TILE * 4 data = bytearray(stride * TILE) for f in range(4): for y in range(TILE): for x in range(TILE): face = ((f == 0 and y >= TILE - 4) or (f == 1 and y < 4) or (f == 2 and x < 4) or (f == 3 and x >= TILE - 4)) data[y * stride + f * TILE + x] = 2 if face else 1 return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=4, stride=stride)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=DOWN)scene.add(hero)
def solid_at(px, py): tx, ty = px // TILE, py // TILE if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS: return True return world.tile(tx, ty) in SOLID
def can_walk(px, py): # probe the hero's body corners (inset 2px) -- all must be free return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def follow(): ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2))) oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2))) scene.set_view(int(ox), int(oy))
follow()while True: btn.poll() dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT) dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP) if dx: hero.frame = RIGHT if dx > 0 else LEFT elif dy: hero.frame = DOWN if dy > 0 else UP
moved = False if dx and can_walk(hero.x + dx * SPEED, hero.y): # test X alone hero.move(hero.x + dx * SPEED, hero.y); moved = True if dy and can_walk(hero.x, hero.y + dy * SPEED): # then Y alone -> slide hero.move(hero.x, hero.y + dy * SPEED); moved = True if moved: follow()
scene.refresh() clock.tick()step 4 — step4_anim.py · walk animation
Section titled “step 4 — step4_anim.py · walk animation”
The hero becomes an 8-frame sheet (2 steps × 4 facings), driven by
picogame_anim.AnimatedSprite: play(name) picks the facing’s animation, tick(dt)
advances it using the real dt from clock.tick() (so the walk speed is frame-rate
independent). Standing still shows the rest frame. Try it: change the animation fps.
56 collapsed lines
# Quest -- step 4: a walking animation.## What you learn: time-based animation. We upgrade the hero to an 8-frame sheet --# two frames per facing (a small step "bob") -- and drive it with# picogame_anim.AnimatedSprite: named animations, advanced by tick(dt). While the# hero moves we play() the animation for the current facing and tick it with the# frame's real dt (from clock.tick()); when standing still we show the still frame.# Using dt (not a frame counter) keeps the walk speed the same at any frame rate.## New vs step 3: an 8-frame hero, picogame_anim.AnimatedSprite, play()/tick(dt),# the dt returned by clock.tick().## Run: python3 sim/run.py tutorials/03-quest/step4_anim.py --hold RIGHT --shot /tmp/q4.png
import arrayimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_animimport picogame_shapes as shp
W, H = 320, 240TILE = 16SPEED = 2MAP = [ "##############################", "#.....:......................#", "#.....:........*.............#", "#..##.:.............~~~~~~...#", "#..##.:.............~~~~~~...#", "#.....N.............~~~~~~...#", "#.....:.......E.....~~~~~~...#", "#.....:......................#", "#.....:.....*.........*......#", "#.....:......................#", "#:::::::P:::::::::*::::::::::#", "#.....:...................*..#", "#.....:.............E........#", "#.....:...WWWWWWW............#", "#.....:...W.....W......##....#", "#.....:.*.W..G..W......##....#", "#.....:...W.....W........E...#", "#.....:...WWWDWWW............#", "#.....:......................#", "##############################",]MCOLS, MROWS = 30, 20CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4, "W": 5, "D": 6, "G": 7}TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30), (120, 120, 130), (150, 90, 40), (240, 210, 60)]SOLID = (3, 4, 5, 6)DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3FACE_NAME = ("down", "up", "left", "right")
scene, bufA, bufB = picogame_game.setup(background=pg.rgb565(0, 0, 0))btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])world = pg.Tilemap(tileset, MCOLS, MROWS)hero_x, hero_y = TILE, TILEfor ty in range(MROWS): for tx in range(MCOLS): ch = MAP[ty][tx] if tx < len(MAP[ty]) else "." world.tile(tx, ty, CH2TILE.get(ch, 1)) if ch == "P": hero_x, hero_y = tx * TILE, ty * TILEscene.add(world)
def hero_bitmap(): """8 frames = 4 facings x 2 walk steps. Step 1 bobs the body down 1px so the walk reads as motion. frame index = facing*2 + step.""" pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170), pg.rgb565(120, 40, 30)]) stride = TILE * 8 data = bytearray(stride * TILE) for f in range(4): for s in range(2): fr = f * 2 + s bob = s # bob down 1px on the second step for y in range(bob, TILE): yy = y - bob for x in range(TILE): face = ((f == 0 and yy >= TILE - 4) or (f == 1 and yy < 4) or (f == 2 and x < 4) or (f == 3 and x >= TILE - 4)) data[y * stride + fr * TILE + x] = 2 if face else 1 # two feet at the bottom, swapping sides per step (extra motion cue) lx = 4 if s == 0 else 6 for x in (lx, TILE - 1 - lx): data[(TILE - 1) * stride + fr * TILE + x] = 3 return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=8, stride=stride, transparent=0)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=0)walk = picogame_anim.AnimatedSprite(hero, { "down": ([0, 1], 8, True), "up": ([2, 3], 8, True), "left": ([4, 5], 8, True), "right": ([6, 7], 8, True)})scene.add(hero)
facing = DOWN
def solid_at(px, py): tx, ty = px // TILE, py // TILE if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS: return True return world.tile(tx, ty) in SOLID
def can_walk(px, py): return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def follow(): ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2))) oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2))) scene.set_view(int(ox), int(oy))
follow()dt = 1 / 30while True: btn.poll() dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT) dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP) if dx: facing = RIGHT if dx > 0 else LEFT elif dy: facing = DOWN if dy > 0 else UP
moved = False if dx and can_walk(hero.x + dx * SPEED, hero.y): hero.move(hero.x + dx * SPEED, hero.y); moved = True if dy and can_walk(hero.x, hero.y + dy * SPEED): hero.move(hero.x, hero.y + dy * SPEED); moved = True if moved: follow() walk.play(FACE_NAME[facing]) # animate the walk while moving walk.tick(dt) else: hero.frame = facing * 2 # still frame for the current facing
scene.refresh() dt = clock.tick()step 5 — step5_items.py · items + a fixed HUD
Section titled “step 5 — step5_items.py · items + a fixed HUD”
Coins are sprites placed from the map; being normal scene items, they scroll with the
world. We collect one when the hero is close, hide it, and count it. picogame_ui.SceneLabel
is a fixed layer — it does NOT scroll, so the counter stays pinned to the corner.
Try it: add more * coins to the map.
59 collapsed lines
# Quest -- step 5: collectible items + a HUD over the scrolling world.## What you learn: world-space pickups and a camera-fixed HUD. Coins are sprites# placed at map positions; because they're normal scene items they scroll with the# world. We collect one when the hero is close enough (a simple distance test), hide# it, and bump a counter. picogame_ui.SceneLabel is a FIXED scene layer -- it does NOT# scroll, so the coin counter stays pinned to the corner while the world moves under# it.## New vs step 4: item sprites placed from the map, distance-based pickup, a fixed# SceneLabel counter.## Run: python3 sim/run.py tutorials/03-quest/step5_items.py --hold RIGHT --shot /tmp/q5.png
import arrayimport terminalioimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_animimport picogame_shapes as shpimport picogame_ui as ui
W, H = 320, 240TILE = 16SPEED = 2MAP = [ "##############################", "#.....:......................#", "#.....:........*.............#", "#..##.:.............~~~~~~...#", "#..##.:.............~~~~~~...#", "#.....N.............~~~~~~...#", "#.....:.......E.....~~~~~~...#", "#.....:......................#", "#.....:.....*.........*......#", "#.....:......................#", "#:::::::P:::::::::*::::::::::#", "#.....:...................*..#", "#.....:.............E........#", "#.....:...WWWWWWW............#", "#.....:...W.....W......##....#", "#.....:.*.W..G..W......##....#", "#.....:...W.....W........E...#", "#.....:...WWWDWWW............#", "#.....:......................#", "##############################",]MCOLS, MROWS = 30, 20CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4, "W": 5, "D": 6, "G": 7}TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30), (120, 120, 130), (150, 90, 40), (240, 210, 60)]SOLID = (3, 4, 5, 6)DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3FACE_NAME = ("down", "up", "left", "right")BG = pg.rgb565(0, 0, 0)
scene, bufA, bufB = picogame_game.setup(background=BG)btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])world = pg.Tilemap(tileset, MCOLS, MROWS)hero_x, hero_y = TILE, TILEcoin_spots = []for ty in range(MROWS): for tx in range(MCOLS): ch = MAP[ty][tx] if tx < len(MAP[ty]) else "." world.tile(tx, ty, CH2TILE.get(ch, 1)) if ch == "P": hero_x, hero_y = tx * TILE, ty * TILE elif ch == "*": coin_spots.append((tx * TILE, ty * TILE))scene.add(world)
coin_bm = shp.circle(8, pg.rgb565(245, 215, 60))coins = [pg.Sprite(coin_bm, x + 4, y + 4) for (x, y) in coin_spots] # +4 to centre in tilefor c in coins: scene.add(c)
def hero_bitmap(): pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170), pg.rgb565(120, 40, 30)]) stride = TILE * 8 data = bytearray(stride * TILE) for f in range(4): for s in range(2): fr = f * 2 + s for y in range(s, TILE): yy = y - s for x in range(TILE): face = ((f == 0 and yy >= TILE - 4) or (f == 1 and yy < 4) or (f == 2 and x < 4) or (f == 3 and x >= TILE - 4)) data[y * stride + fr * TILE + x] = 2 if face else 1 lx = 4 if s == 0 else 6 for x in (lx, TILE - 1 - lx): data[(TILE - 1) * stride + fr * TILE + x] = 3 return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=8, stride=stride, transparent=0)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=0)walk = picogame_anim.AnimatedSprite(hero, { "down": ([0, 1], 8, True), "up": ([2, 3], 8, True), "left": ([4, 5], 8, True), "right": ([6, 7], 8, True)})scene.add(hero)hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, pg.rgb565(255, 255, 255), BG)
facing = DOWNgot = 0
def solid_at(px, py): tx, ty = px // TILE, py // TILE if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS: return True return world.tile(tx, ty) in SOLID
def can_walk(px, py): return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def follow(): ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2))) oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2))) scene.set_view(int(ox), int(oy))
follow()dt = 1 / 30while True: btn.poll() dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT) dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP) if dx: facing = RIGHT if dx > 0 else LEFT elif dy: facing = DOWN if dy > 0 else UP
moved = False if dx and can_walk(hero.x + dx * SPEED, hero.y): hero.move(hero.x + dx * SPEED, hero.y); moved = True if dy and can_walk(hero.x, hero.y + dy * SPEED): hero.move(hero.x, hero.y + dy * SPEED); moved = True if moved: follow() walk.play(FACE_NAME[facing]); walk.tick(dt) else: hero.frame = facing * 2
# pick up any coin we're standing on for c in coins: if c.visible and abs(hero.x - c.x) < 12 and abs(hero.y - c.y) < 12: c.visible = False got += 1
hud.set("COINS %d/%d" % (got, len(coins))) scene.refresh() dt = clock.tick()step 6 — step6_npc.py · talk to an NPC
Section titled “step 6 — step6_npc.py · talk to an NPC”
Stand next to the NPC and press A to enter a dialog state: the world keeps drawing
underneath while picogame_ui.TextBox overlays a message; movement is frozen until you
press a button. You see: a “PRESS A” prompt near the NPC, then a dialog box. Try it:
change the dialog LINES.
62 collapsed lines
# Quest -- step 6: an NPC you can talk to.## What you learn: interaction + a simple state machine for dialog. An NPC is a# sprite; when the hero stands next to it and presses A we switch to a "dialog"# state: the world keeps drawing underneath, and picogame_ui.TextBox draws a# multi-line message box on top (a screen-space overlay, drawn after scene.refresh).# While in dialog we DON'T process movement -- the game is paused on the box until# you press a button to dismiss it.## New vs step 5: an NPC sprite, an over/dialog state, picogame_ui.TextBox, freezing# the world during dialog, an adjacency "PRESS A" prompt.## Run: python3 sim/run.py tutorials/03-quest/step6_npc.py --shot /tmp/q6.png
import arrayimport boardimport terminalioimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_animimport picogame_shapes as shpimport picogame_ui as ui
W, H = 320, 240TILE = 16SPEED = 2MAP = [ "##############################", "#.....:......................#", "#.....:........*.............#", "#..##.:.............~~~~~~...#", "#..##.:.............~~~~~~...#", "#.....N.............~~~~~~...#", "#.....:.......E.....~~~~~~...#", "#.....:......................#", "#.....:.....*.........*......#", "#.....:......................#", "#:::::::P:::::::::*::::::::::#", "#.....:...................*..#", "#.....:.............E........#", "#.....:...WWWWWWW............#", "#.....:...W.....W......##....#", "#.....:.*.W..G..W......##....#", "#.....:...W.....W........E...#", "#.....:...WWWDWWW............#", "#.....:......................#", "##############################",]MCOLS, MROWS = 30, 20CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4, "W": 5, "D": 6, "G": 7}TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30), (120, 120, 130), (150, 90, 40), (240, 210, 60)]SOLID = (3, 4, 5, 6)DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3FACE_NAME = ("down", "up", "left", "right")BG = pg.rgb565(0, 0, 0)WHITE = pg.rgb565(255, 255, 255)NAVY = pg.rgb565(10, 10, 40)
scene, bufA, bufB = picogame_game.setup(background=BG)btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])world = pg.Tilemap(tileset, MCOLS, MROWS)hero_x, hero_y = TILE, TILEnpc_x, npc_y = TILE, TILEcoin_spots = []for ty in range(MROWS): for tx in range(MCOLS): ch = MAP[ty][tx] if tx < len(MAP[ty]) else "." world.tile(tx, ty, CH2TILE.get(ch, 1)) if ch == "P": hero_x, hero_y = tx * TILE, ty * TILE elif ch == "N": npc_x, npc_y = tx * TILE, ty * TILE elif ch == "*": coin_spots.append((tx * TILE, ty * TILE))scene.add(world)
coin_bm = shp.circle(8, pg.rgb565(245, 215, 60))coins = [pg.Sprite(coin_bm, x + 4, y + 4) for (x, y) in coin_spots]for c in coins: scene.add(c)npc = pg.Sprite(shp.rect(TILE, TILE, pg.rgb565(230, 200, 60)), npc_x, npc_y)scene.add(npc)
def hero_bitmap(): pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170), pg.rgb565(120, 40, 30)]) stride = TILE * 8 data = bytearray(stride * TILE) for f in range(4): for s in range(2): fr = f * 2 + s for y in range(s, TILE): yy = y - s for x in range(TILE): face = ((f == 0 and yy >= TILE - 4) or (f == 1 and yy < 4) or (f == 2 and x < 4) or (f == 3 and x >= TILE - 4)) data[y * stride + fr * TILE + x] = 2 if face else 1 lx = 4 if s == 0 else 6 for x in (lx, TILE - 1 - lx): data[(TILE - 1) * stride + fr * TILE + x] = 3 return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=8, stride=stride, transparent=0)
hero = pg.Sprite(hero_bitmap(), hero_x, hero_y, frame=0)walk = picogame_anim.AnimatedSprite(hero, { "down": ([0, 1], 8, True), "up": ([2, 3], 8, True), "left": ([4, 5], 8, True), "right": ([6, 7], 8, True)})scene.add(hero)hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, WHITE, BG)dlg = ui.TextBox(pg, terminalio.FONT, 8, H - 60, W - 16, 54, WHITE, NAVY, maxlines=4)LINES = ["Villager:", "Beware the slimes in the", "tall grass, traveller.", "(press A)"]
facing = DOWNgot = 0state = "over"dlg_shown = False # draw the modal once, not every frame
def solid_at(px, py): tx, ty = px // TILE, py // TILE if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS: return True return world.tile(tx, ty) in SOLID
def can_walk(px, py): return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def near_npc(): return abs(hero.x - npc.x) <= TILE and abs(hero.y - npc.y) <= TILE
def follow(): ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2))) oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2))) scene.set_view(int(ox), int(oy))
follow()dt = 1 / 30while True: btn.poll()
if state == "dialog": if not dlg_shown: # draw ONCE -> no per-frame flicker scene.refresh() # world frozen under the box dlg.draw(board.DISPLAY, bufA, LINES) dlg_shown = True if btn.just_pressed(btn.A) or btn.just_pressed(btn.B): state = "over" scene.invalidate() # repaint over the box next frame clock.tick() continue
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT) dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP) if dx: facing = RIGHT if dx > 0 else LEFT elif dy: facing = DOWN if dy > 0 else UP
moved = False if dx and can_walk(hero.x + dx * SPEED, hero.y): hero.move(hero.x + dx * SPEED, hero.y); moved = True if dy and can_walk(hero.x, hero.y + dy * SPEED): hero.move(hero.x, hero.y + dy * SPEED); moved = True if moved: follow() walk.play(FACE_NAME[facing]); walk.tick(dt) else: hero.frame = facing * 2
for c in coins: if c.visible and abs(hero.x - c.x) < 12 and abs(hero.y - c.y) < 12: c.visible = False got += 1
if near_npc(): hud.set("COINS %d/%d A: TALK" % (got, len(coins))) if btn.just_pressed(btn.A): state = "dialog"; dlg_shown = False else: hud.set("COINS %d/%d" % (got, len(coins)))
scene.refresh() dt = clock.tick()step 7 — step7_combat.py · enemies + bump combat
Section titled “step 7 — step7_combat.py · enemies + bump combat”
Slimes chase the hero (slower than you, respecting walls). Touching one costs HP and
grants brief i-frames; press B to swing at the tile you’re facing and defeat a
slime. HP shows in the HUD; reaching 0 sends you back to start. (A still talks.) Try it:
change the slime speed or your starting HP.
62 collapsed lines
# Quest -- step 7: enemies and bump combat.## What you learn: simple chasing AI, taking damage, and attacking. Slimes step# toward the hero (slower than you, and they respect walls via can_walk). Touching# one costs HP and grants brief invulnerability (i-frames) so one touch doesn't# drain you instantly. Press B to swing: we defeat any slime in the tile just ahead# of the way you're facing. HP shows in the HUD; reaching 0 sends you back to start.## New vs step 6: enemy sprites with chase AI, player HP + i-frames + knock-back,# a B attack in the facing direction. (A still talks to the NPC.)## Run: python3 sim/run.py tutorials/03-quest/step7_combat.py --hold B --shot /tmp/q7.png
import arrayimport boardimport terminalioimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_animimport picogame_shapes as shpimport picogame_ui as ui
W, H = 320, 240TILE = 16SPEED = 2MAP = [ "##############################", "#.....:......................#", "#.....:........*.............#", "#..##.:.............~~~~~~...#", "#..##.:.............~~~~~~...#", "#.....N.............~~~~~~...#", "#.....:.......E.....~~~~~~...#", "#.....:......................#", "#.....:.....*.........*......#", "#.....:......................#", "#:::::::P:::::::::*::::::::::#", "#.....:...................*..#", "#.....:.............E........#", "#.....:...WWWWWWW............#", "#.....:...W.....W......##....#", "#.....:.*.W..G..W......##....#", "#.....:...W.....W........E...#", "#.....:...WWWDWWW............#", "#.....:......................#", "##############################",]MCOLS, MROWS = 30, 20CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4, "W": 5, "D": 6, "G": 7}TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30), (120, 120, 130), (150, 90, 40), (240, 210, 60)]SOLID = (3, 4, 5, 6)DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3DIR = {DOWN: (0, 1), UP: (0, -1), LEFT: (-1, 0), RIGHT: (1, 0)}FACE_NAME = ("down", "up", "left", "right")BG = pg.rgb565(0, 0, 0)WHITE = pg.rgb565(255, 255, 255)NAVY = pg.rgb565(10, 10, 40)
scene, bufA, bufB = picogame_game.setup(background=BG)btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])world = pg.Tilemap(tileset, MCOLS, MROWS)START = (TILE, TILE)npc_x, npc_y = TILE, TILEcoin_spots, enemy_spots = [], []for ty in range(MROWS): for tx in range(MCOLS): ch = MAP[ty][tx] if tx < len(MAP[ty]) else "." world.tile(tx, ty, CH2TILE.get(ch, 1)) if ch == "P": START = (tx * TILE, ty * TILE) elif ch == "N": npc_x, npc_y = tx * TILE, ty * TILE elif ch == "*": coin_spots.append((tx * TILE, ty * TILE)) elif ch == "E": enemy_spots.append((tx * TILE, ty * TILE))scene.add(world)
coin_bm = shp.circle(8, pg.rgb565(245, 215, 60))coins = [pg.Sprite(coin_bm, x + 4, y + 4) for (x, y) in coin_spots]for c in coins: scene.add(c)slime_bm = shp.circle(14, pg.rgb565(120, 200, 80))enemies = [pg.Sprite(slime_bm, x + 1, y + 1) for (x, y) in enemy_spots]for e in enemies: scene.add(e)npc = pg.Sprite(shp.rect(TILE, TILE, pg.rgb565(230, 200, 60)), npc_x, npc_y)scene.add(npc)
def hero_bitmap(): pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170), pg.rgb565(120, 40, 30)]) stride = TILE * 8 data = bytearray(stride * TILE) for f in range(4): for s in range(2): fr = f * 2 + s for y in range(s, TILE): yy = y - s for x in range(TILE): face = ((f == 0 and yy >= TILE - 4) or (f == 1 and yy < 4) or (f == 2 and x < 4) or (f == 3 and x >= TILE - 4)) data[y * stride + fr * TILE + x] = 2 if face else 1 lx = 4 if s == 0 else 6 for x in (lx, TILE - 1 - lx): data[(TILE - 1) * stride + fr * TILE + x] = 3 return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=8, stride=stride, transparent=0)
hero = pg.Sprite(hero_bitmap(), START[0], START[1], frame=0)walk = picogame_anim.AnimatedSprite(hero, { "down": ([0, 1], 8, True), "up": ([2, 3], 8, True), "left": ([4, 5], 8, True), "right": ([6, 7], 8, True)})scene.add(hero)hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, WHITE, BG)dlg = ui.TextBox(pg, terminalio.FONT, 8, H - 60, W - 16, 54, WHITE, NAVY, maxlines=4)LINES = ["Villager:", "Slimes ahead! Press B to", "swing at them.", "(press A)"]
facing = DOWNgot = 0hp = 6inv = 0state = "over"frame = 0dlg_shown = False # draw the modal once, not every frame
def solid_at(px, py): tx, ty = px // TILE, py // TILE if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS: return True return world.tile(tx, ty) in SOLID
def can_walk(px, py): return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def near(a, bx, by, d=TILE): return abs(a.x - bx) < d and abs(a.y - by) < d
def follow(): ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2))) oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2))) scene.set_view(int(ox), int(oy))
follow()dt = 1 / 30while True: btn.poll() frame += 1
if state == "dialog": if not dlg_shown: # draw ONCE -> no per-frame flicker scene.refresh() dlg.draw(board.DISPLAY, bufA, LINES) dlg_shown = True if btn.just_pressed(btn.A) or btn.just_pressed(btn.B): state = "over"; scene.invalidate() clock.tick() continue
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT) dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP) if dx: facing = RIGHT if dx > 0 else LEFT elif dy: facing = DOWN if dy > 0 else UP moved = False if dx and can_walk(hero.x + dx * SPEED, hero.y): hero.move(hero.x + dx * SPEED, hero.y); moved = True if dy and can_walk(hero.x, hero.y + dy * SPEED): hero.move(hero.x, hero.y + dy * SPEED); moved = True if moved: follow(); walk.play(FACE_NAME[facing]); walk.tick(dt) else: hero.frame = facing * 2
# attack: defeat a slime in the tile ahead of the facing if btn.just_pressed(btn.B): ddx, ddy = DIR[facing] ax, ay = hero.x + ddx * TILE, hero.y + ddy * TILE for e in enemies: if e.visible and abs(e.x - ax) < TILE and abs(e.y - ay) < TILE: e.visible = False
# slimes chase (slower: move every other frame) and respect walls if frame % 2 == 0: for e in enemies: if not e.visible: continue sx = (hero.x > e.x) - (hero.x < e.x) sy = (hero.y > e.y) - (hero.y < e.y) if sx and can_walk(e.x + sx, e.y): e.move(e.x + sx, e.y) if sy and can_walk(e.x, e.y + sy): e.move(e.x, e.y + sy)
# take damage on contact (unless in i-frames) if inv > 0: inv -= 1 else: for e in enemies: if e.visible and near(e, hero.x, hero.y, 13): hp -= 1 inv = 40 if hp <= 0: # down -> back to start, full HP hp = 6 hero.move(START[0], START[1]) follow() break
for c in coins: if c.visible and abs(hero.x - c.x) < 12 and abs(hero.y - c.y) < 12: c.visible = False; got += 1
if near(hero, npc.x, npc.y): hud.set("HP %d COINS %d/%d A:TALK B:SWING" % (hp, got, len(coins))) if btn.just_pressed(btn.A): state = "dialog"; dlg_shown = False else: hud.set("HP %d COINS %d/%d" % (hp, got, len(coins)))
scene.refresh() dt = clock.tick()step 8 — step8_quest.py · the quest (capstone)
Section titled “step 8 — step8_quest.py · the quest (capstone)”
Everything becomes a goal: the NPC asks for all the coins; once you have them, talking
again opens the door (we rewrite those tiles from “door” to “path” so collision lets you
through); stepping on the shrine tile wins. A small stage variable drives the dialog
and the door. You see: talk → collect → unlock → reach the shrine → “QUEST COMPLETE”.
Try it: require defeating all slimes too before the door opens.
69 collapsed lines
# Quest -- step 8: a quest, a goal, and a win state (capstone).## What you learn: tying the systems into an actual game. The NPC gives an objective# (collect every coin); once you have them all, talking again OPENS the door (we# rewrite those tiles from "door" to "path", so collision lets you through); stepping# on the shrine tile wins. A small quest-stage variable drives the dialog text and# the door. This is the whole loop: talk -> collect -> unlock -> reach the goal.## New vs step 7: a quest stage, objective-driven dialog, opening the door by editing# tiles, a goal tile + win state.## BIG PICTURE: you've now hand-built an RPG -- map, camera, collision, animation,# items, NPC, combat, quest. You DON'T have to keep hand-coding maps like this: the# editor (editor/) lets you paint the map, place the hero/NPC/coins, and FLAG tiles# (solid/coin/goal) visually, then the picogame_scene loader builds the scene for# you -- the exact things this file does by hand become data. See tutorials/README.md# and examples/picogame_platformer_scene.py for a game whose level is loaded that way.## Run: python3 sim/run.py tutorials/03-quest/step8_quest.py --shot /tmp/q8.png
import arrayimport boardimport terminalioimport picogame as pgimport picogame_gameimport picogame_inputimport picogame_clockimport picogame_animimport picogame_shapes as shpimport picogame_ui as ui
W, H = 320, 240TILE = 16SPEED = 2MAP = [ "##############################", "#.....:......................#", "#.....:........*.............#", "#..##.:.............~~~~~~...#", "#..##.:.............~~~~~~...#", "#.....N.............~~~~~~...#", "#.....:.......E.....~~~~~~...#", "#.....:......................#", "#.....:.....*.........*......#", "#.....:......................#", "#:::::::P:::::::::*::::::::::#", "#.....:...................*..#", "#.....:.............E........#", "#.....:...WWWWWWW............#", "#.....:...W.....W......##....#", "#.....:.*.W..G..W......##....#", "#.....:...W.....W........E...#", "#.....:...WWWDWWW............#", "#.....:......................#", "##############################",]MCOLS, MROWS = 30, 20CH2TILE = {".": 1, "P": 1, "N": 1, "*": 1, "E": 1, ":": 2, "~": 3, "#": 4, "W": 5, "D": 6, "G": 7}TILE_RGB = [(40, 120, 50), (180, 160, 110), (40, 90, 200), (20, 80, 30), (120, 120, 130), (150, 90, 40), (240, 210, 60)]SOLID = (3, 4, 5, 6)DOWN, UP, LEFT, RIGHT = 0, 1, 2, 3DIR = {DOWN: (0, 1), UP: (0, -1), LEFT: (-1, 0), RIGHT: (1, 0)}FACE_NAME = ("down", "up", "left", "right")BG = pg.rgb565(0, 0, 0)WHITE = pg.rgb565(255, 255, 255)NAVY = pg.rgb565(10, 10, 40)
scene, bufA, bufB = picogame_game.setup(background=BG)btn = picogame_input.Buttons()clock = picogame_clock.Clock(30)
tileset = shp.tileset_colors(TILE, TILE, [pg.rgb565(*c) for c in TILE_RGB])world = pg.Tilemap(tileset, MCOLS, MROWS)START = (TILE, TILE)npc_x, npc_y = TILE, TILEcoin_spots, enemy_spots, door_tiles = [], [], []for ty in range(MROWS): for tx in range(MCOLS): ch = MAP[ty][tx] if tx < len(MAP[ty]) else "." world.tile(tx, ty, CH2TILE.get(ch, 1)) if ch == "P": START = (tx * TILE, ty * TILE) elif ch == "N": npc_x, npc_y = tx * TILE, ty * TILE elif ch == "*": coin_spots.append((tx * TILE, ty * TILE)) elif ch == "E": enemy_spots.append((tx * TILE, ty * TILE)) elif ch == "D": door_tiles.append((tx, ty))scene.add(world)
coin_bm = shp.circle(8, pg.rgb565(245, 215, 60))coins = [pg.Sprite(coin_bm, x + 4, y + 4) for (x, y) in coin_spots]for c in coins: scene.add(c)slime_bm = shp.circle(14, pg.rgb565(120, 200, 80))enemies = [pg.Sprite(slime_bm, x + 1, y + 1) for (x, y) in enemy_spots]for e in enemies: scene.add(e)npc = pg.Sprite(shp.rect(TILE, TILE, pg.rgb565(230, 200, 60)), npc_x, npc_y)scene.add(npc)
def hero_bitmap(): pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(210, 80, 60), pg.rgb565(255, 225, 170), pg.rgb565(120, 40, 30)]) stride = TILE * 8 data = bytearray(stride * TILE) for f in range(4): for s in range(2): fr = f * 2 + s for y in range(s, TILE): yy = y - s for x in range(TILE): face = ((f == 0 and yy >= TILE - 4) or (f == 1 and yy < 4) or (f == 2 and x < 4) or (f == 3 and x >= TILE - 4)) data[y * stride + fr * TILE + x] = 2 if face else 1 lx = 4 if s == 0 else 6 for x in (lx, TILE - 1 - lx): data[(TILE - 1) * stride + fr * TILE + x] = 3 return pg.Bitmap(data, TILE, TILE, format=pg.PAL8, palette=pal, frames=8, stride=stride, transparent=0)
hero = pg.Sprite(hero_bitmap(), START[0], START[1], frame=0)walk = picogame_anim.AnimatedSprite(hero, { "down": ([0, 1], 8, True), "up": ([2, 3], 8, True), "left": ([4, 5], 8, True), "right": ([6, 7], 8, True)})scene.add(hero)hud = ui.SceneLabel(scene, pg, terminalio.FONT, 4, 4, WHITE, BG)dlg = ui.TextBox(pg, terminalio.FONT, 8, H - 64, W - 16, 58, WHITE, NAVY, maxlines=4)
facing = DOWNgot = 0hp = 6inv = 0stage = 0 # 0 not started, 1 collecting, 2 door openstate = "over"frame = 0NCOINS = len(coins)overlay_shown = False # draw dialog/win modal once, not every frame
def solid_at(px, py): tx, ty = px // TILE, py // TILE if tx < 0 or tx >= MCOLS or ty < 0 or ty >= MROWS: return True return world.tile(tx, ty) in SOLID
def can_walk(px, py): return not (solid_at(px + 2, py + 2) or solid_at(px + TILE - 3, py + 2) or solid_at(px + 2, py + TILE - 3) or solid_at(px + TILE - 3, py + TILE - 3))
def near(a, bx, by, d=TILE): return abs(a.x - bx) < d and abs(a.y - by) < d
def follow(): ox = max(W - MCOLS * TILE, min(0, W // 2 - (hero.x + TILE // 2))) oy = max(H - MROWS * TILE, min(0, H // 2 - (hero.y + TILE // 2))) scene.set_view(int(ox), int(oy))
def dialog_lines(): if stage == 0: return ["Villager:", "Bring me all %d coins and" % NCOINS, "I'll open the shrine door.", "(press A)"] if stage == 1 and got < NCOINS: return ["Villager:", "You have %d of %d coins." % (got, NCOINS), "Keep looking!", "(press A)"] return ["Villager:", "The door is open.", "Seek the shrine within.", "(press A)"]
def open_door(): for (tx, ty) in door_tiles: world.tile(tx, ty, 2) # door -> path (no longer SOLID) scene.invalidate()
follow()dt = 1 / 30while True: btn.poll() frame += 1
if state == "win": if not overlay_shown: # draw ONCE -> no per-frame flicker scene.refresh() dlg.draw(board.DISPLAY, bufA, ["You reached the shrine!", "", "QUEST COMPLETE", "(press A)"]) overlay_shown = True if btn.just_pressed(btn.A): state = "over"; scene.invalidate() clock.tick() continue
if state == "dialog": if not overlay_shown: # draw ONCE -> no per-frame flicker scene.refresh() dlg.draw(board.DISPLAY, bufA, dialog_lines()) overlay_shown = True if btn.just_pressed(btn.A) or btn.just_pressed(btn.B): if stage == 0: stage = 1 elif stage == 1 and got >= NCOINS: stage = 2 open_door() state = "over"; scene.invalidate() clock.tick() continue
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT) dy = btn.is_pressed(btn.DOWN) - btn.is_pressed(btn.UP) if dx: facing = RIGHT if dx > 0 else LEFT elif dy: facing = DOWN if dy > 0 else UP moved = False if dx and can_walk(hero.x + dx * SPEED, hero.y): hero.move(hero.x + dx * SPEED, hero.y); moved = True if dy and can_walk(hero.x, hero.y + dy * SPEED): hero.move(hero.x, hero.y + dy * SPEED); moved = True if moved: follow(); walk.play(FACE_NAME[facing]); walk.tick(dt) else: hero.frame = facing * 2
if btn.just_pressed(btn.B): ddx, ddy = DIR[facing] ax, ay = hero.x + ddx * TILE, hero.y + ddy * TILE for e in enemies: if e.visible and abs(e.x - ax) < TILE and abs(e.y - ay) < TILE: e.visible = False
if frame % 2 == 0: for e in enemies: if not e.visible: continue sx = (hero.x > e.x) - (hero.x < e.x) sy = (hero.y > e.y) - (hero.y < e.y) if sx and can_walk(e.x + sx, e.y): e.move(e.x + sx, e.y) if sy and can_walk(e.x, e.y + sy): e.move(e.x, e.y + sy)
if inv > 0: inv -= 1 else: for e in enemies: if e.visible and near(e, hero.x, hero.y, 13): hp -= 1 inv = 40 if hp <= 0: hp = 6 hero.move(START[0], START[1]); follow() break
for c in coins: if c.visible and abs(hero.x - c.x) < 12 and abs(hero.y - c.y) < 12: c.visible = False; got += 1
# reach the shrine (goal tile) once the door is open if stage >= 2: ctx = (hero.x + TILE // 2) // TILE cty = (hero.y + TILE // 2) // TILE if world.tile(ctx, cty) == 7: state = "win"; overlay_shown = False
if near(hero, npc.x, npc.y): hud.set("HP %d COINS %d/%d A:TALK" % (hp, got, NCOINS)) if btn.just_pressed(btn.A): state = "dialog"; overlay_shown = False else: obj = "FIND THE COINS" if stage < 1 or got < NCOINS else ("DOOR OPEN!" if stage >= 2 else "RETURN TO NPC") hud.set("HP %d COINS %d/%d %s" % (hp, got, NCOINS, obj))
scene.refresh() dt = clock.tick()The payoff: you hand-built a whole RPG. You don’t have to keep hand-coding maps — the
web editor lets you paint this map, place the hero/NPC/coins, and flag tiles
(solid/coin/goal) visually, and picogame_scene loads it. Everything step8 does by hand
becomes data. See the scene format reference, and the bundled
examples/picogame_platformer_scene.py example for a complete scene loaded from data.