summaryrefslogtreecommitdiff
path: root/src/gui/pet.zig
blob: 2b031f0e76957dcad4c88718119ab0742d76ca46 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
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));
    }
}