2D Games

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.