Skip to content

Instantly share code, notes, and snippets.

@FoamyGuy
Created July 31, 2026 12:28
Show Gist options
  • Select an option

  • Save FoamyGuy/3706ca7b3622e25d5424370505bd51d0 to your computer and use it in GitHub Desktop.

Select an option

Save FoamyGuy/3706ca7b3622e25d5424370505bd51d0 to your computer and use it in GitHub Desktop.
# "It's a UNIX system! I know this!"
#
# A 3D filesystem navigator for the Adafruit Fruit Jam, in the spirit of Silicon
# Graphics' fsn (the IRIX "File System Navigator" the kids fly through in Jurassic
# Park). The current directory is a platform; its files are blocks standing on it,
# each block as tall as the file is big and coloured by type; its subdirectories are
# smaller platforms further out, joined to the parent by walkways. You fly to
# whatever you select.
#
# Controls (the three buttons on the Fruit Jam, or a keyboard over the serial
# console):
# BUTTON1 / Left / A previous item BUTTON2 / Enter / Space enter / fly to
# BUTTON3 / Right / D next item BUTTON2 on ".." go back up
# Up / W nearer item (other axis) Down farther item (other axis)
# S toggle sort: by size / alphabetical
# L toggle file-labels mode: show the names of the four files around
# the selection, not just the selection itself
#
# How it is drawn: the 3D view is a half-resolution picogame Canvas (160x90) shown
# through a 2x Sprite, so every filled pixel costs a quarter of what it would at
# 320x180 - that is the difference between 10 fps and 25. The names floating over
# the scene are *not* in that canvas - they would be blown up 2x along with it - but
# in a full-res StripDraw layer above it, so they come out the same size as the HUD
# text. The two HUD bands are full-resolution Canvases that only repaint when the
# path or selection changes. The whole scene is only redrawn while the camera is
# moving; once it settles the loop costs nothing until you press a button.
import math
import os
import sys
import time
import supervisor
import picogame as pg
import picogame_game
import picogame_input
import picogame_clock
import terminalio
FONT = terminalio.FONT
# ---------------------------------------------------------------------------
# Screen layout
# ---------------------------------------------------------------------------
W, H = 320, 240
HUD_TOP_H = 22 # path band
HUD_BOT_H = 38 # detail band
VIEW_Y = HUD_TOP_H
VIEW_H_PX = H - HUD_TOP_H - HUD_BOT_H # 180 screen rows for the 3D view
VIEW_Y1 = VIEW_Y + VIEW_H_PX
VW = W // 2 # 3D canvas is half-res, blown up 2x
VH = VIEW_H_PX // 2
CX0 = VW * 0.5
CY0 = VH * 0.5
FOCAL = 118.0 # ~68 degrees horizontal
NEAR = 1.0
# ---------------------------------------------------------------------------
# Palette
# ---------------------------------------------------------------------------
HAZE = (46, 66, 96) # colour everything fades toward
SKY = (2, 5, 16)
GROUND = (7, 11, 18)
C_PLATFORM = (54, 78, 112)
C_PARENT = (96, 74, 54)
C_DIRPAD = (38, 66, 104)
C_DIR = (72, 148, 208)
C_WALK = (30, 48, 74)
C_SELECT = (255, 236, 120)
# file colour by extension
C_DEFAULT = (118, 128, 148)
EXT_COLORS = {
"py": (92, 200, 112), "mpy": (66, 148, 84),
"txt": (198, 200, 204), "md": (198, 200, 204), "license": (150, 152, 158),
"json": (232, 138, 74), "toml": (232, 138, 74), "cfg": (232, 138, 74),
"bmp": (214, 108, 200), "png": (214, 108, 200), "jpg": (214, 108, 200),
"gif": (214, 108, 200), "pnm": (214, 108, 200),
"wav": (236, 178, 62), "mp3": (236, 178, 62), "mid": (236, 178, 62),
"uf2": (226, 84, 84), "bin": (226, 84, 84), "dat": (226, 84, 84),
}
def _mix(c, other, t):
return (int(c[0] + (other[0] - c[0]) * t),
int(c[1] + (other[1] - c[1]) * t),
int(c[2] + (other[2] - c[2]) * t))
def _shades(rgb):
"""Six wire colours for one material: (top, end, side) near, then the same hazed.
Faces are flat-shaded by orientation - the top catches the light, the +/-Z ends
are dimmer, the +/-X sides dimmest - which is all the shading an axis-aligned box
needs to read as solid. The second triple is the distance-fogged version."""
out = []
for t in (0.0, 0.55):
c = _mix(rgb, HAZE, t)
out.append(pg.rgb565(c[0], c[1], c[2]))
d = _mix(c, (0, 0, 0), 0.30)
out.append(pg.rgb565(d[0], d[1], d[2]))
d = _mix(c, (0, 0, 0), 0.55)
out.append(pg.rgb565(d[0], d[1], d[2]))
return out
MATERIALS = [] # index -> six-colour shade list
def material(rgb):
MATERIALS.append(_shades(rgb))
return len(MATERIALS) - 1
M_PLATFORM = material(C_PLATFORM)
M_PARENT = material(C_PARENT)
M_DIRPAD = material(C_DIRPAD)
M_DIR = material(C_DIR)
M_WALK = material(C_WALK)
M_SELECT = material(C_SELECT)
M_DEFAULT = material(C_DEFAULT)
M_EXT = {}
for _ext, _rgb in EXT_COLORS.items():
M_EXT[_ext] = material(_rgb)
FOG_START = 26.0 # camera-space depth where tier 1 kicks in
HUD_BG = pg.rgb565(6, 9, 16)
HUD_FG = pg.rgb565(150, 214, 240)
HUD_DIM = pg.rgb565(72, 104, 128)
HUD_RULE = pg.rgb565(30, 62, 88)
HUD_SEL = pg.rgb565(255, 236, 120)
LABEL_DIR = pg.rgb565(150, 214, 240)
LABEL_SEL = pg.rgb565(255, 236, 120)
# ---------------------------------------------------------------------------
# Scene records
#
# One flat list per solid so the depth sort and the draw loop never allocate:
# [kind, item, material, depth, cx, cy, cz, ...geometry]
# kind BOX geometry = x0, y0, z0, x1, y1, z1
# kind QUAD geometry = four world points (12 floats), used for the flat walkways
# `item` is the index of the selectable entry this solid belongs to, or -1.
# ---------------------------------------------------------------------------
BOX = 0
QUAD = 1
R_KIND, R_ITEM, R_MAT, R_DEPTH, R_CX, R_CY, R_CZ, R_G = 0, 1, 2, 3, 4, 5, 6, 7
# Corner index of a box is kx + 2*ky + 4*kz, so a face is four of those indices.
F_TOP = (2, 3, 7, 6)
F_FRONT = (0, 1, 3, 2) # z = z0
F_BACK = (4, 5, 7, 6) # z = z1
F_LEFT = (0, 2, 6, 4) # x = x0
F_RIGHT = (1, 3, 7, 5) # x = x1
_QUAD_F = (0, 1, 2, 3) # the one "face" a walkway quad has
_PX = [0] * 8 # projected corners, reused every box
_PY = [0] * 8 # already rounded to whole pixels
# ---------------------------------------------------------------------------
# Filesystem
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
MAX_DIRS = 15
MAX_FILES = 36
SORT_BY_SIZE = False # see scan()
START_FILE = "code.py" # opened on, zoomed in, at startup
# - see Navigator.open_on()
KIND_UP = 0
KIND_DIR = 1
KIND_FILE = 2
AX_SIDE = 0 # grid step across the platform (x)
AX_DEPTH = 1 # grid step into the screen (z)
def join(path, name):
return path + name if path.endswith("/") else path + "/" + name
def parent_of(path):
if path == "/":
return None
head = path.rsplit("/", 1)[0]
return head or "/"
def _by_size(f):
return f[1]
def scan(path):
"""Return (dirs, files, n_hidden) for `path`; files are (name, size)."""
dirs = []
files = []
try:
names = sorted(os.listdir(path))
except OSError:
return dirs, files, 0
for name in names:
if name.startswith("."): # .Trashes, .fseventsd, ... : noise
continue
try:
st = os.stat(join(path, name))
except OSError:
continue
if st[0] & 0x4000:
dirs.append(name)
else:
files.append((name, st[6]))
over = max(0, len(dirs) - MAX_DIRS) + max(0, len(files) - MAX_FILES)
if SORT_BY_SIZE:
# Ascending, because the grid fills row 0 first and row 0 is the row nearest
# the camera (forward is +z): smallest at your feet, the tall stuff rising
# away from you, so nothing hides behind anything. It also means the cap
# keeps the *largest* files rather than the alphabetically luckiest ones -
# this is a disk-usage view first and a file list second.
files.sort(key=_by_size)
return dirs[:MAX_DIRS], files[-MAX_FILES:], over
return dirs[:MAX_DIRS], files[:MAX_FILES], over
LABEL_CHARS = 17 # names are truncated to fit the view
LABEL_HEAD = 8 # ...by eliding the middle, not the tail
LABEL_TAIL = LABEL_CHARS - LABEL_HEAD - 1
SEPARATORS = "_-. "
def _common_prefix(names):
"""The longest prefix every name shares, cut back to a separator.
The common prefix of a set of strings is the common prefix of its
lexicographic extremes, so this is two scans and one compare loop rather
than a pass over every pair."""
if len(names) < 2:
return ""
a = min(names)
b = max(names)
n = 0
while n < len(a) and n < len(b) and a[n] == b[n]:
n += 1
while n and a[n - 1] not in SEPARATORS: # cut at "adafruit_", not "adafruit_d"
n -= 1
return a[:n]
def make_labels(items):
"""Short, *distinct* labels for a listing.
Head-truncation alone is useless on a real CIRCUITPY card: every directory in
/lib is `adafruit_something`, so nine characters of head made fifteen platforms
all read `adafruit~`. Two fixes, in order:
1. Drop the prefix the whole listing shares - by definition it carries no
information, and the HUD still spells the selection out in full.
2. Elide the *middle* of whatever is still too long, so both ends survive:
`display_text` -> `disp~text`, `requests.mpy` -> `requ~.mpy`.
Returns (labels, prefix); prefix is "" when nothing was dropped."""
dirs = [it[1] for it in items if it[0] == KIND_DIR]
pre = _common_prefix(dirs)
# only worth dropping if it is substantial and leaves something behind - two
# characters is enough (`adafruit_io` -> `io` reads fine and is unambiguous)
if len(pre) < 3 or min([len(nm) for nm in dirs]) - len(pre) < 2:
pre = ""
np = len(pre)
out = []
for it in items:
name = it[1]
if np and name.startswith(pre) and len(name) - np >= 2:
name = name[np:]
if len(name) > LABEL_CHARS:
name = name[:LABEL_HEAD] + "~" + name[-LABEL_TAIL:]
out.append(name)
return out, pre
def file_material(name):
dot = name.rfind(".")
if dot > 0:
return M_EXT.get(name[dot + 1:].lower(), M_DEFAULT)
return M_DEFAULT
def dir_size(path):
"""Sum of the sizes of the files directly inside `path` (not recursive into
sub-subdirectories - one os.listdir()+os.stat() pass per subdirectory is
already the same cost as scan(), so going deeper would multiply that by
every level of nesting)."""
total = 0
try:
names = os.listdir(path)
except OSError:
return 0
for name in names:
if name.startswith("."):
continue
try:
st = os.stat(join(path, name))
except OSError:
continue
if not (st[0] & 0x4000):
total += st[6]
return total
def block_height(size):
"""Block height from file size - log scaled, so a 300-byte file and a 300 KB one
are both on screen instead of one being a speck next to a skyscraper."""
if size <= 0:
return 0.6
return 0.6 + min(7.4, 0.55 * math.log(1.0 + size / 64.0) / 0.6931)
def human(size):
if size < 1024:
return "%d B" % size
if size < 1024 * 1024:
return "%.1f KB" % (size / 1024.0)
return "%.1f MB" % (size / 1048576.0)
# ---------------------------------------------------------------------------
# Layout: a directory listing becomes a landscape
# ---------------------------------------------------------------------------
CELL = 2.6 # file grid pitch
FBOX = 0.85 # half-footprint of a file block
PAD = 2.2 # platform margin around the grid
GRID_COLS = 6
DIR_PITCH = 6.2 # spacing between sibling platforms
DIR_ROW_Z = 7.4 # extra depth per extra row of them
DIR_PLAT = 2.4 # half-size of a subdirectory platform
DIRS_PER_ROW = 5
WALK_W = 0.55 # half-width of a walkway
BEACON_H = 3.4 # how far the selection pillar sticks up
PLAT_BOTTOM = -1.15 # underside of a platform slab
FIT_MARGIN = 5.0 # half-res px left clear round the landscape
MIN_DIST = 13.0 # never orbit closer than this
FIT_SLACK = 1.08 # pull back a touch so LEAN has somewhere to go
LEAN = 0.35 # how far the camera leans toward the selection
FLY_TIME = 0.85 # seconds a fly-through takes, wall clock
DIR_PUSH = 15.0 # fly this much closer when the selection is a
# subdirectory - they sit behind the file grid,
# so fitting their beacon in frame would otherwise
# just zoom out to reveal them
DIR_PUSH_PER_ROW = 2.0 # ...plus this much per extra row of files -
# more rows push the dir cluster back in z, so
# the fixed push alone falls short in a big dir
DIR_RAISE = 8.0 # ...and gain this much altitude, so the camera
# ends up above the file grid rather than level
# with it
def _box(item, mat, x0, y0, z0, x1, y1, z1):
return [BOX, item, mat, 0.0,
(x0 + x1) * 0.5, (y0 + y1) * 0.5, (z0 + z1) * 0.5,
x0, y0, z0, x1, y1, z1]
TILE = 9.0 # see the tile sweep in session 2:
# 5.5 -> 16 tiles under a root platform,
# 9.0 -> 6, and 6.5 ms a frame cheaper.
# Bigger still risks a tile corner crossing
# the near plane and the floor blinking out.
def _slab(out, mat, x0, y0, z0, x1, y1, z1):
"""A platform, emitted as a grid of tiles no larger than TILE across.
Two reasons not to make it one big box: the renderer drops any box with a corner
behind the near plane (no clipper), and a platform is exactly the thing the camera
ends up standing on - one huge box would blink out. Tiles also give the painter's
sort something finer to work with."""
nx = max(1, int(math.ceil((x1 - x0) / TILE)))
nz = max(1, int(math.ceil((z1 - z0) / TILE)))
for i in range(nx):
for j in range(nz):
out.append(_box(-1, mat,
x0 + (x1 - x0) * i / nx, y0, z0 + (z1 - z0) * j / nz,
x0 + (x1 - x0) * (i + 1) / nx, y1, z0 + (z1 - z0) * (j + 1) / nz))
def _walkway(x0, z0, x1, z1):
"""A flat ribbon from (x0,z0) to (x1,z1) just above the ground plane."""
dx = x1 - x0
dz = z1 - z0
d = math.sqrt(dx * dx + dz * dz) or 1.0
px = -dz / d * WALK_W
pz = dx / d * WALK_W
y = -0.5
return [QUAD, -1, M_WALK, 0.0,
(x0 + x1) * 0.5, y, (z0 + z1) * 0.5,
x0 - px, y, z0 - pz, x0 + px, y, z0 + pz,
x1 + px, y, z1 + pz, x1 - px, y, z1 - pz]
class Landscape:
"""The geometry and the selectable-item list for one directory."""
def __init__(self, path):
self.path = path
dirs, files, self.over = scan(path)
self.solids = []
self.items = [] # (kind, name, size, x, ytop, z)
up = parent_of(path)
# --- the current directory: one big platform, files standing on it ------
n = len(files)
cols = min(GRID_COLS, max(1, int(math.ceil(math.sqrt(n))))) if n else 1
rows = int(math.ceil(n / cols)) if n else 1
pw = max(9.0, cols * CELL + 2 * PAD)
pd = max(7.0, rows * CELL + 2 * PAD)
self.pw = pw
self.pd = pd
self.file_rows = rows if n else 0
_slab(self.solids, M_PLATFORM, -pw / 2, -1.15, -pd / 2, pw / 2, 0.0, pd / 2)
# --- ".." : a platform behind you, with a walkway back to it ------------
if up is not None:
uz = -(pd / 2 + 6.4)
self.solids.append(_walkway(0.0, -pd / 2, 0.0, uz + DIR_PLAT))
self.solids.append(_box(-1, M_PARENT, -DIR_PLAT, -0.95, uz - DIR_PLAT,
DIR_PLAT, 0.0, uz + DIR_PLAT))
self.solids.append(_box(len(self.items), M_PARENT, -1.3, 0.0, uz - 1.3,
1.3, 3.2, uz + 1.3))
self.items.append((KIND_UP, "..", 0, 0.0, 3.2, uz))
# --- subdirectories: satellite platforms out in front -------------------
for i, name in enumerate(dirs):
row = i // DIRS_PER_ROW
col = i % DIRS_PER_ROW
in_row = min(DIRS_PER_ROW, len(dirs) - row * DIRS_PER_ROW)
x = (col - (in_row - 1) * 0.5) * DIR_PITCH
z = pd / 2 + 5.6 + row * DIR_ROW_Z
dh = block_height(dir_size(join(path, name)))
self.solids.append(_walkway(0.0, pd / 2, x, z - DIR_PLAT))
self.solids.append(_box(-1, M_DIRPAD, x - DIR_PLAT, -0.95, z - DIR_PLAT,
x + DIR_PLAT, 0.0, z + DIR_PLAT))
self.solids.append(_box(len(self.items), M_DIR, x - 1.3, 0.0, z - 1.3,
x + 1.3, dh, z + 1.3))
self.items.append((KIND_DIR, name, 0, x, dh, z))
# --- files: a city block grid on the main platform ----------------------
self.file_start = len(self.items) # items are appended in grid order,
self.file_cols = cols # row-major, so col/row are recoverable
for i, (name, size) in enumerate(files):
col = i % cols
row = i // cols
x = (col - (cols - 1) * 0.5) * CELL
z = (row - (rows - 1) * 0.5) * CELL
h = block_height(size)
self.solids.append(_box(len(self.items), file_material(name),
x - FBOX, 0.0, z - FBOX, x + FBOX, h, z + FBOX))
self.items.append((KIND_FILE, name, size, x, h, z))
# Screen labels, once per directory rather than once per frame.
self.text, self.prefix = make_labels(self.items)
# World bounding box of everything that was just laid out. view_for()
# turns this into an exact camera distance.
x_max = max(pw * 0.5, (DIRS_PER_ROW - 1) * 0.5 * DIR_PITCH + DIR_PLAT) if dirs \
else pw * 0.5
z_min = -(pd / 2 + 8.5) if up is not None else -pd / 2
z_max = (pd / 2 + 5.6 + ((len(dirs) - 1) // DIRS_PER_ROW) * DIR_ROW_Z + DIR_PLAT) \
if dirs else pd / 2
self.x_max = x_max # the layout is centred on x = 0
self.z_min = z_min
self.z_max = z_max
self.y_max = max([it[4] for it in self.items]) if self.items else 1.0
self.mid_z = (z_min + z_max) * 0.5
self.radius = max(x_max, (z_max - z_min) * 0.5) + 2.0
def file_neighbors(self, i):
"""The up-to-four grid neighbours of file item `i`, as {index: axis}.
Files are appended to `self.items` in row-major grid order starting at
`self.file_start`, so a neighbour is just +/-1 (same row) or +/-cols
(same column) in that index space - no need to store col/row per item.
`axis` is which world axis the step was along: AX_SIDE for +/-1, which
runs across the platform in x, and AX_DEPTH for +/-cols, which runs into
the screen in z. View3D.labels() needs that to put each neighbour's name
on the matching side of the selection's; it cannot recover it from the
projected offsets, because the camera sees the grid at an angle and a
row step lands diagonally on screen just as a column step does."""
cols = self.file_cols
if cols <= 0 or i < self.file_start:
return {}
n = len(self.items) - self.file_start
j = i - self.file_start
_row, col = divmod(j, cols)
out = {}
if col > 0:
out[i - 1] = AX_SIDE
if col < cols - 1 and j + 1 < n:
out[i + 1] = AX_SIDE
if j - cols >= 0:
out[i - cols] = AX_DEPTH
if j + cols < n:
out[i + cols] = AX_DEPTH
return out
def _extent(self, A, B, C, extra):
"""max over every solid (and `extra`) of the linear functional A*x+B*y+C*z.
A linear functional over an axis-aligned box is maximised at whichever
corner the coefficient signs pick out, and those signs are the same for
every box in the landscape - so an exact silhouette extent costs one
multiply-add per solid, not eight."""
ix = R_G + (3 if A > 0 else 0)
iy = R_G + 1 + (3 if B > 0 else 0)
iz = R_G + 2 + (3 if C > 0 else 0)
best = -1e30
for rec in self.solids:
if rec[R_KIND] == BOX:
v = A * rec[ix] + B * rec[iy] + C * rec[iz]
else: # a quad: just try its four points
b = R_G
v = A * rec[b] + B * rec[b + 1] + C * rec[b + 2]
for j in (3, 6, 9):
w = A * rec[b + j] + B * rec[b + j + 1] + C * rec[b + j + 2]
if w > v:
v = w
if v > best:
best = v
if extra is not None:
v = A * extra[0] + B * extra[1] + C * extra[2]
if v > best:
best = v
return best
def frame(self, yaw, pitch, lean=None, extra=None):
"""Solve for the camera pose that fills the frame with this landscape.
The eye is target - forward*dist, so a point's camera-space x and y do not
depend on dist and its depth is just (constant + dist). Write the target as
centre + a*right + b*up and each screen edge becomes linear in (a, b, dist):
right edge a + qx*dist >= max(u - qx*w) = P
left edge -a + qx*dist >= max(-u - qx*w) = M
top edge b + qy*dist >= max(v - qy*w) = Q
bottom edge -b + qy*dist >= max(-v - qy*w) = N
where u, v, w are the point's right/up/forward components. Adding the two
x rows eliminates a, so dist = max((P+M)/2qx, (Q+N)/2qy) exactly, and then
a and b are free anywhere in the slack the binding axis leaves over.
Session 1 used `radius * 1.5 + 12`, which framed the bounding box rather
than the landscape and never centred it - the picture sat low and right and
used barely half the frame."""
cp = math.cos(pitch)
sp = math.sin(pitch)
cy = math.cos(yaw)
sy = math.sin(yaw)
fx = sy * cp; fy = -sp; fz = cy * cp # forward
rx = cy; rz = -sy # right (no roll: its y is 0)
ux = sy * sp; uy = cp; uz = cy * sp # up
qx = (CX0 - FIT_MARGIN) / FOCAL
qy = (CY0 - FIT_MARGIN) / FOCAL
cx0 = 0.0 # the layout is centred on x = 0
cy0 = 2.0
cz0 = self.mid_z
P = self._extent(rx - qx * fx, -qx * fy, rz - qx * fz, extra)
M = self._extent(-rx - qx * fx, -qx * fy, -rz - qx * fz, extra)
Q = self._extent(ux - qy * fx, uy - qy * fy, uz - qy * fz, extra)
N = self._extent(-ux - qy * fx, -uy - qy * fy, -uz - qy * fz, extra)
# the extents are measured from the origin; shift them to the centre point
cf = cx0 * fx + cy0 * fy + cz0 * fz
cr = cx0 * rx + cz0 * rz
cu = cx0 * ux + cy0 * uy + cz0 * uz
P -= cr - qx * cf
M -= -cr - qx * cf
Q -= cu - qy * cf
N -= -cu - qy * cf
dist = (P + M) / (2.0 * qx)
d2 = (Q + N) / (2.0 * qy)
if d2 > dist:
dist = d2
if dist < MIN_DIST:
dist = MIN_DIST
dist *= FIT_SLACK # a little room to lean about in
# lean toward the selection, but only as far as the frame still allows
a = b = 0.0
if lean is not None:
a = (lean[0] - cx0) * rx + (lean[2] - cz0) * rz
b = ((lean[0] - cx0) * ux + (lean[1] - cy0) * uy + (lean[2] - cz0) * uz)
a *= LEAN
b *= LEAN
lo = P - qx * dist
hi = qx * dist - M
a = lo if a < lo else (hi if a > hi else a)
lo = Q - qy * dist
hi = qy * dist - N
b = lo if b < lo else (hi if b > hi else b)
return (cx0 + a * rx + b * ux,
cy0 + b * uy,
cz0 + a * rz + b * uz,
yaw, pitch, dist)
def view_for(self, sel):
"""Camera (target x,y,z, yaw, pitch, distance) that frames item `sel`.
A wide establishing shot of the whole directory, leaning part of the way
toward the selection. Never a close-up: you are flying over the landscape,
not staring at one block, and the beacon says which one is yours."""
if not self.items:
return self.frame(0.28, 0.46)
kind, _name, _size, x, ytop, z = self.items[sel]
if kind == KIND_UP: # swing round to look back the way you came
yaw, pitch = math.pi + 0.28, 0.44
else:
yaw, pitch = 0.28, 0.46
# the beacon has to stay in frame too, not just the blocks
cx, cy, cz, yaw, pitch, dist = self.frame(
yaw, pitch, lean=(x, ytop, z), extra=(x, ytop + BEACON_H, z))
if kind == KIND_DIR:
# subdirectories sit behind the file grid; fitting their beacon would
# otherwise just pull the whole frame back to reveal them - fly closer
# instead, toward the point the lean already picked out, and gain some
# altitude so the camera ends up over the file grid, not level with it.
# More rows of files push the dir cluster back further in z, so scale
# the push with row count rather than a single fixed amount.
push = DIR_PUSH + max(0, self.file_rows - 1) * DIR_PUSH_PER_ROW
dist = max(MIN_DIST, dist - push)
cy += DIR_RAISE
return (cx, cy, cz, yaw, pitch, dist)
# ---------------------------------------------------------------------------
# Camera
# ---------------------------------------------------------------------------
class Camera:
"""Orbit camera that eases toward a goal - the easing IS the fly-through."""
def __init__(self):
self.tx = self.ty = self.tz = 0.0
self.yaw = 0.0
self.pitch = 0.26
self.dist = 20.0
self.goal = (0.0, 0.0, 0.0, 0.0, 0.26, 20.0)
self.start = self.goal
self.t = 1.0
self.last = time.monotonic()
self.moving = True
self.update()
def set_goal(self, g):
# keep the yaw ease on the short way round
yaw = g[3]
while yaw - self.yaw > math.pi:
yaw -= 2 * math.pi
while self.yaw - yaw > math.pi:
yaw += 2 * math.pi
self.start = (self.tx, self.ty, self.tz, self.yaw, self.pitch, self.dist)
self.goal = (g[0], g[1], g[2], yaw, g[4], g[5])
self.t = 0.0 # a new goal restarts the flight
self.last = time.monotonic()
self.moving = True
def snap(self, g):
self.set_goal(g)
self.tx, self.ty, self.tz, self.yaw, self.pitch, self.dist = self.goal
self.start = self.goal
self.t = 1.0
self.update()
def step(self):
"""Advance the flight one frame; returns True while still in motion.
Timed off the wall clock rather than counted in frames, because the frame
rate swings by 50% between a small directory and /lib - a per-frame ease
would make the same move take twice as long in the places it is already
slowest. Smoothstep, so the camera accelerates away and settles rather
than lurching off at full speed the way an exponential ease does."""
if not self.moving:
return False
now = time.monotonic()
dt = now - self.last
self.last = now
if dt > 0.25: # a stall must not teleport the camera
dt = 0.25
self.t += dt / FLY_TIME
if self.t >= 1.0:
self.t = 1.0
self.moving = False
s = self.t * self.t * (3.0 - 2.0 * self.t)
a = self.start
b = self.goal
self.tx = a[0] + (b[0] - a[0]) * s
self.ty = a[1] + (b[1] - a[1]) * s
self.tz = a[2] + (b[2] - a[2]) * s
self.yaw = a[3] + (b[3] - a[3]) * s
self.pitch = a[4] + (b[4] - a[4]) * s
self.dist = a[5] + (b[5] - a[5]) * s
self.update()
return True
def update(self):
"""Rebuild the camera basis and eye position for this frame."""
cp = math.cos(self.pitch)
sp = math.sin(self.pitch)
cy = math.cos(self.yaw)
sy = math.sin(self.yaw)
self.fx = sy * cp # forward (what the camera looks along)
self.fy = -sp
self.fz = cy * cp
self.rx = cy # right (no roll, so its Y is always 0)
self.rz = -sy
self.ux = sy * sp # up
self.uy = cp
self.uz = cy * sp
self.ex = self.tx - self.fx * self.dist
self.ey = self.ty - self.fy * self.dist
self.ez = self.tz - self.fz * self.dist
def horizon(self):
"""Screen row the ground plane recedes to (may be off the top or bottom)."""
cp = math.cos(self.pitch)
if abs(cp) < 1e-4:
return -9999
return CY0 - FOCAL * math.sin(self.pitch) / cp
# ---------------------------------------------------------------------------
# Renderer
# ---------------------------------------------------------------------------
FONT_W, FONT_H = FONT.get_bounding_box()
MAX_LABELS = 7
def _depth_key(rec):
return -rec[R_DEPTH]
def _first(t):
return t[0]
class View3D:
def __init__(self, canvas, cam):
self.cv = canvas
self.cam = cam
# Label placements for the current frame, in absolute screen coordinates:
# (x, y, text, colour). labels() fills this; the StripDraw callback paints it.
self.placed = []
self.layer = pg.StripDraw(self.paint_labels, 0, VIEW_Y, W, VIEW_H_PX)
self.layer.always_dirty = False # parked camera must stay free
self.label_mode = False # 'L': show neighbours' names too
def backdrop(self):
"""Sky and ground as horizontal bands, lightest at the horizon.
This also clears the frame - a full-surface clear() plus a sky would cost
twice as much as just painting every row exactly once."""
cv = self.cv
hz = int(self.cam.horizon())
if hz > VH:
hz = VH
if hz < 0:
hz = 0
bands = 5
if hz > 0: # sky: dark overhead, hazy at the horizon
for i in range(bands):
y0 = hz * i // bands
y1 = hz * (i + 1) // bands
c = _mix(SKY, HAZE, (i / (bands - 1.0)) ** 2 * 0.85)
cv.fill_rect(0, y0, VW, y1 - y0, pg.rgb565(c[0], c[1], c[2]))
if hz < VH: # ground: hazy far, dark underfoot
span = VH - hz
for i in range(bands):
y0 = hz + span * i // bands
y1 = hz + span * (i + 1) // bands
c = _mix(HAZE, GROUND, (i / (bands - 1.0)) ** 0.6)
cv.fill_rect(0, y0, VW, y1 - y0, pg.rgb565(c[0], c[1], c[2]))
def _project_box(self, x0, y0, z0, x1, y1, z1,
NEAR=NEAR, FOCAL=FOCAL, CX0=CX0, CY0=CY0, VW=VW, VH=VH,
px=_PX, py=_PY, _min=min, _max=max, _int=int):
"""Project all eight corners into _PX/_PY. False = reject the whole box.
Split per axis first: each corner's camera-space coordinate is then three
adds, not three multiply-adds, which is most of the cost of a software
projector in Python.
Everything else here is interpreter bookkeeping, and on this board that is
the whole bill - roughly 0.65 us a bytecode. So: the module constants come
in as default arguments (a local load, not a dict lookup), the eight corners
are written out flat instead of run through three nested loops, the screen
bounding box is taken with the C builtins rather than 32 Python compares,
and the corners are stored already rounded so _face does not re-do it.
Together those are worth 2.4x - see the projection notes in session 2."""
cam = self.cam
ex = cam.ex; ey = cam.ey; ez = cam.ez
X0 = x0 - ex; X1 = x1 - ex
Y0 = y0 - ey; Y1 = y1 - ey
Z0 = z0 - ez; Z1 = z1 - ez
rx = cam.rx; rz = cam.rz
ux = cam.ux; uy = cam.uy; uz = cam.uz
fx = cam.fx; fy = cam.fy; fz = cam.fz
ax0 = X0 * rx; ax1 = X1 * rx # -> camera x
az0 = Z0 * rz; az1 = Z1 * rz
bx0 = X0 * ux; bx1 = X1 * ux # -> camera y
by0 = Y0 * uy; by1 = Y1 * uy
bz0 = Z0 * uz; bz1 = Z1 * uz
gx0 = X0 * fx; gx1 = X1 * fx # -> camera z (depth)
gy0 = Y0 * fy; gy1 = Y1 * fy
gz0 = Z0 * fz; gz1 = Z1 * fz
# the y/z halves of each sum only take four values between them
gA = gy0 + gz0; gB = gy1 + gz0; gC = gy0 + gz1; gD = gy1 + gz1
bA = by0 + bz0; bB = by1 + bz0; bC = by0 + bz1; bD = by1 + bz1
a0 = ax0 + az0; a1 = ax1 + az0; a2 = ax0 + az1; a3 = ax1 + az1
cz = gx0 + gA
if cz < NEAR: # any corner behind the near plane
return False # -> drop it (cheaper than clipping)
k = FOCAL / cz
px[0] = _int(CX0 + a0 * k); py[0] = _int(CY0 - (bx0 + bA) * k)
cz = gx1 + gA
if cz < NEAR:
return False
k = FOCAL / cz
px[1] = _int(CX0 + a1 * k); py[1] = _int(CY0 - (bx1 + bA) * k)
cz = gx0 + gB
if cz < NEAR:
return False
k = FOCAL / cz
px[2] = _int(CX0 + a0 * k); py[2] = _int(CY0 - (bx0 + bB) * k)
cz = gx1 + gB
if cz < NEAR:
return False
k = FOCAL / cz
px[3] = _int(CX0 + a1 * k); py[3] = _int(CY0 - (bx1 + bB) * k)
cz = gx0 + gC
if cz < NEAR:
return False
k = FOCAL / cz
px[4] = _int(CX0 + a2 * k); py[4] = _int(CY0 - (bx0 + bC) * k)
cz = gx1 + gC
if cz < NEAR:
return False
k = FOCAL / cz
px[5] = _int(CX0 + a3 * k); py[5] = _int(CY0 - (bx1 + bC) * k)
cz = gx0 + gD
if cz < NEAR:
return False
k = FOCAL / cz
px[6] = _int(CX0 + a2 * k); py[6] = _int(CY0 - (bx0 + bD) * k)
cz = gx1 + gD
if cz < NEAR:
return False
k = FOCAL / cz
px[7] = _int(CX0 + a3 * k); py[7] = _int(CY0 - (bx1 + bD) * k)
return _max(px) >= 0 and _min(px) < VW and _max(py) >= 0 and _min(py) < VH
def _face(self, f, color, px=_PX, py=_PY):
a, b, c, d = f
ax = px[a]
ay = py[a]
cx = px[c]
cy = py[c]
cv = self.cv
cv.fill_triangle(ax, ay, px[b], py[b], cx, cy, color)
cv.fill_triangle(ax, ay, cx, cy, px[d], py[d], color)
def draw_box(self, rec, shades, tier):
g = rec
x0, y0, z0 = g[R_G], g[R_G + 1], g[R_G + 2]
x1, y1, z1 = g[R_G + 3], g[R_G + 4], g[R_G + 5]
if not self._project_box(x0, y0, z0, x1, y1, z1):
return
cam = self.cam
face = self._face
top, end, side = shades[tier], shades[tier + 1], shades[tier + 2]
# Back-face culling for an axis-aligned box is just "which side is the eye on".
if cam.ez < z0:
face(F_FRONT, end)
elif cam.ez > z1:
face(F_BACK, end)
if cam.ex < x0:
face(F_LEFT, side)
elif cam.ex > x1:
face(F_RIGHT, side)
if cam.ey > y1:
face(F_TOP, top)
def draw_quad(self, rec, shades, tier):
cam = self.cam
px = _PX
py = _PY
base = R_G
ex = cam.ex; ey = cam.ey; ez = cam.ez
rx = cam.rx; rz = cam.rz
ux = cam.ux; uy = cam.uy; uz = cam.uz
fx = cam.fx; fy = cam.fy; fz = cam.fz
for i in range(4):
dx = rec[base] - ex
dy = rec[base + 1] - ey
dz = rec[base + 2] - ez
cz = dx * fx + dy * fy + dz * fz
if cz < NEAR:
return
k = FOCAL / cz
px[i] = int(CX0 + (dx * rx + dz * rz) * k)
py[i] = int(CY0 - (dx * ux + dy * uy + dz * uz) * k)
base += 3
self._face(_QUAD_F, shades[tier])
def render(self, land, sel, beacon):
"""One full frame: backdrop, depth-sorted solids, then floating labels."""
cam = self.cam
self.backdrop()
solids = land.solids
fx, fy, fz = cam.fx, cam.fy, cam.fz
ex, ey, ez = cam.ex, cam.ey, cam.ez
# the record-field indices are globals; the depth pass touches every solid,
# so pull them (and NEAR) into locals before the loop
cx_i = R_CX; cy_i = R_CY; cz_i = R_CZ; d_i = R_DEPTH; near = NEAR
draw = []
append = draw.append
for rec in solids:
d = (rec[cx_i] - ex) * fx + (rec[cy_i] - ey) * fy + (rec[cz_i] - ez) * fz
if d < near:
continue
rec[d_i] = d
append(rec)
if beacon is not None:
d = ((beacon[R_CX] - ex) * fx + (beacon[R_CY] - ey) * fy
+ (beacon[R_CZ] - ez) * fz)
if d >= NEAR:
beacon[R_DEPTH] = d
draw.append(beacon)
draw.sort(key=_depth_key) # painter's algorithm, far to near
sel_mat = MATERIALS[M_SELECT]
mats = MATERIALS
fog = FOG_START
item_i = R_ITEM; mat_i = R_MAT; kind_i = R_KIND
box_kind = BOX
draw_box = self.draw_box
draw_quad = self.draw_quad
for rec in draw:
tier = 3 if rec[d_i] > fog else 0
shades = sel_mat if rec[item_i] == sel else mats[rec[mat_i]]
if rec[kind_i] == box_kind:
draw_box(rec, shades, tier)
else:
draw_quad(rec, shades, tier)
self.labels(land, sel)
def labels(self, land, sel):
"""Place the names floating over the directories and over the selection.
Nothing is drawn here - this only decides where the names go, in absolute
screen pixels; paint_labels() puts them on the frame. Names must not go into
the half-res 3D canvas, or the Sprite would magnify them to twice the size of
the HUD, so the projection runs in canvas space (that is where CX0, CY0 and
FOCAL live) and its result is doubled into screen space here, where the glyph
metrics then apply unscaled.
A dozen names is still an unreadable pile: nearest-first, cap the count, and
skip anything that would land on a name already placed."""
cam = self.cam
ex = cam.ex; ey = cam.ey; ez = cam.ez
rx = cam.rx; rz = cam.rz
ux = cam.ux; uy = cam.uy; uz = cam.uz
fx = cam.fx; fy = cam.fy; fz = cam.fz
near2 = NEAR * 2
cand = []
texts = land.text # elided once, when the landscape was built
neighbors = {}
if self.label_mode and land.items and land.items[sel][0] == KIND_FILE:
neighbors = land.file_neighbors(sel)
for i, (kind, _name, _size, x, ytop, z) in enumerate(land.items):
if kind == KIND_FILE and i != sel and i not in neighbors:
continue # file names only when you point at one
# (or its neighbours, in label mode)
dx = x - ex
dy = (ytop + 1.2) - ey
dz = z - ez
cz = dx * fx + dy * fy + dz * fz
if cz < near2:
continue
k = FOCAL / cz
text = texts[i]
# canvas space -> screen space is exactly the Sprite's 2x, then centre
# the run of glyphs and lift it clear of the block it belongs to
# canvas space -> screen space is exactly the Sprite's 2x; then centre the
# run of glyphs and lift it clear of the block it belongs to
sx = int(CX0 + (dx * rx + dz * rz) * k) * 2 - len(text) * (FONT_W // 2)
sy = VIEW_Y + int(CY0 - (dx * ux + dy * uy + dz * uz) * k) * 2 - FONT_H
# Require the full label height in frame, not just some overlap - a
# label straddling VIEW_Y/VIEW_Y1 would still pass a partial-overlap
# test, but the StripDraw layer only paints the rows inside its own
# strip, so the rest silently vanishes and what's left reads as a
# solid sliver rather than a name. Horizontal overrun is left alone:
# a name cut off at the left/right edge is still legible.
if (sy < VIEW_Y or sy + FONT_H > VIEW_Y1
or sx >= W or sx <= -len(text) * FONT_W):
continue
# The selection always wins its slot, and its neighbours are placed
# around it before anything else can take the four sides; the rest is
# nearest-first.
axis = neighbors.get(i) # None, or which grid axis it is off
if i == sel:
key = -2.0
elif axis is not None:
key = -1.0
else:
key = cz
cand.append((key, sx, sy, text, i == sel, kind == KIND_FILE, axis, cz))
cand.sort(key=_first)
placed = self.placed
rects = []
del placed[:]
cap = MAX_LABELS + len(neighbors) # neighbours must not starve
sel_box = None
sel_cz = 0.0
for _key, sx, sy, text, chosen, is_file, nbr_axis, cz in cand:
w = len(text) * FONT_W
# A grid neighbour's block sits right next to the selection's, so at
# this grid pitch its label anchor is only a cell from the selection's
# own (often much wider) name and the two always collide. Stacking
# them all below the selection made the four neighbours read as a flat
# list, losing the one thing they are there to say - where the file is.
# So a neighbour is instead pinned to the side of the selection's label
# that matches where its block actually is: the one behind goes above,
# the one in front below, the ones either side to left and right. The
# grid axis says which pair of sides (a depth step is always above or
# below, never beside), and the projected offset only picks which of the
# two, so it stays correct whichever way the camera has swung round.
#
# Everything else keeps its own anchor and only moves when its slot is
# taken. Which way it then slides is what keeps the scene readable:
# sliding everything down pushed directory names *below* the file names
# they collided with, which reads as the directory standing in front of
# its own contents - backwards, since a directory's beacon is always the
# taller, further thing. So files stack downwards into the empty
# foreground and directories upwards into the sky, and a file label can
# never displace a directory label toward the viewer.
if nbr_axis is not None and sel_box is not None:
bx0, by0, bx1, _by1 = sel_box
if nbr_axis == AX_SIDE: # beside it: butt against the box
stepy = 0
if sx + sx + w < bx0 + bx1: # both doubled: compare centres
sx, stepx = bx0 - w - FONT_W, -(w + FONT_W)
else:
sx, stepx = bx1 + FONT_W, w + FONT_W
sy = by0
else: # behind or in front: a line off
# Which of the two comes from camera depth, not from the
# projected height: a label floats above its own block, so a
# small file two rows further back can still anchor *lower* on
# screen than a tall one in front of it.
stepx = 0
stepy = -FONT_H if cz > sel_cz else FONT_H
sy = by0 + stepy
else:
stepx, stepy = 0, (FONT_H if is_file else -FONT_H)
x1 = sx + w
while True:
for px0, py0, px1, py1 in rects:
if sx < px1 and x1 > px0 and sy < py1 and sy + FONT_H > py0:
sx += stepx
x1 += stepx
sy += stepy
break
else:
break
if (sy < VIEW_Y or sy + FONT_H > VIEW_Y1
or sx >= W or x1 <= 0):
sy = None
break
if sy is None:
continue
# The pinned neighbours skipped the in-frame test the others passed at
# projection time, because they were moved after it.
if sy < VIEW_Y or sy + FONT_H > VIEW_Y1 or sx >= W or x1 <= 0:
continue
if chosen:
sel_box = (sx, sy, x1, sy + FONT_H)
sel_cz = cz
rects.append((sx, sy, x1, sy + FONT_H))
placed.append((sx, sy, text, LABEL_SEL if chosen else LABEL_DIR))
if len(placed) >= cap:
break
self.layer.invalidate()
def paint_labels(self, view, vx, vy, vw, vh):
"""StripDraw callback: stamp this frame's names straight into the strip.
A retained full-res Canvas would be the obvious way to hold text over the
magnified 3D view, but at 320x180 its buffer lands in PSRAM, and clearing it
and compositing it cost 14 ms each - a third of the frame. Drawing into the
strip is the same glyphs with neither of those bills.
Rows outside the layer's rect are never handed to us, so nothing can spill
into the HUD bands; only the strip's own rows have to be filtered here."""
y1 = vy + vh
text = view.text
font = FONT
for sx, sy, s, color in self.placed:
if sy < y1 and sy + FONT_H > vy:
text(sx - vx, sy - vy, s, color, font)
# ---------------------------------------------------------------------------
# HUD
# ---------------------------------------------------------------------------
class Hud:
def __init__(self, scene):
self.top = pg.Canvas(W, HUD_TOP_H)
self.top.move(0, 0)
self.bot = pg.Canvas(W, HUD_BOT_H)
self.bot.move(0, H - HUD_BOT_H)
scene.add(self.top)
scene.add(self.bot)
def draw_path(self, path, n_items, over, prefix=""):
cv = self.top
cv.clear(HUD_BG)
cv.fill_rect(0, HUD_TOP_H - 2, W, 1, HUD_RULE)
shown = path
if len(shown) > 36: # keep the tail: that is the useful end
shown = "..." + shown[-33:]
cv.text(4, 5, shown, HUD_FG, FONT)
tail = "%d" % n_items
if over:
tail += "+%d" % over
tx = W - 4 - len(tail) * 6
cv.text(tx, 5, tail, HUD_DIM, FONT)
if prefix: # the head make_labels() dropped from every label
mark = prefix + "*"
px = 4 + (len(shown) + 2) * 6
if px + (len(mark) + 1) * 6 <= tx: # only when the band has room for it
cv.text(px, 5, mark, HUD_DIM, FONT)
def draw_detail(self, land, sel):
cv = self.bot
cv.clear(HUD_BG)
cv.fill_rect(0, 1, W, 1, HUD_RULE)
if land.items:
kind, name, size, _x, _y, _z = land.items[sel]
if kind == KIND_UP:
line = ".. (parent directory)"
sub = "BUTTON2: go back up"
elif kind == KIND_DIR:
line = name + "/"
sub = "BUTTON2: enter directory"
else:
line = name
sub = human(size)
if len(line) > 34:
line = line[:33] + "~"
cv.text(6, 6, line, HUD_SEL, FONT)
cv.text(6, 20, sub, HUD_DIM, FONT)
pos = "%d/%d" % (sel + 1, len(land.items))
cv.text(W - 6 - len(pos) * 6, 6, pos, HUD_DIM, FONT)
else:
cv.text(6, 6, "(empty directory)", HUD_DIM, FONT)
legend = "1 prev 2 select 3 next"
cv.text(W - 6 - len(legend) * 6, 20, legend, HUD_DIM, FONT)
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
def release_buttons(buttons):
"""Hand the button pins back, so ctrl-C then re-running does not hit
"BUTTON1 in use" - picogame_input.Buttons has no deinit of its own."""
keys = getattr(buttons, "_keys", None)
if keys is not None:
keys.deinit()
for io in getattr(buttons, "_ios", None) or ():
io.deinit()
def make_beacon(land, sel):
"""A thin pillar over the selection, so you can find it from across the map."""
if not land.items:
return None
_kind, _name, _size, x, ytop, z = land.items[sel]
return _box(sel, M_SELECT, x - 0.16, ytop + 0.5, z - 0.16,
x + 0.16, ytop + BEACON_H, z + 0.16)
class Navigator:
def __init__(self):
self.scene, _a, _b = picogame_game.setup(background=pg.rgb565(0, 0, 0))
# The 3D view: one bytearray seen two ways - a half-res Canvas to draw into
# and an RGB565 Bitmap to blit from, shown 2x so it fills the viewport.
self.buf = bytearray(VW * VH * 2)
self.canvas = pg.Canvas(VW, VH, buffer=memoryview(self.buf))
self.bitmap = pg.Bitmap(self.buf, VW, VH, format=pg.RGB565)
self.sprite = pg.Sprite(self.bitmap, 0, VIEW_Y)
self.sprite.scale = 2
self.scene.add(self.sprite)
self.cam = Camera()
self.view = View3D(self.canvas, self.cam)
# The names go on above the 2x sprite, at full resolution, so they come out
# HUD-sized instead of magnified along with the 3D view.
self.scene.add(self.view.layer)
self.hud = Hud(self.scene)
self.land = None
self.sel = 0
self.beacon = None
self.enter("/", snap=True)
self.open_on(START_FILE)
def open_on(self, name):
"""Park on the file `name` in the current listing, already zoomed in.
This is the startup shot: the same place you land if you walk the cursor
onto that file and press select, but snapped rather than flown, since
there is no previous view for a move to read as travel from. A listing
that has no such file (or has it hidden by the MAX_FILES cut) just keeps
the establishing shot."""
for i, it in enumerate(self.land.items):
if it[0] == KIND_FILE and it[1] == name:
self.sel = i
self.beacon = make_beacon(self.land, i)
self.cam.snap(self._file_view(i))
self.hud.draw_detail(self.land, i)
return
# -- navigation ---------------------------------------------------------
def enter(self, path, snap=False, from_below=False):
self.land = Landscape(path)
self.sel = 0
self.beacon = make_beacon(self.land, self.sel)
goal = self.land.view_for(self.sel)
if snap:
self.cam.snap(goal)
else:
# Start the fly-in from high above (descending) or from down low
# (ascending) so entering a directory reads as travel, not a cut.
if from_below:
self.cam.snap((goal[0], goal[1] * 0.3, goal[2] - goal[5] * 0.5,
goal[3], 0.06, goal[5] * 0.45))
else:
self.cam.snap((goal[0], goal[1] + 6.0, goal[2],
goal[3], 0.85, goal[5] * 1.9))
self.cam.set_goal(goal)
self.hud.draw_path(self.land.path, len(self.land.items), self.land.over,
self.land.prefix)
self.hud.draw_detail(self.land, self.sel)
def select(self, delta):
n = len(self.land.items)
if not n:
return
self._set_sel((self.sel + delta) % n)
def select_z(self, delta):
"""Move along the other horizontal axis (depth) - up/down on a
keyboard, rather than left/right through the items list. The list
order only tracks the grid's x axis, so this hunts by geometry
instead: the nearest item whose z lies on the requested side of the
current one, breaking ties by x distance."""
items = self.land.items
n = len(items)
if n < 2:
return
_, _, _, cx, _, cz = items[self.sel]
best, best_key = None, None
for i, (_k, _n, _s, x, _y, z) in enumerate(items):
if i == self.sel:
continue
diff = z - cz
if delta > 0 and diff <= 0:
continue
if delta < 0 and diff >= 0:
continue
key = (abs(diff), abs(x - cx))
if best_key is None or key < best_key:
best_key, best = key, i
if best is not None:
self._set_sel(best)
def _set_sel(self, i):
self.sel = i
self.beacon = make_beacon(self.land, self.sel)
self.cam.set_goal(self.land.view_for(self.sel))
self.hud.draw_detail(self.land, self.sel)
def resort(self):
"""Toggle SORT_BY_SIZE and rebuild the current directory under the new
order. The grid geometry is regenerated from scratch - it isn't just
a re-label - so this re-finds the previously selected entry by name
rather than trusting its old index."""
global SORT_BY_SIZE
SORT_BY_SIZE = not SORT_BY_SIZE
kind, name = self.land.items[self.sel][0], self.land.items[self.sel][1]
self.land = Landscape(self.land.path)
self.sel = 0
for i, it in enumerate(self.land.items):
if it[0] == kind and it[1] == name:
self.sel = i
break
self.beacon = make_beacon(self.land, self.sel)
self.cam.set_goal(self.land.view_for(self.sel))
self.hud.draw_path(self.land.path, len(self.land.items), self.land.over,
self.land.prefix)
self.hud.draw_detail(self.land, self.sel)
def activate(self):
if not self.land.items:
return
kind, name, _size, _x, _y, _z = self.land.items[self.sel]
if kind == KIND_UP:
here = self.land.path
self.enter(parent_of(here), from_below=True)
# put the cursor back on the directory we came out of
leaf = here.rsplit("/", 1)[-1]
for i, it in enumerate(self.land.items):
if it[0] == KIND_DIR and it[1] == leaf:
self.sel = i
self.beacon = make_beacon(self.land, i)
self.cam.set_goal(self.land.view_for(i))
self.hud.draw_detail(self.land, i)
break
elif kind == KIND_DIR:
self.enter(join(self.land.path, name))
else:
self.cam.set_goal(self._file_view(self.sel))
def _file_view(self, i):
"""The camera pose for "look closely at file `i`".
Aim at the block itself rather than scaling down the establishing shot -
view_for() frames (and centres on) the whole landscape, so shortening its
distance just flies the camera into the middle of the city."""
_k, _n, _s, x, ytop, z = self.land.items[i]
return (x, ytop * 0.55, z, 0.28 + 0.9, 0.34, ytop * 1.3 + 7.0)
# -- main loop ----------------------------------------------------------
def run(self):
buttons = picogame_input.Buttons(profile=(
("BUTTON1", picogame_input.LEFT),
("BUTTON2", picogame_input.A),
("BUTTON3", picogame_input.RIGHT),
))
clock = picogame_clock.Clock(30)
kbd_buf = ""
try:
dirty = True
while True:
buttons.poll()
# repeat() counts frames, and holding a button keeps the camera flying,
# so a held frame here is ~80 ms, not the 33 ms the library's defaults
# (15/4) assume - measured, that is 1.5 s before repeat starts and then
# only 3 items a second. 8/2 puts it at ~0.65 s then ~6 a second.
if buttons.just_pressed(buttons.LEFT) or buttons.repeat(buttons.LEFT, 8, 2):
self.select(-1)
if buttons.just_pressed(buttons.RIGHT) or buttons.repeat(buttons.RIGHT, 8, 2):
self.select(1)
if buttons.just_pressed(buttons.A):
self.activate()
# Keyboard over the serial console: arrow keys (as escape
# sequences) or A/D, Enter/Space to activate. Held keys just
# auto-repeat characters at us, so no repeat() bookkeeping
# is needed the way it is for the physical buttons.
available = supervisor.runtime.serial_bytes_available
if available:
kbd_buf += sys.stdin.read(available)
while kbd_buf:
ch = kbd_buf[0]
if ch == "\x1b":
if len(kbd_buf) < 3:
break
seq, kbd_buf = kbd_buf[:3], kbd_buf[3:]
if seq == "\x1b[D":
self.select(-1)
elif seq == "\x1b[C":
self.select(1)
elif seq == "\x1b[A":
self.select_z(1)
elif seq == "\x1b[B":
self.select_z(-1)
continue
kbd_buf = kbd_buf[1:]
if ch in ("a", "A"):
self.select(-1)
elif ch in ("d", "D"):
self.select(1)
elif ch in ("w", "W"):
self.select_z(-1)
elif ch in ("\r", "\n", " "):
self.activate()
elif ch in ("s", "S"):
self.resort()
dirty = True
elif ch in ("l", "L"):
self.view.label_mode = not self.view.label_mode
dirty = True
# Redraw only while the camera is in motion. Parked, the loop is
# just a button poll and a refresh() that finds nothing dirty.
if self.cam.step() or dirty:
self.view.render(self.land, self.sel, self.beacon)
self.sprite.touch()
dirty = False
self.scene.refresh()
clock.tick()
finally:
release_buttons(buttons)
def main():
Navigator().run()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment