Skip to main content

[engine]

# =============================================================================
# ENGINE (FRAMEWORK FILE) VERSION 2025-12-20
# =============================================================================
# The engine uses palix sprites to render the animated actors in a scene.
# It also manages tilemaps and tilesets as well as the gamepad module.
# If you know how to make your own engine, you could delete this file.

# -----------------------------------------------------------------------------
module gamepad
var left: bool
var right: bool
var up: bool
var down: bool
var button_a: bool
var button_b: bool
var button_c: bool
var button_d: bool
end module

# -----------------------------------------------------------------------------
class tile_layer
var tilemap: tilemap
var tileset: tileset

var io_tilemap: io_tilemap
var io_tileset_addresses: int[size 1024]

var x: int, y: int

# If screen_space=true, then the camera does not affect this tilemap
var screen_space: bool

# If wrap=true, the tilemap repeats infinitely in every direction
var wrap: bool

# 8 for regular tiles, 16 for jumbo tiles
var _tile_dimension: int

# ---------------------------------------------------------------------------
func new(io_tilemap: io_tilemap, io_tileset_addresses: int[size 1024])
io_tilemap -> .io_tilemap
io_tileset_addresses -> .io_tileset_addresses
end func

# ---------------------------------------------------------------------------
# Use tileset=null to unload.
func load_tileset(tileset: tileset)
tileset -> .tileset

var io_tilemap: io_tilemap
.io_tilemap -> io_tilemap
var io_tileset_addresses: int[size 1024]
.io_tileset_addresses -> io_tileset_addresses

if tileset = null then
0 -> io_tilemap.jumbo
0 -> io_tilemap.tile_count
return
end if

var old_tile_count: pair
io_tilemap.tile_count -> old_tile_count

var tile_count: pair, p: pair

if tileset.jumbo then
1 -> io_tilemap.jumbo
16 -> ._tile_dimension
else
0 -> io_tilemap.jumbo
8 -> ._tile_dimension
end if

to_pair(tileset.tiles.size) -> tile_count
if tile_count > 1024
do 1024 -> tile_count
tile_count -> io_tilemap.tile_count

0 -> p
loop
if p >= tile_count
do drop

var tile: tile
tileset.tiles[p] -> tile
if tile <> null then
# (+3 skips array size)
to_address(tile.pixels) + 3 -> io_tileset_addresses[p]
end if

to_pair(p + 1) -> p
end loop

# Clear the remainder
loop
if p >= old_tile_count
do drop

0 -> io_tileset_addresses[p]
to_pair(p + 1) -> p
end loop
end func

# ---------------------------------------------------------------------------
# Use tilemap=null to unload.
func load_tilemap(tilemap: tilemap)
tilemap -> .tilemap

var io_tilemap: io_tilemap
.io_tilemap -> io_tilemap

if tilemap = null then
0 -> io_tilemap.col_count
0 -> io_tilemap.row_count
0 -> io_tilemap.tile_codes_address
0 -> io_tilemap.edge_mode
return
end if

tilemap.col_count -> io_tilemap.col_count
tilemap.row_count -> io_tilemap.row_count

# (+3 skips array size)
to_address(tilemap.tile_codes) + 3 -> io_tilemap.tile_codes_address
end func

# ---------------------------------------------------------------------------
func _render()
var io_tilemap: io_tilemap
.io_tilemap -> io_tilemap

if .tilemap <> null then
var x: int, y: int
var x_pair: pair, y_pair: pair

.x -> x
.y -> y

if not .screen_space then
x - engine::camera_x -> x
y - engine::camera_y -> y
end if

to_pair(x) -> x_pair
to_pair(y) -> y_pair

var clip_edge: bool
not .wrap -> clip_edge

to_byte(clip_edge) -> io_tilemap.edge_mode # 0=wrap, 1=clip

if clip_edge and (x_pair <> x or y_pair <> y) then
# Hide the tilemap if to_pair() overflows
-16384 -> io_tilemap.x
else
x_pair -> io_tilemap.x
y_pair -> io_tilemap.y
end if
end if
end func

# ---------------------------------------------------------------------------
func get_tile_at_colrow(col: int, row: int): pair
var tilemap: tilemap
.tilemap -> tilemap

var tile_code: pair
var col_count: int, row_count: int
tilemap.col_count -> col_count
tilemap.row_count -> row_count

if col >= 0 and col < col_count and row >= 0 and row < row_count then
tilemap.tile_codes[row * col_count + col] -> tile_code
else
-1 -> tile_code
end if

return tile_code
end func

# ---------------------------------------------------------------------------
func set_tile_at_colrow(col: int, row: int, tile_code: pair)
var tilemap: tilemap
.tilemap -> tilemap

var col_count: int, row_count: int
tilemap.col_count -> col_count
tilemap.row_count -> row_count

if col >= 0 and col < col_count and row >= 0 and row < row_count then
tile_code -> tilemap.tile_codes[row * col_count + col]
end if
end func

# ---------------------------------------------------------------------------
func get_tile_at_xy(x: int, y: int): pair
var tile_dimension: int
._tile_dimension -> tile_dimension
var result: pair
# Euclidean division handles negative (x,y) correctly
.get_tile_at_colrow(x // tile_dimension, y // tile_dimension) -> result
return result
end func

# ---------------------------------------------------------------------------
func set_tile_at_xy(x: int, y: int, tile_code: pair)
var tile_dimension: int
._tile_dimension -> tile_dimension
.set_tile_at_colrow(x // tile_dimension, y // tile_dimension, tile_code)
end func

# ---------------------------------------------------------------------------
func get_color_at_xy(x: int, y: int): byte
var tile_code: pair, tx: int, ty: int
var tile: tile, color: byte

var tileset: tileset
.tileset -> tileset

var tile_dimension: int
._tile_dimension -> tile_dimension

.get_tile_at_colrow(x // tile_dimension, y // tile_dimension) -> tile_code

# Bits 0..9 encode the tile index (0..1023) from the tileset
var tile_index: pair
to_pair(math::bit_and(tile_code, $03ff)) -> tile_index

if tile_code >= 0 and tile_index < tileset.tiles.size then
tileset.tiles[tile_index] -> tile

if tile <> null then
x %% tile_dimension -> tx
y %% tile_dimension -> ty
tile.pixels[ty * tile_dimension + tx] -> color
return color
end if
end if

0 -> color # clear color
return color
end func
end class

# -----------------------------------------------------------------------------
module engine
var camera_x: int, camera_y: int

var tile_layer_a: tile_layer
var tile_layer_b: tile_layer
var tile_layer_c: tile_layer

var frame_counter: pair

var _thinklist_head: actor
var _thinklist_tail: actor

# A special "fence" node that is not actually used,
# but marks the start/end of the circlular list
var _renderlist_fence: actor

# For a given depth, the next node will be added before this one
# in the renderlist
var _insertion_points: actor[size 4]
var _last_render_actor: actor

var _actor_count: int

# Avoid having to clear sprites that are unused
var _used_sprites: int

# 0=none, 1=thinking, 2=rendering, 3=clearing
var _busy: byte

# ---------------------------------------------------------------------------
func init()
0 -> io::paint_mode
io::frame_counter -> engine::frame_counter

null -> engine::_thinklist_head
null -> engine::_thinklist_tail

var insertion_points: actor[size 4]
new actor[size 4]() -> insertion_points

insertion_points -> engine::_insertion_points

var renderlist_fence: actor
new actor() -> renderlist_fence

# Create the circular list
renderlist_fence -> renderlist_fence._renderlist_prev_link
renderlist_fence -> renderlist_fence._renderlist_next_link

# Wire up the pointers
renderlist_fence -> insertion_points[0]
renderlist_fence -> insertion_points[1]
renderlist_fence -> insertion_points[2]
renderlist_fence -> insertion_points[3]
renderlist_fence -> engine::_last_render_actor
renderlist_fence -> engine::_renderlist_fence

0 -> engine::_actor_count
0 -> engine::_used_sprites

io::background_color <-- 13 # skyscape

new tile_layer(io::tilemap_a, io::tileset_a_addresses)
| -> engine::tile_layer_a
new tile_layer(io::tilemap_b, io::tileset_b_addresses)
| -> engine::tile_layer_b
new tile_layer(io::tilemap_c, io::tileset_c_addresses)
| -> engine::tile_layer_c
end func

# ---------------------------------------------------------------------------
func load_actor_spot(spot: s_actor_spot): actor
var actor: actor

if spot.actor_factory <> null then
spot.actor_factory() -> actor
else
new actor() -> actor
end if

actor.apply_spot(spot)

engine::add_actor_behind(actor)

return actor
end func

# ---------------------------------------------------------------------------
func load_actor_spots(spots: s_actor_spot[])
var i, size
spots.size -> size
0 -> i
loop
if i >= size
do drop
engine::load_actor_spot(spots[i])
i + 1 -> i
end loop
end func

# ---------------------------------------------------------------------------
func clear_actors()
var old_busy: byte
engine::_busy -> old_busy

# 0=none, 1=thinking, 2=rendering, 3=clearing
if old_busy = 3
do return # already clearing

if old_busy = 2
do kernel::fail("Operation is not allowed")

3 -> engine::_busy

# The loop could simply use get_next_actor() and remove_actor(), but the
# loop is optimized based on the guarantee that add_actor() can't happen
var actor: actor
engine::_thinklist_head -> actor

# renderlist is temporarily corrupted during this loop
loop
if actor = null
do drop

if actor._renderlist_prev_link <> null then
# This actor is not already marked for removal, so remove it

# Remove from list
null -> actor._renderlist_prev_link
null -> actor._renderlist_next_link

engine::_actor_count - 1 -> engine::_actor_count

actor.on_removed()
end if

actor._thinklist_next_link -> actor
end loop

# Clear out the thinklist
engine::_prune_thinklist()

# Now reset the renderlist
var renderlist_fence: actor
engine::_renderlist_fence -> renderlist_fence

# Empty the circular list
renderlist_fence -> renderlist_fence._renderlist_prev_link
renderlist_fence -> renderlist_fence._renderlist_next_link

var insertion_points: actor[size 4]
engine::_insertion_points -> insertion_points

# Wire up the pointers
renderlist_fence -> insertion_points[0]
renderlist_fence -> insertion_points[1]
renderlist_fence -> insertion_points[2]
renderlist_fence -> insertion_points[3]
renderlist_fence -> engine::_last_render_actor

0 -> engine::_actor_count

old_busy -> engine::_busy
end func

# ---------------------------------------------------------------------------
# Add the actor in front of all other actors in its depth level
func add_actor(actor: actor)
engine::_add_actor(actor, false)
actor.on_added()
end func

# ---------------------------------------------------------------------------
# Add the actor behind all other actors in its depth level
func add_actor_behind(actor: actor)
engine::_add_actor(actor, true)
actor.on_added()
end func

# ---------------------------------------------------------------------------
func _add_actor(actor: actor, behind: bool)
# 0=none, 1=thinking, 2=rendering, 3=clearing
if engine::_busy >= 2
do kernel::fail("Operation is not allowed")

if actor._renderlist_prev_link <> null
do kernel::fail("Actor already added")

# --- Append to thinklist
if engine::_thinklist_tail = null then
# the list was empty
actor -> engine::_thinklist_head
actor -> engine::_thinklist_tail
null -> actor._thinklist_next_link
elsif actor._thinklist_next_link = null
| and engine::_thinklist_tail <> actor then

# it's not already in thinklist, so add it
actor -> engine::_thinklist_tail._thinklist_next_link
actor -> engine::_thinklist_tail
end if

# --- Insert into renderlist
var insertion_points: actor[size 4]
engine::_insertion_points -> insertion_points
var depth: byte
actor._depth -> depth

var insertion_point: actor
insertion_points[depth] -> insertion_point

if behind then
# Find the depth+1 insertion point
var next_insertion_point: actor
if depth < 3 then
insertion_points[depth + 1] -> next_insertion_point
else
engine::_renderlist_fence -> next_insertion_point
end if
if insertion_point = next_insertion_point then
# Our depth sublist is empty, so revert to the non-behind algorithm
false -> behind
else
# Insert before next_insertion_point, i.e. the end of our sublist
next_insertion_point -> insertion_point
end if
end if

# Add the actor immediately before the insertion_point
insertion_point -> actor._renderlist_next_link
insertion_point._renderlist_prev_link -> actor._renderlist_prev_link

actor -> actor._renderlist_prev_link._renderlist_next_link
actor -> insertion_point._renderlist_prev_link

# (no fixups are needed if we inserted before next_insertion_point)
if not behind then
actor -> insertion_points[depth]

# adjust other insertion points
loop
if depth = 0
do drop
to_byte(depth - 1) -> depth

if insertion_points[depth] = insertion_point
do actor -> insertion_points[depth]
end loop
end if

engine::_actor_count + 1 -> engine::_actor_count
end func

# ---------------------------------------------------------------------------
func remove_actor(actor: actor)
engine::_remove_actor(actor)
actor.on_removed()
end func

# ---------------------------------------------------------------------------
func _remove_actor(actor: actor)
# 0=none, 1=thinking, 2=rendering, 3=clearing
if engine::_busy = 2
do kernel::fail("Operation is not allowed")

var insertion_points: actor[size 4]
var next_link: actor

engine::_insertion_points -> insertion_points
actor._renderlist_next_link -> next_link

if next_link = null or actor = engine::_renderlist_fence
do kernel::fail("Actor not added")

# (We don't process thinklist at all here, because we might be
# in the middle of iterating the list. Clearing _renderlist_next_link
# will mark it for removal by _prune_thinklist().)

# If this is an insertion_points, then move to the next one
if insertion_points[0] = actor
do next_link -> insertion_points[0]

if insertion_points[1] = actor
do next_link -> insertion_points[1]

if insertion_points[2] = actor
do next_link -> insertion_points[2]

if insertion_points[3] = actor
do next_link -> insertion_points[3]

if engine::_last_render_actor = actor
do actor._renderlist_prev_link -> engine::_last_render_actor

# Remove from list
actor._renderlist_prev_link -> actor._renderlist_next_link._renderlist_prev_link
actor._renderlist_next_link -> actor._renderlist_prev_link._renderlist_next_link
null -> actor._renderlist_prev_link
null -> actor._renderlist_next_link

engine::_actor_count - 1 -> engine::_actor_count
end func

# ---------------------------------------------------------------------------
func get_first_actor(): actor
var actor: actor
engine::_thinklist_head -> actor

loop
if actor = null or actor._renderlist_prev_link <> null
do drop

# Skip over this actor because it is marked for removal
actor._thinklist_next_link -> actor
end loop

return actor
end func

# ---------------------------------------------------------------------------
func get_next_actor(current: actor): actor
var actor: actor

if current = null then
null -> actor
else
current._thinklist_next_link -> actor
loop
if actor = null or actor._renderlist_prev_link <> null
do drop

# Skip over this actor because it is marked for removal
actor._thinklist_next_link -> actor
end loop
end if

return actor
end func

# ---------------------------------------------------------------------------
# Remove the actors that were marked for removal
func _prune_thinklist()
var current: actor
var previous: actor
null -> previous
engine::_thinklist_head -> current
loop
if current = null then
# Reached the end
if previous = null then
# Previous was the head
null -> engine::_thinklist_head
null -> engine::_thinklist_tail
else
# Previous was the tail
previous -> engine::_thinklist_tail
null -> previous._thinklist_next_link
end if

drop
end if

var next: actor
current._thinklist_next_link -> next

if current._renderlist_prev_link = null then
# This actor was marked for removal
null -> current._thinklist_next_link

# (Don't update previous, because it is the previous non-removed node.)
else
# This actor was not marked for removal
if previous = null then
current -> engine::_thinklist_head
else
current -> previous._thinklist_next_link
end if

current -> previous
end if

next -> current
end loop
end func

# ---------------------------------------------------------------------------
func get_camera_cx(): int
var result
# Viewport dimensions are 320 x 224, so 160 is half the width
engine::camera_x + 160 -> result
return result
end func

# ---------------------------------------------------------------------------
func set_camera_cx(value: int)
value - 160 -> engine::camera_x
end func

# ---------------------------------------------------------------------------
func get_camera_cy(): int
var result
# Viewport dimensions are 320 x 224, so 112 is half the height
engine::camera_y + 112 -> result
return result
end func

# ---------------------------------------------------------------------------
func set_camera_cy(value: int)
value - 112 -> engine::camera_y
end func

# ---------------------------------------------------------------------------
func render()
# 0=none, 1=thinking, 2=rendering, 3=clearing
if engine::_busy <> 0
do kernel::fail("Operation is not allowed")

2 -> engine::_busy

# Update game camera
engine::tile_layer_a._render()
engine::tile_layer_b._render()
engine::tile_layer_c._render()

# Update frame counter
var frame_delta: pair
engine::frame_counter -> frame_delta
io::frame_counter -> engine::frame_counter
to_pair(engine::frame_counter - frame_delta) -> frame_delta

if frame_delta < 0
do 1 -> frame_delta # overflow

var renderlist_fence: actor
var end_actor: actor
engine::_renderlist_fence -> renderlist_fence
engine::_last_render_actor -> end_actor

var sprite_index: int
0 -> sprite_index

if end_actor = renderlist_fence then
# This situation arises after adding to an empty list;
# ensure that end_actor is an actual actor
renderlist_fence._renderlist_prev_link -> end_actor
end if

# (If we're still at the fence, then the list is empty)
if end_actor <> renderlist_fence then
# Count how many actors until the end
var sprites_left: byte
64 -> sprites_left

var actor: actor
end_actor -> actor
loop
actor._renderlist_next_link -> actor

if actor = renderlist_fence
do drop

if actor.visible then
to_byte(sprites_left - 1) -> sprites_left
if sprites_left = 0
do drop
end if
end loop

# Start allocating sprites

if sprites_left = 0 then
actor -> engine::_last_render_actor
else
# The sequence wraps back to the head, so start rendering with the remainder
# because its z is higher

# actor = renderlist_fence
loop
actor._renderlist_next_link -> actor

if actor.visible then
actor.render(sprite_index, frame_delta)
sprite_index + 1 -> sprite_index

to_byte(sprites_left - 1) -> sprites_left
if sprites_left = 0
do drop
end if

if actor = end_actor
do drop
end loop

actor -> engine::_last_render_actor
end if

# Now do the first part again
64 -> sprites_left
end_actor -> actor
loop
actor._renderlist_next_link -> actor

if actor = renderlist_fence
do drop

if actor.visible then
actor.render(sprite_index, frame_delta)
sprite_index + 1 -> sprite_index

to_byte(sprites_left - 1) -> sprites_left
if sprites_left = 0
do drop
end if
end loop
end if

# Hide any remaining unused sprites
var i: int
sprite_index -> i
loop
if i >= engine::_used_sprites
do drop
if i >= 64
do drop

# Hide this sprite
0 -> io::sprites[i].pixels_address

i + 1 -> i
end loop
sprite_index -> engine::_used_sprites

0 -> engine::_busy
end func

# ---------------------------------------------------------------------------
# Wait for the next video frame to get painted.
# This also synchronizes the main loop to at most 30 fps.
func wait_for_paint()
1 -> io::irq_pending # "write 1 to clear" irq_pending
3 -> io::paint_mode # request paint
1 -> io::irq_wake_mask # sleep until the paint finishes
kernel::sleep()
end func

# ---------------------------------------------------------------------------
func think()
# 0=none, 1=thinking, 2=rendering, 3=clearing
if engine::_busy <> 0
do kernel::fail("Operation is not allowed")

1 -> engine::_busy

var buttons: byte
io::gamepad_0.buttons --> buttons
math::bit_and(buttons, 1) <> 0 -> gamepad::button_a
math::bit_and(buttons, 2) <> 0 -> gamepad::button_b
math::bit_and(buttons, 4) <> 0 -> gamepad::button_c
math::bit_and(buttons, 8) <> 0 -> gamepad::button_d

false -> gamepad::left
false -> gamepad::right
false -> gamepad::up
false -> gamepad::down

var p: pair
io::gamepad_0.x -> p
if p >= 512 then
true -> gamepad::right
elsif p <= -512 then
true -> gamepad::left
end if

io::gamepad_0.y -> p
if p >= 512 then
true -> gamepad::down
elsif p <= -512 then
true -> gamepad::up
end if

var actor: actor
engine::get_first_actor() -> actor
loop
if actor = null
do drop

# Don't process actors that were marked for removal
if actor._renderlist_prev_link <> null
do actor.think()

engine::get_next_actor(actor) -> actor
end loop

# Now that we've finished processing, clean up any nodes that were
# marked for removal by engine::remove_actor()
engine::_prune_thinklist()

0 -> engine::_busy
end func
end module