From 2698e1141433a5afdc38ff5d8acf3c80d887eaf6 Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 5 Sep 2026 09:13:42 +0200 Subject: [PATCH] tui: add Centurion and Jasonelle avatar assets --- codex-rs/tui/assets/centurion/avatar.json | 18 ++ codex-rs/tui/assets/centurion/generator.py | 233 +++++++++++++++++++++ codex-rs/tui/assets/centurion/sheet.png | Bin 0 -> 2308 bytes codex-rs/tui/assets/jasonelle/avatar.json | 18 ++ codex-rs/tui/assets/jasonelle/sheet.png | Bin 0 -> 1420 bytes codex-rs/tui/assets/jasonelle/source.png | Bin 0 -> 1009 bytes 6 files changed, 269 insertions(+) create mode 100644 codex-rs/tui/assets/centurion/avatar.json create mode 100644 codex-rs/tui/assets/centurion/generator.py create mode 100644 codex-rs/tui/assets/centurion/sheet.png create mode 100644 codex-rs/tui/assets/jasonelle/avatar.json create mode 100644 codex-rs/tui/assets/jasonelle/sheet.png create mode 100644 codex-rs/tui/assets/jasonelle/source.png diff --git a/codex-rs/tui/assets/centurion/avatar.json b/codex-rs/tui/assets/centurion/avatar.json new file mode 100644 index 0000000000..f89d8d15dd --- /dev/null +++ b/codex-rs/tui/assets/centurion/avatar.json @@ -0,0 +1,18 @@ +{ + "displayName": "Centurion", + "description": "Fedora, cyan visor, trench coat, and one stubborn cigar ember.", + "renderMode": "ansi-half-block", + "spritesheetPath": "sheet.png", + "frame": { "width": 24, "height": 24, "columns": 22, "rows": 1 }, + "animations": { + "idle": { "frames": [0,0,0,0,0,0,0,0,1,2,1], "fps": 8 }, + "running": { "frames": [3,4,5,6], "fps": 12 }, + "waiting": { "frames": [7,8], "fps": 4 }, + "review": { "frames": [9,10], "fps": 3 }, + "failed": { "frames": [11,12], "fps": 2 }, + "planning": { "frames": [13,14,15,14], "fps": 3 }, + "tired-idle": { "frames": [16,17], "fps": 2 }, + "tired-running": { "frames": [18,19], "fps": 6 }, + "talking": { "frames": [0,20,21,20], "fps": 8 } + } +} diff --git a/codex-rs/tui/assets/centurion/generator.py b/codex-rs/tui/assets/centurion/generator.py new file mode 100644 index 0000000000..9d85b6768f --- /dev/null +++ b/codex-rs/tui/assets/centurion/generator.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Hand-rolled Centurion avatar derived from the host fastfetch ANSI portrait. + +The source portrait is a 60x30-cell true-color half-block illustration at +~/.config/fastfetch/hai-logo.ans. Its identifying anchors survive the 24px +translation: low fedora, cyan visor, high trench collar, lit cigar, and a thin +smoke curl. The frames are redrawn as pixel primitives rather than sampled so +every state remains readable through the ANSI half-block renderer. + +Frame layout: + 0-2 idle visor pulse, ember pulse + 3-6 running coat swing, bob, wind-cut smoke + 7-8 waiting visor glance, impatient ash + 9-10 review visor scan and target reticle + 11-12 failed red fault visor, dead cigar + 13-15 planning cyan hologram accrues detail + 16-17 tired-idle slouch, dim visor, long ash + 18-19 tired-running slouched coat swing + 20-21 talking jaw grille only; closed mouth reuses frame 0 +""" + +from pathlib import Path + +from PIL import Image +from PIL import ImageDraw + + +HERE = Path(__file__).resolve().parent +SIZE = 24 + +# Palette sampled from the fastfetch ANSI source, then consolidated so the +# tiny derivative reads as intentional pixel art instead of a noisy downsample. +VOID = (0, 27, 48) +NAVY = (0, 42, 74) +BLUE = (0, 88, 126) +BLUE_HI = (50, 158, 182) +TEAL = (24, 153, 174) +CYAN = (0, 255, 255) +ICE = (174, 226, 224) +PALE = (140, 189, 191) +BROWN = (78, 36, 12) +EMBER = (255, 83, 0) +EMBER_HI = (255, 155, 16) +FAULT = (255, 62, 54) +FAULT_DARK = (116, 29, 34) +ASH = (102, 117, 122) + + +def frame( + *, + bob=0, + visor="bright", + coat="rest", + smoke="curl", + cigar="lit", + mouth="closed", + extras=(), +): + image = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + + def y(value): + return value + bob + + # Trench coat and split tails. The pale lapels are as important to the + # silhouette as the hat: together they keep this from becoming "cyan bot". + shoulder_left = 2 if coat == "left" else 3 + shoulder_right = 21 if coat == "right" else 20 + draw.polygon( + [ + (shoulder_left, y(17)), + (6, y(14)), + (17, y(14)), + (shoulder_right, y(17)), + (19, y(22)), + (5, y(22)), + ], + fill=NAVY, + ) + draw.rectangle([6, y(17), 17, y(22)], fill=BLUE) + if coat == "slump": + draw.rectangle([7, y(19), 16, y(22)], fill=NAVY) + draw.polygon([(5, y(15)), (10, y(16)), (11, y(21)), (7, y(18))], fill=ICE) + draw.polygon([(18, y(15)), (13, y(16)), (12, y(21)), (16, y(18))], fill=PALE) + draw.polygon([(10, y(16)), (13, y(16)), (12, y(22))], fill=VOID) + draw.line([(12, y(18)), (12, y(22))], fill=TEAL) + + # Arms and coat tails give whole-pixel motion without destabilizing the + # recognizable head silhouette. + if coat == "left": + draw.rectangle([2, y(17), 4, y(20)], fill=BLUE) + draw.polygon([(7, y(21)), (9, y(23)), (11, y(21))], fill=NAVY) + draw.polygon([(15, y(21)), (18, y(22)), (17, y(19))], fill=VOID) + elif coat == "right": + draw.rectangle([19, y(17), 21, y(20)], fill=BLUE) + draw.polygon([(13, y(21)), (15, y(23)), (17, y(21))], fill=NAVY) + draw.polygon([(6, y(21)), (8, y(22)), (7, y(19))], fill=VOID) + else: + draw.rectangle([3, y(17), 5, y(20)], fill=BLUE) + draw.rectangle([18, y(17), 20, y(20)], fill=NAVY) + + # Head, fedora crown, and low brim. + draw.rounded_rectangle([5, y(5), 18, y(15)], radius=2, fill=NAVY) + draw.polygon([(5, y(5)), (6, y(2)), (9, y(0)), (16, y(1)), (18, y(4))], fill=BLUE) + draw.polygon([(7, y(2)), (10, y(0)), (16, y(1)), (17, y(3)), (10, y(3))], fill=BLUE_HI) + draw.polygon([(4, y(4)), (18, y(3)), (21, y(5)), (18, y(6)), (5, y(7)), (3, y(6))], fill=NAVY) + draw.line([(6, y(4)), (18, y(4))], fill=TEAL) + draw.point([(6, y(3)), (7, y(3)), (17, y(2))], fill=ICE) + + # Visor band. Glance states shift only the hot core; scan adds a moving + # pale bar; fault deliberately drops the source palette for an alarm read. + draw.polygon([(5, y(7)), (18, y(6)), (19, y(10)), (17, y(12)), (6, y(12)), (4, y(10))], fill=BLUE) + draw.rectangle([6, y(8), 17, y(10)], fill=TEAL) + if visor == "bright": + draw.rectangle([8, y(8), 16, y(9)], fill=CYAN) + draw.line([(7, y(10)), (17, y(10))], fill=ICE) + elif visor == "pulse": + draw.rectangle([7, y(8), 17, y(10)], fill=CYAN) + draw.line([(9, y(8)), (15, y(8))], fill=(214, 255, 255)) + elif visor == "blink": + draw.line([(7, y(9)), (17, y(9))], fill=CYAN) + elif visor == "left": + draw.rectangle([6, y(8), 12, y(10)], fill=CYAN) + draw.line([(13, y(9)), (17, y(9))], fill=BLUE_HI) + elif visor == "right": + draw.rectangle([12, y(8), 17, y(10)], fill=CYAN) + draw.line([(7, y(9)), (11, y(9))], fill=BLUE_HI) + elif visor == "scan-left": + draw.rectangle([7, y(8), 17, y(10)], fill=TEAL) + draw.rectangle([8, y(8), 9, y(10)], fill=CYAN) + elif visor == "scan-right": + draw.rectangle([7, y(8), 17, y(10)], fill=TEAL) + draw.rectangle([15, y(8), 16, y(10)], fill=CYAN) + elif visor == "fault": + draw.rectangle([7, y(8), 17, y(10)], fill=FAULT_DARK) + draw.line([(7, y(8)), (17, y(10))], fill=FAULT) + draw.line([(7, y(10)), (17, y(8))], fill=FAULT) + elif visor == "dim": + draw.line([(7, y(10)), (17, y(10))], fill=BLUE_HI) + + # Jaw grille; talking frames change only this local region relative to + # frame zero, satisfying the mouth-flap continuity contract. + draw.rectangle([8, y(12), 16, y(14)], fill=VOID) + if mouth == "closed": + draw.line([(9, y(13)), (15, y(13))], fill=BLUE_HI) + elif mouth == "half": + draw.rectangle([10, y(13), 15, y(13)], fill=ICE) + elif mouth == "open": + draw.rectangle([10, y(12), 15, y(14)], fill=VOID) + draw.line([(10, y(12)), (15, y(12))], fill=ICE) + draw.line([(11, y(14)), (14, y(14))], fill=TEAL) + + # Cigar, ember, and smoke curl from the original portrait. + cigar_y = y(12) + if cigar != "gone": + draw.rectangle([16, cigar_y, 21, cigar_y + 1], fill=BROWN) + if cigar == "lit": + draw.rectangle([21, cigar_y - 1, 22, cigar_y + 1], fill=EMBER) + draw.point([(22, cigar_y - 1)], fill=EMBER_HI) + elif cigar == "ash": + draw.rectangle([20, cigar_y, 22, cigar_y + 1], fill=ASH) + elif cigar == "dead": + draw.rectangle([21, cigar_y, 22, cigar_y + 1], fill=FAULT_DARK) + + if smoke == "curl": + draw.point([(22, y(10)), (23, y(8)), (22, y(6)), (22, y(5)), (23, y(3))], fill=ICE) + draw.point([(21, y(7)), (23, y(2))], fill=PALE) + elif smoke == "puff": + draw.point([(22, y(9)), (23, y(8)), (21, y(7)), (22, y(6)), (23, y(6))], fill=ICE) + elif smoke == "wind-left": + draw.line([(21, y(10)), (16, y(8))], fill=PALE) + draw.point([(14, y(8)), (18, y(9))], fill=ICE) + elif smoke == "wind-right": + draw.line([(22, y(9)), (19, y(7))], fill=ICE) + draw.point([(17, y(7)), (20, y(8))], fill=PALE) + elif smoke == "question": + draw.line([(22, y(9)), (23, y(7)), (21, y(6)), (22, y(4))], fill=ICE) + draw.point([(22, y(2))], fill=ICE) + elif smoke == "fault": + draw.point([(21, y(9)), (22, y(8)), (20, y(6)), (23, y(5))], fill=ASH) + + if "reticle" in extras: + draw.rectangle([13, y(7), 18, y(11)], outline=EMBER_HI) + draw.point([(15, y(9)), (16, y(9))], fill=EMBER) + if "ash-fall" in extras: + draw.point([(23, y(15)), (22, y(17))], fill=ASH) + if "holo0" in extras or "holo1" in extras or "holo2" in extras: + stage = int(next(extra[-1] for extra in extras if extra.startswith("holo"))) + draw.rectangle([0, y(13), 4, y(19)], outline=TEAL) + draw.point([(1, y(15)), (3, y(15))], fill=CYAN) + if stage >= 1: + draw.line([(1, y(17)), (3, y(17))], fill=ICE) + if stage >= 2: + draw.point([(2, y(18)), (4, y(14))], fill=CYAN) + if "sweat" in extras: + draw.point([(20, y(6)), (21, y(7))], fill=CYAN) + + return image + + +FRAMES = [ + frame(), + frame(visor="pulse", smoke="puff"), + frame(visor="blink"), + frame(coat="left", smoke="wind-left"), + frame(bob=1, coat="right", smoke="wind-right"), + frame(coat="right", smoke="wind-left"), + frame(bob=1, coat="left", smoke="wind-right"), + frame(visor="left", smoke="question", cigar="ash"), + frame(visor="right", smoke="puff", extras=("ash-fall",)), + frame(visor="scan-left", smoke="curl"), + frame(visor="scan-right", smoke="curl", extras=("reticle",)), + frame(visor="fault", smoke="fault", cigar="dead"), + frame(bob=1, visor="fault", smoke="fault", cigar="gone", coat="slump"), + frame(visor="scan-left", smoke="puff", extras=("holo0",)), + frame(visor="scan-right", smoke="puff", extras=("holo1",)), + frame(visor="bright", smoke="curl", extras=("holo2",)), + frame(bob=1, visor="dim", coat="slump", cigar="ash", smoke="puff"), + frame(bob=1, visor="blink", coat="slump", cigar="ash", smoke="curl"), + frame(bob=1, visor="dim", coat="left", cigar="ash", smoke="wind-left"), + frame(bob=1, visor="dim", coat="right", cigar="ash", smoke="wind-right", extras=("sweat",)), + frame(mouth="half"), + frame(mouth="open"), +] + +strip = Image.new("RGBA", (SIZE * len(FRAMES), SIZE), (0, 0, 0, 0)) +for index, source in enumerate(FRAMES): + alpha = source.getchannel("A").point(lambda value: 255 if value >= 128 else 0) + source.putalpha(alpha) + strip.paste(source, (index * SIZE, 0)) + +strip.save(HERE / "sheet.png") +print(f"strip: {strip.size}, frames: {len(FRAMES)}") diff --git a/codex-rs/tui/assets/centurion/sheet.png b/codex-rs/tui/assets/centurion/sheet.png new file mode 100644 index 0000000000000000000000000000000000000000..f19de4e46be138b0bdce3d169ec6ac4158fbe2ef GIT binary patch literal 2308 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 literal 0 HcmV?d00001