Add Chips Challenge for guide

This commit is contained in:
Melissa LeBlanc-Williams 2025-04-01 16:12:51 -07:00
parent d2abcbff2e
commit b4a7cf88bb
37 changed files with 3911 additions and 0 deletions

Binary file not shown.

View file

@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
import audiocore
import audiobusio
from definitions import PLAY_SOUNDS
class Audio:
def __init__(self, *, bit_clock, word_select, data):
self._audio = audiobusio.I2SOut(bit_clock, word_select, data)
self._wav_files = {}
def add_sound(self, sound_name, file):
self._wav_files[sound_name] = file
def play(self, sound_name, wait=False):
if not PLAY_SOUNDS:
return
if sound_name in self._wav_files:
with open(self._wav_files[sound_name], "rb") as wave_file:
wav = audiocore.WaveFile(wave_file)
self._audio.play(wav)
if wait:
while self._audio.playing:
pass

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
import time
import board
import picodvi
import framebufferio
import displayio
from game import Game
from definitions import SECOND_LENGTH, TICKS_PER_SECOND
# Disable auto-reload to prevent the game from restarting
# TODO: Enable after testing
#import supervisor
#supervisor.runtime.autoreload = False
# Change this to use a different data file
DATA_FILE = "CHIPS.DAT"
displayio.release_displays()
audio_settings = {
'bit_clock': board.D9,
'word_select': board.D10,
'data': board.D11
}
fb = picodvi.Framebuffer(320, 240, clk_dp=board.CKP, clk_dn=board.CKN,
red_dp=board.D0P, red_dn=board.D0N,
green_dp=board.D1P, green_dn=board.D1N,
blue_dp=board.D2P, blue_dn=board.D2N,
color_depth=8)
display = framebufferio.FramebufferDisplay(fb)
game = Game(display, DATA_FILE, **audio_settings)
tick_length = SECOND_LENGTH / 1000 / TICKS_PER_SECOND
while True:
start = time.monotonic()
game.tick()
while time.monotonic() - start < tick_length:
pass

View file

@ -0,0 +1,132 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
from point import Point
from definitions import NONE, TYPE_BLOCK, TYPE_CHIP, NORTH, SOUTH, WEST, EAST
DIR_UP = 0
DIR_LEFT = 1
DIR_DOWN = 2
DIR_RIGHT = 3
# creatures should move based on chip, tiles near them, and their own AI
# creatures should be able to move in any direction assuming they are not blocked
# Abstract class
class Creature:
def __init__(self, *, position=None, direction=NONE, creature_type=NONE):
self.cur_pos = position or Point(0, 0)
self.type = creature_type or TYPE_BLOCK
self.direction = direction
self.state = 0x00
self.hidden = False
self.on_slip_list = False
self.to_direction = NONE
def move(self, destination):
if destination.y < self.cur_pos.y:
self.direction = NORTH
elif destination.x < self.cur_pos.x:
self.direction = WEST
elif destination.y > self.cur_pos.y:
self.direction = SOUTH
elif destination.x > self.cur_pos.x:
self.direction = EAST
else:
self.direction = NONE
self.cur_pos = destination
def image_number(self):
tile_index = 0
if self.type == TYPE_CHIP:
tile_index = 0x6C
elif self.type == TYPE_BLOCK:
tile_index = 0x0A
else:
tile_index = 0x40 + ((self.type - 1) * 4)
if self.direction == WEST:
tile_index += DIR_LEFT
elif self.direction == EAST:
tile_index += DIR_RIGHT
elif self.direction == NORTH:
tile_index += DIR_UP
elif self.direction in (SOUTH, NONE):
tile_index += DIR_DOWN
return tile_index
def get_tile_in_dir(self, direction):
pt_dir = Point(self.cur_pos.x, self.cur_pos.y)
if direction == WEST:
pt_dir.x -= 1
elif direction == EAST:
pt_dir.x += 1
elif direction == NORTH:
pt_dir.y -= 1
elif direction == SOUTH:
pt_dir.y += 1
return pt_dir
def left(self):
# return the point to the left of the creature
pt_dest = Point(self.cur_pos.x, self.cur_pos.y)
if self.direction == NORTH:
pt_dest.x -= 1
elif self.direction == WEST:
pt_dest.y += 1
elif self.direction == SOUTH:
pt_dest.x += 1
elif self.direction == EAST:
pt_dest.y -= 1
return pt_dest
def right(self):
# Return point to the right of the creature
pt_dest = Point(self.cur_pos.x, self.cur_pos.y)
if self.direction == NORTH:
pt_dest.x += 1
elif self.direction == WEST:
pt_dest.y -= 1
elif self.direction == SOUTH:
pt_dest.x -= 1
elif self.direction == EAST:
pt_dest.y += 1
return pt_dest
def back(self):
# Return point behind the creature
pt_dest = Point(self.cur_pos.x, self.cur_pos.y)
if self.direction == NORTH:
pt_dest.y += 1
elif self.direction == WEST:
pt_dest.x += 1
elif self.direction == SOUTH:
pt_dest.y -= 1
elif self.direction == EAST:
pt_dest.x -= 1
return pt_dest
def front(self):
# Return point in front of the creature
pt_dest = Point(self.cur_pos.x, self.cur_pos.y)
if self.direction == NORTH:
pt_dest.y -= 1
elif self.direction == WEST:
pt_dest.x -= 1
elif self.direction == SOUTH:
pt_dest.y += 1
elif self.direction == EAST:
pt_dest.x += 1
return pt_dest
def reverse(self):
if self.direction == NORTH:
return SOUTH
elif self.direction == SOUTH:
return NORTH
elif self.direction == WEST:
return EAST
elif self.direction == EAST:
return WEST
else:
return self.direction

View file

@ -0,0 +1,43 @@
# SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams for Adafruit Industries
#
# SPDX-License-Identifier: MIT
class DataBuffer:
def __init__(self):
self._dataset = {}
self._default_data = {}
def set_data_structure(self, data_structure):
self._default_data = data_structure
self.reset()
def reset(self, field=None):
# Copy the default data to the dataset
if field is not None:
if not isinstance(field, (tuple, list)):
field = [field]
for item in field:
self._dataset[item] = self.deepcopy(self._default_data[item])
else:
self._dataset = self.deepcopy(self._default_data)
def deepcopy(self, data):
# Iterate through the data and copy each element
new_data = {}
if isinstance(data, (dict)):
for key, value in data.items():
if isinstance(value, (dict, list)):
new_data[key] = self.deepcopy(value)
else:
new_data[key] = value
elif isinstance(data, (list)):
for idx, item in enumerate(data):
if isinstance(item, (dict, list)):
new_data[idx] = self.deepcopy(item)
else:
new_data[idx] = item
return new_data
@property
def dataset(self):
return self._dataset

View file

