| """ |
| 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 = "#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'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r + 1.5:.1f}" fill="{INK}" opacity="0.30"/>' |
| f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r:.1f}" fill="{GOLD}" opacity="0.62"/>' |
| f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r:.1f}" fill="none" ' |
| f'stroke="{INK}" stroke-width="0.8" opacity="0.45"/>' |
| ) |
|
|
| 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 |
| |
| 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'<path d="{d}" fill="{GOLD}" opacity="0.20"/>' |
| f'<path d="{d}" fill="none" stroke="{GOLD_BRIGHT}" ' |
| f'stroke-width="0.7" opacity="0.4"/>' |
| ) |
|
|
|
|
| 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'<line x1="{x:.1f}" y1="{y:.1f}" x2="{tx:.1f}" y2="{ty:.1f}" ' |
| f'stroke="{INK}" stroke-width="1.1" stroke-linecap="round"/>' |
| ) |
| |
| fx, fy = tx, ty |
| fw = h * 0.5 |
| fh = h * 0.42 |
| if broken: |
| |
| flag = ( |
| f'<path d="M {fx:.1f} {fy:.1f} L {fx+fw:.1f} {fy+fh*0.2:.1f} ' |
| f'L {fx+fw*0.7:.1f} {fy+fh*0.5:.1f} L {fx+fw:.1f} {fy+fh*0.8:.1f} ' |
| f'L {fx:.1f} {fy+fh:.1f} Z" fill="{color}" opacity="0.85"/>' |
| ) |
| else: |
| flag = ( |
| f'<path d="M {fx:.1f} {fy:.1f} L {fx+fw:.1f} {fy+fh*0.25:.1f} ' |
| f'L {fx+fw:.1f} {fy+fh*0.75:.1f} L {fx:.1f} {fy+fh:.1f} Z" ' |
| f'fill="{color}" opacity="0.9"/>' |
| ) |
| return pole + flag |
|
|
|
|
| def _kabuto(x: float, y: float, s: float, ink: str) -> str: |
| """A kabuto helmet silhouette with the crescent (maedate) crest.""" |
| |
| dome = ( |
| f'<path d="M {x - s*0.26:.1f} {y:.1f} ' |
| f'Q {x:.1f} {y - s*0.42:.1f} {x + s*0.26:.1f} {y:.1f} Z" fill="{ink}"/>' |
| ) |
| |
| flare = ( |
| f'<path d="M {x - s*0.30:.1f} {y:.1f} L {x - s*0.34:.1f} {y + s*0.12:.1f} ' |
| f'L {x + s*0.34:.1f} {y + s*0.12:.1f} L {x + s*0.30:.1f} {y:.1f} Z" ' |
| f'fill="{ink}"/>' |
| ) |
| |
| crest = ( |
| f'<path d="M {x - s*0.22:.1f} {y - s*0.12:.1f} ' |
| f'Q {x:.1f} {y - s*0.55:.1f} {x + s*0.22:.1f} {y - s*0.12:.1f} ' |
| f'Q {x:.1f} {y - s*0.34:.1f} {x - s*0.22:.1f} {y - s*0.12:.1f} Z" ' |
| f'fill="{ink}"/>' |
| ) |
| 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'<path d="M {x1:.1f} {y1:.1f} Q {mx:.1f} {my:.1f} {x2:.1f} {y2:.1f}" ' |
| f'fill="none" stroke="{ink}" stroke-width="1.4" stroke-linecap="round"/>' |
| ) |
| guard = f'<circle cx="{x1:.1f}" cy="{y1:.1f}" r="1.6" fill="{ink}"/>' |
| return blade + guard |
|
|
|
|
| def _general(x: float, y: float, s: float) -> str: |
| """The developer: a standing samurai general, kabuto + katana raised.""" |
| |
| torso = ( |
| f'<path d="M {x - s*0.20:.1f} {y - s*0.52:.1f} ' |
| f'L {x - s*0.30:.1f} {y:.1f} L {x + s*0.30:.1f} {y:.1f} ' |
| f'L {x + s*0.20:.1f} {y - s*0.52:.1f} Z" fill="{INK}"/>' |
| ) |
| |
| lames = ( |
| f'<line x1="{x - s*0.26:.1f}" y1="{y - s*0.16:.1f}" ' |
| f'x2="{x + s*0.26:.1f}" y2="{y - s*0.16:.1f}" stroke="{GOLD_PALE}" ' |
| f'stroke-width="0.6" opacity="0.5"/>' |
| f'<line x1="{x - s*0.22:.1f}" y1="{y - s*0.04:.1f}" ' |
| f'x2="{x + s*0.22:.1f}" y2="{y - s*0.04:.1f}" stroke="{GOLD_PALE}" ' |
| f'stroke-width="0.6" opacity="0.5"/>' |
| ) |
| |
| head = f'<circle cx="{x:.1f}" cy="{y - s*0.60:.1f}" r="{s*0.12:.1f}" fill="{INK}"/>' |
| helm = _kabuto(x, y - s*0.64, s*0.9, INK) |
| |
| 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.""" |
| |
| g = f'<g transform="rotate(82 {x:.1f} {y:.1f})" opacity="0.9">' |
| torso = ( |
| f'<path d="M {x - s*0.18:.1f} {y - s*0.48:.1f} ' |
| f'L {x - s*0.26:.1f} {y:.1f} L {x + s*0.26:.1f} {y:.1f} ' |
| f'L {x + s*0.18:.1f} {y - s*0.48:.1f} Z" fill="{ink}"/>' |
| ) |
| head = f'<circle cx="{x:.1f}" cy="{y - s*0.56:.1f}" r="{s*0.11:.1f}" fill="{ink}"/>' |
| helm = _kabuto(x, y - s*0.60, s*0.8, ink) |
| g_end = '</g>' |
| |
| 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.""" |
| |
| body = ( |
| f'<path d="M {x - s*0.05:.1f} {y - s*0.40:.1f} ' |
| f'L {x - s*0.28:.1f} {y:.1f} L {x + s*0.10:.1f} {y:.1f} Z" fill="{ink}"/>' |
| ) |
| head = f'<circle cx="{x - s*0.02:.1f}" cy="{y - s*0.48:.1f}" r="{s*0.10:.1f}" fill="{ink}"/>' |
| helm = _kabuto(x - s*0.02, y - s*0.52, s*0.7, ink) |
| |
| bx = x + s * 0.28 |
| bow = ( |
| f'<path d="M {bx:.1f} {y - s*0.75:.1f} ' |
| f'Q {bx + s*0.34:.1f} {y - s*0.15:.1f} {bx:.1f} {y + s*0.32:.1f}" ' |
| f'fill="none" stroke="{ink}" stroke-width="1.1"/>' |
| ) |
| |
| string = ( |
| f'<line x1="{bx:.1f}" y1="{y - s*0.75:.1f}" x2="{bx:.1f}" y2="{y + s*0.32:.1f}" ' |
| f'stroke="{ink}" stroke-width="0.5"/>' |
| ) |
| arrow = ( |
| f'<line x1="{x + s*0.02:.1f}" y1="{y - s*0.30:.1f}" ' |
| f'x2="{bx + s*0.10:.1f}" y2="{y - s*0.30:.1f}" stroke="{ink}" stroke-width="0.7"/>' |
| ) |
| 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) |
| |
| 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'<path d="{d}" fill="none" stroke="{INK}" stroke-width="{s*0.30:.1f}" ' |
| f'stroke-linecap="round" opacity="0.92"/>' |
| ) |
| |
| spikes = "" |
| for i in range(1, len(spine) - 1, 2): |
| sx, sy = spine[i] |
| spikes += ( |
| f'<circle cx="{sx:.1f}" cy="{sy:.1f}" r="{s*0.07:.1f}" fill="{INK}"/>' |
| ) |
| |
| legs = "" |
| for i in (2, 5): |
| if i < len(spine): |
| lx, ly = spine[i] |
| legs += ( |
| f'<line x1="{lx:.1f}" y1="{ly:.1f}" x2="{lx - s*0.3:.1f}" ' |
| f'y2="{ly + s*0.3:.1f}" stroke="{INK}" stroke-width="{s*0.10:.1f}" ' |
| f'stroke-linecap="round"/>' |
| ) |
| |
| hx, hy = x, y |
| head = ( |
| f'<circle cx="{hx:.1f}" cy="{hy:.1f}" r="{s*0.26:.1f}" fill="{INK}"/>' |
| |
| f'<line x1="{hx - s*0.1:.1f}" y1="{hy - s*0.18:.1f}" ' |
| f'x2="{hx - s*0.28:.1f}" y2="{hy - s*0.42:.1f}" stroke="{INK}" ' |
| f'stroke-width="{s*0.06:.1f}" stroke-linecap="round"/>' |
| f'<line x1="{hx + s*0.1:.1f}" y1="{hy - s*0.18:.1f}" ' |
| f'x2="{hx + s*0.18:.1f}" y2="{hy - s*0.44:.1f}" stroke="{INK}" ' |
| f'stroke-width="{s*0.06:.1f}" stroke-linecap="round"/>' |
| |
| f'<path d="M {hx + s*0.2:.1f} {hy:.1f} q {s*0.2:.1f} {s*0.1:.1f} {s*0.12:.1f} {s*0.3:.1f}" ' |
| f'fill="none" stroke="{INK}" stroke-width="{s*0.05:.1f}"/>' |
| |
| f'<circle cx="{hx + s*0.08:.1f}" cy="{hy - s*0.06:.1f}" r="{s*0.06:.1f}" ' |
| f'fill="{GOLD_BRIGHT}"/>' |
| ) |
| 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_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'<g class="battle-fig" data-pos="{p:.3f}">{inner_svg}</g>' |
|
|
|
|
| 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 = ["<g class=\"battle-layer\">"] |
|
|
| |
| |
| 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))) |
|
|
| |
| 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)) |
|
|
| |
| for de in dead_ends: |
| pos = max(0.0, min(1.0, float(de.get("position", 0.5)))) |
| rfrac = 0.48 + pos * 0.46 |
| 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)) |
|
|
| |
| 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)) |
|
|
| |
| tip = centerline[-1] |
| pre = centerline[max(0, len(centerline) - 18)] |
| dx, dy = pre[0], pre[1] |
| bn = tip[4] |
| dragon = ( |
| f'<ellipse cx="{dx:.1f}" cy="{dy:.1f}" rx="46" ry="30" ' |
| f'transform="rotate({math.degrees(bn):.0f} {dx:.1f} {dy:.1f})" ' |
| f'fill="{GOLD}" opacity="0.30"/>' |
| + _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("</g>") |
| return "\n".join(parts) |
|
|