""" Byobu battle layer for the shell. The nautilus IS the battlefield. The spiral is the campaign path: the eye is where the session began, the outward sweep is time. Session events become stylized ink-on-gold figures, in the flat perspective of Japanese folding screens (byobu): developer -> the lone general near the eye, a banner at their side dead ends -> fallen warriors + broken banners at their spiral positions gotchas -> archers along the outer rim (the ambushes) breakthrough -> the dragon coiled at the aperture, a victory banner upright sentiment -> gold cloud-bands and a few hill/pine terrain strokes Figures are dark ink silhouettes riding on gold-leaf cloud bands. They are drawn small so they sit IN the landscape, not over it. The layer is produced from the SAME spiral geometry the shell uses, so a fallen warrior sits exactly where its dead-end knot is. """ from __future__ import annotations import math import random # Ink + gold palette (byobu). The figures are near-black sumi ink; the clouds # and banners are gold leaf. These are fixed, not sentiment-driven, because the # byobu convention is ink-on-gold regardless of the campaign's mood (the mood # lives in the shell's own colors underneath). INK = "#1a1410" INK_SOFT = "#2c2218" GOLD = "#c8a24c" GOLD_BRIGHT = "#e6c870" GOLD_PALE = "#efe0b0" def _gold_backing(x: float, y: float, r: float) -> str: """A gold-leaf disc behind a figure so dark ink reads against gold. A faint dark ring keeps the figure legible even on gold-heavy (warm) shells where the gold backing would otherwise blend into the body.""" return ( f'' f'' f'' ) def _cloud_band(cx: float, cy: float, w: float, h: float, seed: int) -> str: """A gold-leaf byobu cloud band: a soft lobed cloud (smooth quad curves). Real byobu clouds are scalloped, billowing shapes, not rectangles. We build a closed path of rounded lobes along the top and bottom edges. """ rng = random.Random(seed) n = rng.randint(4, 6) left = cx - w / 2 step = w / n # top edge: a series of upward lobes; bottom edge: downward lobes d = f"M {left:.1f} {cy:.1f} " x = left for i in range(n): nx = x + step peak = cy - h / 2 - rng.uniform(0, h * 0.3) d += f"Q {x + step/2:.1f} {peak:.1f} {nx:.1f} {cy:.1f} " x = nx for i in range(n): nx = x - step trough = cy + h / 2 + rng.uniform(0, h * 0.3) d += f"Q {x - step/2:.1f} {trough:.1f} {nx:.1f} {cy:.1f} " x = nx d += "Z" return ( f'' f'' ) def _banner(x: float, y: float, h: float, color: str, broken: bool = False, angle: float = 0.0) -> str: """A small byobu banner on a pole. Broken = leaning, torn.""" pole_top = (x, y - h) lean = 28 if broken else 0 tx = x + math.sin(math.radians(angle + lean)) * h ty = y - math.cos(math.radians(angle + lean)) * h pole = ( f'' ) # flag near the top of the pole fx, fy = tx, ty fw = h * 0.5 fh = h * 0.42 if broken: # torn flag: a notched quad flag = ( f'' ) else: flag = ( f'' ) return pole + flag def _kabuto(x: float, y: float, s: float, ink: str) -> str: """A kabuto helmet silhouette with the crescent (maedate) crest.""" # dome dome = ( f'' ) # neck guard (shikoro) flare flare = ( f'' ) # crescent crest (maedate) - two horns crest = ( f'' ) return dome + flare + crest def _katana(x1, y1, x2, y2, ink) -> str: """A katana: a slightly curved blade stroke with a guard.""" mx = (x1 + x2) / 2 + (y2 - y1) * 0.12 my = (y1 + y2) / 2 - (x2 - x1) * 0.12 blade = ( f'' ) guard = f'' return blade + guard def _general(x: float, y: float, s: float) -> str: """The developer: a standing samurai general, kabuto + katana raised.""" # torso (armored, slightly trapezoidal do) torso = ( f'' ) # armor lames (two horizontal segment lines) lames = ( f'' f'' ) # head + kabuto head = f'' helm = _kabuto(x, y - s*0.64, s*0.9, INK) # katana raised diagonally sword = _katana(x + s*0.24, y - s*0.30, x + s*0.62, y - s*0.78, INK) return torso + lames + head + helm + sword def _fallen(x: float, y: float, s: float, ink: str) -> str: """A fallen warrior: toppled samurai, kabuto askew, katana dropped.""" # body lying down (rotated trapezoid torso) g = f'' torso = ( f'' ) head = f'' helm = _kabuto(x, y - s*0.60, s*0.8, ink) g_end = '' # dropped katana lying separately, near the body sword = _katana(x - s*0.5, y + s*0.15, x - s*0.05, y + s*0.30, ink) return g + torso + head + helm + g_end + sword def _archer(x: float, y: float, s: float, ink: str) -> str: """A samurai archer: kneeling, drawing a tall asymmetric yumi bow.""" # kneeling body body = ( f'' ) head = f'' helm = _kabuto(x - s*0.02, y - s*0.52, s*0.7, ink) # tall yumi bow (asymmetric: grip below center, long upper limb) bx = x + s * 0.28 bow = ( f'' ) # bowstring + nocked arrow drawn back string = ( f'' ) arrow = ( f'' ) return body + head + helm + bow + string + arrow def _dragon(x: float, y: float, s: float, angle_deg: float) -> str: """The breakthrough: an Eastern dragon, maned head + horns + sinuous segmented body + clawed legs, coiling toward the aperture.""" seg = s a = math.radians(angle_deg) # build the spine path d = f"M {x:.1f} {y:.1f} " px, py = x, y spine = [(px, py)] for i in range(1, 8): swing = (1 if i % 2 else -1) * seg * 0.55 nx = px + math.cos(a) * seg * 0.55 + math.cos(a + math.pi/2) * swing ny = py + math.sin(a) * seg * 0.55 + math.sin(a + math.pi/2) * swing d += f"Q {px + math.cos(a+math.pi/2)*swing:.1f} {py + math.sin(a+math.pi/2)*swing:.1f} {nx:.1f} {ny:.1f} " spine.append((nx, ny)) px, py = nx, ny body = ( f'' ) # dorsal ridge: small spikes along the spine spikes = "" for i in range(1, len(spine) - 1, 2): sx, sy = spine[i] spikes += ( f'' ) # clawed legs at two points legs = "" for i in (2, 5): if i < len(spine): lx, ly = spine[i] legs += ( f'' ) # head at the start (where the dragon faces): maned, horned, gold eye hx, hy = x, y head = ( f'' # two horns f'' f'' # mane (a few short strokes behind the head) f'' # gold eye f'' ) return body + spikes + legs + head def _point_at_radius_frac(centerline, rfrac): """Find the spiral point whose radius is closest to rfrac of the max radius. The log-spiral bunches points near the eye, so placing by point-index clusters figures in the center. Placing by RADIUS spreads them across the visible outer arms where there is room for figures. """ if not centerline: return None radii = [p[3] for p in centerline] # r is index 3 r_min, r_max = min(radii), max(radii) target = r_min + (r_max - r_min) * rfrac best_i = min(range(len(centerline)), key=lambda i: abs(radii[i] - target)) return centerline[best_i] def _fig(pos_frac: float, inner_svg: str) -> str: """Wrap a figure cluster in a group tagged with its unroll position (0..1), so the birth animation can ink it in when the scroll reaches it.""" p = max(0.0, min(1.0, float(pos_frac))) return f'{inner_svg}' def build_battle_layer(features: dict, centerline: list, outer_pts: list, thickness_at, n_full: int, pal: dict, seed: int) -> str: """Return the byobu battle layer SVG, placed on the shell's spiral by RADIUS so figures spread across the visible outer arms (not the bunched eye). Each figure sits on a gold backing (byobu ink-on-gold). Figures are sized to be legible on the 640px canvas. """ rng = random.Random(seed ^ 0xBA771E) dead_ends = features.get("dead_ends", []) or [] gotchas = features.get("gotchas", []) or [] parts = [""] # ---- gold cloud bands drifting across the OUTER field ---- # Clouds are atmosphere; they ink in early and at their radius position. for i in range(3): rfrac = 0.55 + 0.15 * i p = _point_at_radius_frac(centerline, min(0.95, rfrac)) if p: x, y = p[0], p[1] parts.append(_fig(min(0.95, rfrac), _cloud_band( x, y, w=rng.uniform(120, 180), h=rng.uniform(30, 44), seed=seed + i))) # ---- the general (developer): mid-outer arm. Inks in when scroll reaches 0.66. p = _point_at_radius_frac(centerline, 0.66) if p: gx, gy = p[0], p[1] general = ( _gold_backing(gx, gy - 10, 30) + _general(gx, gy, s=40) + _banner(gx + 26, gy + 4, h=46, color=INK) ) parts.append(_fig(0.66, general)) # ---- fallen warriors at each dead end, placed + revealed by RADIUS ---- for de in dead_ends: pos = max(0.0, min(1.0, float(de.get("position", 0.5)))) rfrac = 0.48 + pos * 0.46 # 0.48 .. 0.94 (keep out of the crowded eye) p = _point_at_radius_frac(centerline, rfrac) if not p: continue x, y = p[0], p[1] fallen = ( _gold_backing(x, y, 22) + _fallen(x, y, s=28, ink=INK) + _banner(x + 12, y - 2, h=26, color=INK, broken=True, angle=rng.uniform(-12, 12)) ) parts.append(_fig(rfrac, fallen)) # ---- archers along the rim at each gotcha ---- n_arch = min(len(gotchas), 8) for i in range(n_arch): frac = 0.52 + (i / max(1, n_arch)) * 0.44 idx = int(frac * (len(outer_pts) - 1)) if outer_pts else 0 if idx >= len(outer_pts): continue ox, oy, _, _, nrm = outer_pts[idx] ax = ox + math.cos(nrm) * 18 ay = oy + math.sin(nrm) * 18 archer = _gold_backing(ax, ay, 15) + _archer(ax, ay, s=24, ink=INK) parts.append(_fig(frac, archer)) # ---- the dragon (breakthrough): inks in LAST, when the scroll reaches the tip. tip = centerline[-1] pre = centerline[max(0, len(centerline) - 18)] dx, dy = pre[0], pre[1] bn = tip[4] dragon = ( f'' + _dragon(dx, dy, s=42, angle_deg=math.degrees(bn) + 150) + _banner(dx - 20, dy + 10, h=56, color=INK) ) parts.append(_fig(1.0, dragon)) parts.append("") return "\n".join(parts)