@ -0,0 +1,336 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
from micropython import const
# Settings
PLAY_SOUNDS = False
# Timing Constants
TICKS_PER_SECOND = const(20)
SECOND_LENGTH = const(1000)
# Tile Constants
TYPE_NOTILE = const(-1)
TYPE_EMPTY = const(0x00)
TYPE_WALL = const(0x01)
TYPE_ICCHIP = const(0x02)
TYPE_WATER = const(0x03)
TYPE_FIRE = const(0x04)
TYPE_HIDDENWALL_PERM = const(0x05)
TYPE_WALL_NORTH = const(0x06)
TYPE_WALL_WEST = const(0x07)
TYPE_WALL_SOUTH = const(0x08)
TYPE_WALL_EAST = const(0x09)
TYPE_BLOCK_STATIC = const(0x0a)
TYPE_DIRT = const(0x0b)
TYPE_ICE = const(0x0c)
TYPE_SLIDE_SOUTH = const(0x0d)
TYPE_SLIDE_NORTH = const(0x12)
TYPE_SLIDE_EAST = const(0x13)
TYPE_SLIDE_WEST = const(0x14)
TYPE_EXIT = const(0x15)
TYPE_DOOR_BLUE = const(0x16)
TYPE_DOOR_RED = const(0x17)
TYPE_DOOR_GREEN = const(0x18)
TYPE_DOOR_YELLOW = const(0x19)
TYPE_ICEWALL_SOUTHEAST = const(0x1a)
TYPE_ICEWALL_SOUTHWEST = const(0x1b)
TYPE_ICEWALL_NORTHWEST = const(0x1c)
TYPE_ICEWALL_NORTHEAST = const(0x1d)
TYPE_BLUEWALL_FAKE = const(0x1e)
TYPE_BLUEWALL_REAL = const(0x1f)
TYPE_THIEF = const(0x21)
TYPE_SOCKET = const(0x22)
TYPE_BUTTON_GREEN = const(0x23)
TYPE_BUTTON_RED = const(0x24)
TYPE_SWITCHWALL_CLOSED = const(0x25)
TYPE_SWITCHWALL_OPEN = const(0x26)
TYPE_BUTTON_BROWN = const(0x27)
TYPE_BUTTON_BLUE = const(0x28)
TYPE_TELEPORT = const(0x29)
TYPE_BOMB = const(0x2a)
TYPE_BEARTRAP = const(0x2b)
TYPE_HIDDENWALL_TEMP = const(0x2c)
TYPE_GRAVEL = const(0x2d)
TYPE_POPUPWALL = const(0x2e)
TYPE_HINTBUTTON = const(0x2f)
TYPE_WALL_SOUTHEAST = const(0x30)
TYPE_CLONEMACHINE = const(0x31)
TYPE_SLIDE_RANDOM = const(0x32)
TYPE_CHIP_DROWNED = const(0x33)
TYPE_CHIP_BURNED = const(0x34)
TYPE_CHIP_BOMBED = const(0x35)
TYPE_EXITED_CHIP = const(0x39)
TYPE_EXIT_EXTRA_1 = const(0x3a)
TYPE_EXIT_EXTRA_2 = const(0x3b)
TYPE_BLOCK = const(0xd0)
TYPE_CHIP_SWIMMING = const(0x3c)
TYPE_BUG = const(0x40)
TYPE_FIREBALL = const(0x44)
TYPE_BALL = const(0x48)
TYPE_TANK = const(0x4c)
TYPE_GLIDER = const(0x50)
TYPE_TEETH = const(0x54)
TYPE_WALKER = const(0x58)
TYPE_BLOB = const(0x5c)
TYPE_PARAMECIUM = const(0x60)
TYPE_KEY_BLUE = const(0x64)
TYPE_KEY_RED = const(0x65)
TYPE_KEY_GREEN = const(0x66)
TYPE_KEY_YELLOW = const(0x67)
TYPE_BOOTS_WATER = const(0x68)
TYPE_BOOTS_FIRE = const(0x69)
TYPE_BOOTS_ICE = const(0x6a)
TYPE_BOOTS_SLIDE = const(0x6b)
TYPE_CHIP = const(0x6c)
TYPE_NOTHING = const(0xff)
# Map Directional Constants
NONE = const(-1)
NORTH = const(1)
WEST = const(2)
SOUTH = const(4)
EAST = const(8)
NWSE = const(NORTH | WEST | SOUTH | EAST)
# Command Constants
UP = const(0)
LEFT = const(1)
DOWN = const(2)
RIGHT = const(3)
NEXT_LEVEL = const(4)
PREVIOUS_LEVEL = const(5)
RESTART_LEVEL = const(6)
GOTO_LEVEL = const(7)
PAUSE = const(8)
QUIT = const(9)
OK = const(10)
CANCEL = const(11)
CHANGE_FIELDS = const(12)
DELCHAR = const(13)
# Keycode Constants
UP_ARROW = const("\x1b[A")
DOWN_ARROW = const("\x1b[B")
RIGHT_ARROW = const("\x1b[C")
LEFT_ARROW = const("\x1b[D")
SPACE = const(" ")
CTRL_G = const("\x07") # Ctrl+G
CTRL_N = const("\x0E") # Ctrl+N
CTRL_P = const("\x10") # Ctrl+P
CTRL_Q = const("\x11") # Ctrl+Q
CTRL_R = const("\x12") # Ctrl+R
BACKSPACE = const("\x08")
TAB = const("\x09")
ENTER = const("\n")
ESC = const("\x1b")
# Mapping Buttons to Commands for different modes
GAMEPLAY_COMMANDS = {
UP_ARROW: UP,
LEFT_ARROW: LEFT,
DOWN_ARROW: DOWN,
RIGHT_ARROW: RIGHT,
SPACE: PAUSE,
CTRL_G: GOTO_LEVEL,
CTRL_N: NEXT_LEVEL,
CTRL_P: PREVIOUS_LEVEL,
CTRL_Q: QUIT,
CTRL_R: RESTART_LEVEL,
}
MESSAGE_COMMANDS = {
ENTER: OK,
SPACE: OK,
}
# Password commands include only letters, enter, tab, and backspace
PASSWORD_COMMANDS = {
ESC: CANCEL,
TAB: CHANGE_FIELDS,
ENTER: OK,
BACKSPACE: DELCHAR,
}
# The rest are input characters
for i in range(65, 91):
PASSWORD_COMMANDS[chr(i)] = chr(i)
for i in range(97, 123):
PASSWORD_COMMANDS[chr(i)] = chr(i)
for i in range(48, 58):
PASSWORD_COMMANDS[chr(i)] = chr(i)
# Can Make Move Constants
CMM_NOLEAVECHECK = const(0x0001)
CMM_NOEXPOSEWALLS = const(0x0002)
CMM_CLONECANTBLOCK = const(0x0004)
CMM_NOPUSHING = const(0x0008)
CMM_TELEPORTPUSH = const(0x0010)
CMM_NOFIRECHECK = const(0x0020)
CMM_NODEFERBUTTONS = const(0x0040)
# Creature States
CS_RELEASED = const(0x01)
CS_CLONING = const(0x02)
CS_HASMOVED = const(0x04)
CS_TURNING = const(0x08)
CS_SLIP = const(0x10)
CS_SLIDE = const(0x20)
CS_DEFERPUSH = const(0x40)
CS_MUTANT = const(0x80)
#Floor State Constants
FS_BUTTONDOWN = const(0x01)
FS_CLONING = const(0x02)
FS_BROKEN = const(0x04)
FS_HASMUTANT = const(0x08)
FS_MARKER = const(0x10)
# Status Flag Constants
SF_CHIPWAITMASK = const(0x0007)
SF_CHIPOKAY = const(0x0000)
SF_CHIPBURNED = const(0x0010)
SF_CHIPBOMBED = const(0x0020)
SF_CHIPDROWNED = const(0x0030)
SF_CHIPHIT = const(0x0040)
SF_CHIPTIMEUP = const(0x0050)
SF_CHIPBLOCKHIT = const(0x0060)
SF_CHIPNOTOKAY = const(0x0070)
SF_CHIPSTATUSMASK = const(0x0070)
SF_DEFERBUTTONS = const(0x0080)
SF_COMPLETED = const(0x0100)
SF_SHOWHINT = const(0x10000000)
# Game Mode Constants
GM_NONE = const(0) # No mode (not sure if this should be a mode)
GM_PAUSED = const(1) # Paused
GM_CHIPDEAD = const(2) # Chip is dead
GM_GAMEWON = const(3) # Game is won
GM_LEVELWON = const(4) # Level is won
GM_LOADING = const(5) # Not sure
GM_MESSAGE = const(6) # Message is displayed
GM_NEWGAME = const(7) # Not sure
GM_NORMAL = const(8) # Normal gameplay
# Key Constants
RED_KEY = const(0)
BLUE_KEY = const(1)
YELLOW_KEY = const(2)
GREEN_KEY = const(3)
# Boot Constants
ICE_BOOTS = const(0)
SUCTION_BOOTS = const(1)
FIRE_BOOTS = const(2)
WATER_BOOTS = const(3)
death_messages = {
SF_CHIPHIT: "Ooops! Look out for creatures!",
SF_CHIPDROWNED: "Ooops! Chip can't swim without flippers!",
SF_CHIPBURNED: "Ooops! Don't step in the fire without fire boots!",
SF_CHIPBOMBED: "Ooops! Don't touch the bombs!",
SF_CHIPTIMEUP: "Ooops! Out of time!",
SF_CHIPBLOCKHIT: "Ooops! Watch out for moving blocks!",
}
decade_messages = {
10: ("After warming up on the first levels of the challenge, "
"Chip is raring to go! 'This isn't so hard,' he thinks."),
20: ("But the challenge turns out to be harder than Chip thought. "
"The Bit Busters want it that way -- to keep out lobotomy heads."),
30: ("Chip's thick-soled shoes and pop-bottle glasses speed him through "
"the mazes while his calculator watch keeps track of time."),
40: "Chip reads the clues so he won't lose.",
50: ("Picking up chips is what the challenge is all about. But on ice, "
"Chip gets chapped and feels like a chump instead of a champ."),
60: ("Chip hits the ice and decides to chill out. Then he runs into a "
"fake wall and turns the maze into a thrash-a-thon!"),
70: ("Chip is halfway through the world's hardest puzzle. If he suceeds, "
"maybe the kids will stop calling him computer breath!"),
80: ("Chip used to spend his time programming computer games and making "
"models. But that was just practice for this brain-buster!"),
90: ("'I can do it! I know I can!' Chip thinks as the going gets tougher. "
"Besides, Melinda the Mental Marvel waits at the end."),
100: ("Besides being an angel on earth, Melinda is the top scorer in the "
"Challenge--and the president of the Bit Busters."),
110: ("Chip can't wait to join the Bit Busters! The club's already figured "
"out the school's password and accessed everyone's grades!"),
120: ("If Chip's grades aren't as good as Melinda's, maybe she'll come "
"over to his house and help him study!"),
130: ("'I've made it this far,' Chip thinks. 'Totally fair, with my "
"mega-brain.' Then he starts the next maze. 'Totally unfair!' he yelps."),
140: "Groov-u-loids! Chip makes it almost to the end. He's stoked!",
144: ("Melinda herself offers Chip membership in the exclusive Bit Busters "
"computer club, and gives him access to the club's computer system. "
"Chip is in heaven!"),
149: ("Melinda herself offers Chip membership in the exclusive Bit Busters "
"computer club, and gives him access to the club's computer system. "
"Chip is in heaven!"),
}
victory_messages = {
0: "Yowser! First Try!",
2: "Go Bit Buster!",
4: "Finished! Good Work!",
5: "At last! You did it!",
}
winning_message = (
"You completed {completed_levels} levels, and your total score for the "
"challenge is {total_score} points.\n\n"
"You can still improve your score, by completing levels that you skipped, "
"and getting better times on each level. When you replay a level, if your "
"new score is better than your old, your score will be adjusted by the "
"difference. Select Best Times from the Game menu to see your scores for "
"each level."
)
# This will show the game won sequence for any of these levels
# -1 represents the last level
final_levels = [144, -1]
def left(direction):
return ((direction << 1) | (direction >> 3)) & 15
def back(direction):
return ((direction << 2) | (direction >> 2)) & 15
def right(direction):
return ((direction << 3) | (direction >> 1)) & 15
def creature_id(tile_id):
return tile_id & ~3
def idx_dir(index):
return 1 << (index & 3)
def dir_idx(direction):
return (0x30210 >> (direction * 2)) & 3
def creature_dir_id(tile_id):
return idx_dir(tile_id & 3)
def cr_tile(tile_id, direction):
return tile_id | dir_idx(direction)
def is_key(tile):
return TYPE_KEY_BLUE <= tile <= TYPE_KEY_YELLOW
def is_boots(tile):
return TYPE_BOOTS_WATER <= tile <= TYPE_BOOTS_SLIDE
def is_creature(tile):
return ((0x40 <= tile <= 0x63) or (TYPE_BLOCK <= tile <= TYPE_BLOCK + 3) or
(TYPE_CHIP_SWIMMING <= tile <= TYPE_CHIP_SWIMMING + 3) or
(TYPE_CHIP <= tile <= TYPE_CHIP + 3))
def is_door(tile):
return TYPE_DOOR_BLUE <= tile <= TYPE_DOOR_YELLOW

View file

@ -0,0 +1,9 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
from point import Point
class Device:
def __init__(self, button=None, device=None):
self.button = button if button else Point(0, 0)
self.device = device if device else Point(0, 0)

View file

@ -0,0 +1,544 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
from micropython import const
import displayio
import bitmaptools
from adafruit_display_text import bitmap_label
from adafruit_display_text.text_box import TextBox
BORDER_STYLE_INSET = 0
BORDER_STYLE_OUTSET = 1
BORDER_STYLE_FLAT = 2
BORDER_STYLE_LIGHT_OUTLINE = 3
BORDER_STYLE_DARK_OUTLINE = 4
class InputFields:
ALPHANUMERIC = const(0)
ALPHA = const(1)
NUMERIC = const(2)
"""Class to keep track of input fields in a dialog"""
def __init__(self):
self._input_fields = []
self._active_field = 0
def add(self, label, field_type=ALPHANUMERIC, value=""):
value_type = int if isinstance(value, int) else str
if value in ("", 0):
value = " "
if value_type not in (str, int):
raise ValueError("value_type must be str or int")
key = label.lower().replace(" ", "_")
focused = len(self._input_fields) == 0
self._input_fields.append({
"key": key,
"label": label,
"font": None,
"value": str(value),
"x": 0,
"y": 0,
"width": 0,
"height": 0,
"color_index": None,
"bgcolor_index": None,
"padding": 10,
"redraw": True, # This is to keep track of whether to redraw the field or not
"max_length": None,
"type": field_type,
"buffer": None,
"focused": focused,
"value_type": value_type,
})
def redraw_all(self):
for field in self._input_fields:
field["redraw"] = True
def clear(self):
self._active_field = 0
self._input_fields = []
def get_field(self, key):
for field in self._input_fields:
if field["key"] == key:
return field
return None
def next_field(self):
self._input_fields[self._active_field]["focused"] = False
self._input_fields[self._active_field]["redraw"] = True
self._active_field = (self._active_field + 1) % len(self._input_fields)
self._input_fields[self._active_field]["focused"] = True
self._input_fields[self._active_field]["redraw"] = True
def get_value(self, key):
field = self.get_field(key)
if field is None:
return None
value = field["value"]
if value == " ":
return "" if field["value_type"] == str else 0
return field["value_type"](value)
@property
def active_field(self):
return self._input_fields[self._active_field]
@property
def active_field_value(self):
if self.active_field["value"] == " ":
return ""
return self.active_field["value"]
@active_field_value.setter
def active_field_value(self, value):
if value == "":
value = " "
self.active_field["value"] = value
self.active_field["redraw"] = True
@property
def fields(self):
return self._input_fields
class Dialog:
def __init__(self, color_index, shader):
self._color_index = color_index
self.shader = shader
def _reassign_indices(self, bitmap, foreground_color_index, background_color_index):
# This will reassign the indices in the bitmap to match the palette
new_bitmap = displayio.Bitmap(bitmap.width, bitmap.height, len(self.shader))
if background_color_index is not None:
for x in range(bitmap.width):
for y in range(bitmap.height):
if bitmap[(x, y)] == 0:
new_bitmap[(x, y)] = background_color_index
if foreground_color_index is not None:
for x in range(bitmap.width):
for y in range(bitmap.height):
if bitmap[(x, y)] == 1:
new_bitmap[(x, y)] = foreground_color_index
return new_bitmap
def _add_border(self, bitmap, border_color_ul, border_color_br):
if border_color_ul is not None:
for x in range(bitmap.width):
bitmap[x, 0] = border_color_ul
for y in range(bitmap.height):
bitmap[0, y] = border_color_ul
if border_color_br is not None:
for x in range(bitmap.width):
bitmap[x, bitmap.height - 1] = border_color_br
for y in range(bitmap.height):
bitmap[bitmap.width - 1, y] = border_color_br
return bitmap
def _convert_padding(self, padding):
if isinstance(padding, int): # Top, Right, Bottom Left (same as CSS)
padding = {
"top": padding // 2,
"right": padding // 2,
"bottom": padding // 2,
"left": padding // 2
}
elif isinstance(padding, (tuple, list)) and len(padding) == 2: # Top/Bottom, Left/Right
padding = {
"top": padding[0],
"right": padding[1],
"bottom": padding[0],
"left": padding[1]
}
elif isinstance(padding, (tuple, list)) and len(padding) == 4: # Top, Right, Bottom, Left
padding = {
"top": padding[0],
"right": padding[1],
"bottom": padding[2],
"left": padding[3]
}
return padding
def _text_bounding_box(self, text, font, line_spacing=0.75):
temp_label = bitmap_label.Label(
font,
text=text,
line_spacing=line_spacing,
background_tight=True
)
return temp_label.bounding_box
def _draw_button(self, buffer, text, font, x_position, y_position,
width=None, height=None, center_button=True, **kwargs):
del kwargs["center_dialog_vertically"]
del kwargs["center_dialog_horizontally"]
if "padding" not in kwargs:
kwargs["padding"] = 10
return self.display_simple(
text,
font,
width,
height,
x_position,
y_position,
buffer,
border_dark_index=self._color_index["dark_gray"],
background_color_index=self._color_index["light_gray"],
center_dialog_horizontally=center_button,
center_dialog_vertically=False,
**kwargs)
def _draw_background(
self,
x_position,
y_position,
width,
height,
buffer,
*,
border_style=BORDER_STYLE_OUTSET,
background_color_index=None,
border_light_index=None,
border_dark_index=None,
):
# Draw a background for the dialog
# This will be a simple rectangle with a border
if border_light_index is None:
# The index of the light border color in the palette
border_light_index = self._color_index["bounding_box_light"]
if border_dark_index is None:
# The index of the dark border color in the palette
border_dark_index = self._color_index["bounding_box_dark"]
if background_color_index is None:
background_color_index = self._color_index["dialog_background"]
if border_style == BORDER_STYLE_OUTSET:
(border_color_ul, border_color_br) = (border_light_index, border_dark_index)
elif border_style == BORDER_STYLE_INSET:
border_color_ul, border_color_br = border_dark_index, border_light_index
elif border_style == BORDER_STYLE_DARK_OUTLINE:
border_color_ul, border_color_br = border_dark_index, border_dark_index
elif border_style == BORDER_STYLE_LIGHT_OUTLINE:
border_color_ul, border_color_br = border_light_index, border_light_index
else:
border_color_ul, border_color_br = None, None
background_bitmap = displayio.Bitmap(width, height, len(self.shader))
background_bitmap.fill(background_color_index)
background_bitmap = self._add_border(background_bitmap, border_color_ul, border_color_br)
bitmaptools.blit(buffer, background_bitmap, x_position, y_position)
def _calculate_dialog_size(self, text, font, width, height, padding):
# Calculate the size of the dialog based on the text and font
if text is not None:
text_width = self._text_bounding_box(text, font)[2]
if width is None:
width = text_width + padding["right"] + padding["left"]
if height is None:
height = self._text_bounding_box(text, font)[3] + padding["top"] + padding["bottom"]
else:
if width is None:
width = 0
if height is None:
height = 0
return width, height
def display_simple(
self,
text, # the text to display in the dialog
font, # the font to use for the dialog
width, # the width of the dialog
height, # the height of the dialog
x_position, # the x coordinate the dialog should be centered on in the buffer
y_position, # the y coordinate the dialog should be centered on in the buffer
buffer,
*,
center_dialog_horizontally=False, # x position in center of the dialog
center_dialog_vertically=False, # y position in center of the dialog
horizontal_text_alignment=TextBox.ALIGN_CENTER, # The alignment of the text
center_text_vertically=True, # whether the text should be centered vertically
background_color_index=None, # the index of the background color in the palette
font_color_index=None, # the index of the font color in the palette
padding=10, # the padding around the text
border_light_index=None,
border_dark_index=None,
line_spacing=0.75, # Space between each line of text in pixels
border_style=BORDER_STYLE_OUTSET, # The style of the border
):
#pylint: disable=too-many-locals, too-many-branches
border_width = 1
if horizontal_text_alignment is None:
horizontal_text_alignment = TextBox.ALIGN_CENTER
if font_color_index is None:
font_color_index = self._color_index["default_dialog_text_color"]
if background_color_index is None:
background_color_index = self._color_index["dialog_background"]
padding = self._convert_padding(padding)
if text is not None:
text_area_padding = (0, 0)
if width is None:
# Create a regular bitmap label with the text to get the width
text_width = self._text_bounding_box(text, font, line_spacing=line_spacing)[2]
text_area_padding = (-padding["left"], -padding["right"])
else:
text_width = width - padding["right"] - padding["left"] - border_width * 2
# Colors don't matter for bitmap fonts
text_area = TextBox(
font,
text_width,
TextBox.DYNAMIC_HEIGHT,
align=horizontal_text_alignment,
text=text,
background_tight=True,
line_spacing=line_spacing,
padding_left=text_area_padding[0],
padding_right=text_area_padding[1],
)
text_bmp = self._reassign_indices(
text_area.bitmap, font_color_index, background_color_index
)
if width is None:
width = text_bmp.width + padding["right"] + padding["left"] + border_width * 2
if height is None:
height = text_bmp.height + padding["top"] + padding["bottom"] + border_width * 2
text_bitmap_position = [padding["left"] + border_width, padding["top"] + border_width]
if center_text_vertically:
text_bitmap_position[1] = (height - text_bmp.height) // 2
else:
text_bmp = None
if width is None:
width = padding["right"] + padding["left"] + border_width * 2
if height is None:
height = padding["top"] + padding["bottom"] + border_width * 2
if x_position is None:
x_position = (buffer.width - width) // 2
elif center_dialog_horizontally and x_position is not None:
x_position = x_position - width // 2
if y_position is None:
y_position = (buffer.height - height) // 2
elif center_dialog_vertically and y_position is not None:
y_position = y_position - height // 2
# Draw the background
self._draw_background(
x_position,
y_position,
width,
height,
border_style=border_style,
background_color_index=background_color_index,
border_light_index=border_light_index,
border_dark_index=border_dark_index,
buffer=buffer,
)
if text_bmp:
bitmaptools.blit(
buffer, text_bmp, x_position + text_bitmap_position[0],
y_position + text_bitmap_position[1]
)
# return the width and height of the dialog in a tuple
return width, height, text_bmp.height if text_bmp else 0
def display_message(self, text, font, width, height, x_position, y_position, buffer, **kwargs):
#pylint: disable=too-many-locals
button_font = font
button_text = "OK"
if "button_font" in kwargs:
button_font = kwargs.pop("button_font")
if "button_text" in kwargs:
button_text = kwargs.pop("button_text")
padding = self._convert_padding(kwargs.get("padding", 5))
control_spacing = 5
button_height = button_font.get_bounding_box()[1] + control_spacing + padding["bottom"]
# Draw dialog and text
dialog_width, dialog_height, _ = self.display_simple(
text,
font,
width,
height,
x_position,
y_position,
buffer,
padding=(
padding["top"], padding["right"],
button_height + padding["bottom"], padding["left"]
),
center_text_vertically=False,
border_light_index=self._color_index["light_gray"],
border_dark_index=self._color_index["black"],
**kwargs
)
if x_position is None:
if kwargs.get("center_dialog_horizontally", True):
x_position = buffer.width // 2
else:
x_position = (buffer.width - dialog_width) // 2
# Draw a button
if y_position is None:
y_position = (buffer.height - dialog_height) // 2
y_position += dialog_height - button_height
self._draw_button(buffer, button_text, button_font, x_position, y_position, **kwargs)
def draw_field(self, field, first_draw=False):
# Draw a singular field
# A field should draw a label and a box to enter text
# The label should be on the left of the coordinates
# The box should be on the right of the coordinates
# The width and height should be the size of the box
# The font should be the font to use for the label and the text box
if first_draw:
# draw the label
label = TextBox(
field["font"],
self._text_bounding_box(field["label"], field["font"])[2],
TextBox.DYNAMIC_HEIGHT,
align=TextBox.ALIGN_RIGHT,
text=field["label"],
background_tight=True,
line_spacing=0.75,
padding_left=-field["padding"]["left"],
padding_right=-field["padding"]["right"],
)
label_bmp = self._reassign_indices(
label.bitmap,
field["color_index"],
field["bgcolor_index"],
)
bitmaptools.blit(
field["buffer"], label_bmp, field["x"] - label_bmp.width - 3, field["y"]
)
if field["redraw"]:
# draw the text box
# This will draw a border around the text box
textbox = TextBox(
field["font"],
field["width"],
TextBox.DYNAMIC_HEIGHT,
align=TextBox.ALIGN_LEFT,
text=field["value"],
line_spacing=0.75,
padding_left=field["padding"]["left"],
padding_right=field["padding"]["right"],
padding_top=-7,
padding_bottom=-1,
)
textbox_bmp = self._reassign_indices(
textbox.bitmap,
field["color_index"],
field["bgcolor_index"],
)
col_index = self._color_index
if field["focused"]:
border_color_ul, border_color_br = col_index["black"], col_index["black"]
else:
border_color_ul, border_color_br = col_index["light_gray"], col_index["light_gray"]
textbox_bmp = self._add_border(textbox_bmp, border_color_ul, border_color_br)
bitmaptools.blit(field["buffer"], textbox_bmp, field["x"], field["y"] - 2)
field["redraw"] = False
def display_input(self, text, font, fields, buttons, width,
height, x_position, y_position, buffer, **kwargs):
#pylint: disable=too-many-locals
button_font = font
padding = 10
if "button_font" in kwargs:
button_font = kwargs.pop("button_font")
padding = self._convert_padding(kwargs.get("padding", 10))
control_spacing = 8
button_height = button_font.get_bounding_box()[1] + control_spacing + padding["bottom"]
# Calculate total field height
field_height = self._text_bounding_box(fields[0]["label"], font, line_spacing=0.75)[3]
field_area_height = (field_height + control_spacing )* len(fields)
# Draw dialog (and text if present)
dialog_width, dialog_height, message_height = self.display_simple(
text,
font,
width,
height,
x_position,
y_position,
buffer,
padding=(
padding["top"], padding["right"],
field_area_height + button_height + padding["bottom"], padding["left"]
),
center_text_vertically=False,
border_light_index=self._color_index["light_gray"],
border_dark_index=self._color_index["black"],
**kwargs
)
max_field_label_width = 0
for field in fields:
max_field_label_width = max(
max_field_label_width,
self._text_bounding_box(
field["label"], font)[2] + padding["right"] + padding["left"]
)
if x_position is None:
if kwargs.get("center_dialog_horizontally", True):
x_position = buffer.width // 2
else:
x_position = (buffer.width - dialog_width) // 2
if y_position is None:
y_position = buffer.height // 2
if kwargs.get("center_dialog_vertically", True):
y_position -= dialog_height // 2
y_position += padding["top"] + message_height
# Add field parameters and draw
field_width = 100
y_position += control_spacing
field_x_position = (x_position + (
max_field_label_width - (field_width + padding["right"] + padding["left"])
) // 2)
for field in fields:
field["font"] = font
field["width"] = field_width
field["height"] = field_height
field["y"] = y_position
field["x"] = field_x_position
field["color_index"] = self._color_index["default_dialog_text_color"]
field["bgcolor_index"] = self._color_index["dialog_background"]
field["padding"] = padding
field["max_length"] = 9
field["buffer"] = buffer
y_position += field_height + control_spacing
self.draw_field(field, True)
# Draw buttons
# Figure out the maximum width of the buttons by checking the bounding box of their text
total_button_width = 0
for button_text in buttons:
total_button_width += self._text_bounding_box(
button_text, button_font)[2] + padding["right"] + padding["left"] + 2
button_spacing = (dialog_width - total_button_width) // (len(buttons) + 1)
if kwargs.get("center_dialog_horizontally", True):
x_position -= dialog_width // 2
x_position += button_spacing
for button_text in buttons:
# Calculate X-position so that the buttons are spaced evenly apart and within the width
button_width, _, _ = self._draw_button(
buffer, button_text, button_font, x_position,
y_position, None, None, False, **kwargs
)
x_position += button_spacing + button_width

View file

@ -0,0 +1,13 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
class Element:
def __init__(self, walkable=(0, 0, 0)):
self.chip_walk = walkable[0]
self.block_move = walkable[1]
self.creature_walk = walkable[2]
def set_walk(self, chip_walk, block_move, creature_walk):
self.chip_walk = chip_walk
self.block_move = block_move
self.creature_walk = creature_walk

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,854 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
from random import randint, random
from time import sleep
import sys
import math
import bitmaptools
import adafruit_imageload
import displayio
from databuffer import DataBuffer
from gamelogic import GameLogic
from point import Point
from definitions import victory_messages, winning_message, final_levels
from definitions import GM_NEWGAME, GM_NORMAL, GM_PAUSED, GM_LEVELWON, GM_CHIPDEAD, GM_GAMEWON
from definitions import GAMEPLAY_COMMANDS, MESSAGE_COMMANDS, PASSWORD_COMMANDS
from definitions import NONE, QUIT, NEXT_LEVEL, PREVIOUS_LEVEL, RESTART_LEVEL, GOTO_LEVEL
from definitions import PAUSE, OK, CANCEL, CHANGE_FIELDS, DELCHAR, SF_SHOWHINT
from definitions import TYPE_EMPTY, TYPE_EXIT, TYPE_EXITED_CHIP, TYPE_CHIP
from definitions import TYPE_EXIT_EXTRA_1, TYPE_EXIT_EXTRA_2, DOWN, TICKS_PER_SECOND
from keyboard import KeyboardBuffer
from adafruit_bitmap_font import bitmap_font
from dialog import Dialog, InputFields
from savestate import SaveState
from microcontroller import nvm
# Colors must be colors in palette
LARGE_FONT = bitmap_font.load_font("/fonts/Arial-Bold-10.pcf")
SMALL_FONT = bitmap_font.load_font("/fonts/Arial-8.pcf")
colors = {
"key_color": 0xAAFF00, # Light Green
"title_text_color": 0xFFFF00, # Yellow
"hint_text_color": 0x00FFFF, # Cyan
"default_dialog_text_color": 0x000000, # Black
"paused_text_color": 0xFF0000, # Red
"dialog_background": 0xFFFFFF, # Black
"bounding_box_light": 0xFFFFFF, # White
"bounding_box_dark": 0x808080, # Dark Gray
"tile_bg_color": 0xAABFAA, # Light Gray
"light_gray": 0xAABFAA, # Light Gray
"dark_gray": 0x808080, # Dark Gray
"black": 0x000000, # Black
"white": 0xFFFFFF, # White
"purple": 0xAA00ff # Purple
}
# Image Files
SPRITESHEET_FILE = "bitmaps/spritesheet_24_keyed.bmp"
BACKGROUND_FILE = "bitmaps/background.bmp"
INFO_FILE = "bitmaps/info.bmp"
DIGITS_FILE = "bitmaps/digits.bmp"
CHIPEND_FILE = "bitmaps/chipend.bmp"
# Layout Offsets
VIEWPORT_OFFSET = (1, 10)
INFO_OFFSET = (219, 10)
LEVEL_DIGITS_OFFSET = (INFO_OFFSET[0] + 26, INFO_OFFSET[1] + 23)
TIME_DIGITS_OFFSET = (INFO_OFFSET[0] + 26, INFO_OFFSET[1] + 69)
CHIPS_DIGITS_OFFSET = (INFO_OFFSET[0] + 26, INFO_OFFSET[1] + 123)
ITEMS_OFFSET = (INFO_OFFSET[0] + 2, INFO_OFFSET[1] + 153)
HINT_OFFSET = (INFO_OFFSET[0], INFO_OFFSET[1] + 96)
def get_victory_message(deaths):
# go through victory message in reverse order
for i in range(5, -1, -1):
if deaths >= i:
return victory_messages.get(i, "Something went wrong!")
return None
class Game:
def __init__(self, display, data_file, **kwargs):
self._display = display
self._images = {}
self._buffers = {}
self._message_group = displayio.Group()
self._loading_group = displayio.Group()
self._tile_size = 24 # Default tile size (length and width)
self._digit_dims = (0, 0)
self._gamelogic = GameLogic(data_file, **kwargs)
self._databuffer = DataBuffer()
self._color_index = {}
self._init_display()
self._databuffer.set_data_structure({
"info_drawn": False,
"title_visible": False,
"level": -1,
"time_left": 0,
"chips_needed": -1,
"keys": [False, False, False, False],
"boots": [False, False, False, False],
"viewport_tiles_top": [[-1]*9 for _ in range(9)],
"viewport_tiles_bottom": [[-1]*9 for _ in range(9)],
"hint_visible": False,
"pause_visible": False,
"message_shown": False,
})
self.dialog = Dialog(self._color_index, self._shader)
self._input_fields = InputFields()
self._show_loading()
self._savestate = SaveState()
self._current_command_set = GAMEPLAY_COMMANDS
self._keyboard = KeyboardBuffer(self._current_command_set.keys())
self._deaths = 0
self._pw_request_level = None
def _init_display(self):
# Set up the Shader and Color Index
self._shader = self._load_images()
self.extract_color_indices()
self._shader.make_transparent(self._color_index["key_color"])
# Create the Buffers and add key color for transparency
buffer_group = displayio.Group()
self._buffers["main"] = displayio.Bitmap(self._display.width, self._display.height, 256)
self._buffers["main"].fill(self._color_index["key_color"])
self._buffers["loading"] = displayio.Bitmap(self._display.width, self._display.height, 256)
self._buffers["loading"].fill(self._color_index["key_color"])
buffer_group.append(
displayio.TileGrid(
self._images["background"],
pixel_shader=self._shader,
width=2,
height=2,
)
)
buffer_group.append(
displayio.TileGrid(
self._buffers["main"],
pixel_shader=self._shader,
)
)
buffer_group.append(self._message_group)
buffer_group.append(self._loading_group)
self._display.root_group = buffer_group
def _load_images(self):
self._images["spritesheet"], shader = adafruit_imageload.load(SPRITESHEET_FILE)
self._images["background"], _ = adafruit_imageload.load(BACKGROUND_FILE)
self._tile_size = self._images["spritesheet"].height // 16
self._images["info"], _ = adafruit_imageload.load(INFO_FILE)
self._images["digits"], _ = adafruit_imageload.load(DIGITS_FILE)
self._images["chipend"], _ = adafruit_imageload.load(CHIPEND_FILE)
self._digit_dims = (self._images["digits"].width, self._images["digits"].height // 24)
return shader
def extract_color_indices(self):
for key, color in colors.items():
self._color_index[key] = self.get_color_index(color)
def get_color_index(self, color, shader=None):
if shader is None:
shader = self._shader
for index, palette_color in enumerate(shader):
if palette_color == color:
return index
return None
def reset_level(self, reset_deaths=True):
self._show_loading()
if reset_deaths:
self._deaths = 0
self._gamelogic.reset()
self._remove_all_message_layers()
self._databuffer.reset((
"viewport_tiles_top",
"level",
"time_left",
"chips_needed",
"keys",
"boots",
"viewport_tiles_top",
"title_visible",
"message_shown",
"pause_visible",
))
self._databuffer.reset()
self._keyboard.clear()
self._pw_request_level = None
def change_input_commands(self, commands):
previous_commands = self._current_command_set
self._current_command_set = commands
self._keyboard.set_valid_sequences(commands.keys())
return previous_commands
def input(self):
key = self._keyboard.get_key()
if key:
return self._current_command_set[key]
return NONE
def wait_for_valid_input(self):
# Wait for a valid input (useful for dialogs)
while True:
key = self._keyboard.get_key()
if key:
return self._current_command_set[key]
def save_level(self):
self._savestate.add_level_password(
self._gamelogic.current_level_number,
self._gamelogic.current_level.password
)
def tick(self):
"""
This is the main game function. It will be responsible for handling game states
and handling keyboard input.
"""
game_mode = self._gamelogic.get_game_mode()
if game_mode == GM_NEWGAME:
self._draw_frame()
self.reset_level()
level = nvm[0]
level_password = self._gamelogic.current_level.password
save_password = ""
for byte, _ in enumerate(level_password):
save_password += chr(nvm[1 + byte])
if level_password != save_password:
level = 1
self._gamelogic.set_level(level)
self.save_level()
command = self._handle_commands()
# Handle Game Modes
if game_mode == GM_NORMAL:
if command == PAUSE:
self._gamelogic.set_game_mode(GM_PAUSED)
self._draw_pause_screen()
self._gamelogic.advance_game(command)
elif game_mode == GM_CHIPDEAD:
self.show_message(self._gamelogic.get_death_message())
self.reset_level(False)
self._gamelogic.set_level(self._gamelogic.current_level_number)
elif game_mode == GM_PAUSED:
if command == PAUSE:
self._draw_pause_screen(False)
self._gamelogic.revert_game_mode()
elif game_mode == GM_LEVELWON:
self.handle_win()
# Draw every other tick to increase responsiveness
if not self._gamelogic.get_tick() or self._gamelogic.get_tick() & 1:
self._draw_frame()
def _handle_commands(self):
command = self.input()
self._keyboard.clear()
# Handle Commands
if command == QUIT:
sys.exit()
elif command == NEXT_LEVEL:
if self._gamelogic.current_level_number < self._gamelogic.last_level:
if self._savestate.is_level_unlocked(self._gamelogic.current_level_number + 1):
self.reset_level()
self._gamelogic.inc_level()
self.save_level()
else:
self._input_fields.clear()
self._input_fields.add("Password", InputFields.ALPHANUMERIC)
self._databuffer.dataset["message_shown"] = False
self._pw_request_level = self._gamelogic.current_level_number + 1
self.request_password()
elif command == PREVIOUS_LEVEL:
if self._gamelogic.current_level_number > 1:
if self._savestate.is_level_unlocked(self._gamelogic.current_level_number - 1):
self.reset_level()
self._gamelogic.dec_level()
self.save_level()
else:
# If not, load the password dialog
self._input_fields.clear()
self._input_fields.add("Password", InputFields.ALPHANUMERIC)
self._databuffer.dataset["message_shown"] = False
self._pw_request_level = self._gamelogic.current_level_number - 1
self.request_password()
elif command == RESTART_LEVEL:
self.reset_level()
self._gamelogic.set_level(self._gamelogic.current_level_number)
elif command == GOTO_LEVEL:
# We need to establish fields to keep track of where we are typing and the values
self._input_fields.clear()
self._input_fields.add("Level number", InputFields.NUMERIC, 0)
self._input_fields.add("Password", InputFields.ALPHANUMERIC)
self.request_password()
return command
def show_score_tally(self):
time_left = (self._gamelogic.current_level.time_limit -
math.ceil(self._gamelogic.get_tick() / TICKS_PER_SECOND))
time_left = max(time_left, 0)
level = self._gamelogic.current_level_number
score = self._savestate.calculate_score(level, time_left, self._deaths)
previous_score = self._savestate.level_score(self._gamelogic.current_level_number)
best_score = self._savestate.set_level_score(level, score[2], time_left)
score_message = ""
if previous_score[0] == 0:
score_message = "\n\nYou have established a time record for this level!"
elif best_score[1] < previous_score[1]:
difference = previous_score[1] - best_score[1]
score_message = (
f"\n\nYou beat the previous time record by {difference} seconds!"
)
elif best_score[0] > previous_score[0]:
difference = best_score[0] - previous_score[0]
score_message = (
f"\n\nYou increased your score on this level by {difference} points!"
)
# Update the score (with new total score)
score = self._savestate.calculate_score(level, time_left, self._deaths)
message = f"""Level {self._gamelogic.current_level_number} Complete!
{get_victory_message(self._deaths)}
Time Bonus: {score[0]}
Level Bonus: {score[1]}
Level Score: {score[2]}
Total Score: {score[3]}"""
message += score_message
self.show_message(message, button_text="Onward")
def handle_win(self):
self._draw_frame()
# Show the level score tally
self.show_score_tally()
# Check if we are at the last level and set game mode appropriately
level_check = self._gamelogic.current_level_number
if self._gamelogic.current_level_number == self._gamelogic.last_level:
level_check = -1
if level_check in final_levels:
self._show_winning_sequence()
# check for decade message
decade_message = self._gamelogic.get_decade_message()
if decade_message:
self.show_message(decade_message)
if self._gamelogic.get_game_mode() == GM_GAMEWON:
# Show winning message
self.show_message(winning_message.format(
completed_levels=self._savestate.total_completed_levels,
total_score=self._savestate.total_score,
), width=200)
self.change_input_commands(GAMEPLAY_COMMANDS)
else:
# Go to the next level
self.reset_level()
self._gamelogic.inc_level()
self.save_level()
def show_message(self, message, *, button_text="OK", width=150):
buffer = self._add_message_layer()
self.dialog.display_message(
message,
SMALL_FONT,
width,
None,
None,
None,
buffer,
center_dialog_horizontally=True,
center_dialog_vertically=True,
button_text=button_text,
)
current_commands = self.change_input_commands(MESSAGE_COMMANDS)
# Await input
self.wait_for_valid_input()
# Clear message
self._remove_message_layer()
# Set input commands to previous
self.change_input_commands(current_commands)
# Maybe remove item from sequence later
def request_password(self):
#pylint: disable=too-many-branches
current_commands = self.change_input_commands(PASSWORD_COMMANDS)
self._draw_pause_screen()
while True:
command = NONE
while command not in (OK, CANCEL):
self._draw_password_dialog()
command = self.wait_for_valid_input()
if command == CHANGE_FIELDS:
self._input_fields.next_field()
elif command == DELCHAR:
self._input_fields.active_field_value = (
self._input_fields.active_field_value[:-1]
)
elif isinstance(command, str):
command = command.upper()
active_field = self._input_fields.active_field
if (active_field["max_length"] is None or
0 <= len(active_field["value"]) < active_field["max_length"]):
if active_field["type"] == InputFields.NUMERIC and command.isdigit():
self._input_fields.active_field_value += command
elif active_field["type"] == InputFields.ALPHA and command.isalpha():
self._input_fields.active_field_value += command
elif active_field["type"] == InputFields.ALPHANUMERIC:
self._input_fields.active_field_value += command
if command == OK:
level = self._input_fields.get_value("level_number")
if level is None and self._pw_request_level is not None:
level = self._pw_request_level
password = self._input_fields.get_value("password")
if level == 0 and self._savestate.find_unlocked_level(password) is not None:
level = self._savestate.find_unlocked_level(password)
if not 0 < level <= self._gamelogic.last_level:
self.show_message("That is not a valid level number.")
elif (level and password and
self._gamelogic.current_level.passwords[level] != password):
self.show_message("You must enter a valid password.")
elif (self._savestate.is_level_unlocked(level) and
self._savestate.find_unlocked_level(level) is None
and self._savestate.find_unlocked_level(password) is None):
self.show_message("You must enter a valid password.")
else:
self._remove_all_message_layers()
self.change_input_commands(current_commands)
self.reset_level()
self._gamelogic.set_level(level)
self.save_level()
return
elif command == CANCEL:
self._remove_all_message_layers()
self.change_input_commands(current_commands)
return
def _draw_number(self, value, offset, yellow_condition = None):
yellow = False
if yellow_condition is not None:
yellow = yellow_condition(value)
buffer = self._buffers["main"]
if value < 0:
# All digits are hyphens
for slot in range(3):
bitmaptools.blit(
buffer,
self._images["digits"],
offset[0] + slot * self._digit_dims[0],
offset[1],
0,
0, self._digit_dims[0],
self._digit_dims[1]
)
return
color_offset = 0 if yellow else self._digit_dims[1] * 12
calc_value = value
for slot in range(3):
if (value < 100 and slot == 0) or (value < 10 and slot == 1):
tile_offset = self._digit_dims[1] # a space
else:
tile_offset = (11 - (calc_value // (10 ** (2 - slot)))) * self._digit_dims[1]
calc_value -= (calc_value // (10 ** (2 - slot)) * (10 ** (2 - slot)))
bitmaptools.blit(
buffer,
self._images["digits"],
offset[0] + slot * self._digit_dims[0],
offset[1],
0,
tile_offset + color_offset, self._digit_dims[0],
tile_offset + self._digit_dims[1] + color_offset
)
def _add_message_layer(self):
# Add the message layer to the display group
# Erase any existing stuff
buffer = displayio.Bitmap(self._display.width, self._display.height, 256)
buffer.fill(self._color_index["key_color"])
self._message_group.append(
displayio.TileGrid(
buffer,
pixel_shader=self._shader,
)
)
return buffer
def _remove_message_layer(self):
# Remove the message layer from the display group
if len(self._message_group) == 0:
return
self._message_group.pop()
def _remove_all_message_layers(self):
# Remove all message layers from the display group
while len(self._message_group) > 0:
self._message_group.pop()
def _show_loading(self):
while len(self._loading_group) > 0:
self._loading_group.pop()
self.dialog.display_simple(
"Loading...",
LARGE_FONT,
None,
None,
None,
None,
self._buffers["loading"],
center_dialog_horizontally=True,
background_color_index=self._color_index["white"],
font_color_index=self._color_index["purple"],
padding=10,
)
self._loading_group.append(
displayio.TileGrid(
self._buffers["loading"],
pixel_shader=self._shader,
)
)
def _show_winning_sequence(self):
#pylint: disable=too-many-locals
self._gamelogic.set_game_mode(GM_GAMEWON)
def get_frame_image(frame):
# Create a tile sized bitmap
tile_buffer = displayio.Bitmap(self._tile_size, self._tile_size, 256)
self._draw_tile(tile_buffer, 0, 0, frame[0], frame[1])
return tile_buffer
# Get chips coordinates
chip = self._gamelogic.get_chip_coords_in_viewport()
viewport_size = self._tile_size * 9
# Get centered screen coordinates of chip
chip_position = Point(
VIEWPORT_OFFSET[0] + chip.x * self._tile_size + self._tile_size // 2,
VIEWPORT_OFFSET[1] + chip.y * self._tile_size + self._tile_size // 2
)
viewport_center = Point(
VIEWPORT_OFFSET[0] + viewport_size // 2 - 1,
VIEWPORT_OFFSET[1] + viewport_size // 2 - 1
)
# Chip Frames
frames = {
"cheering": (TYPE_EXITED_CHIP, TYPE_EMPTY),
"standing_1": (TYPE_CHIP + DOWN, TYPE_EXIT),
"standing_2": (TYPE_CHIP + DOWN, TYPE_EXIT_EXTRA_1),
"standing_3": (TYPE_CHIP + DOWN, TYPE_EXIT_EXTRA_2),
}
# Chip Sequences
zoom_sequence = (
get_frame_image(frames["standing_1"]),
get_frame_image(frames["standing_2"]),
get_frame_image(frames["standing_3"]),
)
cheer_sequence = (
get_frame_image(frames["cheering"]),
get_frame_image(frames["standing_1"]),
)
viewport_upper_left = Point(
VIEWPORT_OFFSET[0],
VIEWPORT_OFFSET[1]
)
viewport_lower_right = Point(
VIEWPORT_OFFSET[0] + viewport_size,
VIEWPORT_OFFSET[1] + viewport_size
)
for i in range(32):
source_bmp = zoom_sequence[i % len(zoom_sequence)]
scale = 1 + ((i + 1) / 32) * 8
scaled_tile_size = math.ceil(self._tile_size * scale)
x = chip_position.x
y = chip_position.y
# Make sure the scaled tile is within the viewport
scaled_tile_upper_left = Point(
x - scaled_tile_size // 2,
y - scaled_tile_size // 2
)
scaled_tile_lower_right = Point(
x + scaled_tile_size // 2,
y + scaled_tile_size // 2
)
if scaled_tile_upper_left.y < viewport_upper_left.y:
y += viewport_upper_left.y - scaled_tile_upper_left.y
elif scaled_tile_lower_right.y > viewport_lower_right.y:
y -= scaled_tile_lower_right.y - viewport_lower_right.y
if scaled_tile_upper_left.x < viewport_upper_left.x:
x += viewport_upper_left.x - scaled_tile_upper_left.x
elif scaled_tile_lower_right.x > viewport_lower_right.x:
x -= scaled_tile_lower_right.x - viewport_lower_right.x
bitmaptools.rotozoom(self._buffers["main"], source_bmp, ox=x, oy=y, scale=scale)
sleep(0.1)
for i in range(randint(16, 20)):
source_bmp = cheer_sequence[i % len(cheer_sequence)]
bitmaptools.rotozoom(
self._buffers["main"],
source_bmp,
ox=viewport_center.x,
oy=viewport_center.y,
scale=9
)
sleep(random() * 0.5 + 0.25) # Sleep for a random time between 0.25 and 0.75 seconds
bitmaptools.blit(
self._buffers["main"],
self._images["chipend"],
VIEWPORT_OFFSET[0],
VIEWPORT_OFFSET[1],
)
self.show_message("Great Job Chip! You did it! You finished the challenge!")
def _hide_loading(self):
while len(self._loading_group) > 0:
self._loading_group.pop()
self._buffers["loading"].fill(self._color_index["key_color"])
def _draw_title_dialog(self):
if self._gamelogic.get_game_mode() != GM_NORMAL:
return
data = self._databuffer.dataset
if self._gamelogic.get_tick() > 0:
if data["title_visible"]:
data["title_visible"] = False
self._remove_message_layer()
return
if not data["title_visible"]:
data["title_visible"] = True
text = (
self._gamelogic.current_level.title +
"\nPassword: " +
self._gamelogic.current_level.password
)
buffer = self._add_message_layer()
self.dialog.display_simple(
text,
LARGE_FONT,
None,
None,
VIEWPORT_OFFSET[0] + 108,
160,
buffer,
center_dialog_horizontally=True,
background_color_index=self._color_index["black"],
font_color_index=self._color_index["title_text_color"],
padding=10,
)
self._hide_loading()
def _draw_password_dialog(self):
data = self._databuffer.dataset
message = None
if not data["message_shown"]:
data["message_shown"] = True
buttons = ("OK", "Cancel")
if self._pw_request_level is not None:
message = f"Enter a password\nfor level {self._pw_request_level}."
else:
message = "Enter a level number\n and/or password."
buffer = self._add_message_layer()
self.dialog.display_input(
message,
SMALL_FONT,
self._input_fields.fields,
buttons,
200,
None,
None,
None,
buffer,
center_dialog_horizontally=True,
center_dialog_vertically=True,
)
# Update fields if needed
for field in self._input_fields.fields:
if field["redraw"]:
self.dialog.draw_field(field)
def _draw_hint(self):
data = self._databuffer.dataset
if not self._gamelogic.status & SF_SHOWHINT:
if data["hint_visible"]:
data["hint_visible"] = False
self._remove_message_layer()
return
if not data["hint_visible"]:
data["hint_visible"] = True
buffer = self._add_message_layer()
self.dialog.display_simple(
"Hint: " + self._gamelogic.current_level.hint,
SMALL_FONT,
100,
120,
HINT_OFFSET[0],
HINT_OFFSET[1],
buffer,
center_text_vertically=False,
font_color_index=self._color_index["hint_text_color"],
background_color_index=self._color_index["black"],
padding=10,
line_spacing=0.75,
)
def _draw_pause_screen(self, show=True):
data = self._databuffer.dataset
if show:
if not data["pause_visible"]:
data["pause_visible"] = True
buffer = self._add_message_layer()
self.dialog.display_simple(
"Paused",
LARGE_FONT,
216,
216,
VIEWPORT_OFFSET[0],
VIEWPORT_OFFSET[1],
buffer,
font_color_index=self._color_index["paused_text_color"],
background_color_index=self._color_index["black"],
padding=10,
line_spacing=5,
)
return
if data["pause_visible"]:
data["pause_visible"] = False
self._remove_message_layer()
def _draw_tile(self, buffer, x, y, top_tile, bottom_tile):
# Create a bitmap of the tile size
tile_size = self._tile_size
if 0xD0 <= top_tile <= 0xD3:
top_tile -= 0xC2
# Bottom Layer
if top_tile > 0x40 and bottom_tile != TYPE_EMPTY: # Bottom Tile not visible
if 0xD0 <= bottom_tile <= 0xD3:
bottom_tile -= 0xC2
top_tile += 48 # Make top tile transparent
x_src = (bottom_tile // 16) * tile_size
y_src = (bottom_tile % 16) * tile_size
bitmaptools.blit(
buffer, self._images["spritesheet"], x, y, x_src, y_src,
x_src + tile_size, y_src + tile_size
)
# Top Layer
x_src = (top_tile // 16) * tile_size
y_src = (top_tile % 16) * tile_size
bitmaptools.blit(
buffer, self._images["spritesheet"], x, y, x_src, y_src,
x_src + tile_size, y_src + tile_size,
skip_source_index=self._color_index["key_color"]
)
def _draw_frame(self):
"""
This will be responsible for drawing everything to the buffer.
"""
#pylint: disable=too-many-locals, too-many-branches
game_mode = self._gamelogic.get_game_mode()
buffer = self._buffers["main"]
data = self._databuffer.dataset
if game_mode in (GM_NORMAL, GM_LEVELWON, GM_CHIPDEAD, GM_PAUSED):
# Draw Info Window
if not data["info_drawn"]:
data["info_drawn"] = True
bitmaptools.blit(buffer, self._images["info"], INFO_OFFSET[0], INFO_OFFSET[1])
# Draw Level Number
if self._gamelogic.current_level_number != data["level"]:
data["level"] = self._gamelogic.current_level_number
self._draw_number(self._gamelogic.current_level_number, LEVEL_DIGITS_OFFSET)
# Draw Time Left
time_elapsed = math.ceil(self._gamelogic.get_tick() / TICKS_PER_SECOND)
time_left = self._gamelogic.current_level.time_limit - time_elapsed
if self._gamelogic.current_level.time_limit == 0:
time_left = -1
if time_left != data["time_left"]:
data["time_left"] = time_left
self._draw_number(
time_left,
TIME_DIGITS_OFFSET,
lambda x: x <= 15,
)
# Draw Chips Needed
if self._gamelogic.get_chips_needed() != data["chips_needed"]:
data["chips_needed"] = self._gamelogic.get_chips_needed()
self._draw_number(
self._gamelogic.get_chips_needed(),
CHIPS_DIGITS_OFFSET, lambda x: x < 1
)
# Draw Keys Collected
keys_images = (0x65, 0x64, 0x67, 0x66)
for i in range(4):
if self._gamelogic.keys[i] != data["keys"][i]:
data["keys"][i] = self._gamelogic.keys[i]
tile_id = keys_images[i] if self._gamelogic.keys[i] else TYPE_EMPTY
self._draw_tile(
buffer, ITEMS_OFFSET[0] + i * self._tile_size,
ITEMS_OFFSET[1], tile_id, 0
)
# Draw Boots Collected
boot_images = (0x6A, 0x6B, 0x69, 0x68)
for i in range(4):
if self._gamelogic.boots[i] != data["boots"][i]:
data["boots"][i] = self._gamelogic.boots[i]
tile_id = boot_images[i] if self._gamelogic.boots[i] else TYPE_EMPTY
self._draw_tile(
buffer, ITEMS_OFFSET[0] + i * self._tile_size,
ITEMS_OFFSET[1] + self._tile_size, tile_id, 0
)
if game_mode in (GM_NORMAL, GM_LEVELWON):
view_port = self._gamelogic.get_view_port()
for x_pos, x in enumerate(range(view_port.x - 4, view_port.x + 5)):
for y_pos, y in enumerate(range(view_port.y - 4, view_port.y + 5)):
tile_position = Point(x, y)
cell = self._gamelogic.current_level.get_cell(tile_position)
top_tile = cell.top.id
bottom_tile = cell.bottom.id
if (data["viewport_tiles_top"][x_pos][y_pos] != top_tile or
(top_tile >= 0x40 and
data["viewport_tiles_bottom"][x_pos][y_pos] != bottom_tile)):
data["viewport_tiles_top"][x_pos][y_pos] = top_tile
data["viewport_tiles_bottom"][x_pos][y_pos] = bottom_tile
self._draw_tile(
buffer, x_pos * self._tile_size + VIEWPORT_OFFSET[0],
y_pos * self._tile_size + VIEWPORT_OFFSET[1], top_tile, bottom_tile
)
self._draw_hint()
self._draw_title_dialog()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
import sys
import supervisor
class KeyboardBuffer:
def __init__(self, valid_sequences):
self.key_buffer = ""
self._valid_sequences = valid_sequences
def update(self):
while supervisor.runtime.serial_bytes_available:
self.key_buffer += sys.stdin.read(1)
def print(self):
print("buffer", end=": ")
for key in self.key_buffer:
print(hex(ord(key)), end=" ")
def set_valid_sequences(self, valid_sequences):
self._valid_sequences = valid_sequences
def clear(self):
self.key_buffer = ""
def get_key(self):
"""
Check for keyboard input and return the first valid key sequence.
"""
# Check if serial data is available
self.update()
if self.key_buffer:
for sequence in self._valid_sequences:
if self.key_buffer.startswith(sequence):
key = sequence
self.key_buffer = self.key_buffer[len(sequence):]
return key
# Remove first character
self.key_buffer = self.key_buffer[1:]
return None

View file

@ -0,0 +1,247 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
# SPDX-License-Identifier: GPL-1.0-or-later
# Based on Pocket Chip's Challenge (https://github.com/makermelissa/PocketChipsChallenge)
#
# pylint: disable=too-many-lines, wildcard-import, unused-wildcard-import
from point import Point
from device import Device
from definitions import TYPE_EMPTY, TYPE_SWITCHWALL_OPEN, TYPE_SWITCHWALL_CLOSED
COMPRESSED = 0
UNCOMPRESSED = 1
# These are the used optional field types
FIELD_TITLE = 3
FIELD_BEAR_TRAPS = 4
FIELD_CLONING_MACHINES = 5
FIELD_PASSWORD = 6
FIELD_HINT = 7
FIELD_MOVING_CREATURES = 10
class Tile:
def __init__(self):
self.id = 0
self.state = 0
class Cell:
def __init__(self):
self.top = Tile()
self.bottom = Tile()
def __repr__(self):
return f"Top: {hex(self.top.id)} Bottom: {hex(self.bottom.id)}"
class Level:
def __init__(self, data_file):
# Initialize any variables
self._data_file = data_file
self.level_number = 0
self.last_level = 0
self.time_limit = 0
self.best_time = 0
self.chips_required = 0
self.password = ""
self.hint = ""
self.title = ""
self.level_map = [Cell() for _ in range(1024)]
self.traps = []
self.cloners = []
self.creatures = []
self.passwords = {}
def _reset_data(self):
self.level_map = [Cell() for _ in range(1024)]
self.traps = []
self.cloners = []
self.creatures = []
def get_cell(self, coords):
if isinstance(coords, int):
coords = self.position_to_coords(coords)
return self.level_map[self.coords_to_position(coords)]
def coords_to_position(self, coords):
return coords.y * 32 + coords.x
def position_to_coords(self, position):
return Point(position % 32, position // 32)
def _read_int(self, file, byte_count):
return int.from_bytes(file.read(byte_count), "little")
def _update_cell_id(self, coords, tile_id, layer):
getattr(self.get_cell(coords), layer).id = tile_id
def _get_map_representation(self, layer):
level_map = f"{layer} layer\n"
for y in range(32):
for x in range(32):
level_map += f"{hex(getattr(self.get_cell(Point(x, y)), layer).id)} "
level_map += "\n"
return level_map
def _process_map_data(self, map_data, layer):
"""
Store RLE mapdata in uncompressed form
"""
current_byte = 0
current_position = 0
while current_byte < len(map_data):
if map_data[current_byte] == 0xFF:
tile_id = map_data[current_byte + 2]
if 0x0E <= tile_id <= 0x11:
tile_id += 0xC2
for _ in range(map_data[current_byte + 1]):
coords = self.position_to_coords(current_position)
self._update_cell_id(coords, tile_id, layer)
current_position += 1
current_byte += 3
else:
tile_id = map_data[current_byte]
if 0x0E <= tile_id <= 0x11:
tile_id += 0xC2
coords = self.position_to_coords(current_position)
self._update_cell_id(coords, tile_id, layer)
current_position += 1
current_byte += 1
def load(self, level_number):
#pylint: disable=too-many-branches
# Reset the data prior to loading
self._reset_data()
# Read the file and fill in the variables
with open(self._data_file, "rb") as file:
# Read the first 4 bytes in little endian format
if self._read_int(file, 4) not in (0x0002AAAC, 0x0102AAAC):
raise ValueError("Not a CHIP file")
self.last_level = self._read_int(file, 2)
if not 0 < level_number <= self.last_level:
raise ValueError("Invalid level number")
self.level_number = level_number
# Seek to the start of the level data for the specified level
while True:
level_bytes = self._read_int(file, 2)
if self._read_int(file, 2) == level_number:
break
# Go to next level
file.seek(level_bytes - 2, 1)
# Read the level data
self.time_limit = self._read_int(file, 2)
self.chips_required = self._read_int(file, 2)
compression = self._read_int(file, 2)
if compression == COMPRESSED:
raise ValueError("Compressed levels not supported")
# Process the top map data
layer_bytes = self._read_int(file, 2)
map_data = file.read(layer_bytes)
self._process_map_data(map_data, "top")
# Process the bottom map data
layer_bytes = self._read_int(file, 2)
map_data = file.read(layer_bytes)
self._process_map_data(map_data, "bottom")
remaining_bytes = self._read_int(file, 2)
while remaining_bytes > 0:
field_type = self._read_int(file, 1)
field_size = self._read_int(file, 1)
remaining_bytes -= (2 + field_size)
if field_type == FIELD_TITLE:
self.title = file.read(field_size).decode("utf-8").replace("\x00", "")
elif field_type == FIELD_HINT:
self.hint = file.read(field_size).decode("utf-8").replace("\x00", "")
elif field_type == FIELD_PASSWORD:
self.password = (
"".join([chr(c ^ 0x99) for c in file.read(field_size)]).replace("\x99", "")
)
elif field_type == FIELD_BEAR_TRAPS:
trap_count = field_size // 10
for _ in range(trap_count):
button = Point(self._read_int(file, 2), self._read_int(file, 2))
device = Point(self._read_int(file, 2), self._read_int(file, 2))
self.traps.append(Device(button, device))
file.seek(2, 1)
elif field_type == FIELD_CLONING_MACHINES:
cloner_count = field_size // 8
for _ in range(cloner_count):
button = Point(self._read_int(file, 2), self._read_int(file, 2))
device = Point(self._read_int(file, 2), self._read_int(file, 2))
self.cloners.append(Device(button, device))
elif field_type == FIELD_MOVING_CREATURES:
creature_count = field_size // 2
for _ in range(creature_count):
self.creatures.append(Point(
self._read_int(file, 1),
self._read_int(file, 1)
))
# Load passwords if not already loaded
if len(self.passwords) == 0:
self._load_passwords(file)
def _load_passwords(self, file):
file.seek(6) # Skip the file header
while True:
file.seek(2, 1)
level_number = self._read_int(file, 2)
file.seek(6, 1)
layer_bytes = self._read_int(file, 2) # Number of bytes in the top layer
file.seek(layer_bytes, 1) # Skip top layer
layer_bytes = self._read_int(file, 2) # Number of bytes in the top layer
file.seek(layer_bytes, 1) # Skip bottom layer
remaining_bytes = self._read_int(file, 2)
while remaining_bytes > 0:
field_type = self._read_int(file, 1)
field_size = self._read_int(file, 1)
remaining_bytes -= (2 + field_size)
if field_type == FIELD_PASSWORD:
password = file.read(field_size)
self.passwords[level_number] = (
"".join([chr(c ^ 0x99) for c in password]).replace("\x99", "")
)
file.seek(remaining_bytes, 1)
break
file.seek(field_size, 1)
if len(self.passwords) == self.last_level:
break
def toggle_blocks(self):
for cell in self.level_map:
if cell.top.id == TYPE_SWITCHWALL_OPEN:
cell.top.id = TYPE_SWITCHWALL_CLOSED
elif cell.top.id == TYPE_SWITCHWALL_CLOSED:
cell.top.id = TYPE_SWITCHWALL_OPEN
if cell.bottom.id == TYPE_SWITCHWALL_OPEN:
cell.bottom.id = TYPE_SWITCHWALL_CLOSED
elif cell.bottom.id == TYPE_SWITCHWALL_CLOSED:
cell.bottom.id = TYPE_SWITCHWALL_OPEN
def pop_tile(self, coords):
tile = Tile()
cell = self.get_cell(coords)
tile.id = cell.top.id
tile.state = cell.top.state
cell.top.id = cell.bottom.id
cell.top.state = cell.bottom.state
cell.bottom.id = TYPE_EMPTY
cell.bottom.state = 0
return tile
def push_tile(self, coords, tile):
cell = self.get_cell(coords)
cell.bottom.id = cell.top.id
cell.bottom.state = cell.top.state
cell.top.id = tile.id
cell.top.state = tile.state
def __str__(self):
# print the map ids from the level
return self._get_map_representation("top") + "\n" + self._get_map_representation("bottom")

View file

@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
import math
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f'({self.x}, {self.y})'
def __repr__(self):
return f'({self.x}, {self.y})'
def __add__(self, other):
return Point(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return Point(self.x - other.x, self.y - other.y)
def __mul__(self, other):
return Point(self.x * other.x, self.y * other.y)
def __truediv__(self, other):
return Point(self.x / other.x, self.y / other.y)
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __ne__(self, other):
return not self.__eq__(other)
def __lt__(self, other):
return self.x < other.x and self.y < other.y
def __le__(self, other):
return self.x <= other.x and self.y <= other.y
def __gt__(self, other):
return self.x > other.x and self.y > other.y
def __ge__(self, other):
return self.x >= other.x and self.y >= other.y
def __neg__(self):
return Point(-self.x, -self.y)
def __pos__(self):
return Point(+self.x, +self.y)
def __abs__(self):
return Point(abs(self.x), abs(self.y))
def __invert__(self):
return Point(~self.x, ~self.y)
def __round__(self, n=0):
return Point(round(self.x, n), round(self.y, n))
def __floor__(self):
return Point(math.floor(self.x), math.floor(self.y))
def __ceil__(self):
return Point(math.ceil(self.x), math.ceil(self.y))
def __trunc__(self):
return Point(math.trunc(self.x), math.trunc(self.y))
def __hash__(self):
return hash((self.x, self.y))
def __len__(self):
return 2
def __getitem__(self, index):
if index == 0:
return self.x
elif index == 1:
return self.y
else:
raise IndexError

View file

@ -0,0 +1,154 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
from math import floor
import json
import board
from microcontroller import nvm
from digitalio import DigitalInOut, Pull
import busio
import sdcardio
import storage
SAVESTATE_FILE = "chips.json"
class SaveState:
def __init__(self):
self._levels = {}
self._has_sdcard = self._mount_sd_card()
if self._has_sdcard:
print("SD Card detected")
else:
print("SD Card not detected. Level data will NOT be saved.")
self.load()
self._sdcard = None
def _mount_sd_card(self):
self._card_detect = DigitalInOut(board.SD_CARD_DETECT)
self._card_detect.switch_to_input(pull=Pull.UP)
if self._card_detect.value:
return False
# Attempt to unmount the SD card
try:
storage.umount("/sd")
except OSError:
pass
spi = busio.SPI(board.SD_SCK, MOSI=board.SD_MOSI, MISO=board.SD_MISO)
try:
sdcard = sdcardio.SDCard(spi, board.SD_CS, baudrate=20_000_000)
vfs = storage.VfsFat(sdcard)
storage.mount(vfs, "/sd")
except OSError:
return False
return True
def save(self):
if not self._has_sdcard:
return
with open("/sd/" + SAVESTATE_FILE, "w") as f:
json.dump({"levels": self._levels}, f)
def load(self):
if not self._has_sdcard:
return
# Use try in case the file doesn't exist
try:
with open("/sd/" + SAVESTATE_FILE, "r") as f:
data = json.load(f)
self._levels = data["levels"]
except OSError:
pass
def set_level_score(self, level, score, time_left):
level_key = f"level{level}"
new_high_score = False
lower_time = False
if level_key not in self._levels:
self._levels[level_key] = {}
if score > self._levels[level_key].get("score", 0):
new_high_score = True
self._levels[level_key]["score"] = score
if time_left > self._levels[level_key].get("time_left", 0):
lower_time = True
self._levels[level_key]["time_left"] = time_left
self.save()
return new_high_score, lower_time
def add_level_password(self, level, password):
nvm[0] = level
for byte, char in enumerate(password):
nvm[1 + byte] = ord(char)
level_key = f"level{level}"
if level_key not in self._levels:
self._levels[level_key] = {}
self._levels[level_key]["password"] = password.upper()
self.save()
def find_unlocked_level(self, level_or_password):
if isinstance(level_or_password, int):
level_key = f"level{level_or_password}"
password = None
else:
level_key = None
password = level_or_password
# Look for level by number
if level_key in self._levels:
return level_or_password
for key, data in self._levels.items():
if "password" in data and data["password"] == password:
return int(key[5:])
return None
def calculate_score(self, level, time_left, deaths):
time_bonus = time_left * 10
level_bonus = floor(level * 500 * 0.8**deaths)
level_score = time_bonus + level_bonus
total_score = self.total_score
return time_bonus, level_bonus, level_score, total_score
def has_password(self, level, password):
level_key = f"level{level}"
if level_key in self._levels:
return self._levels[level_key]["password"] == password.upper()
return False
def level_score(self, level):
level_key = f"level{level}"
if (level_key in self._levels and "score" in self._levels[level_key] and
"time_left" in self._levels[level_key]):
return self._levels[level_key]["score"], self._levels[level_key]["time_left"]
return 0, 0
def is_level_unlocked(self, level):
level_key = f"level{level}"
if level_key in self._levels and "password" in self._levels[level_key]:
return True
return False
@property
def has_sdcard(self):
return self._has_sdcard
@property
def total_score(self):
total_score = 0
for data in self._levels.values():
if "score" in data:
total_score += data["score"]
return total_score
@property
def total_completed_levels(self):
completed_levels = 0
for data in self._levels.values():
if "score" in data:
completed_levels += 1
return completed_levels

View file

@ -0,0 +1 @@
CIRCUITPY_PYSTACK_SIZE = 2400

View file

@ -0,0 +1,10 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
class Slip:
def __init__(self):
self.creature = None
self.dir = None
def __repr__(self):
return f"Creature: {self.creature} | Slip Direction: {self.dir}"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.