diff options
Diffstat (limited to 'src/gui/pet.zig')
| -rw-r--r-- | src/gui/pet.zig | 170 |
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)); + } +} |
