mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): restore 3D pi logo easter egg alongside 3D Armin
Both share one lazily loaded renderer in easter-egg-3d.ts. Drops the easter egg changelog entries.
This commit is contained in:
parent
1c1e9c0efc
commit
c450f2c0f8
5 changed files with 223 additions and 99 deletions
|
|
@ -2,14 +2,6 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- `/arminsayshi` now plays a 3D version in fullscreen mode, with one cube per pixel of Armin. The 3D pi logo easter egg on header logo click is replaced by it.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the daxnuts easter egg shown when selecting `opencode/kimi-k2.5`.
|
||||
|
||||
## [1.0.0] - 2026-10-01
|
||||
|
||||
### New Features
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { type TUI, TuiAltScreen } from "@earendil-works/pi-tui";
|
||||
|
||||
/**
|
||||
* Plays the 3D Armin easter egg (see armin-3d.ts), which loads on first use. Only fullscreen mode can show it,
|
||||
* because it dissolves the rendered screen. The screen is captured before loading. Returns false when it cannot
|
||||
* play, so the caller can fall back to the inline version.
|
||||
*/
|
||||
export function playArmin3d(tui: TUI): boolean {
|
||||
if (!(tui instanceof TuiAltScreen)) return false;
|
||||
if (tui.hasOverlay()) return true;
|
||||
const screen = tui.getScreenLines();
|
||||
import("./armin-3d.ts").then(
|
||||
(module) => module.playArmin3d(tui, screen),
|
||||
() => {},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { type TUI, TuiAltScreen } from "@earendil-works/pi-tui";
|
||||
import type { EasterEgg3d } from "./easter-egg-3d.ts";
|
||||
|
||||
/**
|
||||
* Plays a 3D easter egg (see easter-egg-3d.ts), which loads on first use. Only fullscreen mode can show it, because
|
||||
* it dissolves the rendered screen. The screen is captured before loading. Returns false when it cannot play.
|
||||
*/
|
||||
function playEasterEgg3d(tui: TUI, egg: EasterEgg3d): boolean {
|
||||
if (!(tui instanceof TuiAltScreen)) return false;
|
||||
if (tui.hasOverlay()) return true;
|
||||
const screen = tui.getScreenLines();
|
||||
import("./easter-egg-3d.ts").then(
|
||||
(module) => module.playEasterEgg3d(tui, screen, egg),
|
||||
() => {},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Plays the 3D pi logo, lifting off the header logo whose top-left cell is at `column`, `row`. */
|
||||
export function playPiLogo3d(tui: TUI, column: number, row: number): void {
|
||||
playEasterEgg3d(tui, { kind: "pi-logo", column, row });
|
||||
}
|
||||
|
||||
/** Plays the 3D Armin. Returns false when it cannot play, so the caller can fall back to the inline version. */
|
||||
export function playArmin3d(tui: TUI): boolean {
|
||||
return playEasterEgg3d(tui, { kind: "armin" });
|
||||
}
|
||||
|
|
@ -17,11 +17,15 @@ import { ARMIN_HEIGHT, ARMIN_WIDTH, isArminPixel } from "./armin.ts";
|
|||
import { formatKeyText } from "./keybinding-hints.ts";
|
||||
|
||||
/**
|
||||
* Fullscreen 3D version of the /arminsayshi easter egg. The current screen dissolves into braille dust, spreading
|
||||
* out from the center, while Armin's bitmap, built from one cube per pixel, grows out of the center and spins.
|
||||
* Leaving plays the same timeline backwards, so Armin shrinks away and the screen reassembles.
|
||||
* Fullscreen 3D easter eggs: the pi logo (header logo click) and Armin (/arminsayshi). Both are bitmaps built from
|
||||
* one block per pixel. The current screen dissolves into braille dust while the model spins in the center and its
|
||||
* blocks play a sliding puzzle. Leaving plays the same timeline backwards, so the screen reassembles.
|
||||
*
|
||||
* The cubes are ray cast per braille dot. A braille cell holds 2x4 roughly square dots.
|
||||
* The pi logo lifts off the header, flies to the center, and grows; the dust spreads out from the header logo.
|
||||
* Armin grows out of a speck at the center; the dust spreads out from the center.
|
||||
*
|
||||
* The blocks are ray cast per braille dot. A braille cell holds 2x4 roughly square dots, so one pixel of a
|
||||
* half-block bitmap is exactly 2x2 dots, and the header logo (4x2 cells) is 8x8 dots when it lifts off.
|
||||
*/
|
||||
|
||||
type Rgb = readonly [number, number, number];
|
||||
|
|
@ -52,15 +56,16 @@ interface Star {
|
|||
speed: number;
|
||||
}
|
||||
|
||||
interface LogoBox {
|
||||
interface Box {
|
||||
min: readonly [number, number, number];
|
||||
max: readonly [number, number, number];
|
||||
color: Rgb;
|
||||
}
|
||||
|
||||
interface Pose {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
/** Braille dots per logo pixel at depth 0. */
|
||||
/** Braille dots per model pixel at depth 0. */
|
||||
scale: number;
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
|
|
@ -68,28 +73,89 @@ interface Pose {
|
|||
}
|
||||
|
||||
const DEPTH = 0.7;
|
||||
// Far enough that the perspective stays mild for a figure about 36 cubes tall.
|
||||
const CAMERA_DISTANCE = 80;
|
||||
const FLY_START = 0.1;
|
||||
const FLY_DURATION = 1.3;
|
||||
/** Braille dots per pixel when Armin appears, growing from a speck at the center. */
|
||||
/** Braille dots per pixel when a model without an origin appears, growing from a speck at the center. */
|
||||
const START_SCALE = 0.1;
|
||||
|
||||
/** Grid position of a block: column and row in the bitmap, and layer (-1, 0, 1) in depth. */
|
||||
type Cell3 = readonly [number, number, number];
|
||||
|
||||
// One block per foreground pixel of Armin's bitmap. Each is a block that the puzzle slides around.
|
||||
const BLOCKS: Array<{ home: Cell3 }> = [];
|
||||
for (let row = 0; row < ARMIN_HEIGHT; row++) {
|
||||
for (let column = 0; column < ARMIN_WIDTH; column++) {
|
||||
if (isArminPixel(column, row)) BLOCKS.push({ home: [column, row, 0] });
|
||||
}
|
||||
/** A bitmap built from one block per foreground pixel. Each is a block that the puzzle slides around. */
|
||||
interface Model {
|
||||
/** Bitmap size in pixels. */
|
||||
columns: number;
|
||||
rows: number;
|
||||
blocks: Array<{ home: Cell3; color: Rgb }>;
|
||||
/** Camera distance from the model's center, in pixels. */
|
||||
cameraDistance: number;
|
||||
/** Farthest distance of any block corner from the center, with blocks on the outer depth layers. */
|
||||
radius: number;
|
||||
/** Largest share of the screen width the spinning model covers. */
|
||||
widthShare: number;
|
||||
/** Number of blocks the puzzle moves per step, given a random number from 0 to 1. */
|
||||
puzzleMoves: (random: number) => number;
|
||||
/**
|
||||
* Top-left cell of the model's half-block rendering on screen. The model lifts off from there and replaces it.
|
||||
* Undefined to grow out of the center.
|
||||
*/
|
||||
origin: { column: number; row: number } | undefined;
|
||||
}
|
||||
// Farthest distance of any block corner from the origin, with blocks on the outer depth layers.
|
||||
const LOGO_RADIUS = Math.hypot(ARMIN_WIDTH / 2, ARMIN_HEIGHT / 2, 1 + DEPTH / 2);
|
||||
|
||||
// The sliding puzzle: after Armin spun for a while, blocks slide into free neighboring cells, a few at a time.
|
||||
// Each cycle shuffles, flies every block back home, and holds the figure briefly.
|
||||
function createModel(
|
||||
columns: number,
|
||||
rows: number,
|
||||
pixel: (column: number, row: number) => Rgb | undefined,
|
||||
options: Pick<Model, "cameraDistance" | "widthShare" | "origin"> & {
|
||||
puzzleMoves: (random: number, blockCount: number) => number;
|
||||
},
|
||||
): Model {
|
||||
const blocks: Model["blocks"] = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let column = 0; column < columns; column++) {
|
||||
const color = pixel(column, row);
|
||||
if (color) blocks.push({ home: [column, row, 0], color });
|
||||
}
|
||||
}
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
blocks,
|
||||
cameraDistance: options.cameraDistance,
|
||||
radius: Math.hypot(columns / 2, rows / 2, 1 + DEPTH / 2),
|
||||
widthShare: options.widthShare,
|
||||
puzzleMoves: (random) => options.puzzleMoves(random, blocks.length),
|
||||
origin: options.origin,
|
||||
};
|
||||
}
|
||||
|
||||
const CORAL: Rgb = [228, 138, 122];
|
||||
const BLUE: Rgb = [79, 142, 179];
|
||||
const YELLOW: Rgb = [234, 182, 93];
|
||||
const PI_LOGO_PIXELS = ["ccc.", "b.c.", "bb.y", "b..y"];
|
||||
const PI_LOGO_COLORS: Record<string, Rgb> = { c: CORAL, b: BLUE, y: YELLOW };
|
||||
|
||||
function piLogoModel(origin: { column: number; row: number }): Model {
|
||||
return createModel(4, 4, (column, row) => PI_LOGO_COLORS[PI_LOGO_PIXELS[row]![column]!], {
|
||||
cameraDistance: 10,
|
||||
widthShare: 0.35,
|
||||
puzzleMoves: (random) => (random < 0.4 ? 2 : 1),
|
||||
origin,
|
||||
});
|
||||
}
|
||||
|
||||
function arminModel(color: Rgb): Model {
|
||||
return createModel(ARMIN_WIDTH, ARMIN_HEIGHT, (column, row) => (isArminPixel(column, row) ? color : undefined), {
|
||||
// Far enough that the perspective stays mild for a figure about 36 blocks tall.
|
||||
cameraDistance: 80,
|
||||
widthShare: 0.45,
|
||||
puzzleMoves: (random, blockCount) => Math.round(blockCount * (0.03 + random * 0.04)),
|
||||
origin: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// The sliding puzzle: after the model spun for a while, blocks slide into free neighboring cells, a few at a time.
|
||||
// Each cycle shuffles, flies every block back home, and holds the model briefly.
|
||||
const PUZZLE_START = FLY_START + FLY_DURATION + 3;
|
||||
const PUZZLE_STEP = 0.3;
|
||||
const PUZZLE_STEPS = 12;
|
||||
|
|
@ -110,18 +176,19 @@ const PUZZLE_MOVES: Cell3[] = [
|
|||
];
|
||||
|
||||
/** Block positions after each step of one shuffle cycle, starting at home. Deterministic per cycle. */
|
||||
function shuffleSteps(cycle: number): Cell3[][] {
|
||||
let positions: Cell3[] = BLOCKS.map((block) => block.home);
|
||||
function shuffleSteps(model: Model, cycle: number): Cell3[][] {
|
||||
const { columns, rows } = model;
|
||||
let positions: Cell3[] = model.blocks.map((block) => block.home);
|
||||
const steps = [positions];
|
||||
const lastMoved = new Set<number>();
|
||||
let random = cycle * 7_919 + 1;
|
||||
const next = () => hash(random++);
|
||||
const key = ([x, y, z]: Cell3) => ((z + 1) * ARMIN_HEIGHT + y) * ARMIN_WIDTH + x;
|
||||
const key = ([x, y, z]: Cell3) => ((z + 1) * rows + y) * columns + x;
|
||||
for (let step = 0; step < PUZZLE_STEPS; step++) {
|
||||
const occupied = new Set(positions.map(key));
|
||||
const nextPositions = [...positions];
|
||||
const moved = new Set<number>();
|
||||
const moveCount = Math.round(BLOCKS.length * (0.03 + next() * 0.04));
|
||||
const moveCount = model.puzzleMoves(next());
|
||||
for (let move = 0; move < moveCount; move++) {
|
||||
const candidates: Array<{ block: number; target: Cell3 }> = [];
|
||||
for (let block = 0; block < positions.length; block++) {
|
||||
|
|
@ -129,7 +196,7 @@ function shuffleSteps(cycle: number): Cell3[][] {
|
|||
const [x, y, z] = positions[block]!;
|
||||
for (const [dx, dy, dz] of PUZZLE_MOVES) {
|
||||
const target: Cell3 = [x + dx, y + dy, z + dz];
|
||||
if (target[0] < 0 || target[0] >= ARMIN_WIDTH || target[1] < 0 || target[1] >= ARMIN_HEIGHT) continue;
|
||||
if (target[0] < 0 || target[0] >= columns || target[1] < 0 || target[1] >= rows) continue;
|
||||
if (target[2] < -1 || target[2] > 1 || occupied.has(key(target))) continue;
|
||||
candidates.push({ block, target });
|
||||
}
|
||||
|
|
@ -416,11 +483,11 @@ interface Face {
|
|||
}
|
||||
|
||||
/**
|
||||
* Renders the logo blocks into braille cells. Only faces that point at the camera and are not covered by a
|
||||
* Renders the model's blocks into braille cells. Only faces that point at the camera and are not covered by a
|
||||
* touching block are drawn. Each face is rasterized over its projected bounds by intersecting each dot's ray
|
||||
* with the face's plane, with a depth buffer resolving overlaps. Buffers are reused between frames.
|
||||
*/
|
||||
class LogoRaster {
|
||||
class BlockRaster {
|
||||
/** Braille dot bits per cell. */
|
||||
bits = new Uint8Array(0);
|
||||
/** Lit dots per cell. */
|
||||
|
|
@ -441,13 +508,17 @@ class LogoRaster {
|
|||
private dirty: { minX: number; minY: number; maxX: number; maxY: number } | undefined;
|
||||
/** Cells with a halo from the previous frame, cleared before the next one. */
|
||||
private haloDirty: { minX: number; minY: number; maxX: number; maxY: number } | undefined;
|
||||
private readonly cameraDistance: number;
|
||||
|
||||
constructor(cameraDistance: number) {
|
||||
this.cameraDistance = cameraDistance;
|
||||
}
|
||||
|
||||
render(
|
||||
width: number,
|
||||
height: number,
|
||||
pose: Pose,
|
||||
boxes: readonly LogoBox[],
|
||||
color: Rgb,
|
||||
boxes: readonly Box[],
|
||||
background: Rgb,
|
||||
haloStrength: number,
|
||||
): void {
|
||||
|
|
@ -485,10 +556,11 @@ class LogoRaster {
|
|||
|
||||
const m = rotation(pose.yaw, pose.pitch, pose.roll);
|
||||
const { centerX, centerY, scale } = pose;
|
||||
// The camera sits at (0, 0, CAMERA_DISTANCE) in camera space; object space is the transpose rotation.
|
||||
const origin = [m[6]! * CAMERA_DISTANCE, m[7]! * CAMERA_DISTANCE, m[8]! * CAMERA_DISTANCE];
|
||||
const cameraDistance = this.cameraDistance;
|
||||
// The camera sits at (0, 0, cameraDistance) in camera space; object space is the transpose rotation.
|
||||
const origin = [m[6]! * cameraDistance, m[7]! * cameraDistance, m[8]! * cameraDistance];
|
||||
const light = isLight(background);
|
||||
const faces = this.visibleFaces(m, origin, pose, boxes, color, dotWidth, dotHeight, light);
|
||||
const faces = this.visibleFaces(m, origin, pose, boxes, dotWidth, dotHeight, light);
|
||||
if (faces.length === 0) return;
|
||||
let minX = dotWidth;
|
||||
let minY = dotHeight;
|
||||
|
|
@ -519,9 +591,9 @@ class LogoRaster {
|
|||
const sx = (face.minX + 0.5 - centerX) / scale;
|
||||
for (let dotY = face.minY; dotY <= face.maxY; dotY++) {
|
||||
const sy = (dotY + 0.5 - centerY) / scale;
|
||||
let directionA = m[a]! * sx + m[3 + a]! * sy - m[6 + a]! * CAMERA_DISTANCE;
|
||||
let directionU = m[u]! * sx + m[3 + u]! * sy - m[6 + u]! * CAMERA_DISTANCE;
|
||||
let directionV = m[v]! * sx + m[3 + v]! * sy - m[6 + v]! * CAMERA_DISTANCE;
|
||||
let directionA = m[a]! * sx + m[3 + a]! * sy - m[6 + a]! * cameraDistance;
|
||||
let directionU = m[u]! * sx + m[3 + u]! * sy - m[6 + u]! * cameraDistance;
|
||||
let directionV = m[v]! * sx + m[3 + v]! * sy - m[6 + v]! * cameraDistance;
|
||||
let index = dotY * dotWidth + face.minX;
|
||||
for (let dotX = face.minX; dotX <= face.maxX; dotX++, index++) {
|
||||
const t = (face.plane - originA) / directionA;
|
||||
|
|
@ -555,7 +627,7 @@ class LogoRaster {
|
|||
if (id === 0) continue;
|
||||
const face = faces[id - 1]!;
|
||||
// Points farther from the camera fade slightly toward the background for depth.
|
||||
const fog = clamp01(0.15 - CAMERA_DISTANCE * (1 - depth[index]!) * 0.12) * (light ? 0.5 : 1);
|
||||
const fog = clamp01(0.15 - cameraDistance * (1 - depth[index]!) * 0.12) * (light ? 0.5 : 1);
|
||||
faceIds[index] = 0;
|
||||
depth[index] = Infinity;
|
||||
const cellX = dotX >> 1;
|
||||
|
|
@ -669,13 +741,13 @@ class LogoRaster {
|
|||
m: number[],
|
||||
origin: number[],
|
||||
pose: Pose,
|
||||
boxes: readonly LogoBox[],
|
||||
color: Rgb,
|
||||
boxes: readonly Box[],
|
||||
dotWidth: number,
|
||||
dotHeight: number,
|
||||
lightBackground: boolean,
|
||||
): Face[] {
|
||||
const faces: Face[] = [];
|
||||
const cameraDistance = this.cameraDistance;
|
||||
for (const box of boxes) {
|
||||
for (let a = 0; a < 3; a++) {
|
||||
const u = (a + 1) % 3;
|
||||
|
|
@ -709,7 +781,7 @@ class LogoRaster {
|
|||
const x = m[0]! * corner[0]! + m[1]! * corner[1]! + m[2]! * corner[2]!;
|
||||
const y = m[3]! * corner[0]! + m[4]! * corner[1]! + m[5]! * corner[2]!;
|
||||
const z = m[6]! * corner[0]! + m[7]! * corner[1]! + m[8]! * corner[2]!;
|
||||
const perspective = (pose.scale * CAMERA_DISTANCE) / (CAMERA_DISTANCE - z);
|
||||
const perspective = (pose.scale * cameraDistance) / (cameraDistance - z);
|
||||
const screenX = pose.centerX + x * perspective;
|
||||
const screenY = pose.centerY + y * perspective;
|
||||
minX = Math.min(minX, screenX);
|
||||
|
|
@ -749,9 +821,9 @@ class LogoRaster {
|
|||
vMin: box.min[v]!,
|
||||
vMax: box.max[v]!,
|
||||
...face,
|
||||
red: Math.min(255, color[0] * light + specular),
|
||||
green: Math.min(255, color[1] * light + specular),
|
||||
blue: Math.min(255, color[2] * light + specular),
|
||||
red: Math.min(255, box.color[0] * light + specular),
|
||||
green: Math.min(255, box.color[1] * light + specular),
|
||||
blue: Math.min(255, box.color[2] * light + specular),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -760,13 +832,18 @@ class LogoRaster {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which easter egg to play. The pi logo lifts off the header logo, whose top-left cell is at `column`, `row`.
|
||||
*/
|
||||
export type EasterEgg3d = { kind: "pi-logo"; column: number; row: number } | { kind: "armin" };
|
||||
|
||||
let playing = false;
|
||||
|
||||
/**
|
||||
* Show the animation as a fullscreen overlay until it is dismissed. The overlay takes focus and mouse input and
|
||||
* returns focus when hidden, so the rest of the UI keeps running underneath untouched.
|
||||
*/
|
||||
export async function playArmin3d(tui: TUI, screen: readonly string[]): Promise<void> {
|
||||
export async function playEasterEgg3d(tui: TUI, screen: readonly string[], egg: EasterEgg3d): Promise<void> {
|
||||
if (playing) return;
|
||||
playing = true;
|
||||
// Fading needs the terminal's actual default colors; the theme only knows its own.
|
||||
|
|
@ -777,26 +854,27 @@ export async function playArmin3d(tui: TUI, screen: readonly string[]): Promise<
|
|||
const colors = {
|
||||
foreground: toRgbTuple(reported.foreground, toRgb(theme.colors.text)),
|
||||
background: toRgbTuple(reported.background, dark ? [0, 0, 0] : [255, 255, 255]),
|
||||
armin: toRgb(theme.colors.accent),
|
||||
};
|
||||
if (tui.hasOverlay()) {
|
||||
playing = false;
|
||||
return;
|
||||
}
|
||||
const animation = new Armin3d(tui, screen, colors, () => {
|
||||
const model =
|
||||
egg.kind === "armin" ? arminModel(toRgb(theme.colors.accent)) : piLogoModel({ column: egg.column, row: egg.row });
|
||||
const animation = new EasterEgg3dAnimation(tui, screen, model, colors, () => {
|
||||
playing = false;
|
||||
overlay.hide();
|
||||
});
|
||||
const overlay = tui.showOverlay(animation, { anchor: "top-left", width: "100%", maxHeight: "100%" });
|
||||
}
|
||||
|
||||
export class Armin3d implements Component {
|
||||
export class EasterEgg3dAnimation implements Component {
|
||||
private readonly tui: TUI;
|
||||
/** The screen to dissolve, as rendered lines. */
|
||||
private readonly screen: readonly string[];
|
||||
private readonly model: Model;
|
||||
private readonly foreground: Rgb;
|
||||
private readonly background: Rgb;
|
||||
private readonly color: Rgb;
|
||||
private readonly onDone: () => void;
|
||||
private readonly startTime = performance.now();
|
||||
private lastRender = performance.now();
|
||||
|
|
@ -811,19 +889,21 @@ export class Armin3d implements Component {
|
|||
private stars: Array<Star | undefined> = [];
|
||||
private cells: ScreenCell[][] = [];
|
||||
private readonly ansiCache = new Map<number, string>();
|
||||
private readonly logo = new LogoRaster();
|
||||
private readonly raster: BlockRaster;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
screen: readonly string[],
|
||||
colors: { foreground: Rgb; background: Rgb; armin: Rgb },
|
||||
model: Model,
|
||||
colors: { foreground: Rgb; background: Rgb },
|
||||
onDone: () => void,
|
||||
) {
|
||||
this.tui = tui;
|
||||
this.screen = screen;
|
||||
this.model = model;
|
||||
this.raster = new BlockRaster(model.cameraDistance);
|
||||
this.foreground = colors.foreground;
|
||||
this.background = colors.background;
|
||||
this.color = colors.armin;
|
||||
this.onDone = onDone;
|
||||
this.timer = setInterval(() => {
|
||||
// Also stop when no longer rendered, e.g. when pi hides all overlays on exit.
|
||||
|
|
@ -908,18 +988,19 @@ export class Armin3d implements Component {
|
|||
offsets = this.blockOffsets(time);
|
||||
}
|
||||
|
||||
const boxes: LogoBox[] = BLOCKS.map((block, index) => {
|
||||
const { columns, rows } = this.model;
|
||||
const boxes: Box[] = this.model.blocks.map((block, index) => {
|
||||
const [dx, dy, dz] = offsets[index]!;
|
||||
const x = block.home[0] - ARMIN_WIDTH / 2 + dx;
|
||||
const y = block.home[1] - ARMIN_HEIGHT / 2 + dy;
|
||||
return { min: [x, y, dz - DEPTH / 2], max: [x + 1, y + 1, dz + DEPTH / 2] };
|
||||
const x = block.home[0] - columns / 2 + dx;
|
||||
const y = block.home[1] - rows / 2 + dy;
|
||||
return { min: [x, y, dz - DEPTH / 2], max: [x + 1, y + 1, dz + DEPTH / 2], color: block.color };
|
||||
});
|
||||
const logo = this.logo;
|
||||
logo.render(width, height, pose, boxes, this.color, background, isLight(background) ? LIGHT_HALO_STRENGTH : 0);
|
||||
const raster = this.raster;
|
||||
raster.render(width, height, pose, boxes, background, isLight(background) ? LIGHT_HALO_STRENGTH : 0);
|
||||
const halo = (index: number, base: Rgb | undefined): Rgb | undefined => {
|
||||
const amount = logo.haloAmount[index]!;
|
||||
const amount = raster.haloAmount[index]!;
|
||||
if (amount <= 0) return base;
|
||||
const color = logo.haloRgb;
|
||||
const color = raster.haloRgb;
|
||||
return mix(base ?? background, [color[index * 3]!, color[index * 3 + 1]!, color[index * 3 + 2]!], amount);
|
||||
};
|
||||
const backgroundFade = smooth(dissolveTime / BACKGROUND_FADE);
|
||||
|
|
@ -975,11 +1056,11 @@ export class Armin3d implements Component {
|
|||
emit(hint.text[offset]!, offset < hint.keyLength ? hint.keyColor : hint.color, cellBg);
|
||||
continue;
|
||||
}
|
||||
const dots = logo.bits[index]!;
|
||||
const dots = raster.bits[index]!;
|
||||
if (dots) {
|
||||
// Quantized so neighboring cells on one face usually share a color and its escape sequence.
|
||||
const step = LOGO_COLOR_STEP * logo.counts[index]!;
|
||||
const rgb = logo.rgb;
|
||||
const step = LOGO_COLOR_STEP * raster.counts[index]!;
|
||||
const rgb = raster.rgb;
|
||||
const color: Rgb = [
|
||||
Math.round(rgb[index * 3]! / step) * LOGO_COLOR_STEP,
|
||||
Math.round(rgb[index * 3 + 1]! / step) * LOGO_COLOR_STEP,
|
||||
|
|
@ -994,7 +1075,7 @@ export class Armin3d implements Component {
|
|||
continue;
|
||||
}
|
||||
const dust = (dissolveTime - cell.delay) / DUST_DURATION;
|
||||
if (dust < 0 && cell.width === 2 && !logo.bits[index + 1]) {
|
||||
if (dust < 0 && cell.width === 2 && !raster.bits[index + 1]) {
|
||||
emit(cell.text, mix(cell.fg, background, textFade), cellBg);
|
||||
column++;
|
||||
continue;
|
||||
|
|
@ -1041,16 +1122,17 @@ export class Armin3d implements Component {
|
|||
this.onDone();
|
||||
}
|
||||
|
||||
/** Each block's displacement from its place in the logo at `time`, in grid units. */
|
||||
/** Each block's displacement from its place in the model at `time`, in grid units. */
|
||||
private blockOffsets(time: number): Array<[number, number, number]> {
|
||||
const home = (): Array<[number, number, number]> => BLOCKS.map(() => [0, 0, 0]);
|
||||
const blocks = this.model.blocks;
|
||||
const home = (): Array<[number, number, number]> => blocks.map(() => [0, 0, 0]);
|
||||
if (time < PUZZLE_START) return home();
|
||||
const cycle = Math.floor((time - PUZZLE_START) / PUZZLE_CYCLE);
|
||||
const local = time - PUZZLE_START - cycle * PUZZLE_CYCLE;
|
||||
if (this.shuffle?.cycle !== cycle) this.shuffle = { cycle, steps: shuffleSteps(cycle) };
|
||||
if (this.shuffle?.cycle !== cycle) this.shuffle = { cycle, steps: shuffleSteps(this.model, cycle) };
|
||||
const steps = this.shuffle.steps;
|
||||
const offset = (position: Cell3, index: number): [number, number, number] => {
|
||||
const block = BLOCKS[index]!.home;
|
||||
const block = blocks[index]!.home;
|
||||
return [position[0] - block[0], position[1] - block[1], position[2] - block[2]];
|
||||
};
|
||||
const shuffleEnd = PUZZLE_STEP * PUZZLE_STEPS;
|
||||
|
|
@ -1089,12 +1171,19 @@ export class Armin3d implements Component {
|
|||
}
|
||||
|
||||
private pose(width: number, height: number, progress: number, time: number): Pose {
|
||||
const reach = LOGO_RADIUS * (CAMERA_DISTANCE / (CAMERA_DISTANCE - LOGO_RADIUS));
|
||||
const startScale = START_SCALE;
|
||||
const endScale = Math.max(startScale, Math.min(width * 2 * 0.45, (height * 4 - 8) * 0.48) / reach);
|
||||
const { columns, rows, cameraDistance, radius, widthShare, origin } = this.model;
|
||||
const reach = radius * (cameraDistance / (cameraDistance - radius));
|
||||
// Lifting off, the front face must cover exactly the half-block cells (2x2 dots per pixel) despite the
|
||||
// perspective.
|
||||
const startScale = origin ? (2 * (cameraDistance - DEPTH / 2)) / cameraDistance : START_SCALE;
|
||||
const endScale = Math.max(startScale, Math.min(width * 2 * widthShare, (height * 4 - 8) * 0.48) / reach);
|
||||
const endX = width;
|
||||
const endY = height * 2 - 2;
|
||||
const startX = origin ? origin.column * 2 + columns : endX;
|
||||
const startY = origin ? origin.row * 4 + rows : endY;
|
||||
return {
|
||||
centerX: width,
|
||||
centerY: height * 2 - 2,
|
||||
centerX: startX + (endX - startX) * progress,
|
||||
centerY: startY + (endY - startY) * progress,
|
||||
// Interpolate the zoom geometrically so it feels uniform.
|
||||
scale: startScale * (endScale / startScale) ** progress,
|
||||
yaw: spinAngle(time),
|
||||
|
|
@ -1144,8 +1233,10 @@ export class Armin3d implements Component {
|
|||
private prepareCells(width: number, foreground: Rgb): ScreenCell[][] {
|
||||
const cells = parseScreen(this.screen, width, foreground, this.background);
|
||||
const height = Math.max(1, this.tui.terminal.rows);
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const { columns, rows, origin } = this.model;
|
||||
// The dust spreads out from where the model starts.
|
||||
const centerX = origin ? origin.column + columns / 2 : width / 2;
|
||||
const centerY = origin ? origin.row + rows / 4 : height / 2;
|
||||
const farthest = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2);
|
||||
for (let row = 0; row < cells.length; row++) {
|
||||
const line = cells[row]!;
|
||||
|
|
@ -1164,6 +1255,22 @@ export class Armin3d implements Component {
|
|||
cell.ink = glyphInk(owner.text, seed);
|
||||
}
|
||||
}
|
||||
// The 3D model replaces its half-block rendering on screen.
|
||||
if (origin) {
|
||||
for (let row = origin.row; row < origin.row + Math.ceil(rows / 2); row++) {
|
||||
const line = cells[row];
|
||||
if (!line) continue;
|
||||
for (let column = origin.column; column < origin.column + columns; column++) {
|
||||
const cell = line[column];
|
||||
if (cell) {
|
||||
cell.text = " ";
|
||||
cell.width = 1;
|
||||
cell.ink = 0;
|
||||
cell.bg = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +48,8 @@ import {
|
|||
type TUI,
|
||||
TuiAltScreen,
|
||||
TuiMainScreen,
|
||||
type TuiMouseEvent,
|
||||
type TuiMouseEventResult,
|
||||
visibleWidth,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import chalk from "chalk";
|
||||
|
|
@ -132,7 +134,6 @@ import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-
|
|||
import { reportBug } from "./bug-report.ts";
|
||||
import { createChatViewport } from "./chat-viewport.ts";
|
||||
import { ArminComponent } from "./components/armin.ts";
|
||||
import { playArmin3d } from "./components/armin-3d.lazy.ts";
|
||||
import { AssistantMessageComponent } from "./components/assistant-message.ts";
|
||||
import { BashExecutionComponent } from "./components/bash-execution.ts";
|
||||
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";
|
||||
|
|
@ -142,6 +143,7 @@ import { CustomEntryComponent } from "./components/custom-entry.ts";
|
|||
import { CustomMessageComponent } from "./components/custom-message.ts";
|
||||
import { DynamicBorder } from "./components/dynamic-border.ts";
|
||||
import { EarendilAnnouncementComponent } from "./components/earendil-announcement.ts";
|
||||
import { playArmin3d, playPiLogo3d } from "./components/easter-egg-3d.lazy.ts";
|
||||
import { ExtensionEditorComponent } from "./components/extension-editor.ts";
|
||||
import { ExtensionInputComponent } from "./components/extension-input.ts";
|
||||
import { ExtensionSelectorComponent } from "./components/extension-selector.ts";
|
||||
|
|
@ -248,6 +250,17 @@ class ExpandableText extends ThemedText implements Expandable {
|
|||
}
|
||||
}
|
||||
|
||||
/** The built-in header. Clicking its logo (the first two lines, after one column of padding) plays an easter egg. */
|
||||
class BuiltInHeader extends ExpandableText {
|
||||
onLogoClick: ((column: number, row: number) => void) | undefined;
|
||||
|
||||
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
||||
if (event.type !== "click" || event.y > 1 || event.x < 1 || event.x > 4 || !this.onLogoClick) return undefined;
|
||||
this.onLogoClick(event.screenX - event.x + 1, event.screenY - event.y);
|
||||
return { handled: true };
|
||||
}
|
||||
}
|
||||
|
||||
type CompactionQueuedMessage = {
|
||||
text: string;
|
||||
mode: "steer" | "followUp";
|
||||
|
|
@ -1034,13 +1047,15 @@ export class InteractiveMode {
|
|||
);
|
||||
const onboarding = () =>
|
||||
theme.fg("dim", `Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`);
|
||||
this.builtInHeader = new ExpandableText(
|
||||
const header = new BuiltInHeader(
|
||||
() => `${withLogo(compactInstructions())}\n${compactOnboarding()}\n\n${onboarding()}`,
|
||||
() => `${withLogo(expandedInstructions())}\n\n${onboarding()}`,
|
||||
this.getStartupExpansionState(),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
if (showLogo) header.onLogoClick = (column, row) => playPiLogo3d(this.renderer, column, row);
|
||||
this.builtInHeader = header;
|
||||
|
||||
// Setup UI layout
|
||||
this.headerContainer.addChild(new Spacer(1));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue