2D Games¶
This page describes part of the Python framework that comes in the
acron wheel. It has no C++ counterpart.
The 2D tier of the acron package is what a tile platformer needs on top of
the engine: a pixel stage with draw order, tile maps that render and collide,
flip-book sprites, a kinematic 2D character, overlap sensors, a following
camera, parallax layers and CPU particles. It was built by porting a Godot 2D
platformer, so its units and names are Godot’s: positions are pixels with y
pointing down, and a ported constant like speed = 400 keeps its meaning.
Each module is imported on its own:
from acron.canvas2d import Canvas2D, set_pos_2d, set_z_index
from acron.tile_map import build_layer_geometry, build_collision_shapes
from acron.sprite2d import Sprite2D, AnimatedSprite2D
from acron.character_body_2d import CharacterBody2D
from acron.area2d import Area2D, Area2DWorld
from acron.camera_2d import Camera2D
from acron.parallax import ParallaxLayer, ParallaxWorld
from acron.particles2d import ParticleSystem2D, EmitterSpec
The stage¶
Canvas2D puts the 2D world on the engine’s XZ plane: a 2D point
(x, y) is the 3D point (x, 0, -y), seen by an orthographic camera
looking down +Y. The camera’s film is view_size pixels, so the visible
area stays the same whatever the window size, as Godot’s viewport stretch
mode does.
from direct.showbase.ShowBase import ShowBase
from acron.canvas2d import Canvas2D
base = ShowBase()
canvas = Canvas2D(base, view_size=(1280, 720))
canvas.set_camera_center(640, 360)
canvas.set_camera_zoom(2.0) # > 1 magnifies
Level content goes under canvas.root. set_pos_2d(np, x, y) and
get_pos_2d(np) place a node in pixel coordinates; g2p_2d and
p2g_2d convert single points.
Depth testing is off for the whole canvas. Draw order is Godot’s
z_index: set_z_index(np, n) puts a node in the fixed cull bin with
that sort, higher on top; nodes with the same z_index draw in scene-graph
order. Pass base=None to get just the root, with no camera, for tests or
a server.
Tile maps¶
acron.tile_map turns tile layers into geometry and collision. It reads
plain data: TileMapLayer (index, name, z_index, enabled,
modulate and a list of TileCell) and a tileset definition. The
tileset classes and the readers that fill them from Godot files are in
acron.gdformat (see Porting Games from Godot):
from acron.gdformat.res import ResRoot
from acron.gdformat.tileset import parse_tileset
from acron.gdformat.tscn import parse_file
from acron.gdformat.tile_map import parse_tile_map_node
res = ResRoot("path/to/godot_project")
tileset = parse_tileset(res.path("res://tiles/tileset.tres"), res=res)
section = next(s for s in parse_file(res.path("res://level.tscn"))
if s.kind == "node" and s.attrs.get("type") == "TileMap")
layers = parse_tile_map_node(section)
build_layer_geometry(layer, tileset, parent) builds one layer as textured
cards under parent and returns its node. Cards are grouped and flattened
into one Geom per atlas texture, so thousands of tiles cost a handful of draw
calls. Flip bits are applied to the UVs; an animated tile shows its first
frame. Tiles that carry a Godot material get a group of their own, tagged
tile_material, which a game finds with
layer_np.find_all_matches("**/=tile_material") to put its shader on.
UVs are computed against the texture’s original file size. The engine may scale a texture whose sides are not powers of two when it loads it, and the loaded size would put every atlas coordinate in the wrong place.
build_collision_shapes(layers, tileset) returns a list of
(shape, cell, one_way) triples: each physics polygon of a tile becomes a
BulletConvexHullShape prism, extruded across the plane, with its world
position baked into the points. Attach them all to one static body at the
origin:
from acron.bullet import BulletRigidBodyNode
from acron.world import PhysicsWorld, layer_bit
LEVEL, PLAYER = layer_bit(1), layer_bit(2)
physics = PhysicsWorld()
body = BulletRigidBodyNode("tiles")
for shape, cell, one_way in build_collision_shapes(layers, tileset):
body.add_shape(shape)
canvas.root.attach_new_node(body).set_collide_mask(LEVEL)
physics.attach(body)
Pass scale and offset when the TileMap node itself is scaled or moved,
so the collision lands where the tiles draw. scene_tile_instances(layers,
tileset) yields (scene_path, x, y, cell) for every tile that stands for
a scene (springs, platforms): the game spawns its own object there.
The 2D character¶
CharacterBody2D is Godot’s 2D move_and_slide on the physics
world. It runs the same collide-and-slide core as the 3D mover (see
Physics and Characters), swept through the tile prisms, so slopes
and any convex tile shape collide exactly. velocity is in pixels per
second, y down; is_on_floor, is_on_wall and is_on_ceiling report
contact against floor_max_angle_deg (45 by default).
from acron.character_body_2d import CharacterBody2D
GRAVITY = 1200.0 # px/s^2; +y is down
JUMP = -575.0
class Player(CharacterBody2D):
def _physics_process(self, dt):
if self.is_on_floor() and self.velocity.y > 0:
self.velocity.y = 0
self.velocity.y += GRAVITY * dt
self.move_and_slide(dt)
player = Player(physics, size=(32, 48),
collide_mask=LEVEL, body_mask=PLAYER)
player.np.reparent_to(canvas.root)
player.set_position(100, 200)
shape is "rect" (the default) or "capsule"; set_rect(size,
offset) swaps the box, for a crouch. The defaults are Godot’s:
max_slides=4 and floor_snap_length=1.0 pixel, and the snap only runs
when the body stood on the floor the step before and is not moving up, so it
never cancels a jump.
One-way platforms go on collision layers of their own, named in
one_way_masks: {layer: (dx, dy)} is swept only while the body moves
against that direction. A jump-through floor is (0, -1):
ONE_WAY = layer_bit(5)
player = Player(physics, size=(32, 48), collide_mask=LEVEL,
body_mask=PLAYER, one_way_masks={ONE_WAY: (0, -1)})
After moving the body to a different level, call reset_collision_state():
the contact flags only refresh when move_and_slide runs, so for one step
the body would otherwise still report the floor of the old level.
Step the body from the fixed-step loop (loop.register(player); see
The Game Loop), which also advances the physics world after every
_physics_process.
Overlap sensors¶
Area2D is a rectangle that fires body_entered and
body_exited (Signal objects) as bodies overlap it
– pickups, hit boxes, triggers. An Area2DWorld holds the areas and
bodies and is stepped once per fixed step. A body is anything with
get_position() and a size, such as a CharacterBody2D.
from acron.area2d import Area2D, Area2DWorld
areas = Area2DWorld()
coin = Area2D(size=(24, 24), name="coin")
coin.set_position(400, 180)
coin.body_entered.connect(lambda body: print("picked up by", body))
areas.add_area(coin)
areas.add_body(player)
areas.step() # once per fixed step
Setting monitoring = False pauses an area; get_overlapping_bodies()
lists what is inside it now.
Sprites¶
Sprite2D is one textured card; AnimatedSprite2D plays named
flip-book animations. Both load their textures with nearest filtering and
alpha, are centred by default, and take offset, flip_h and flip_v
(change the flips with set_flip). Place them through their np.
The animations are plain data: a dict of {name: SpriteAnimation}, each
with speed (frames per second), loop and a list of FrameDef
(a texture path, an optional (x, y, w, h) atlas region, a relative
duration). acron.gdformat.sprite_frames reads them from a Godot
SpriteFrames resource.
from acron.sprite2d import (AnimatedSprite2D, FrameDef, SpriteAnimation,
first_playable)
frames = {
"run": SpriteAnimation("run", speed=10, frames=[
FrameDef("art/hero.png", region=(0, 0, 32, 48)),
FrameDef("art/hero.png", region=(32, 0, 32, 48)),
]),
}
hero = AnimatedSprite2D(canvas.root, frames,
autoplay=first_playable(frames, "idle", "run"))
hero.animation_finished.connect(lambda: print("done"))
hero.advance(dt) # each fixed step or render frame
play(name) and stop() control playback; a non-looping animation emits
animation_finished at its end, a looping one animation_looped at every
wrap. first_playable picks the first named animation that has frames:
shipped sprite sets often declare empty ones, and a sprite parked on one draws
nothing.
The camera¶
Camera2D follows a target over the canvas camera, clamped so that the
view never leaves the limit_left, limit_top, limit_right and
limit_bottom rectangle, with optional exponential smoothing:
from acron.camera_2d import Camera2D
cam = Camera2D(canvas, limit_left=0, limit_top=0,
limit_right=6400, limit_bottom=720,
smoothing_enabled=True, smoothing_speed=5.0)
cam.snap_to(*player.get_position()) # at level start
# every render frame, with the (interpolated) player position:
cam.update(x, y, dt)
offset, zoom and rotation (degrees) are attributes.
visible_rect(margin) returns the view as (left, top, right, bottom),
for culling decorations that are off screen.
Parallax¶
A ParallaxLayer with motion_scale m scrolls at m times the
camera’s speed: 1 moves with the world, 0 stays on the screen.
mirroring=(mx, my) repeats the layer every mx/my pixels for an
endless background. Parent the layer’s content under its np, and update
the layers with the camera centre each frame:
from acron.parallax import ParallaxLayer, ParallaxWorld
parallax = ParallaxWorld()
sky = parallax.add(ParallaxLayer(canvas.root, motion_scale=(0.2, 0.0),
mirroring=(1920, 0)))
# ... sky art under sky.np ...
parallax.update(*canvas.get_camera_center())
Particles¶
ParticleSystem2D simulates emitters on the CPU as one pooled card per
particle. An emitter is an EmitterSpec of plain numbers – emission
shape, direction and spread, velocity, gravity, angle and scale ranges with
their Curve, a colour Gradient, hue variation, the texture
and an optional region – and acron.gdformat.particles2d reads them
from Godot CPUParticles2D and GPUParticles2D nodes.
import random
from acron.gdformat.particles2d import emitter_specs
from acron.particles2d import ParticleSystem2D
specs = emitter_specs("res://fx/dust.tscn", res)
fx = ParticleSystem2D(canvas.root, rng=random.Random(1), max_live=400)
fx.emit(specs[0], 120, 300) # a burst of spec.amount particles
fx.step(dt) # every frame
Give the system a random stream of its own: an effect is cosmetic and must not
shift the game’s own random rolls. max_live caps the pool; clear()
retires every particle and queue_free() removes the system. Not
simulated, because no emitter it was built for uses them: explosiveness and
randomness (a burst is emitted at once), radial, tangential and linear
acceleration, damping, orbit velocity, attractors and sub-emitters. A spec
that sets them simulates as if they were 0.