The code for the project is broken into 3 files, from highest level to lowest they are code.py, workspace.py and entity.py.
code.py
This file is responsible for initializing the hardware peripherals and setting up the display group and Workspace object. The main loop first checks for mouse and keyboard input, clicks and keypresses get passed into the Workspace object to be dealt with, mouse movement gets handled here in code.py. Next the code reads the current values of the hardware buttons on the Fruit Jam and if any button values have changed it calls workspace.update() to run the logic simulation with the new values from the buttons.
The code.py file is embedded below, it contains comments describing the purpose of each part of the code.
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
Fruit Jam Logic Gates Simulator
"""
import sys
import time
import board
from digitalio import DigitalInOut, Pull, Direction
from displayio import Group
import supervisor
from neopixel import NeoPixel
from adafruit_usb_host_mouse import find_and_init_boot_mouse
from adafruit_fruitjam.peripherals import request_display_config
from workspace import Workspace
# cooldown time to ignore double clicks
CLICK_COOLDOWN = 0.15
last_click_time = None
# set the display size to 320,240
request_display_config(320, 240)
display = supervisor.runtime.display
# setup the mouse
mouse = find_and_init_boot_mouse()
mouse.sensitivity = 1.5
if mouse is None:
raise RuntimeError(
"No mouse found connected to USB Host. A mouse is required for this app."
)
# setup displayio Group for visuals
main_group = Group()
display.root_group = main_group
# setup physical hardware buttons
btn_1 = DigitalInOut(board.BUTTON1)
btn_1.direction = Direction.INPUT
btn_1.pull = Pull.UP
btn_2 = DigitalInOut(board.BUTTON2)
btn_2.direction = Direction.INPUT
btn_2.pull = Pull.UP
btn_3 = DigitalInOut(board.BUTTON3)
btn_3.direction = Direction.INPUT
btn_3.pull = Pull.UP
# setup neopixels
neopixels = NeoPixel(board.NEOPIXEL, 5, brightness=0.2, auto_write=True)
# setup Workspace object, giving it the neopixels, and hardware button objects
workspace = Workspace(neopixels, (btn_1, btn_2, btn_3))
# add workspace elements to the Group to be shown on display
main_group.append(workspace.group)
main_group.append(workspace.mouse_moving_tg)
# add the mouse to the Group to be shown on top of everything else
main_group.append(mouse.tilegrid)
# hardware button state variables
old_button_values = [True, True, True]
button_values_changed = False
waiting_for_release = False
while True:
# update mouse, and get any mouse buttons that are pressed
pressed_btns = mouse.update()
# enforce click cooldown to ignore double clicks
now = time.monotonic()
if last_click_time is None or now > last_click_time + CLICK_COOLDOWN:
# if any buttons are pressed
if not waiting_for_release and pressed_btns is not None and len(pressed_btns) > 0:
waiting_for_release = True
click_x = mouse.x
click_y = mouse.y
click_btns = pressed_btns
elif waiting_for_release and not mouse.pressed_btns:
waiting_for_release = False
last_click_time = now
# let workspace handle the click event
workspace.handle_mouse_click(click_x, click_y, click_btns)
# if there is an entity on the mouse being moved
if not workspace.mouse_moving_tg.hidden:
# update its TileGrid's location to follow the mouse
workspace.mouse_moving_tg.x = mouse.x - 12
workspace.mouse_moving_tg.y = mouse.y - 24 - 12
# check how many bytes are available from keyboard
available = supervisor.runtime.serial_bytes_available
# if there are some bytes available
if available:
# read data from the keyboard input
c = sys.stdin.read(available)
key_bytes = c.encode("utf-8")
# let workspace handle the key press event
workspace.handle_key_press(key_bytes, mouse.x, mouse.y)
# get hardware button states
btn_1_current = btn_1.value
btn_2_current = btn_2.value
btn_3_current = btn_3.value
button_values_changed = False
# check if any hardware button states have changed
if btn_1_current != old_button_values[0]:
button_values_changed = True
if btn_2_current != old_button_values[1]:
button_values_changed = True
if btn_3_current != old_button_values[2]:
button_values_changed = True
# update the old button states to compare with next iteration
old_button_values[0] = btn_1_current
old_button_values[1] = btn_2_current
old_button_values[2] = btn_3_current
# if any button state changed, update workspace to run simulation
if button_values_changed:
workspace.update()
workspace.py
The workspace.py file contains two classes Workspace and ToolBox. These objects handle most of the user interface and interactions when the user presses a key or clicks the mouse.
Workspace
This class provides a scroll-able area that logic gates and other Entity objects can be placed onto. At its core, visually, is a TileGrid that is made larger than the display resolution and moved around when the user scrolls. The add_entity() and remove_entity() take care of everything needed for placing or removing entities. User interactions are handled inside of handle_key_press() and handle_mouse_click(). For key presses, there is a dictionary defined for hotkeys that can be customized to change which key on the keyboard does any specific action, see the Usage page for details on custom hotkeys.
Saving and loading from any of 10 different slots is handled by the functions: json(), create_entity_from_json(), load_from_json(). The user is prompted to select a slot by entering a number 0-9, their input is read with the read_input() function. A multi-purpose Label object is used for both outputting messages to the user, and displaying the input that they type.
The update() function will run one the logic simulation, updating everything visually on the display and NeoPixels as needed based on the logic values and connections. The bulk of the functionality is defined within the Entity classes detailed below, workspace.update() calls the update and value functions on all existing Entity objects.
entity_at() is the last notable function. It accepts a location in x, y tile coordinates and returns the Entity object that is at the specified location if there is one, otherwise it returns None.
There is an add button with a plus icon in the top left corner, when clicked it shows the ToolBox on top of the Workspace for the user to select a new part from.
ToolBox
The ToolBox responsible for showing a grid of available parts that the user can select from when they want to add something new to the workspace. A ToolBox instance is created and held inside of the Workspace object. It uses a GridLayout object with each cell containing a TextBox and icon TileGrid.
The ToolBox grid is created dynamically from a list of objects that represent each type of logic gate and other Entity that are available. Each object contains the necessary information to show the proper icon for the part and a reference to the constructor function that will create an instance of the Entity for the part.
ToolBox has its own handle_mouse_click() which gets called by the Workspace whenever the mouse is clicked while the ToolBox is showing. If the user clicks on a part, then an instance of that parts Entity class is created and attached to the mouse, ready to place it at the desired location. If the user clicked on empty space, the ToolBox is closed, revealing the Workspace underneath.
The code for workspace.py included both Workspace and ToolBox classes is embedded below. There are comments describing the purpose of each part of the code.
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import os
import json
import time
import adafruit_imageload
from displayio import TileGrid, OnDiskBitmap, Palette, Group, Bitmap
from tilepalettemapper import TilePaletteMapper
from terminalio import FONT
from adafruit_anchored_group import AnchoredGroup
from adafruit_display_text.text_box import TextBox
from adafruit_display_text.bitmap_label import Label
from adafruit_displayio_layout.layouts.grid_layout import GridLayout
from entity import (
TwoInputOneOutputGate,
AndGate,
OrGate,
NandGate,
NorGate,
NotGate,
XorGate,
XnorGate,
VirtualPushButton,
OutputPanel,
NeoPixelOutput,
PhysicalButton,
Wire,
SignalReceiver,
SignalTransmitter,
)
# pylint: disable=too-many-branches, too-many-statements
VALID_SAVE_SLOTS = tuple("0123456789")
DEFAULT_HOTKEYS = {
b"\x1b[A": "scroll_up", # up arrow
b"\x1b[B": "scroll_down", # down arrow
b"\x1b[C": "scroll_right", # right arrow
b"\x1b[D": "scroll_left", # left arrow
b"q": "eyedropper",
b"s": "save",
b"o": "open",
b"a": "add",
b"i": "import",
b"\x08": "remove", # backspace
b"\x1b[3~": "remove", # delete
}
# index of empty tile in the spritesheet
EMPTY = 8
# map class names to constructor functions
ENTITY_CLASS_CONSTRUCTOR_MAP = {
"AndGate": AndGate,
"OrGate": OrGate,
"NandGate": NandGate,
"NorGate": NorGate,
"XorGate": XorGate,
"XnorGate": XnorGate,
"NotGate": NotGate,
"Wire": Wire,
"SignalReceiver": SignalReceiver,
"SignalTransmitter": SignalTransmitter,
"OutputPanel": OutputPanel,
"VirtualPushButton": VirtualPushButton,
"PhysicalButton": PhysicalButton,
"NeoPixelOutput": NeoPixelOutput,
}
# maximum number of connectors allowed on workspace
MAX_CONNECTORS = 5
class Workspace:
"""
A scrollable area to place logic gates and other Entities. Handles interactions with the user
via mouse and keyboard event functions. Contains a Toolbox for selecting new parts to be
added to the workspace.
:param neopixels: NeoPixel object used with NeoPixelOutput entities
:param buttons: Tuple containing DigitalInOut instances connected to the 3
physical buttons on the Fruit Jam.
:param hotkeys: Optional hotkeys dictionary to override the defaults.
"""
def __init__(self, neopixels, buttons, hotkeys=None):
# store hardware peripheral references
self.neopixels = neopixels
self.buttons = buttons
# load default hotkeys if user has not supplied custom ones.
self.hotkeys = DEFAULT_HOTKEYS if hotkeys is None else hotkeys
# load the spritesheet bitmap
self.spritesheet, palette = adafruit_imageload.load(
"logic_gates_assets/sprites.bmp"
)
self.spritesheet_pixel_shader = palette
# setup TilePaletteMapper
self.tile_palette_mapper = TilePaletteMapper(
self.spritesheet_pixel_shader, # input pixel_shader
len(self.spritesheet_pixel_shader), # input color count
)
# setup main workspace TileGrid
self.tilegrid = TileGrid(
bitmap=self.spritesheet,
pixel_shader=self.tile_palette_mapper,
default_tile=EMPTY,
width=26,
height=18,
tile_width=24,
tile_height=24,
)
# setup group to hold all visual elements
self.group = Group()
self.group.append(self.tilegrid)
# setup overlay elements that float on top of the workspace
self.overlay_palette = Palette(18)
for i, color in enumerate(self.spritesheet_pixel_shader):
self.overlay_palette[i] = color
self.overlay_palette.make_transparent(0)
# setup Overlay TilePaletteMapper
self.overlay_palette_mapper = TilePaletteMapper(
self.overlay_palette, # input pixel_shader
len(self.overlay_palette), # input color count
)
# setup overlay TileGrid
self.overlay_tilegrid = TileGrid(
bitmap=self.spritesheet,
pixel_shader=self.overlay_palette_mapper,
default_tile=EMPTY,
width=26,
height=18,
tile_width=24,
tile_height=24,
)
self.group.append(self.overlay_tilegrid)
# setup add entity button bitmap and TileGrid
self.add_btn_bmp = OnDiskBitmap("logic_gates_assets/add_btn.bmp")
self.add_btn_tg = TileGrid(
bitmap=self.add_btn_bmp, pixel_shader=self.add_btn_bmp.pixel_shader
)
self.group.append(self.add_btn_tg)
# setup message label, empty for now, used to get input or give feedback to user
self.message_lbl = Label(FONT, text="")
self.message_lbl.anchor_point = (0, 0)
self.message_lbl.anchored_position = (
self.add_btn_tg.x + self.add_btn_tg.tile_width + 2,
2,
)
self.group.append(self.message_lbl)
# Setup moving entity graphics used when an entity is following the mouse
# waiting to be placed on the workspace
self.mouse_moving_palette = Palette(len(self.spritesheet_pixel_shader))
for i, color in enumerate(self.spritesheet_pixel_shader):
self.mouse_moving_palette[i] = color
self.mouse_moving_palette.make_transparent(0)
self.mouse_moving_palette[3] = 0x000000
self.mouse_moving_palette[4] = 0x000000
self.mouse_moving_palette[5] = 0x000000
self.mouse_moving_tg = TileGrid(
bitmap=self.spritesheet,
pixel_shader=self.mouse_moving_palette,
width=2,
height=3,
default_tile=EMPTY,
tile_width=24,
tile_height=24,
)
self.mouse_moving_tg.hidden = True
# list to hold all Entities on the workspace
self.entities = []
# list to hold available connectors
self.available_connectors = list(range(MAX_CONNECTORS))
# variable to hold an Entity that is moving with the mouse
self.moving_entity = None
# workspace scroll offsets
self._scroll_x = 0
self._scroll_y = 0
# setup ToolBox for selecting entities to add
self.toolbox = ToolBox(self.spritesheet, self)
self.group.append(self.toolbox.group)
# variables for message control
self.waiting_for_save_slot_input = False
self.waiting_for_open_slot_input = False
self.waiting_for_import_slot_input = False
self.hide_message_at = None
def add_entity(self, entity):
"""
Add the given entity to the workspace at the location specified by entity.location.
Setting the appropriate tile sprites.
"""
self.entities.append(entity)
for i in range(len(entity.tiles)):
self.tilegrid[entity.tile_locations[i]] = entity.tiles[i]
entity.apply_state_palette_mapping()
def remove_entity(self, entity):
"""
Remove the given entity from the workspace.
"""
self.entities.remove(entity)
for i in range(len(entity.tiles)):
self.tilegrid[entity.tile_locations[i]] = EMPTY
self.overlay_tilegrid[entity.tile_locations[i]] = EMPTY
# Special case for SignalTransmitter to clear its input wire tap overlay
if isinstance(entity, SignalTransmitter):
if isinstance(entity.input_entity, Wire):
self.overlay_tilegrid[entity.input_entity_location] = EMPTY
def _set_mouse_moving_tiles(self, entity):
"""
Set the sprite tiles on the mouse moving TileGrid based on the given entity.
"""
for i in range(self.mouse_moving_tg.width * self.mouse_moving_tg.height):
self.mouse_moving_tg[i] = EMPTY
midpoint_offset = 1 if entity.size[1] == 1 else 0
for i, loc in enumerate(entity.tile_locations):
self.mouse_moving_tg[
loc[0] - entity.location[0],
loc[1] - entity.location[1] + midpoint_offset,
] = entity.tiles[i]
def _clear_cursor(self):
# Special SignalTransmitter case, add connection_number back into available list
if isinstance(self.moving_entity, SignalTransmitter):
self.available_connectors.append(self.moving_entity.connector_number)
self.available_connectors.sort()
# Remove connections from SignalReceivers pointing to this SignalTransmitter
for entity in self.entities:
if (
isinstance(entity, SignalReceiver)
and entity.connector_number == self.moving_entity.connector_number
):
entity.connector_number = None
entity.input_one = None
self.moving_entity = None
self.mouse_moving_tg.hidden = True
self.update()
def handle_mouse_click(self, screen_x, screen_y, pressed_btns):
"""
Handle mouse click events sent from code.py
"""
# hand add button click & Toolbox outside of scroll logic
# since it floats on top and is unaffected by scroll.
if "left" in pressed_btns:
# if the toolbox is visible let it handle the click event.
if not self.toolbox.hidden:
self.toolbox.handle_mouse_click((screen_x, screen_y))
return
# if the click is on the add button
if self.add_btn_tg.contains((screen_x, screen_y, 0)):
# if there is no entity on the mouse, open the ToolBox
if self.moving_entity is None:
self.toolbox.hidden = False
# if there is an entity on the mouse, remove it
else:
self._clear_cursor()
return
# apply offset value based on scroll position
screen_x -= self._scroll_x * self.tilegrid.tile_width * 1
screen_y -= self._scroll_y * self.tilegrid.tile_height * 1
# calculate tile coordinates
tile_x = screen_x // self.tilegrid.tile_width
tile_y = screen_y // self.tilegrid.tile_height
# get the entity at the coordinates if there is one
clicked_entity = self.entity_at((tile_x, tile_y))
# handle left click
if "left" in pressed_btns:
# if there is an entity moving with the mouse
if self.moving_entity is not None:
# set the entities location to the clicked coordinates
if isinstance(
self.moving_entity, (TwoInputOneOutputGate, NeoPixelOutput)
):
self.moving_entity.location = (tile_x, tile_y - 1)
else:
self.moving_entity.location = (tile_x, tile_y)
# Ignore the click if the landing space is occupied
if self.landing_zone_clear(self.moving_entity):
# for Wires, guess the appropriate state if the flag to keep state is not set
if (
isinstance(self.moving_entity, Wire)
and not self.moving_entity.keep_state_after_drop
):
self.moving_entity.state = (
self.moving_entity.guess_appropriate_state(
self.moving_entity.location
)
)
# add the entity at the clicked coordinates
self.add_entity(self.moving_entity)
# run the logic simulation with the new entity in it
self.update()
# remove the moving entity from the mouse
self.moving_entity = None
self.mouse_moving_tg.hidden = True
return
# ignore left click on empty space
if clicked_entity is None:
return
# if the entity has a handle_click() function call
# it to allow entity to handle the click event
if hasattr(clicked_entity, "handle_click"):
clicked_entity.handle_click()
# update the logic simulation after click has been handled
self.update()
# handle right click event
elif "right" in pressed_btns:
# if there is not a moving entity following the mouse
if self.moving_entity is None:
# if the click was on empty space do nothing
if clicked_entity is None:
return
# remove the entity from the workspace and set it
# as the moving entity following the mouse
self.moving_entity = clicked_entity
self._set_mouse_moving_tiles(self.moving_entity)
self.mouse_moving_tg.hidden = False
self.remove_entity(self.moving_entity)
# for Wires keep the existing state when they're dropped
if isinstance(self.moving_entity, Wire):
self.moving_entity.keep_state_after_drop = True
def handle_key_press(self, key_bytes, mouse_x, mouse_y):
"""
Handle keyboard key press events sent from code.py.
Also receives the current mouse location.
"""
# pylint: disable=too-many-locals
# if we're waiting for user to input the save slot number
if self.waiting_for_save_slot_input:
# convert keypress to string
key_str = key_bytes.decode("utf-8")
# if the entered value is a valid save slot 0-9
if key_str in VALID_SAVE_SLOTS:
# update the message to the user with the value they typed
self.message_lbl.text += key_str
# get the JSON data to save
save_data = self.json()
# write JSON data to the save file with slot number in filename
with open(f"/saves/logic_gates_{key_str}.json", "w") as f:
f.write(save_data)
# update the message to user with save success
self.message_lbl.text = f"Saved in slot: {key_str}"
# set the time to hide the message to user
self.hide_message_at = time.monotonic() + 2.0
# set flag back to not waiting for save slot input
self.waiting_for_save_slot_input = False
return
# if we're waiting for user to input the open slot number
if self.waiting_for_open_slot_input:
# convert keypress to string
key_str = key_bytes.decode("utf-8")
# if the entered value is a valid save slot 0-9
if key_str in VALID_SAVE_SLOTS:
# try to open and read JSON data from the specified save slot file
try:
with open(f"/saves/logic_gates_{key_str}.json", "r") as f:
save_data = json.loads(f.read())
# load the workspace state from the JSON data
self.load_from_json(save_data)
# update message to user with opened save slot success
self.message_lbl.text = f"Opened from slot: {key_str}"
# set the time to hide the user message
self.hide_message_at = time.monotonic() + 2.0
except OSError:
# inform user no file was found at the entered save slot
self.message_lbl.text = f"No save in slot: {key_str}"
self.hide_message_at = time.monotonic() + 2.0
self.waiting_for_open_slot_input = False
return
# if we're waiting for user to input the import slot number
if self.waiting_for_import_slot_input:
# convert keypress to string
key_str = key_bytes.decode("utf-8")
# if the entered value is a valid save slot 0-9
if key_str in VALID_SAVE_SLOTS:
# update the message to the user with the value they typed
self.message_lbl.text += key_str
if "logic_gates_import.json" in os.listdir("/"):
with open("/logic_gates_import.json", "r") as f:
import_data = f.read()
# write JSON data to the save file with slot number in filename
with open(f"/saves/logic_gates_{key_str}.json", "w") as f:
f.write(import_data)
# update the message to user with save success
self.message_lbl.text = f"Imported to slot: {key_str}"
# set the time to hide the message to user
self.hide_message_at = time.monotonic() + 2.0
# set flag back to not waiting for import slot input
self.waiting_for_import_slot_input = False
else:
self.message_lbl.text = "logic_gates_import.json not found"
# set the time to hide the message to user
self.hide_message_at = time.monotonic() + 2.0
# set flag back to not waiting for import slot input
self.waiting_for_import_slot_input = False
return
# lookup the pressed key int he hotkey map
action = self.hotkeys.get(key_bytes, None)
# if the pressed key doesn't map to any actions
# just print its bytes for debugging.
if action is None:
print(key_bytes)
return
# scroll up
if action == "scroll_up":
self._scroll_y += 1
self.apply_scroll()
# scroll down
elif action == "scroll_down":
self._scroll_y -= 1
self.apply_scroll()
# scroll right
elif action == "scroll_right":
self._scroll_x -= 1
self.apply_scroll()
# scroll left
elif action == "scroll_left":
self._scroll_x += 1
self.apply_scroll()
# save action, ask user for the slot to save in
elif action == "save":
self.waiting_for_save_slot_input = True
self.message_lbl.text = "Save Slot: "
# keep message showing until user responds
self.hide_message_at = None
# open action, ask user for slot to open from
elif action == "open":
self.waiting_for_open_slot_input = True
self.message_lbl.text = "Open Slot: "
# keep message showing until user responds
self.hide_message_at = None
# import action, ask user for slot to import to
elif action == "import":
self.waiting_for_import_slot_input = True
self.message_lbl.text = "Import To Slot: "
# keep message showing until user responds
self.hide_message_at = None
# add action, show the ToolBox to select a new entity to add
elif action == "add":
if self.moving_entity is None:
self.toolbox.hidden = False
# remove action, clear out the entity moving with the mouse
elif action == "remove":
if self.moving_entity is not None:
self._clear_cursor()
# eyedropper or pipette action
elif action == "eyedropper":
# if there is already an entity moving with the mouse
if self.moving_entity is not None:
self._clear_cursor()
return
# adjust mouse coordinates for the scroll position
mouse_x -= self._scroll_x * self.tilegrid.tile_width * 1
mouse_y -= self._scroll_y * self.tilegrid.tile_height * 1
# get the tile coordinates of mouse location
tile_x = mouse_x // self.tilegrid.tile_width
tile_y = mouse_y // self.tilegrid.tile_height
# try to get the entity at the tile coordinates
target_entity = self.entity_at((tile_x, tile_y))
# special case only limited number of SignalTransmitters
if target_entity is not None and isinstance(
target_entity, SignalTransmitter
):
num_SignalTransmitters = 0
for entity in self.entities:
if isinstance(entity, SignalTransmitter):
num_SignalTransmitters += 1
if num_SignalTransmitters >= MAX_CONNECTORS:
# Can't create additional SignalTransmitters
target_entity = None
# if there was an entity at the coordinates
if target_entity is not None:
# set up an object to clone the entity at the coordinates
clone_json = {
"class": target_entity.__class__.__name__,
"location": (0, 0),
}
# apply any possible attributes to the clone object
if hasattr(target_entity, "state"):
clone_json["state"] = target_entity.state
if hasattr(target_entity, "pressed_state"):
clone_json["pressed_state"] = target_entity.pressed_state
if hasattr(target_entity, "index"):
clone_json["index"] = target_entity.index
if hasattr(target_entity, "inputs_left"):
clone_json["inputs_left"] = target_entity.inputs_left
# create an Entity from the clone object
cloned_entity = self.create_entity_from_json(
clone_json, add_to_workspace=False
)
# set the newly created entity as the entity moving with mouse
self.moving_entity = cloned_entity
self._set_mouse_moving_tiles(cloned_entity)
self.mouse_moving_tg.x = mouse_x - 12
self.mouse_moving_tg.y = mouse_y - 24 - 12
self.mouse_moving_tg.hidden = False
# for Wires keep the existing state when they're dropped
if isinstance(self.moving_entity, Wire):
self.moving_entity.keep_state_after_drop = True
def landing_zone_clear(self, entity):
"""True if there are no entities in the space that would be occupied by the given entity"""
for loc in entity.tile_locations:
if self.entity_at(loc) is not None:
return False
return True
def read_input(self):
"""
Read input from the user stripping the prompt message
"""
return self.message_lbl.text.replace("Save Slot: ", "").replace(
"Open Slot: ", ""
)
def json(self):
"""
Build and return a JSON object representing the current workspace state.
"""
# loop over all entities and create serialized objects with their state
save_obj = {"entities": []}
for entity in self.entities:
entity_obj = {
"class": entity.__class__.__name__,
"location": entity.location,
}
if hasattr(entity, "state"):
entity_obj["state"] = entity.state
if hasattr(entity, "pressed_state"):
entity_obj["pressed_state"] = entity.pressed_state
if hasattr(entity, "index"):
entity_obj["index"] = entity.index
if hasattr(entity, "inputs_left"):
entity_obj["inputs_left"] = entity.inputs_left
if hasattr(entity, "connector_number"):
entity_obj["connector_number"] = entity.connector_number
save_obj["entities"].append(entity_obj)
return json.dumps(save_obj)
def create_entity_from_json(self, entity_json, add_to_workspace=True):
"""
Create an entity from a JSON object representing an entity state.
"""
location = tuple(entity_json["location"])
# special case NeoPixelOutput needs the NeoPixel object
if entity_json["class"] == "NeoPixelOutput":
new_entity = ENTITY_CLASS_CONSTRUCTOR_MAP[entity_json["class"]](
location, self, self.neopixels, add_to_workspace=add_to_workspace
)
# special case PhysicalButton needs the button index
elif entity_json["class"] == "PhysicalButton":
new_entity = ENTITY_CLASS_CONSTRUCTOR_MAP[entity_json["class"]](
location, self, entity_json["index"], add_to_workspace=add_to_workspace
)
# special case Wire needs the wire state number
elif entity_json["class"] == "Wire":
new_entity = ENTITY_CLASS_CONSTRUCTOR_MAP[entity_json["class"]](
location, self, entity_json["state"], add_to_workspace=add_to_workspace
)
# special case Connectors need the connector number
elif (
entity_json["class"] == "SignalReceiver"
or entity_json["class"] == "SignalTransmitter"
):
if entity_json.get("connector_number") is not None:
new_entity = ENTITY_CLASS_CONSTRUCTOR_MAP[entity_json["class"]](
location,
self,
entity_json["connector_number"],
add_to_workspace=add_to_workspace,
)
else:
new_entity = ENTITY_CLASS_CONSTRUCTOR_MAP[entity_json["class"]](
location, self, add_to_workspace=add_to_workspace
)
# default case all other entity types
else:
new_entity = ENTITY_CLASS_CONSTRUCTOR_MAP[entity_json["class"]](
location, self, add_to_workspace=add_to_workspace
)
# if the entity has the inputs_left property then set it according to object
if "inputs_left" in entity_json:
new_entity.inputs_left = entity_json["inputs_left"]
return new_entity
def load_from_json(self, json_data):
"""
Load the workspace state from a JSON object.
"""
# reset list of available SignalTransmitters
self.available_connectors = list(range(MAX_CONNECTORS))
self.neopixels.fill(0)
# clear out all sprites in the tilegrid
for i in range(self.tilegrid.width * self.tilegrid.height):
self.tilegrid[i] = EMPTY
self.overlay_tilegrid[i] = EMPTY
# clear out entities list
self.entities.clear()
# loop over entities in JSON object
for entity_json in json_data["entities"]:
# create current entity
self.create_entity_from_json(entity_json)
# Connect any connectors
for entity in self.entities:
if isinstance(entity, SignalTransmitter):
if entity.connector_number in self.available_connectors:
self.available_connectors.remove(entity.connector_number)
for entity_in in self.entities:
if (
isinstance(entity_in, SignalReceiver)
and entity_in.connector_number is not None
and entity.connector_number == entity_in.connector_number
):
entity_in.input_one = entity
# update the logic simulation with all new entities
self.update()
def apply_scroll(self):
"""
Move the main workspace TileGrid based on the current scroll position
"""
self.tilegrid.x = self._scroll_x * self.tilegrid.tile_width * 1
self.tilegrid.y = self._scroll_y * self.tilegrid.tile_height * 1
self.overlay_tilegrid.x = self._scroll_x * self.overlay_tilegrid.tile_width * 1
self.overlay_tilegrid.y = self._scroll_y * self.overlay_tilegrid.tile_height * 1
def entity_at(self, location):
"""
Get the Entity at the given location or None if there isn't one.
"""
for entity in self.entities:
if location in entity.tile_locations:
return entity
return None
def update(self):
"""
Run the logic simulation based on everything's current state.
"""
# hide the message label if it's time to
now = time.monotonic()
if self.hide_message_at is not None and self.hide_message_at < now:
self.hide_message_at = None
self.message_lbl.text = ""
# loop over all entities and update each one
# with update() function or value property.
for entity in self.entities:
if hasattr(entity, "update"):
entity.update()
else:
_ = entity.value
class ToolBox:
"""
A grid of all possible Entities for the user to choose from to
add new entities to the workspace.
"""
# basic objects representing each item in the Grid
# will be looped over to set up Grid dynamically
GRID_ITEMS = [
{
"label": "And Gate",
"tiles": (10, 11),
"constructor": AndGate,
"size": (2, 1),
},
{
"label": "Nand Gate",
"tiles": (14, 15),
"constructor": NandGate,
"size": (2, 1),
},
{"label": "Or Gate", "tiles": (12, 13), "constructor": OrGate, "size": (2, 1)},
{"label": "Nor Gate", "tiles": (6, 7), "constructor": NorGate, "size": (2, 1)},
{"label": "Not Gate", "tiles": (22,), "constructor": NotGate, "size": (1, 1)},
{"label": "Xor Gate", "tiles": (4, 5), "constructor": XorGate, "size": (2, 1)},
{
"label": "Xnor Gate",
"tiles": (24, 25),
"constructor": XnorGate,
"size": (2, 1),
},
{
"label": "Virtual PushButton",
"tiles": (21,),
"constructor": VirtualPushButton,
"size": (1, 1),
},
{
"label": "Output Panel",
"tiles": (2, 3),
"constructor": OutputPanel,
"size": (2, 1),
},
{
"label": "NeoPixel Output",
"tiles": (16,),
"constructor": NeoPixelOutput,
"size": (1, 1),
},
{
"label": "Physical Button",
"tiles": (23,),
"constructor": PhysicalButton,
"index": 0,
"size": (1, 1),
},
{
"label": "Physical Button",
"tiles": (26,),
"constructor": PhysicalButton,
"index": 1,
"size": (1, 1),
},
{
"label": "Physical Button",
"tiles": (27,),
"constructor": PhysicalButton,
"index": 2,
"size": (1, 1),
},
{
"label": "Signal Transmit'r",
"tiles": (31,),
"constructor": SignalTransmitter,
"size": (1, 1),
},
{
"label": "Signal Receiver",
"tiles": (30,),
"constructor": SignalReceiver,
"size": (1, 1),
},
{
"label": "Wire",
"tiles": (19,),
"constructor": Wire,
"size": (1, 1),
},
]
def __init__(self, spritesheet_bmp, workspace):
self._workspace = workspace
# main group to hold all visual elements
self.group = Group()
# setup solid background to hide the workspace underneath the ToolBox
bg_group = Group(scale=20)
bg_bitmap = Bitmap(320 // 20, 240 // 20, 1)
bg_palette = Palette(1)
bg_palette[0] = 0x888888
bg_tg = TileGrid(bitmap=bg_bitmap, pixel_shader=bg_palette)
bg_group.append(bg_tg)
self.group.append(bg_group)
# setup GridLayout to hold entities to choose from
self.grid = GridLayout(
0, 0, 320, 240, (5, 4), divider_lines=True, divider_line_color=0x666666
)
self.group.append(self.grid)
# store spritesheet and palette for use later
self.spritesheet = spritesheet_bmp
self.spritesheet_pixel_shader = workspace.spritesheet_pixel_shader
# initialize all Entities in the grid
self._init_grid()
# ToolBox is hidden by default when created
self.hidden = True
def _init_grid(self):
"""
Setup all Entities in the Grid
"""
# loop over objects in GRID_ITEMS
for i, item in enumerate(self.GRID_ITEMS):
# calculate x,y position in the grid
grid_pos = (i % self.grid.grid_size[0], i // self.grid.grid_size[0])
# setup a label for the entity
item_lbl = TextBox(
FONT,
text=item["label"],
width=320 // self.grid.grid_size[0],
height=TextBox.DYNAMIC_HEIGHT,
align=TextBox.ALIGN_CENTER,
y=8,
)
x_pos = 6 if item["size"][0] == 2 else 18
# setup an icon TileGrid for the entity
item_icon = TileGrid(
bitmap=self.spritesheet,
pixel_shader=self.spritesheet_pixel_shader,
tile_width=24,
tile_height=24,
width=item["size"][0],
height=item["size"][1],
default_tile=EMPTY,
y=item_lbl.bounding_box[3] + 4,
x=x_pos,
)
for tile_index in range(len(item["tiles"])):
item_icon[tile_index] = item["tiles"][tile_index]
item_icon[tile_index] = item["tiles"][tile_index]
# put the entity label and icon in an AnchoredGroup
item_group = AnchoredGroup()
item_group.append(item_lbl)
item_group.append(item_icon)
# Add the AnchoredGroup to the Grid.
self.grid.add_content(item_group, grid_position=grid_pos, cell_size=(1, 1))
@property
def hidden(self):
"""
True if the ToolBox is hidden, False if it is visible.
"""
return self._hidden
@hidden.setter
def hidden(self, value):
self._hidden = value
self.group.hidden = value
def handle_mouse_click(self, screen_coords):
"""
Handle mouse click event sent by code.py
"""
# get the grid cell location that was clicked
clicked_cell_coords = self.grid.which_cell_contains(screen_coords)
# calculate the 0 based index of the clicked cell
clicked_cell_index = (
clicked_cell_coords[0] + clicked_cell_coords[1] * self.grid.grid_size[0]
)
# if the click was on an empty cell close the ToolBox
if clicked_cell_index > len(self.GRID_ITEMS) - 1:
self.hidden = True
return
# get the object representing the item at the clicked cell
clicked_item = self.GRID_ITEMS[clicked_cell_index]
# special case NeoPixelOutout needs the NeoPixel object
if clicked_item["label"] == "NeoPixel Output":
new_entity = clicked_item["constructor"](
(0, 0),
self._workspace,
self._workspace.neopixels,
add_to_workspace=False,
)
# special case PhysicalButton needs the button index
elif clicked_item["label"] == "Physical Button":
new_entity = clicked_item["constructor"](
(0, 0), self._workspace, clicked_item["index"], add_to_workspace=False
)
# special case only limited number of SignalTransmitters
elif clicked_item["label"] == "Signal Transmit'r":
num_SignalTransmitters = 0
for entity in self._workspace.entities:
if isinstance(entity, SignalTransmitter):
num_SignalTransmitters += 1
if num_SignalTransmitters >= MAX_CONNECTORS:
# close the ToolBox
self.hidden = True
return
new_entity = clicked_item["constructor"](
(0, 0), self._workspace, add_to_workspace=False
)
# default behavior all other entity types
else:
new_entity = clicked_item["constructor"](
(0, 0), self._workspace, add_to_workspace=False
)
# set the created entity as the entity moving with mouse
self._workspace.moving_entity = new_entity
self._workspace._set_mouse_moving_tiles( # pylint: disable=protected-access
new_entity
)
self._workspace.mouse_moving_tg.hidden = False
# close the ToolBox
self.hidden = True
entity.py
This files contains the lowest level functionality of all of the logic gates and other types of parts. There is a base class Entity that contains shared functionality which all of the other objects extend. There is a TwoInputOneOutputGate class that extends Entity and implements all of the functionality and state management for all logic gates that have two inputs and one output, which is all gates except the NOT gate.
The gates will check in their input entity locations to find the Entity or Wire that is connected to the inputs and get its value. Once the input values are known it applies the gate-specific logic rules to determine what signal should get output. The relevant Entity objects all have a value property that is used to access their current logic signal output.
The entity types that are left-clickable to cycle through possible states have a handle_click() function that gets called by Workspace. Inside they update the sprite tiles and input entity locations as appropriate for the new state.
Color Mapping with TilePaletteMapper
All Entity objects make use of the core module TilePaletteMapper to set the wire colors to green when the logic signal is 1 and black when the logic signal is 0. This is a fundamental building block that the graphics of the Logic Gates Simulator are based on. I've included a small example and illustration to show how it works. This basic script demonstrates how it's used.
from displayio import OnDiskBitmap, TileGrid, Group
import supervisor
from tilepalettemapper import TilePaletteMapper
main_group = Group()
display = supervisor.runtime.display
display.root_group = main_group
spritesheet_bmp = OnDiskBitmap("logic_gates_assets/sprites.bmp")
tpm = TilePaletteMapper(
spritesheet_bmp.pixel_shader, # input Palette
len(spritesheet_bmp.pixel_shader), # input color count
)
tilegrid = TileGrid(
bitmap=spritesheet_bmp,
pixel_shader=tpm,
default_tile=8,
width=10, height=10,
tile_width=24, tile_height=24
)
main_group.append(tilegrid)
# left gate and wire tiles
tilegrid[1, 1] = 0
tilegrid[1, 2] = 4
tilegrid[2, 2] = 5
tilegrid[1, 3] = 20
# right gate and wire tiles
tilegrid[4, 1] = 0
tilegrid[4, 2] = 4
tilegrid[5, 2] = 5
tilegrid[4, 3] = 20
# use TilePaletteMapper to map wire colors
# from spritesheet to green or black
color_index_mapping = list(range(len(spritesheet_bmp.pixel_shader)))
# change dark red top input wire to green
color_index_mapping[3] = 7
# change dark green bottom input wire to black
color_index_mapping[4] = 1
# change dark blue output wire to green
color_index_mapping[5] = 7
print(color_index_mapping)
# apply changed color mapping to all tiles used by the right gate and wires
tpm[4, 1] = color_index_mapping
tpm[4, 2] = color_index_mapping
tpm[5, 2] = color_index_mapping
tpm[4, 3] = color_index_mapping
while True:
pass
This script uses a TileGrid with the project's spritesheet bitmap to draw two XOR gates. The left gate is has the default colors that appear in the spritesheet. The right gate has one of its input wires and the output wire mapped to a bright green color, and its other input mapped to black.
TilePaletteMapper allows setting a different color mapping for every tile within a TileGrid. For Palette based mappings, it can substitute a different color index for any existing index in the input palette. This code maps index 7 in place of indexes 3 and 5 the input and output wire colors respectively. It also maps index 1 in place of index 4 to set the bottom input wire color to black.
The screenshot of the palette shows the default color mapping that is embedded within the spritesheet, these colors and indexes are what the TilePaletteMapper manipulates for each tile in the grid.
The following classes are Entities that aren't logic gates:
-
OutputPanel- A basic rectangle visual output that shows a 1 or 0 based on the logic signal that is input to it. -
VirtualPushButton- A circle push button that can be clicked with the mouse to toggle the logic signal that it outputs between 1 and 0. -
PhysicalButton- A circle button that is hooked up to one of the hardware buttons on the Fruit Jam. Pressing the matching physical button will set the logic signal output to 1 and releasing the button will return the signal to 0. -
Wire- A straight or 90 degree turn line that connects two entities together. Logic signal flows through the wire getting passed from outputs of one entity to inputs of another. -
NeoPixelOutput- Accepts 3 logic signal inputs that correspond to red, green, and blue. The NeoPixels on the Fruit Jam are updated to reflect the color matching the value of logic signals being input. -
SignalTransmitter- Accepts a logic signal input and transmits it to all paired receivers. -
SignalReceiver- Receives signal from the paired transmitter and outputs it to be connected to wires and other entities.
The following classes are all of the logic gates supported by the simulator.
-
AndGate- Outputs logic signal 1 when both inputs are logic signal 1, otherwise outputs 0. -
NandGate- Outputs logic signal 1 when at least one input signal is 0, otherwise outputs 0. -
OrGate- Outputs logic signal 1 when at least one input signal is 1, otherwise outputs 0. -
NorGate- Outputs logic signal 1 when both input signals are 0, otherwise outputs 0. -
XorGate- Outputs logic signal 1 when one input signal is 1 and the other input signal is 0, otherwise outputs 0. -
XnorGate- Outputs logic signal 1 when both input signals are 1 or both input signals are 0, otherwise outputs 0. -
NotGate- Outputs logic signal 1 when input signal is 0 and outputs logic signal 0 when input signal is 1.
The entity.py code file is embedded below. It contains comments detailing what each part of the code does.
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
# pylint: disable=abstract-method, attribute-defined-outside-init, unsubscriptable-object, useless-object-inheritance
# pylint: disable=unsupported-assignment-operation, line-too-long, too-many-lines, too-many-branches
try:
from typing import Optional
except ImportError:
pass
COLOR_COUNT = 8
EMPTY = 8
X = 0
Y = 1
WIDTH = 0
HEIGHT = 1
class Entity(object):
"""
Class representing an entity that can be added to a workspace.
All Gates and other objects that can be used in the simulation will extend this class.
"""
size = (1, 1)
"""Size in tiles of the rectangle that contains the entity.
Note that some tiles with this maybe unused."""
_location = (0, 0)
"""The location of the top left tile of the entity within the workspace"""
tile_locations = (_location,)
"""Locations of all tiles used by the entity"""
tiles = (EMPTY,)
"""Tile indexes from the spritesheet used by the entity"""
type = "None"
"""The entity type"""
_workspace = None
def apply_state_palette_mapping(self):
"""
Apply the dynamic palette color mapping based on the
current state of the entity.
"""
raise NotImplementedError()
@property
def value(self):
"""
The logic output value of the entity
"""
raise NotImplementedError()
def _init_tile_locations(self):
"""
Initialize the tile locations and input entity tile locations.
"""
raise NotImplementedError()
@property
def location(self):
"""Location of the top left tile of the entity within the workspace, in tile coordinates"""
return self._location
@location.setter
def location(self, value):
self._location = value
# re-calculate tile locations
self._init_tile_locations()
class OutputPanel(Entity):
"""
Output panel that displays logic values as 1 or 0
"""
def __init__(self, location, workspace, add_to_workspace=True):
self.type = "output"
self.size = (2, 1)
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [2, 3]
self._input_one = False
if add_to_workspace:
self._workspace.add_entity(self)
def _init_tile_locations(self):
self.tile_locations = (self.location, (self.location[X] + 1, self.location[Y]))
# Location of the entity connected to the input of the panel in x,y tile coords
self.input_entity_location = (self.location[X] - 1, self.location[Y])
@property
def palette_mapping(self):
"""Dynamic palette mapping list of color indexes
based on the current state of the entity"""
pal = list(range(COLOR_COUNT))
pal[3] = 7 if self._input_one else 1
pal[4] = 1 if self._input_one else 2
pal[5] = 1 if not self._input_one else 2
return pal
def apply_state_palette_mapping(self):
cur_state_palette = self.palette_mapping
for loc in self.tile_locations:
self._workspace.tilegrid.pixel_shader[loc] = cur_state_palette
@property
def input_one(self):
"""Logic value of input one"""
return self._input_one
@input_one.setter
def input_one(self, value):
self._input_one = value
self.apply_state_palette_mapping()
@property
def value(self):
# Output panel does not have a logic output value
return False
def update(self):
"""
Run logic simulation for this entity
"""
input_entity = self.input_entity
# if no input entity then set logic value False
if input_entity is None:
self.input_one = False
self.apply_state_palette_mapping()
return
# update logic value based on entity value
self.input_one = self.input_entity.value
@property
def input_entity(self):
"""Entity at the input location"""
return self._workspace.entity_at(self.input_entity_location)
class VirtualPushButton(Entity):
"""
Virtual push button that can be on or off, clicked by the mouse to change state
"""
def __init__(
self, location, workspace, initial_pressed_state=False, add_to_workspace=True
):
self.type = "input"
self.size = (1, 1)
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [21]
self.pressed_state = initial_pressed_state
if add_to_workspace:
self._workspace.add_entity(self)
def _init_tile_locations(self):
self.tile_locations = (self.location,)
@property
def value(self):
return self.pressed_state
def apply_state_palette_mapping(self):
pal = list(range(COLOR_COUNT))
pal[5] = 7 if self.value else 1
pal[2] = 7 if self.value else 6
for loc in self.tile_locations:
self._workspace.tilegrid.pixel_shader[loc] = pal
def handle_click(self):
self.pressed_state = not self.pressed_state
self.apply_state_palette_mapping()
self._workspace.update()
class PhysicalButton(Entity):
"""
Physical button tied to one of the hardware buttons on the Fruit Jam
"""
SPRITE_INDEXES = (23, 26, 27)
def __init__(self, location, workspace, index, add_to_workspace=True):
self.index = index
self.type = "input"
self.size = (1, 1)
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [self.SPRITE_INDEXES[index]]
if add_to_workspace:
self._workspace.add_entity(self)
def _init_tile_locations(self):
self.tile_locations = (self.location,)
def update(self):
self.apply_state_palette_mapping()
@property
def value(self):
return not self._workspace.buttons[self.index].value
def apply_state_palette_mapping(self):
pal = list(range(COLOR_COUNT))
pal[5] = 7 if self.value else 1
pal[2] = 7 if self.value else 6
for loc in self.tile_locations:
self._workspace.tilegrid.pixel_shader[loc] = pal
class SignalReceiver(Entity):
"""
Virtual Connector used to bring signals into an entity from a Signal Transmitter
"""
def __init__(self, location, workspace, connector_number=None, add_to_workspace=True):
self.type = "input"
self.size = (1, 1)
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [30]
self.connector_number = None
self._overlay_tile = None
self._input_one = None
if connector_number is None:
# Find the last SignalTransmitter entity added to the workspace to link to
for entity in self._workspace.entities:
if isinstance(entity, SignalTransmitter):
self.connector_number = entity.connector_number
self._input_one = entity
else:
self.connector_number = connector_number
if add_to_workspace:
self._workspace.add_entity(self)
self.apply_state_palette_mapping()
def _init_tile_locations(self):
self.tile_locations = (self.location,)
def update(self):
"""
Run logic simulation for this entity
"""
self.apply_state_palette_mapping()
@property
def input_one(self):
"""The input entity for this Wire."""
return self._input_one
@input_one.setter
def input_one(self, input_entity):
if isinstance(input_entity, SignalTransmitter):
self._input_one = input_entity
self.apply_state_palette_mapping()
@property
def value(self):
if self.connector_number is None:
return False
elif self.input_one is None:
return False
elif self.input_one.input_one is None:
return False
elif self._input_one.input_one == self:
return False
return self.input_one.input_one.value
def apply_state_palette_mapping(self):
pal = list(range(COLOR_COUNT))
pal[5] = 7 if self.value else 1
pal[2] = 7 if self.value else 6
for loc in self.tile_locations:
self._workspace.tilegrid.pixel_shader[loc] = pal
if self._overlay_tile is not None and self._overlay_tile != self.tile_locations[0]:
self._workspace.overlay_tilegrid[self._overlay_tile] = EMPTY
pal = list(range(COLOR_COUNT))
pal[3] = 1
pal[4] = 0
if self.connector_number is not None:
self._workspace.overlay_palette_mapper[self.tile_locations[0]] = pal
self._workspace.overlay_tilegrid[self.tile_locations[0]] = 34 + self.connector_number
self._overlay_tile = self.tile_locations[0]
else:
self._workspace.overlay_tilegrid[self.tile_locations[0]] = EMPTY
self._overlay_tile = None
def handle_click(self):
"""
Cycle through available SignalTransmitter entities on the workspace
"""
connector_outs = []
inuse_connector_numbers = []
for entity in self._workspace.entities:
if isinstance(entity, SignalTransmitter):
connector_outs.append(entity)
inuse_connector_numbers.append(entity.connector_number)
if len(inuse_connector_numbers) == 0:
return
inuse_connector_numbers.sort()
if self.connector_number is None:
self.connector_number = inuse_connector_numbers[0]
else:
if self.connector_number in inuse_connector_numbers:
connector_index = inuse_connector_numbers.index(self.connector_number)
else:
connector_index = -1
self.connector_number = \
inuse_connector_numbers[(connector_index + 1) % max(len(connector_outs),1)]
self.input_one = None
for entity in connector_outs:
if entity.connector_number == self.connector_number:
self.input_one = entity
class SignalTransmitter(Entity):
"""
Virtual Connector used to send signals from an entity to a Signal Receiver
"""
def __init__(self, location, workspace, connector_number=None, add_to_workspace=True):
self.type = "output"
self.size = (1, 1)
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [31]
self._input_one = None
self._overlay_tile = None
if connector_number is None:
# Set the connection_number to the next value available
if len(self._workspace.available_connectors) > 0:
self.connector_number = self._workspace.available_connectors.pop(0)
else:
# Too many connectors, workspace shouldn't allow this to happen
raise RuntimeError("Too many Signal Transmitters")
else:
self.connector_number = connector_number
self._tap = None
if add_to_workspace:
self._workspace.add_entity(self)
def _init_tile_locations(self):
self.tile_locations = (self.location,)
# Location of the entity connected to the left of the Signal Transmitter in x,y tile coords
self.input_entity_location = (self.location[X] - 1, self.location[Y])
def update(self):
"""
Run logic simulation for this entity
"""
# if no input entity
if self.input_entity is None:
self.input_one = None
self.apply_state_palette_mapping()
return
# update logic value based on entity value
self.input_one = self.input_entity
self.apply_state_palette_mapping()
@property
def input_entity(self):
"""Entity at the input location"""
# Only recieve input from output wire on larger gates
if self._workspace.entity_at(self.input_entity_location) == \
self._workspace.entity_at((self.location[X], self.location[Y] - 1)) or \
self._workspace.entity_at(self.input_entity_location) == \
self._workspace.entity_at((self.location[X], self.location[Y] + 1)):
# If entity to left is also above or below us then we're not connected to it's output
return None
return self._workspace.entity_at(self.input_entity_location)
@property
def palette_mapping(self):
"""The palette mapping for the current state. Used for
setting the appropriate color on the input and output lines."""
pal = list(range(COLOR_COUNT))
if self.input_one is not None:
pal[2] = 7 if self.input_one.value else 6
pal[5] = 7 if self.input_one.value else 1
else:
pal[2] = 6
pal[5] = 1
return pal
def apply_state_palette_mapping(self):
cur_state_palette = self.palette_mapping
for loc in self.tile_locations:
self._workspace.tilegrid.pixel_shader[loc] = cur_state_palette
if self._tap is not None and self._tap != self.input_entity_location:
self._workspace.overlay_tilegrid[self._tap] = EMPTY
if self._overlay_tile is not None and self._overlay_tile != self.tile_locations[0]:
self._workspace.overlay_tilegrid[self._overlay_tile] = EMPTY
pal = list(range(COLOR_COUNT))
if isinstance(self.input_entity,Wire):
pal[3] = 7 if self.input_one.value else 1
self._workspace.overlay_palette_mapper[self.input_entity_location] = pal
self._workspace.overlay_tilegrid[self.input_entity_location] = 33
self._tap = self.input_entity_location
else:
self._tap = None
if self.connector_number is not None:
pal[3] = 0
pal[4] = 1
self._workspace.overlay_palette_mapper[self.tile_locations[0]] = pal
self._workspace.overlay_tilegrid[self.tile_locations[0]] = 34 + self.connector_number
self._overlay_tile = self.tile_locations[0]
else:
self._overlay_tile = None
@property
def input_one(self):
"""Entity at input one"""
self._input_one = self.input_entity
return self._input_one
@input_one.setter
def input_one(self, value):
self._input_one = value
self.apply_state_palette_mapping()
@property
def value(self):
# Output panel does not have a logic output value
return False
class Wire(Entity):
"""
Wire used to connect entities together
"""
STATES = [
"left_right",
"up_down",
"up_right",
"down_left",
"up_left",
"down_right",
# unused states
# "left_up_right",
# "left_down_right",
# "left_up_down",
# "right_up_down"
]
# sprite sheet tile indexes matching states from STATES list
TILE_INDEXES = [19, 9, 1, 0, 17, 32]
def __init__(self, location, workspace, state=None, add_to_workspace=True):
self._recursion_guard = False
self.type = "wire"
self.size = (1, 1)
self._workspace = workspace
self._state = None
self.tiles = [19]
if state is None:
guessed_state = self.guess_appropriate_state(location)
self.state = guessed_state
else:
self.state = state
self.location = location
self._output = False
if add_to_workspace:
self._workspace.add_entity(self)
# whether to keep the existing state after
# being placed on the workspace.
self.keep_state_after_drop = False
def update(self):
_ = self.value
self._recursion_guard = False
@property
def state(self):
"""The index of the current state of the Wire. Different states
represent connections on different sides."""
return self._state
@state.setter
def state(self, value):
self._state = value
self._init_tile_locations()
self.tiles[0] = self.TILE_INDEXES[self.state]
try:
self._workspace.remove_entity(self)
self._workspace.add_entity(self)
except ValueError:
# This Wire entity was not on the Workspace
pass
def guess_appropriate_state(self, location):
"""
Try to guess the appropriate state to use for the Wire based
on the surrounding entities.
:param location: The location to check around
:return int: the index of the guessed most appropriate state
"""
entity_above = (
self._workspace.entity_at((location[0], location[1] - 1)) is not None
)
entity_left = (
self._workspace.entity_at((location[0] - 1, location[1])) is not None
)
entity_below = (
self._workspace.entity_at((location[0], location[1] + 1)) is not None
)
entity_right = (
self._workspace.entity_at((location[0] + 1, location[1])) is not None
)
ret_val = 0
if entity_left and entity_right:
ret_val = 0
elif entity_above and entity_below:
ret_val = 1
elif entity_above and entity_right:
ret_val = 2
elif entity_below and entity_left:
ret_val = 3
elif entity_above and entity_left:
ret_val = 4
elif entity_below and entity_right:
ret_val = 5
return ret_val
def handle_click(self):
"""
Change the state of the Wire cycling between all possible states.
"""
if self.state < 5:
self.state += 1
else:
self.state = 0
def _init_tile_locations(self):
self.tile_locations = (self.location,)
@property
def palette_mapping(self):
"""
Dynamic palette mapping list of colors based on the current state
"""
pal = list(range(COLOR_COUNT))
pal[3] = 7 if self._output else 1
return pal
def apply_state_palette_mapping(self):
cur_state_palette = self.palette_mapping
for loc in self.tile_locations:
self._workspace.tilegrid.pixel_shader[loc] = cur_state_palette
def find_neighboring_wire_end(self, direction, wire_segments=None):
"""
Find the end of the wire segment in the given direction.
:param direction: Direction to search in ("left", "right", "up", "down")
:return: tuple of (end entity, list of wire segments in this direction)
"""
if wire_segments is None:
wire_segments = []
# exit recursion if we have already visited this wire segment
if self in wire_segments:
return (None, wire_segments)
wire_segments.append(self)
current_location = self.location
if direction == "left":
opposite = "right"
next_location = (current_location[X] - 1, current_location[Y])
elif direction == "right":
opposite = "left"
next_location = (current_location[X] + 1, current_location[Y])
elif direction == "up":
opposite = "down"
next_location = (current_location[X], current_location[Y] - 1)
elif direction == "down":
opposite = "up"
next_location = (current_location[X], current_location[Y] + 1)
neighbor_entity = self._workspace.entity_at(next_location)
if neighbor_entity is not None:
if neighbor_entity.type == "wire":
if opposite in self.STATES[neighbor_entity.state]:
# wire is properly connected to another wire segment, follow it
neighbor_state = self.STATES[neighbor_entity.state].split('_')
neighbor_direction = neighbor_state[1-neighbor_state.index(opposite)]
return neighbor_entity.find_neighboring_wire_end(neighbor_direction, wire_segments)
elif direction == "left" and neighbor_entity.type in ("input", "gate"):
# Only recieve input from output wire on larger gates
if self._workspace.entity_at(next_location) != \
self._workspace.entity_at((current_location[X], current_location[Y] - 1)) and \
self._workspace.entity_at(next_location) != \
self._workspace.entity_at((current_location[X], current_location[Y] + 1)):
# wire is properly connected to an entity that supplies a value
return(neighbor_entity, wire_segments)
# no wire or input entity found, or not properly connected
return (None, wire_segments)
@property
def output(self):
return self._output
@output.setter
def output(self, value):
self._output = value
@property
def recursion_guard(self):
return self._recursion_guard
@recursion_guard.setter
def recursion_guard(self, value):
self._recursion_guard = value
@property
def value(self):
if self._recursion_guard:
if self._output is None:
self._output = False
self.apply_state_palette_mapping()
return self._output
self._recursion_guard = True
# traverse connected wire segments to find input entities and map entire wire
wires = []
wire_value = False
# only check for entities connected to the two sides defined by the state
for direction in self.STATES[self.state].split('_'):
end_entity, wire_seg = self.find_neighboring_wire_end(direction,[])
wires.append(wire_seg)
# Set wire value to the value of the input entity found at the end of the wire segment
if end_entity is not None and end_entity.type in ("input", "gate"):
wire_value = end_entity.value
# Set the output of all the line segments to the value of the input entity
for seg in wires[0]+wires[1]:
if seg is not self:
seg.output = wire_value
# _recursion_guard is now being used as a sort of "dirty" flag to improve performance
seg.recursion_guard = True
seg.apply_state_palette_mapping()
self._output = wire_value
self.apply_state_palette_mapping()
return self._output
class TwoInputOneOutputGate(Entity):
"""
Super class for all Gate objects that have two inputs and one output.
Implements function and logics that are shared between the different types of Gates.
"""
_input_one = None
_input_two = None
_output = None
size = (2, 3)
"""Size in tiles of the rectangle that contains the entity.
Note that some tiles with this maybe unused."""
input_one_entity_location = None
"""location of input entity one in x,y tile coordinates"""
input_two_entity_location = None
"""location of input entity two in x,y tile coordinates"""
_inputs_left = True
"""Whether the inputs are to the left (True), or top/bottom (False)"""
def __init__(self):
self._recursion_guard = False
self.type = "gate"
self._input_one = False
self._input_two = False
self._output = False
@property
def output(self) -> bool:
"""Value of the output"""
return self._output
@property
def input_one_entity(self) -> Optional[Entity]:
"""Entity connected to input one"""
return self._workspace.entity_at(self.input_one_entity_location)
@property
def input_two_entity(self) -> Entity:
"""Entity connected to input two"""
return self._workspace.entity_at(self.input_two_entity_location)
@property
def input_one(self) -> bool:
"""Logic value of input one"""
return self._input_one
@property
def input_two(self):
"""Logic value of input one"""
return self._input_two
@input_one.setter
def input_one(self, value):
raise NotImplementedError()
@input_two.setter
def input_two(self, value):
raise NotImplementedError()
@property
def value(self):
"""Calculate the output logic value of this gate. Will call
value property on input entities."""
if self._recursion_guard:
self._recursion_guard = False
return self.output
self._recursion_guard = True
input_one_entity = self.input_one_entity
input_two_entity = self.input_two_entity
# print(f"Gate.value input_one_entity: {input_one_entity}, input_two_entity: {input_two_entity}")
if input_one_entity is None:
self.input_one = False
else:
self.input_one = input_one_entity.value
if input_two_entity is None:
self.input_two = False
else:
self.input_two = input_two_entity.value
self._recursion_guard = False
return self.output
def handle_click(self):
"""Toggle between inputs left and inputs above/below"""
self.inputs_left = not self._inputs_left
@property
def inputs_left(self):
"""True if inputs are to the left, False if they are above and below"""
return self._inputs_left
@inputs_left.setter
def inputs_left(self, value):
self._inputs_left = value
if self._inputs_left:
self.input_one_entity_location = (self.location[X] - 1, self.location[Y])
self.input_two_entity_location = (
self.location[X] - 1,
self.location[Y] + 2,
)
self.tiles[0] = 0
self.tiles[3] = 20
else:
self.input_one_entity_location = (self.location[X], self.location[Y] - 1)
self.input_two_entity_location = (self.location[X], self.location[Y] + 3)
self.tiles[0] = 9
self.tiles[3] = 28
try:
self._workspace.remove_entity(self)
self._workspace.add_entity(self)
except ValueError:
# was not in entities list
pass
def _init_tile_locations(self):
self.tile_locations = (
self.location,
(self.location[X], self.location[Y] + 1),
(self.location[X] + 1, self.location[Y] + 1),
(self.location[X], self.location[Y] + 2),
)
self.input_one_entity_location = (self.location[X] - 1, self.location[Y])
self.input_two_entity_location = (self.location[X] - 1, self.location[Y] + 2)
@property
def palette_mapping(self):
"""The palette mapping for the current state. Used for
setting the appropriate color on the input and output lines."""
pal = list(range(COLOR_COUNT))
pal[3] = 7 if self.input_one else 1
pal[4] = 7 if self.input_two else 1
pal[5] = 7 if self.output else 1
return pal
def apply_state_palette_mapping(self):
"""
Apply the current palette mapping to all tiles used by this Gate.
"""
cur_state_palette = self.palette_mapping
for loc in self.tile_locations:
self._workspace.tilegrid.pixel_shader[loc] = cur_state_palette
class AndGate(TwoInputOneOutputGate):
"""AndGate - When both inputs are True the output will be True, otherwise False."""
def __init__(self, location, workspace, add_to_workspace=True):
super().__init__()
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [0, 10, 11, 20]
if add_to_workspace:
self._workspace.add_entity(self)
@TwoInputOneOutputGate.input_one.setter
def input_one(self, value):
self._input_one = value
self._output = self._input_one and self._input_two
self.apply_state_palette_mapping()
@TwoInputOneOutputGate.input_two.setter
def input_two(self, value):
self._input_two = value
self._output = self._input_one and self._input_two
self.apply_state_palette_mapping()
class NandGate(TwoInputOneOutputGate):
"""NandGate - When at least one input is False the output will be True, otherwise False."""
def __init__(self, location, workspace, add_to_workspace=True):
super().__init__()
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [0, 14, 15, 20]
if add_to_workspace:
self._workspace.add_entity(self)
@TwoInputOneOutputGate.input_one.setter
def input_one(self, value):
self._input_one = value
self._output = not self._input_one or not self._input_two
self.apply_state_palette_mapping()
@TwoInputOneOutputGate.input_two.setter
def input_two(self, value):
self._input_two = value
self._output = not self._input_one or not self._input_two
self.apply_state_palette_mapping()
class OrGate(TwoInputOneOutputGate):
"""OrGate - When either input is True the output will be True, otherwise False."""
def __init__(self, location, workspace, add_to_workspace=True):
super().__init__()
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [0, 12, 13, 20]
if add_to_workspace:
self._workspace.add_entity(self)
@TwoInputOneOutputGate.input_one.setter
def input_one(self, value):
self._input_one = value
self._output = self._input_one or self._input_two
self.apply_state_palette_mapping()
@TwoInputOneOutputGate.input_two.setter
def input_two(self, value):
self._input_two = value
self._output = self._input_one or self._input_two
self.apply_state_palette_mapping()
class NorGate(TwoInputOneOutputGate):
"""NorGate - When both inputs are False the output will be True, otherwise False."""
def __init__(self, location, workspace, add_to_workspace=True):
super().__init__()
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [0, 6, 7, 20]
if add_to_workspace:
self._workspace.add_entity(self)
@TwoInputOneOutputGate.input_one.setter
def input_one(self, value):
self._input_one = value
self._output = not self._input_one and not self._input_two
self.apply_state_palette_mapping()
@TwoInputOneOutputGate.input_two.setter
def input_two(self, value):
self._input_two = value
self._output = not self._input_one and not self._input_two
self.apply_state_palette_mapping()
class XorGate(TwoInputOneOutputGate):
"""XorGate - When one input is True and the other input is False the output will be True, otherwise False."""
def __init__(self, location, workspace, add_to_workspace=True):
super().__init__()
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [0, 4, 5, 20]
if add_to_workspace:
self._workspace.add_entity(self)
@TwoInputOneOutputGate.input_one.setter
def input_one(self, value):
self._input_one = value
self._output = False
if self._input_one and not self._input_two:
self._output = True
if not self._input_one and self._input_two:
self._output = True
self.apply_state_palette_mapping()
@TwoInputOneOutputGate.input_two.setter
def input_two(self, value):
self._input_two = value
self._output = False
if self._input_one and not self._input_two:
self._output = True
if not self._input_one and self._input_two:
self._output = True
self.apply_state_palette_mapping()
class XnorGate(TwoInputOneOutputGate):
"""XNOR Gate - When both inputs are True, or both inputs are False the output will be True, otherwise False"""
def __init__(self, location, workspace, add_to_workspace=True):
super().__init__()
self._workspace = workspace
self.location = location
self._init_tile_locations()
self.tiles = [0, 24, 25, 20]
if add_to_workspace:
self._workspace.add_entity(self)
@TwoInputOneOutputGate.input_one.setter
def input_one(self, value):
self._input_one = value
self._output = False
if self._input_one and self._input_two:
self._output = True
if not self._input_one and not self._input_two:
self._output = True
self.apply_state_palette_mapping()
@TwoInputOneOutputGate.input_two.setter
def input_two(self, value):
self._input_two = value
self._output = False
if self._input_one and self._input_two:
self._output = True
if not self._input_one and not self._input_two:
self._output = True
self.apply_state_palette_mapping()
class NotGate(Entity):
"""NOT Gate - When the input is False the output will be True, otherwise False."""
def __init__(self, location, workspace, add_to_workspace=True):
self._recursion_guard = False
self.type = "gate"
self._workspace = workspace
self.location = location
self.tiles = [22]
self._input_one = False
self._output = True
if add_to_workspace:
self._workspace.add_entity(self)
@property
def input_one(self):
return self._input_one
@input_one.setter
def input_one(self, value):
self._input_one = value
self._output = not self._input_one
self.apply_state_palette_mapping()
@property
def output(self):
return self._output
def apply_state_palette_mapping(self):
pal = list(range(COLOR_COUNT))
pal[4] = 7 if self.input_one else 1
pal[5] = 7 if self.output else 1
for loc in self.tile_locations:
self._workspace.tilegrid.pixel_shader[loc] = pal
@property
def input_one_entity(self):
return self._workspace.entity_at(self.input_one_entity_location)
@property
def value(self):
if self._recursion_guard:
self._recursion_guard = False
return self.output
self._recursion_guard = True
input_one_entity = self.input_one_entity
if input_one_entity is not None:
self.input_one = input_one_entity.value
else:
self.input_one = False
self._recursion_guard = False
return self.output
def _init_tile_locations(self):
self.tile_locations = (self.location,)
self.input_one_entity_location = (self.location[X] - 1, self.location[Y])
class NeoPixelOutput(Entity):
"""
NeoPixelOutput with 3 inputs that correspond to red, green, and blue.
Connected to physical NeoPixels on the Fruit Jam.
"""
_input_red = None
_input_green = None
_input_blue = None
size = (1, 3)
input_one_entity_location = None
"""location of input entity one in x,y tile coordinates"""
input_two_entity_location = None
"""location of input entity two in x,y tile coordinates"""
input_three_entity_location = None
"""location of input entity two in x,y tile coordinates"""
def __init__(self, location, workspace, neopixel_obj, add_to_workspace=True):
self.type = "output"
self.neopixels = neopixel_obj
self.tiles = [0, 16, 20]
self._workspace = workspace
self.location = location
self._init_tile_locations()
self._inputs_left = False
if add_to_workspace:
self._workspace.add_entity(self)
@property
def input_one_entity(self) -> Optional[Entity]:
"""Entity connected to input one"""
return self._workspace.entity_at(self.input_one_entity_location)
@property
def input_two_entity(self) -> Entity:
"""Entity connected to input two"""
return self._workspace.entity_at(self.input_two_entity_location)
@property
def input_three_entity(self) -> Entity:
"""Entity connected to input two"""
return self._workspace.entity_at(self.input_three_entity_location)
@property
def input_one(self) -> bool:
"""Logic value of input one"""
return self._input_red
@property
def input_two(self):
"""Logic value of input one"""
return self._input_green
@property
def input_three(self):
"""Logic value of input one"""
return self._input_blue
@input_one.setter
def input_one(self, value):
self._input_red = value
@input_two.setter
def input_two(self, value):
self._input_green = value
@input_three.setter
def input_three(self, value):
self._input_blue = value
def _init_tile_locations(self):
self.tile_locations = (
self.location,
(self.location[X], self.location[Y] + 1),
(self.location[X], self.location[Y] + 2),
)
self.input_one_entity_location = (self.location[X] - 1, self.location[Y])
self.input_two_entity_location = (self.location[X] - 1, self.location[Y] + 1)
self.input_three_entity_location = (self.location[X] - 1, self.location[Y] + 2)
def handle_click(self):
"""Toggle between inputs left and inputs above/below"""
self.inputs_left = not self._inputs_left
@property
def inputs_left(self):
"""True if the inputs are to the left, False if they are above and below"""
return self._inputs_left
@inputs_left.setter
def inputs_left(self, value):
self._inputs_left = value
if self._inputs_left:
self.input_one_entity_location = (self.location[X] - 1, self.location[Y])
self.input_three_entity_location = (
self.location[X] - 1,
self.location[Y] + 2,
)
self.tiles[0] = 0
self.tiles[2] = 20
else:
self.input_one_entity_location = (self.location[X], self.location[Y] - 1)
self.input_three_entity_location = (self.location[X], self.location[Y] + 3)
self.tiles[0] = 9
self.tiles[2] = 28
self._workspace.remove_entity(self)
self._workspace.add_entity(self)
@property
def palette_mapping(self):
"""The palette mapping for the current state. Used for
setting the appropriate color on the input and output lines."""
pal = list(range(COLOR_COUNT))
pal[3] = 7 if self.input_one else 1
pal[4] = 7 if self.input_two else 1
pal[5] = 7 if self.input_three else 1
return pal
@property
def value(self):
return False
def apply_state_palette_mapping(self):
"""
Apply the current palette mapping to all tiles used by this Gate.
"""
cur_state_palette = self.palette_mapping
# top and middle tile behave 'normally'
for loc in self.tile_locations[:-1]:
self._workspace.tilegrid.pixel_shader[loc] = cur_state_palette
# bottom tile different behavior because extension wire
# is using palette index that is typically for input two,
# but on this object the same index is used for input two and
# input three.
pal = list(range(COLOR_COUNT))
pal[4] = 7 if self.input_three else 1
self._workspace.tilegrid.pixel_shader[self.tile_locations[-1]] = pal
def update(self):
"""Run the logic simulation with the current state of the inputs.
Update physical NeoPixels based on logic values of inputs."""
input_one_entity = self.input_one_entity
input_two_entity = self.input_two_entity
input_three_entity = self.input_three_entity
if input_one_entity is not None:
self.input_one = input_one_entity.value
else:
self.input_one = False
if input_two_entity is not None:
self.input_two = input_two_entity.value
else:
self.input_two = False
if input_three_entity is not None:
self.input_three = input_three_entity.value
else:
self.input_three = False
self.neopixels.fill(
(
255 if self.input_one else 0,
255 if self.input_two else 0,
255 if self.input_three else 0,
)
)
self.apply_state_palette_mapping()
Page last edited October 31, 2025
Text editor powered by tinymce.