mirror of
https://github.com/fairyglade/ly.git
synced 2026-09-22 12:00:19 +00:00
## What are the changes about? Allows the game of life animation to be ran with configurable rules, specifically: the neighbors required for birth and survival. ### Example: [B3/S12345 (Maze)](https://conwaylife.com/wiki/OCA:Maze) (where numbers after B are neighbors required for birth, and S for survival) <video src="/attachments/49120c5e-ce21-4025-852e-924befbc2621" title="Screencast_20260814_233338" controls></video> Introduces two keys to the config: ```ini # Sets the multiple amounts of neighbors needed for a cell to survive. # Each numerical digit from 0 to 8 inclusive in this string becomes # one of the targets required for this cell to survive. # Example: a string of "23" makes the cell survive on 2 or 3 neighbors gameoflife_param_survival = "23" # Sets the multiple amounts of neighbors needed for a cell to be born. # Each numerical digit from 0 to 8 inclusive in this string becomes # one of the targets required for this cell to be born. # Example: a string of "3" makes the cell be born on 3 neighbors gameoflife_param_birth = "3" ``` ## What existing issue(s) does this resolve? Solves !1023 ## Pre-requisites - [x] I have tested & confirmed the changes work locally - [x] I have read and fully adhere to the rules set in the contributing guidelines found in `CONTRIBUTING.md` Reviewed-on: https://codeberg.org/fairyglade/ly/pulls/1041 Reviewed-by: AnErrupTion <anerruption+codeberg@disroot.org>
257 lines
7.6 KiB
Zig
257 lines
7.6 KiB
Zig
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 GameOfLife = @This();
|
|
|
|
// Visual styles - using block characters like other animations
|
|
const ALIVE_CHAR: u21 = 0x2588; // Full block █
|
|
const DEAD_CHAR: u21 = ' ';
|
|
const NEIGHBOR_DIRS = [_][2]i8{
|
|
.{ -1, -1 }, .{ -1, 0 }, .{ -1, 1 },
|
|
.{ 0, -1 }, .{ 0, 1 }, .{ 1, -1 },
|
|
.{ 1, 0 }, .{ 1, 1 },
|
|
};
|
|
|
|
instance: ?Widget = null,
|
|
start_time: TimeOfDay,
|
|
allocator: Allocator,
|
|
terminal_buffer: *TerminalBuffer,
|
|
current_grid: []bool,
|
|
next_grid: []bool,
|
|
frame_counter: usize,
|
|
generation: u64,
|
|
fg_color: u32,
|
|
entropy_interval: usize,
|
|
frame_delay: usize,
|
|
initial_density: f32,
|
|
animate: *bool,
|
|
timeout_sec: u12,
|
|
animation_frame_delay: u16,
|
|
dead_cell: Cell,
|
|
width: usize,
|
|
height: usize,
|
|
// Where N is the amount of neighbors, determines if the cell can survive/be born
|
|
// by checking if the Nth bit (where if N == 0, it is the least significant bit,
|
|
// or 2 to the power of 0) is set to 1.
|
|
cell_survival: u9,
|
|
cell_birth: u9,
|
|
|
|
pub fn init(
|
|
allocator: Allocator,
|
|
terminal_buffer: *TerminalBuffer,
|
|
fg_color: u32,
|
|
entropy_interval: usize,
|
|
frame_delay: usize,
|
|
initial_density: f32,
|
|
animate: *bool,
|
|
timeout_sec: u12,
|
|
animation_frame_delay: u16,
|
|
string_cell_survival: []const u8,
|
|
string_cell_birth: []const u8,
|
|
) !GameOfLife {
|
|
const width = terminal_buffer.width;
|
|
const height = terminal_buffer.height;
|
|
const grid_size = width * height;
|
|
|
|
const current_grid = try allocator.alloc(bool, grid_size);
|
|
const next_grid = try allocator.alloc(bool, grid_size);
|
|
|
|
var game = GameOfLife{
|
|
.instance = null,
|
|
.start_time = try interop.getTimeOfDay(),
|
|
.allocator = allocator,
|
|
.terminal_buffer = terminal_buffer,
|
|
.current_grid = current_grid,
|
|
.next_grid = next_grid,
|
|
.frame_counter = 0,
|
|
.generation = 0,
|
|
.fg_color = fg_color,
|
|
.entropy_interval = entropy_interval,
|
|
.frame_delay = frame_delay,
|
|
.initial_density = initial_density,
|
|
.animate = animate,
|
|
.timeout_sec = timeout_sec,
|
|
.animation_frame_delay = animation_frame_delay,
|
|
.dead_cell = .{ .ch = DEAD_CHAR, .fg = @intCast(TerminalBuffer.Color.DEFAULT), .bg = terminal_buffer.bg },
|
|
.width = width,
|
|
.height = height,
|
|
.cell_birth = 0,
|
|
.cell_survival = 0,
|
|
};
|
|
for (string_cell_survival) |c| switch (c) {
|
|
'0'...'8' => game.cell_survival |= (@as(u9, 1) << @truncate(@as(u9, c - '0'))),
|
|
else => {},
|
|
};
|
|
for (string_cell_birth) |c| switch (c) {
|
|
'0'...'8' => game.cell_birth |= (@as(u9, 1) << @truncate(@as(u9, c - '0'))),
|
|
else => {},
|
|
};
|
|
|
|
// Initialize grid
|
|
game.initializeGrid();
|
|
|
|
return game;
|
|
}
|
|
|
|
pub fn widget(self: *GameOfLife) *Widget {
|
|
if (self.instance) |*instance| return instance;
|
|
self.instance = Widget.init(
|
|
"GameOfLife",
|
|
null,
|
|
self,
|
|
deinit,
|
|
realloc,
|
|
draw,
|
|
update,
|
|
null,
|
|
calculateTimeout,
|
|
);
|
|
return &self.instance.?;
|
|
}
|
|
|
|
fn deinit(self: *GameOfLife) void {
|
|
self.allocator.free(self.current_grid);
|
|
self.allocator.free(self.next_grid);
|
|
}
|
|
|
|
fn realloc(self: *GameOfLife) !void {
|
|
const new_width = self.terminal_buffer.width;
|
|
const new_height = self.terminal_buffer.height;
|
|
const new_size = new_width * new_height;
|
|
|
|
const current_grid = try self.allocator.realloc(self.current_grid, new_size);
|
|
const next_grid = try self.allocator.realloc(self.next_grid, new_size);
|
|
|
|
self.current_grid = current_grid;
|
|
self.next_grid = next_grid;
|
|
self.width = new_width;
|
|
self.height = new_height;
|
|
|
|
self.initializeGrid();
|
|
self.generation = 0;
|
|
}
|
|
|
|
fn draw(self: *GameOfLife) void {
|
|
if (!self.animate.*) return;
|
|
|
|
// Update game state at controlled frame rate
|
|
self.frame_counter += 1;
|
|
if (self.frame_counter >= self.frame_delay) {
|
|
self.frame_counter = 0;
|
|
self.updateGeneration();
|
|
self.generation += 1;
|
|
|
|
// Add entropy based on configuration (0 = disabled, >0 = interval)
|
|
if (self.entropy_interval > 0 and self.generation % self.entropy_interval == 0) {
|
|
self.addEntropy();
|
|
}
|
|
}
|
|
|
|
// Render with the configured color
|
|
const alive_cell = Cell{ .ch = ALIVE_CHAR, .fg = self.fg_color, .bg = self.terminal_buffer.bg };
|
|
|
|
for (0..self.height) |y| {
|
|
const row_offset = y * self.width;
|
|
for (0..self.width) |x| {
|
|
const cell = if (self.current_grid[row_offset + x]) alive_cell else self.dead_cell;
|
|
cell.put(x, y) catch {};
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update(self: *GameOfLife, _: *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: *GameOfLife, _: *anyopaque) !?usize {
|
|
return self.animation_frame_delay;
|
|
}
|
|
|
|
fn updateGeneration(self: *GameOfLife) void {
|
|
// Conway's Game of Life rules with optimized neighbor counting
|
|
for (0..self.height) |y| {
|
|
const row_offset = y * self.width;
|
|
for (0..self.width) |x| {
|
|
const index = row_offset + x;
|
|
const neighbors = self.countNeighborsOptimized(x, y);
|
|
const is_alive = self.current_grid[index];
|
|
|
|
// Optimized rule application
|
|
self.next_grid[index] = if (is_alive)
|
|
self.cell_survival & (@as(u9, 1) << @truncate(neighbors)) != 0
|
|
else
|
|
self.cell_birth & (@as(u9, 1) << @truncate(neighbors)) != 0;
|
|
}
|
|
}
|
|
|
|
// Efficient grid swap
|
|
std.mem.swap([]bool, &self.current_grid, &self.next_grid);
|
|
}
|
|
|
|
fn countNeighborsOptimized(self: *GameOfLife, x: usize, y: usize) u8 {
|
|
var count: u8 = 0;
|
|
|
|
for (NEIGHBOR_DIRS) |dir| {
|
|
const neighbor_x = @as(i32, @intCast(x)) + dir[0];
|
|
const neighbor_y = @as(i32, @intCast(y)) + dir[1];
|
|
const width_i32: i32 = @intCast(self.width);
|
|
const height_i32: i32 = @intCast(self.height);
|
|
|
|
// Toroidal wrapping with modular arithmetic
|
|
const wx: usize = @intCast(@mod(neighbor_x + width_i32, width_i32));
|
|
const wy: usize = @intCast(@mod(neighbor_y + height_i32, height_i32));
|
|
|
|
if (self.current_grid[wy * self.width + wx]) {
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
fn initializeGrid(self: *GameOfLife) void {
|
|
const total_cells = self.width * self.height;
|
|
|
|
// Clear grid
|
|
@memset(self.current_grid, false);
|
|
@memset(self.next_grid, false);
|
|
|
|
// Random initialization with configurable density
|
|
for (0..total_cells) |i| {
|
|
self.current_grid[i] = self.terminal_buffer.random.float(f32) < self.initial_density;
|
|
}
|
|
}
|
|
|
|
fn addEntropy(self: *GameOfLife) void {
|
|
// Add fewer random cells but in clusters for more interesting patterns
|
|
const clusters = 2;
|
|
for (0..clusters) |_| {
|
|
const cx = self.terminal_buffer.random.intRangeAtMost(usize, 1, self.width - 2);
|
|
const cy = self.terminal_buffer.random.intRangeAtMost(usize, 1, self.height - 2);
|
|
|
|
// Small cluster around center point
|
|
for (0..3) |dy| {
|
|
for (0..3) |dx| {
|
|
if (self.terminal_buffer.random.float(f32) < 0.4) {
|
|
const x = (cx + dx) % self.width;
|
|
const y = (cy + dy) % self.height;
|
|
self.current_grid[y * self.width + x] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|