summaryrefslogtreecommitdiff
path: root/src/gui/pet.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-11 12:38:56 -0300
committerGabriel Schneider <[email protected]>2026-09-15 17:24:42 -0300
commit682e237df7e8b22f820d14a4adee58e6e2f84268 (patch)
tree5d888cb46f1a49e89fc55e2a4c232d3af0265b1d /src/gui/pet.zig
parent6878e1c309172d624c0d7a3555f6f0217ae6770e (diff)
downloadpardes-682e237df7e8b22f820d14a4adee58e6e2f84268.tar.gz
pardes-682e237df7e8b22f820d14a4adee58e6e2f84268.zip
trunk: resume before the Reload experiment
Empty marker on the last pre-Reload change. Keep the Reload experiment on reload (3801914), its first change on reload-start (200a1fc), and the unfinished performance investigation on reload-perf-wip.
Diffstat (limited to 'src/gui/pet.zig')
-rw-r--r--src/gui/pet.zig170
1 files changed, 170 insertions, 0 deletions
diff --git a/src/gui/pet.zig b/src/gui/pet.zig
new file mode 100644
index 00000000..2b031f0e
--- /dev/null
+++ b/src/gui/pet.zig
@@ -0,0 +1,170 @@
+//! Original code-native pixel companions. Pure, bounded geometry and a 10 Hz
+//! animation clock: no textures, allocation, input handling or editor state.
+const std = @import("std");
+
+pub const Kind = enum { off, cat, frog };
+pub const width = 12;
+pub const height = 9;
+pub const max_vertices = width * height * 6;
+pub const Lane = struct { left: f32, right: f32, top: f32, scale: f32 };
+pub const Pose = struct { x: f32, y: f32, scale: f32, left: bool, walking: bool, frame: u8 };
+
+/// Only the trailing blank part of the global tag is eligible. The caller
+/// supplies its last occupied cell, including any wide-grapheme continuation.
+pub fn lane(occupied: usize, cell_w: f32, screen_w: f32, top: f32, band_h: f32, editing: bool) ?Lane {
+ if (editing or cell_w <= 0 or band_h < height + 2) return null;
+ const scale = @min(3, @floor((band_h - 2) / height));
+ const left = @as(f32, @floatFromInt(occupied + 2)) * cell_w;
+ const right = screen_w - 2 * cell_w - width * scale;
+ if (right - left < 4 * scale) return null;
+ return .{ .left = left, .right = right, .top = top + band_h - height * scale - 1, .scale = scale };
+}
+
+pub const State = struct {
+ kind: Kind = .off,
+ tick: ?u64 = null,
+ x: f32 = 0,
+ left: bool = false,
+ idle: u8 = 12,
+ frame: u8 = 0,
+
+ pub fn sample(s: *State, kind: Kind, now_ms: u64, area: ?Lane) ?Pose {
+ const region = area orelse {
+ s.* = .{};
+ return null;
+ };
+ if (kind == .off) {
+ s.* = .{};
+ return null;
+ }
+ const tick = now_ms / 100;
+ if (s.kind != kind or s.tick == null) {
+ s.* = .{ .kind = kind, .tick = tick, .x = region.left };
+ }
+ s.x = std.math.clamp(s.x, region.left, region.right);
+ // Coming back from suspension never launches the pet across the tag.
+ const steps = @min(tick -| s.tick.?, 2);
+ s.tick = tick;
+ for (0..steps) |_| {
+ s.frame +%= 1;
+ if (s.idle > 0) {
+ s.idle -= 1;
+ continue;
+ }
+ const speed: f32 = if (kind == .cat) 1.8 else 1.3;
+ s.x += (if (s.left) -speed else speed) * region.scale;
+ if (s.x >= region.right or s.x <= region.left) {
+ s.x = std.math.clamp(s.x, region.left, region.right);
+ s.left = !s.left;
+ s.idle = if (kind == .cat) 18 else 24;
+ }
+ }
+ return .{ .x = @floor(s.x), .y = region.top, .scale = region.scale, .left = s.left, .walking = s.idle == 0, .frame = s.frame };
+ }
+};
+
+const cat = [_][]const u8{
+ " # # ",
+ " #####",
+ " ##o##",
+ " # ######",
+ " # ######## ",
+ " ######### ",
+ " ######## ",
+ " ## ## ",
+ " # # ",
+};
+const frog = [_][]const u8{
+ " ",
+ " ## ## ",
+ " #o####o# ",
+ " ######## ",
+ " ###### ",
+ " ######## ",
+ " ########## ",
+ " ## ## ",
+ "### ###",
+};
+
+/// 0 transparent, 1 theme ink, 2 eye cutout. Walk legs and idle blinks are
+/// generated in this fixed 12x9 tile; neither can spill into an adjacent row.
+pub fn pixel(kind: Kind, pose: Pose, x_in: usize, y: usize) u2 {
+ if (x_in >= width or y >= height or kind == .off) return 0;
+ const x = if (pose.left) width - 1 - x_in else x_in;
+ const alternate = (pose.frame / 2) % 2 == 1;
+ // An idle companion still looks alive during its first pause: a slow
+ // two-pixel tail-tip flick, or the frog's throat gently inflating. These
+ // remain inside the tile and mirror with the rest of the animal.
+ const breathing = (pose.frame / 4) % 2 == 1;
+ if (!pose.walking and breathing) {
+ if (kind == .cat and y == 3 and x <= 1)
+ return @intFromBool(x == 0);
+ if (kind == .frog and y == 4 and (x == 2 or x == 9)) return 1;
+ }
+ if (pose.walking and alternate and y >= 7) {
+ if (kind == .cat) return @intFromBool((y == 7 and (x == 3 or x == 7)) or (y == 8 and (x == 2 or x == 8)));
+ return @intFromBool((y == 7 and (x == 2 or x == 9)) or (y == 8 and (x <= 2 or x >= 9)));
+ }
+ const ch = (if (kind == .cat) cat else frog)[y][x];
+ return switch (ch) {
+ '#' => 1,
+ 'o' => if (!pose.walking and pose.frame % 30 >= 27) 1 else 2,
+ else => 0,
+ };
+}
+
+test "pet stays in trailing workspace blanks and disappears during editing" {
+ try std.testing.expect(lane(20, 8, 200, 0, 16, false) == null);
+ try std.testing.expect(lane(3, 8, 200, 0, 16, true) == null);
+ try std.testing.expect(lane(3, 8, 200, 0, 10, false) == null);
+ const region = lane(3, 8, 200, 2, 20, false).?;
+ try std.testing.expect(region.left >= 5 * 8);
+ var state: State = .{};
+ var moved = false;
+ var reversed = false;
+ for (0..2000) |i| {
+ const pose = state.sample(.cat, i * 100, region).?;
+ try std.testing.expect(pose.x >= region.left and pose.x + width * pose.scale <= 200 - 16);
+ try std.testing.expect(pose.y >= 2 and pose.y + height * pose.scale < 22);
+ moved = moved or pose.walking;
+ reversed = reversed or pose.left;
+ }
+ try std.testing.expect(moved and reversed);
+ try std.testing.expect(state.sample(.cat, 300000, null) == null);
+ try std.testing.expect(state.sample(.off, 300100, region) == null);
+}
+
+test "pet clock is fixed rate and sprites bounded in both directions" {
+ const region = lane(1, 8, 400, 0, 16, false).?;
+ var state: State = .{};
+ const start = state.sample(.frog, 1000, region).?;
+ try std.testing.expectEqualDeep(start, state.sample(.frog, 1099, region).?);
+ for ([_]Kind{ .cat, .frog }) |kind| for ([_]bool{ false, true }) |left| {
+ const pose: Pose = .{ .x = 0, .y = 0, .scale = 1, .left = left, .walking = true, .frame = 3 };
+ var count: usize = 0;
+ for (0..height) |y| for (0..width) |x| {
+ count += @intFromBool(pixel(kind, pose, x, y) != 0);
+ };
+ try std.testing.expect(count > 20 and count <= width * height);
+ try std.testing.expectEqual(@as(u2, 0), pixel(kind, pose, width, height));
+ };
+}
+
+test "pet initial idle visibly animates without walking or leaving its tile" {
+ const region = lane(1, 8, 400, 0, 16, false).?;
+ for ([_]Kind{ .cat, .frog }) |kind| {
+ var state: State = .{};
+ const initial = state.sample(kind, 0, region).?;
+ var later = initial;
+ for (1..5) |tick| later = state.sample(kind, tick * 100, region).?;
+ try std.testing.expect(!initial.walking and !later.walking);
+ try std.testing.expectEqual(initial.x, later.x);
+ try std.testing.expectEqual(initial.y, later.y);
+ var changes: usize = 0;
+ for (0..height) |y| for (0..width) |x| {
+ changes += @intFromBool(pixel(kind, initial, x, y) != pixel(kind, later, x, y));
+ };
+ try std.testing.expectEqual(@as(usize, 2), changes);
+ try std.testing.expectEqual(@as(u2, 0), pixel(kind, later, width, height));
+ }
+}