mirror of
https://codeberg.org/fairyglade/ly.git
synced 2026-08-20 12:34:19 +02:00
feat(animations): add bonsai widget
A procedurally-generated bonsai tree animation, inspired by cbonsai (https://gitlab.com/jallbrit/cbonsai). The growth algorithm — recursive branch / shoot / dying / dead phases with weighted-dice deltas, and the two pot styles — follows cbonsai's structure but is a Zig re-implementation, not a line-by-line port. Generation runs once at init (and on terminal resize), recording each glyph as an event in DFS order. The draw loop then plays back events at `bonsai_step_ms` per cell, freezes the finished tree for `bonsai_finish_pause_ms`, and regenerates with a fresh seed — giving the display-manager backdrop a perpetual grow/regrow cycle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8773ba5cc4
commit
7b56998f7c
1 changed files with 514 additions and 0 deletions
514
src/animations/Bonsai.zig
Normal file
514
src/animations/Bonsai.zig
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const ly_ui = @import("ly-ui");
|
||||
const Cell = ly_ui.Cell;
|
||||
const TerminalBuffer = ly_ui.TerminalBuffer;
|
||||
const Widget = ly_ui.Widget;
|
||||
|
||||
const ly_core = ly_ui.ly_core;
|
||||
const interop = ly_core.interop;
|
||||
const TimeOfDay = interop.TimeOfDay;
|
||||
|
||||
const Bonsai = @This();
|
||||
|
||||
const BranchType = enum { trunk, shoot_left, shoot_right, dying, dead };
|
||||
|
||||
const Event = struct {
|
||||
x: u16,
|
||||
y: u16,
|
||||
ch: u32,
|
||||
fg: u32,
|
||||
};
|
||||
|
||||
const Counters = struct {
|
||||
shoots: u32 = 0,
|
||||
shoot_counter: u32,
|
||||
};
|
||||
|
||||
const PotArt = struct {
|
||||
rows: []const Row,
|
||||
width: usize,
|
||||
|
||||
const Row = []const Span;
|
||||
const Span = struct { text: []const u8, kind: Kind };
|
||||
const Kind = enum { text_color, leaf_bright, wood_bright };
|
||||
};
|
||||
|
||||
const pot_large: PotArt = .{
|
||||
.width = 31,
|
||||
.rows = &.{
|
||||
&.{
|
||||
.{ .text = ":", .kind = .text_color },
|
||||
.{ .text = "___________", .kind = .leaf_bright },
|
||||
.{ .text = "./~~~\\.", .kind = .wood_bright },
|
||||
.{ .text = "___________", .kind = .leaf_bright },
|
||||
.{ .text = ":", .kind = .text_color },
|
||||
},
|
||||
&.{
|
||||
.{ .text = " \\ / ", .kind = .text_color },
|
||||
},
|
||||
&.{
|
||||
.{ .text = " \\_________________________/ ", .kind = .text_color },
|
||||
},
|
||||
&.{
|
||||
.{ .text = " (_) (_) ", .kind = .text_color },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pot_small: PotArt = .{
|
||||
.width = 15,
|
||||
.rows = &.{
|
||||
&.{
|
||||
.{ .text = "(", .kind = .text_color },
|
||||
.{ .text = "---", .kind = .leaf_bright },
|
||||
.{ .text = "./~~~\\.", .kind = .wood_bright },
|
||||
.{ .text = "---", .kind = .leaf_bright },
|
||||
.{ .text = ")", .kind = .text_color },
|
||||
},
|
||||
&.{
|
||||
.{ .text = " ( ) ", .kind = .text_color },
|
||||
},
|
||||
&.{
|
||||
.{ .text = " (_________) ", .kind = .text_color },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
instance: ?Widget = null,
|
||||
start_time: TimeOfDay,
|
||||
last_step_time: TimeOfDay,
|
||||
finished_at: ?TimeOfDay,
|
||||
|
||||
allocator: Allocator,
|
||||
terminal_buffer: *TerminalBuffer,
|
||||
animate: *bool,
|
||||
timeout_sec: u12,
|
||||
frame_delay: u16,
|
||||
|
||||
life_start: u16,
|
||||
multiplier: u8,
|
||||
base_type: u8,
|
||||
leaves: []const u8,
|
||||
leaf_dark: u32,
|
||||
leaf_bright: u32,
|
||||
wood_dark: u32,
|
||||
wood_bright: u32,
|
||||
text_color: u32,
|
||||
step_ms: u16,
|
||||
finish_pause_ms: u32,
|
||||
|
||||
events: std.ArrayList(Event),
|
||||
played: usize,
|
||||
prng: std.Random.DefaultPrng,
|
||||
|
||||
pub fn init(
|
||||
allocator: Allocator,
|
||||
terminal_buffer: *TerminalBuffer,
|
||||
base_type: u8,
|
||||
life_start: u16,
|
||||
multiplier: u8,
|
||||
leaves: []const u8,
|
||||
leaf_dark: u32,
|
||||
leaf_bright: u32,
|
||||
wood_dark: u32,
|
||||
wood_bright: u32,
|
||||
text_color: u32,
|
||||
step_ms: u16,
|
||||
finish_pause_ms: u32,
|
||||
animate: *bool,
|
||||
timeout_sec: u12,
|
||||
frame_delay: u16,
|
||||
) !Bonsai {
|
||||
const now = try interop.getTimeOfDay();
|
||||
const seed: u64 = terminal_buffer.random.int(u64);
|
||||
var bonsai: Bonsai = .{
|
||||
.instance = null,
|
||||
.start_time = now,
|
||||
.last_step_time = now,
|
||||
.finished_at = null,
|
||||
.allocator = allocator,
|
||||
.terminal_buffer = terminal_buffer,
|
||||
.animate = animate,
|
||||
.timeout_sec = timeout_sec,
|
||||
.frame_delay = frame_delay,
|
||||
.life_start = life_start,
|
||||
.multiplier = if (multiplier == 0) 1 else multiplier,
|
||||
.base_type = base_type,
|
||||
.leaves = if (leaves.len == 0) "&" else leaves,
|
||||
.leaf_dark = leaf_dark,
|
||||
.leaf_bright = leaf_bright,
|
||||
.wood_dark = wood_dark,
|
||||
.wood_bright = wood_bright,
|
||||
.text_color = text_color,
|
||||
.step_ms = if (step_ms == 0) 1 else step_ms,
|
||||
.finish_pause_ms = finish_pause_ms,
|
||||
.events = .empty,
|
||||
.played = 0,
|
||||
.prng = std.Random.DefaultPrng.init(seed),
|
||||
};
|
||||
try bonsai.generate();
|
||||
return bonsai;
|
||||
}
|
||||
|
||||
pub fn widget(self: *Bonsai) *Widget {
|
||||
if (self.instance) |*instance| return instance;
|
||||
self.instance = Widget.init(
|
||||
"Bonsai",
|
||||
null,
|
||||
self,
|
||||
deinit,
|
||||
realloc,
|
||||
draw,
|
||||
update,
|
||||
null,
|
||||
calculateTimeout,
|
||||
);
|
||||
return &self.instance.?;
|
||||
}
|
||||
|
||||
fn deinit(self: *Bonsai) void {
|
||||
self.events.deinit(self.allocator);
|
||||
}
|
||||
|
||||
fn realloc(self: *Bonsai) !void {
|
||||
self.events.clearRetainingCapacity();
|
||||
self.played = 0;
|
||||
self.finished_at = null;
|
||||
self.last_step_time = try interop.getTimeOfDay();
|
||||
try self.generate();
|
||||
}
|
||||
|
||||
fn update(self: *Bonsai, _: *anyopaque) !void {
|
||||
const time = try interop.getTimeOfDay();
|
||||
if (self.timeout_sec > 0 and time.seconds - self.start_time.seconds > self.timeout_sec) {
|
||||
self.animate.* = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn calculateTimeout(self: *Bonsai, _: *anyopaque) !?usize {
|
||||
return self.frame_delay;
|
||||
}
|
||||
|
||||
fn draw(self: *Bonsai) void {
|
||||
if (!self.animate.*) return;
|
||||
|
||||
self.drawPot();
|
||||
|
||||
const now = interop.getTimeOfDay() catch return;
|
||||
|
||||
if (self.played < self.events.items.len) {
|
||||
const elapsed_ms = elapsedMs(self.last_step_time, now);
|
||||
if (elapsed_ms >= self.step_ms) {
|
||||
const advance: usize = @intCast(@divTrunc(elapsed_ms, @as(i64, @intCast(self.step_ms))));
|
||||
self.played = @min(self.played + advance, self.events.items.len);
|
||||
self.last_step_time = now;
|
||||
if (self.played == self.events.items.len) self.finished_at = now;
|
||||
}
|
||||
} else if (self.finished_at) |finish_time| {
|
||||
if (elapsedMs(finish_time, now) >= self.finish_pause_ms) {
|
||||
self.events.clearRetainingCapacity();
|
||||
self.played = 0;
|
||||
self.finished_at = null;
|
||||
self.last_step_time = now;
|
||||
self.generate() catch return;
|
||||
}
|
||||
}
|
||||
|
||||
for (self.events.items[0..self.played]) |ev| {
|
||||
const cell = Cell.init(ev.ch, ev.fg, self.terminal_buffer.bg);
|
||||
cell.put(ev.x, ev.y);
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsedMs(start: TimeOfDay, end: TimeOfDay) i64 {
|
||||
return (end.seconds - start.seconds) * 1000 +
|
||||
@divTrunc(end.microseconds - start.microseconds, 1000);
|
||||
}
|
||||
|
||||
fn potHeight(base_type: u8) usize {
|
||||
return switch (base_type) {
|
||||
1 => pot_large.rows.len,
|
||||
2 => pot_small.rows.len,
|
||||
else => 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn potWidth(base_type: u8) usize {
|
||||
return switch (base_type) {
|
||||
1 => pot_large.width,
|
||||
2 => pot_small.width,
|
||||
else => 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn drawPot(self: *Bonsai) void {
|
||||
const art: ?PotArt = switch (self.base_type) {
|
||||
1 => pot_large,
|
||||
2 => pot_small,
|
||||
else => null,
|
||||
};
|
||||
const pot = art orelse return;
|
||||
|
||||
if (self.terminal_buffer.height < pot.rows.len) return;
|
||||
if (self.terminal_buffer.width < pot.width) return;
|
||||
|
||||
const x_start: usize = self.terminal_buffer.width / 2 - pot.width / 2;
|
||||
const y_start: usize = self.terminal_buffer.height - pot.rows.len;
|
||||
|
||||
for (pot.rows, 0..) |row, row_idx| {
|
||||
var x = x_start;
|
||||
for (row) |span| {
|
||||
const fg: u32 = switch (span.kind) {
|
||||
.text_color => self.text_color,
|
||||
.leaf_bright => self.leaf_bright,
|
||||
.wood_bright => self.wood_bright,
|
||||
};
|
||||
TerminalBuffer.drawText(span.text, x, y_start + row_idx, fg, self.terminal_buffer.bg);
|
||||
x += TerminalBuffer.strWidth(span.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate(self: *Bonsai) !void {
|
||||
const w = self.terminal_buffer.width;
|
||||
const h = self.terminal_buffer.height;
|
||||
const pot_h = potHeight(self.base_type);
|
||||
if (h <= pot_h + 1 or w < 4) return;
|
||||
|
||||
const tree_h = h - pot_h;
|
||||
const max_life: u16 = @intCast(@min(@as(usize, self.life_start), tree_h - 1));
|
||||
if (max_life == 0) return;
|
||||
|
||||
const x0: i32 = @intCast(w / 2);
|
||||
const y0: i32 = @intCast(tree_h - 1);
|
||||
|
||||
var counters: Counters = .{ .shoot_counter = self.prng.random().int(u32) };
|
||||
try self.branch(&counters, y0, x0, .trunk, max_life);
|
||||
}
|
||||
|
||||
fn branch(
|
||||
self: *Bonsai,
|
||||
counters: *Counters,
|
||||
y_in: i32,
|
||||
x_in: i32,
|
||||
type_in: BranchType,
|
||||
life_in: u16,
|
||||
) !void {
|
||||
var y = y_in;
|
||||
var x = x_in;
|
||||
var life: i32 = @intCast(life_in);
|
||||
var shoot_cooldown: i32 = @intCast(self.multiplier);
|
||||
const rng = self.prng.random();
|
||||
const max_y: i32 = @intCast(self.terminal_buffer.height - potHeight(self.base_type));
|
||||
const mult: i32 = @intCast(self.multiplier);
|
||||
const life_start_i: i32 = @intCast(self.life_start);
|
||||
|
||||
while (life > 0) {
|
||||
life -= 1;
|
||||
const age = life_start_i - life;
|
||||
|
||||
var dxdy = setDeltas(type_in, life, age, mult, rng);
|
||||
if (dxdy.dy > 0 and y > max_y - 2) dxdy.dy -= 1;
|
||||
|
||||
if (life < 3) {
|
||||
try self.branch(counters, y, x, .dead, @intCast(@max(life, 0)));
|
||||
} else if (type_in == .trunk and life < mult + 2) {
|
||||
try self.branch(counters, y, x, .dying, @intCast(@max(life, 0)));
|
||||
} else if ((type_in == .shoot_left or type_in == .shoot_right) and life < mult + 2) {
|
||||
try self.branch(counters, y, x, .dying, @intCast(@max(life, 0)));
|
||||
} else if (type_in == .trunk and (rng.intRangeLessThan(u32, 0, 3) == 0 or @mod(life, mult) == 0)) {
|
||||
if (rng.intRangeLessThan(u32, 0, 8) == 0 and life > 7) {
|
||||
shoot_cooldown = mult * 2;
|
||||
const offset = @as(i32, @intCast(rng.intRangeLessThan(u32, 0, 5))) - 2;
|
||||
const new_life = life + offset;
|
||||
if (new_life > 0) {
|
||||
try self.branch(counters, y, x, .trunk, @intCast(new_life));
|
||||
}
|
||||
} else if (shoot_cooldown <= 0) {
|
||||
shoot_cooldown = mult * 2;
|
||||
counters.shoots += 1;
|
||||
counters.shoot_counter += 1;
|
||||
const sub: BranchType = if (counters.shoot_counter % 2 == 0) .shoot_left else .shoot_right;
|
||||
const shoot_life = life + @as(i32, @intCast(self.multiplier));
|
||||
try self.branch(counters, y, x, sub, @intCast(@max(shoot_life, 0)));
|
||||
}
|
||||
}
|
||||
shoot_cooldown -= 1;
|
||||
|
||||
x += dxdy.dx;
|
||||
y += dxdy.dy;
|
||||
|
||||
const effective_type: BranchType = if (life < 4) .dying else type_in;
|
||||
const ch = chooseGlyph(effective_type, dxdy.dx, dxdy.dy, self.leaves, rng);
|
||||
const fg = self.chooseColor(effective_type, rng);
|
||||
|
||||
if (x >= 0 and y >= 0 and
|
||||
x < @as(i32, @intCast(self.terminal_buffer.width)) and
|
||||
y < @as(i32, @intCast(self.terminal_buffer.height)))
|
||||
{
|
||||
try self.events.append(self.allocator, .{
|
||||
.x = @intCast(x),
|
||||
.y = @intCast(y),
|
||||
.ch = ch,
|
||||
.fg = fg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Delta = struct { dx: i32, dy: i32 };
|
||||
|
||||
fn setDeltas(branch_type: BranchType, life: i32, age: i32, multiplier: i32, rng: std.Random) Delta {
|
||||
var dx: i32 = 0;
|
||||
var dy: i32 = 0;
|
||||
switch (branch_type) {
|
||||
.trunk => {
|
||||
if (age <= 2 or life < 4) {
|
||||
dy = 0;
|
||||
dx = @as(i32, @intCast(rng.intRangeLessThan(u32, 0, 3))) - 1;
|
||||
} else if (age < multiplier * 3) {
|
||||
const period: i32 = @max(1, @divTrunc(multiplier, 2));
|
||||
dy = if (@mod(age, period) == 0) -1 else 0;
|
||||
const dice = rng.intRangeLessThan(u32, 0, 10);
|
||||
dx = switch (dice) {
|
||||
0 => -2,
|
||||
1, 2, 3 => -1,
|
||||
4, 5 => 0,
|
||||
6, 7, 8 => 1,
|
||||
9 => 2,
|
||||
else => 0,
|
||||
};
|
||||
} else {
|
||||
dy = if (rng.intRangeLessThan(u32, 0, 10) > 2) -1 else 0;
|
||||
dx = @as(i32, @intCast(rng.intRangeLessThan(u32, 0, 3))) - 1;
|
||||
}
|
||||
},
|
||||
.shoot_left => {
|
||||
const dice_y = rng.intRangeLessThan(u32, 0, 10);
|
||||
dy = switch (dice_y) {
|
||||
0, 1 => -1,
|
||||
2, 3, 4, 5, 6, 7 => 0,
|
||||
else => 1,
|
||||
};
|
||||
const dice_x = rng.intRangeLessThan(u32, 0, 10);
|
||||
dx = switch (dice_x) {
|
||||
0, 1 => -2,
|
||||
2, 3, 4, 5 => -1,
|
||||
6, 7, 8 => 0,
|
||||
else => 1,
|
||||
};
|
||||
},
|
||||
.shoot_right => {
|
||||
const dice_y = rng.intRangeLessThan(u32, 0, 10);
|
||||
dy = switch (dice_y) {
|
||||
0, 1 => -1,
|
||||
2, 3, 4, 5, 6, 7 => 0,
|
||||
else => 1,
|
||||
};
|
||||
const dice_x = rng.intRangeLessThan(u32, 0, 10);
|
||||
dx = switch (dice_x) {
|
||||
0, 1 => 2,
|
||||
2, 3, 4, 5 => 1,
|
||||
6, 7, 8 => 0,
|
||||
else => -1,
|
||||
};
|
||||
},
|
||||
.dying => {
|
||||
const dice_y = rng.intRangeLessThan(u32, 0, 10);
|
||||
dy = switch (dice_y) {
|
||||
0, 1 => -1,
|
||||
2, 3, 4, 5, 6, 7, 8 => 0,
|
||||
else => 1,
|
||||
};
|
||||
const dice_x = rng.intRangeLessThan(u32, 0, 15);
|
||||
dx = switch (dice_x) {
|
||||
0 => -3,
|
||||
1, 2 => -2,
|
||||
3, 4, 5 => -1,
|
||||
6, 7, 8 => 0,
|
||||
9, 10, 11 => 1,
|
||||
12, 13 => 2,
|
||||
else => 3,
|
||||
};
|
||||
},
|
||||
.dead => {
|
||||
const dice_y = rng.intRangeLessThan(u32, 0, 10);
|
||||
dy = switch (dice_y) {
|
||||
0, 1, 2 => -1,
|
||||
3, 4, 5, 6 => 0,
|
||||
else => 1,
|
||||
};
|
||||
dx = @as(i32, @intCast(rng.intRangeLessThan(u32, 0, 3))) - 1;
|
||||
},
|
||||
}
|
||||
return .{ .dx = dx, .dy = dy };
|
||||
}
|
||||
|
||||
fn chooseGlyph(
|
||||
branch_type: BranchType,
|
||||
dx: i32,
|
||||
dy: i32,
|
||||
leaves: []const u8,
|
||||
rng: std.Random,
|
||||
) u32 {
|
||||
return switch (branch_type) {
|
||||
.trunk => if (dy == 0)
|
||||
'/'
|
||||
else if (dx < 0)
|
||||
'\\'
|
||||
else if (dx == 0)
|
||||
'/'
|
||||
else
|
||||
'|',
|
||||
.shoot_left => if (dy > 0)
|
||||
'\\'
|
||||
else if (dy == 0)
|
||||
'\\'
|
||||
else if (dx < 0)
|
||||
'\\'
|
||||
else if (dx == 0)
|
||||
'/'
|
||||
else
|
||||
'/',
|
||||
.shoot_right => if (dy > 0)
|
||||
'/'
|
||||
else if (dy == 0)
|
||||
'_'
|
||||
else if (dx < 0)
|
||||
'\\'
|
||||
else if (dx == 0)
|
||||
'/'
|
||||
else
|
||||
'/',
|
||||
.dying, .dead => pickLeaf(leaves, rng),
|
||||
};
|
||||
}
|
||||
|
||||
fn pickLeaf(leaves: []const u8, rng: std.Random) u32 {
|
||||
const view = std.unicode.Utf8View.init(leaves) catch return '&';
|
||||
var iter = view.iterator();
|
||||
var count: usize = 0;
|
||||
while (iter.nextCodepoint()) |_| count += 1;
|
||||
if (count == 0) return '&';
|
||||
|
||||
const idx = rng.intRangeLessThan(usize, 0, count);
|
||||
iter = view.iterator();
|
||||
var i: usize = 0;
|
||||
while (iter.nextCodepoint()) |cp| : (i += 1) {
|
||||
if (i == idx) return cp;
|
||||
}
|
||||
return '&';
|
||||
}
|
||||
|
||||
fn chooseColor(self: *Bonsai, branch_type: BranchType, rng: std.Random) u32 {
|
||||
return switch (branch_type) {
|
||||
.trunk, .shoot_left, .shoot_right => if (rng.intRangeLessThan(u32, 0, 2) == 0)
|
||||
self.wood_bright
|
||||
else
|
||||
self.wood_dark,
|
||||
.dying => self.leaf_bright,
|
||||
.dead => self.leaf_dark,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue