xpet-fork/xpet.c
2025-11-04 14:59:39 -05:00

740 lines
16 KiB
C

/*
* xpet:
*
* a suckless, X (and hopefully wayland) implementation of
* xneko and other on screen pets
*
* > uint 2025
*/
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <time.h>
#include <X11/Xlib.h>
#include <X11/extensions/shape.h>
#include <X11/xpm.h>
#include "xpet.h"
#include "config.h"
void create_window(void);
void draw_bubble(void);
void get_mouse_pos(void);
void grab_keys(void);
void hide_speech_bubble(void);
void load_animations(void);
void move_to(int tx, int ty);
enum state find_octant(int dx, int dy);
void on_button_press(XButtonEvent* b);
void on_button_release(XButtonEvent* b);
void on_key(KeySym sym);
void on_motion(XMotionEvent* m);
void pick_random_destination(void);
void quit(void);
void run(void);
void set_pet_state(enum state ns);
void setup(void);
void show_speech_bubble(const char* s);
void step(enum state d, double* step_x, double* step_y);
void update_animation(void);
void wander(void);
Bool walking(enum state s);
void xsleep(long ms);
Display* dpy;
Window root;
int scr;
int scr_width;
int scr_height;
XpmAttributes xpm_attrs;
XFontSet font_set;
struct mouse mouse;
struct pet pet;
void create_window(void)
{
pet.x = scr_width / 2;
pet.y = scr_height / 2;
pet.subpixel_x = pet.x;
pet.subpixel_y = pet.y;
XSetWindowAttributes attrs;
attrs.override_redirect = True;
pet.window = XCreateWindow(
dpy, root, pet.x, pet.y, 64, 64, 0, CopyFromParent,
InputOutput, CopyFromParent, CWOverrideRedirect, &attrs
);
struct frame* f = &pet.current_animation->frames[0];
XShapeCombineMask(
dpy, pet.window, ShapeBounding,
0, 0, f->mask, ShapeSet
);
XSetWindowBackgroundPixmap(dpy, pet.window, f->pix);
XSelectInput(
dpy, pet.window,
ButtonPressMask | ButtonReleaseMask | PointerMotionMask
);
XMapWindow(dpy, pet.window);
XClearWindow(dpy, pet.window);
XRaiseWindow(dpy, pet.window);
}
void draw_bubble(void)
{
if (!pet.speech || !pet.bubble_window) {
return;
}
XClearWindow(dpy, pet.bubble_window);
GC graphics_context = DefaultGC(dpy, scr);
XSetForeground(dpy, graphics_context, BlackPixel(dpy, scr));
if (font_set) {
XRectangle ink;
XRectangle log;
XmbTextExtents(font_set, pet.speech, (int)strlen(pet.speech), &ink, &log);
XmbDrawString(
dpy, pet.bubble_window, font_set, graphics_context,
SPEECH_PAD_X, SPEECH_PAD_Y - log.y, pet.speech, strlen(pet.speech)
);
}
else {
XFontStruct* f = XQueryFont(dpy, XGContextFromGC(graphics_context));
int ascent = f ? f->ascent : 12;
XDrawString(
dpy, pet.bubble_window, graphics_context,
SPEECH_PAD_X, SPEECH_PAD_Y + ascent, pet.speech, strlen(pet.speech)
);
if (f) {
XFreeFontInfo(NULL, f, 1);
}
}
}
void get_mouse_pos(void)
{
Window root_ret;
Window child_ret;
int x_ret;
int y_ret;
int winx_ret ;
int winy_ret ;
unsigned int mask_ret;
XQueryPointer(
dpy, root, &root_ret, &child_ret,
&x_ret, &y_ret, &winx_ret, &winy_ret, &mask_ret
);
mouse.x = x_ret;
mouse.y = y_ret;
}
void grab_keys(void)
{
unsigned int mods[] = {
0, LockMask, Mod2Mask, LockMask | Mod2Mask
};
for (unsigned i = 0; i < N_KEYBINDS; i++) {
KeyCode code = XKeysymToKeycode(dpy, bindings[i].sym);
for (unsigned j = 0; j < sizeof(mods) / sizeof(mods[0]); j++) {
XGrabKey(
dpy, code, bindings[i].mask | mods[j], root, True,
GrabModeAsync, GrabModeAsync
);
}
}
}
void hide_speech_bubble(void)
{
if (pet.bubble_window) {
XUnmapWindow(dpy, pet.bubble_window);
}
pet.speech = NULL;
pet.speech_time = 0;
}
void load_animations(void)
{
memset(&xpm_attrs, 0, sizeof(xpm_attrs));
for (int i = 0; i < STATE_LAST; i++) {
int frame_count = animations[i].n_frames;
if (frame_count == 0) {
char path[512];
frame_count = 0;
for (;;) {
snprintf(path, sizeof(path), "%s/%s/%d.xpm",
PET_ASSET_DIR, animations[i].name, frame_count
);
FILE* f = fopen(path, "r");
if (!f) {
break;
}
fclose(f);
frame_count++;
}
if (frame_count == 0) {
fprintf(stderr, "xpet: warning: no frames for '%s'\n",
animations[i].name
);
animations[i].frames = NULL;
continue;
}
}
animations[i].frames = malloc(sizeof(struct frame) * frame_count);
if (!animations[i].frames) {
perror("malloc");
exit(1);
}
for (int frame_index = 0; frame_index < frame_count; frame_index++) {
char path[512];
snprintf(path, sizeof(path), "%s/%s/%d.xpm",
PET_ASSET_DIR, animations[i].name, frame_index
);
Pixmap pixmap = 0;
Pixmap mask = 0;
int xpm_result = XpmReadFileToPixmap(
dpy, root, path, &pixmap, &mask, &xpm_attrs
);
if (xpm_result != XpmSuccess) {
fprintf(stderr, "xpet: load failed '%s': %d\n", path, xpm_result);
exit(EXIT_FAILURE);
}
animations[i].frames[frame_index].pix = pixmap;
animations[i].frames[frame_index].mask = mask;
animations[i].frames[frame_index].duration =
animations[i].frame_durations ?
animations[i].frame_durations[frame_index] : FRAME_DURATION;
}
animations[i].n_frames = frame_count;
}
}
void move_to(int target_x, int target_y)
{
double delta_x = target_x - pet.subpixel_x;
double delta_y = target_y - pet.subpixel_y;
double distance_squared = delta_x * delta_x + delta_y * delta_y;
if (distance_squared < 4.0) {
set_pet_state(IDLE);
return;
}
enum state direction = find_octant((int)delta_x, (int)delta_y);
double step_x = 0;
double step_y = 0;
step(direction, &step_x, &step_y);
if (distance_squared <= (PET_SPEED * PET_SPEED)) {
pet.subpixel_x = target_x;
pet.subpixel_y = target_y;
}
else {
pet.subpixel_x += step_x;
pet.subpixel_y += step_y;
}
pet.x = (int)(pet.subpixel_x + (pet.subpixel_x >= 0 ? 0.5 : -0.5));
pet.y = (int)(pet.subpixel_y + (pet.subpixel_y >= 0 ? 0.5 : -0.5));
set_pet_state(direction);
XMoveWindow(dpy, pet.window, pet.x, pet.y);
}
enum state find_octant(int delta_x, int delta_y)
{
int abs_delta_x = ABS(delta_x);
int abs_delta_y = ABS(delta_y);
/* if horizontal movement stronger than vertical */
if (abs_delta_x > abs_delta_y * 2) {
return delta_x > 0 ? E : W;
}
/* if vertical movement stronger than horizontal */
if (abs_delta_y > abs_delta_x * 2) {
return delta_y > 0 ? S : N;
}
return delta_x > 0 ?
(delta_y < 0 ? NE : SE) : (delta_y < 0 ? NW : SW);
}
void on_button_press(XButtonEvent* b)
{
if (b->button == Button1) {
pet.was_chasing = pet.chasing;
pet.was_frozen = pet.frozen;
pet.dragging = True;
pet.drag_offset_x = b->x;
pet.drag_offset_y = b->y;
pet.frozen = True;
pet.chasing = False;
set_pet_state(DRAGGED);
}
else if (b->button == Button3) {
if (pet.state != HAPPY) {
pet.previous_state = pet.state;
set_pet_state(HAPPY);
pet.happy_time = 0;
int n = 0;
while (pet_phrases[n]) {
n++;
}
if (n > 0) {
show_speech_bubble(pet_phrases[rand() % n]);
}
}
}
}
void on_button_release(XButtonEvent* b)
{
if (b->button != Button1) {
return;
}
pet.dragging = False;
pet.frozen = pet.was_frozen;
pet.chasing = pet.was_chasing;
pet.subpixel_x = pet.x;
pet.subpixel_y = pet.y;
if (pet.was_frozen) {
set_pet_state(IDLE);
pet.frozen_time = 0;
}
else if (!pet.was_chasing) {
pick_random_destination();
set_pet_state(IDLE);
}
else {
set_pet_state(E);
}
}
void on_key(KeySym sym)
{
if (sym == bindings[0].sym) { /* chasing */
pet.chasing = !pet.chasing;
if (pet.chasing) {
pet.frozen = False;
set_pet_state(E);
}
else {
pick_random_destination();
}
}
else if (sym == bindings[1].sym) { /* freeze */
pet.frozen = !pet.frozen;
if (pet.frozen) {
set_pet_state(IDLE);
pet.frozen_time = 0;
}
}
else if (sym == bindings[2].sym) { /* quit */
quit();
}
}
void on_motion(XMotionEvent* m)
{
(void)m;
if (!pet.dragging) {
return;
}
get_mouse_pos();
pet.x = mouse.x - pet.drag_offset_x;
pet.y = mouse.y - pet.drag_offset_y;
XMoveWindow(dpy, pet.window, pet.x, pet.y);
if (pet.speech) {
show_speech_bubble(pet.speech);
}
}
void pick_random_destination(void)
{
int min_x = WANDER_MARGIN;
int min_y = WANDER_MARGIN;
int max_x = scr_width - WANDER_MARGIN - 64;
int max_y = scr_height - WANDER_MARGIN - 64;
/* clamping */
if (max_x < min_x) {
max_x = min_x;
}
if (max_y < min_y) {
max_y = min_y;
}
pet.target_x = min_x + (rand() % (max_x - min_x + 1));
pet.target_y = min_y + (rand() % (max_y - min_y + 1));
pet.wander_wait = 0;
}
void quit(void)
{
if (pet.bubble_window) {
XDestroyWindow(dpy, pet.bubble_window);
}
if (font_set) {
XFreeFontSet(dpy, font_set);
}
for (int i = 0; i < STATE_LAST; i++) {
if (animations[i].frames) {
for (int j = 0; j < animations[i].n_frames; j++) {
XFreePixmap(dpy, animations[i].frames[j].pix);
XFreePixmap(dpy, animations[i].frames[j].mask);
}
free(animations[i].frames);
}
}
XCloseDisplay(dpy);
exit(EXIT_SUCCESS);
}
void run(void)
{
for (;;) {
while (XPending(dpy)) {
XEvent ev;
XNextEvent(dpy, &ev);
if (ev.type == KeyPress) {
on_key(XLookupKeysym(&ev.xkey, 0));
}
else if (ev.type == ButtonPress && ev.xbutton.window == pet.window) {
on_button_press(&ev.xbutton);
}
else if (ev.type == ButtonRelease && ev.xbutton.window == pet.window) {
on_button_release(&ev.xbutton);
}
else if (ev.type == MotionNotify && ev.xmotion.window == pet.window) {
on_motion(&ev.xmotion);
}
else if (ev.type == Expose && ev.xexpose.window == pet.bubble_window) {
draw_bubble();
}
}
if (pet.speech) {
pet.speech_time += PET_REFRESH;
if (pet.speech_time >= SPEECH_DURATION) {
hide_speech_bubble();
}
}
if (pet.state == HAPPY) {
pet.happy_time += PET_REFRESH;
if (pet.happy_time >= HAPPY_DURATION) {
set_pet_state(pet.previous_state);
}
}
else if (pet.dragging) { /* animate only */
}
else if (!pet.frozen) {
if (pet.chasing) {
get_mouse_pos();
move_to(mouse.x, mouse.y);
}
else {
wander();
}
}
else {
pet.frozen_time += PET_REFRESH;
if (pet.frozen_time >= SLEEP_DELAY && pet.state != SLEEPING) {
set_pet_state(SLEEPING);
}
}
update_animation();
/* keep windows on top of others */
XRaiseWindow(dpy, pet.window);
if (pet.bubble_window) {
XRaiseWindow(dpy, pet.bubble_window);
}
xsleep(PET_REFRESH);
}
}
void set_pet_state(enum state new_state)
{
if (pet.state != new_state) {
if (walking(pet.state) && walking(new_state)) {
int current_frame = pet.current_frame;
long frame_time = pet.frame_time;
pet.state = new_state;
pet.current_animation = &animations[new_state];
if (pet.current_animation->n_frames > 0) {
pet.current_frame = current_frame % pet.current_animation->n_frames;
int dur = pet.current_animation->frames[pet.current_frame].duration;
pet.frame_time = (frame_time < dur) ? frame_time : 0;
}
else {
pet.current_frame = pet.frame_time = 0;
}
}
else {
pet.state = new_state;
pet.current_animation = &animations[new_state];
pet.current_frame = 0;
pet.frame_time = 0;
}
}
else if (pet.current_animation != &animations[new_state]) {
pet.current_animation = &animations[new_state];
if (!walking(new_state)) {
pet.current_frame = pet.frame_time = 0;
}
else if (pet.current_animation->n_frames > 0) {
pet.current_frame %= pet.current_animation->n_frames;
int duration = pet.current_animation->frames[pet.current_frame].duration;
if (pet.frame_time >= duration) {
pet.frame_time = 0;
}
}
}
}
void setup(void)
{
/* for utf8 support */
setlocale(LC_ALL, "");
dpy = XOpenDisplay(NULL);
if (!dpy) {
fputs("xpet: no display\n", stderr);
exit(1);
}
scr = DefaultScreen(dpy);
scr_width = DisplayWidth(dpy, scr);
scr_height = DisplayHeight(dpy, scr);
root = RootWindow(dpy, scr);
char** missing; /* missing character sets */
int n_missing;
char* def; /* default string */
const char* patterns[] = {
"-*-*-medium-r-*-*-14-*-*-*-*-*-*-*",
"fixed",
"*",
NULL
};
for (int i = 0; !font_set && patterns[i]; i++) {
font_set = XCreateFontSet(
dpy, patterns[i], &missing, &n_missing, &def
);
if (font_set && n_missing > 0) {
XFreeStringList(missing);
}
}
if (!font_set) {
fputs("xpet: warn: no fontset; non-ascii will fallback\n", stderr);
}
srand((unsigned)time(NULL));
load_animations();
pet.current_frame = 0;
pet.frame_time = 0;
pet.frozen_time = 0;
pet.happy_time = 0;
pet.previous_state = IDLE;
pet.dragging = False;
pet.was_chasing = False;
pet.was_frozen = False;
pet.speech = NULL;
pet.speech_time = 0;
pet.bubble_window = 0;
pet.chasing = False;
pet.frozen = False;
set_pet_state(IDLE);
pick_random_destination();
create_window();
grab_keys();
XSelectInput(dpy, root, KeyPressMask);
}
void show_speech_bubble(const char* s)
{
if (!s) {
return;
}
if (!font_set) {
for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
if (*p & 0x80) {
/* if no font set, replace it with ":3" */
s = ":3";
break;
}
}
}
pet.speech = s;
pet.speech_time = 0;
if (!pet.bubble_window) {
XSetWindowAttributes attr;
attr.override_redirect = True;
attr.background_pixel = WhitePixel(dpy, scr);
attr.border_pixel = BlackPixel(dpy, scr);
pet.bubble_window = XCreateWindow(
dpy, root, 0, 0, 10, 10, 2,
CopyFromParent, InputOutput, CopyFromParent,
CWOverrideRedirect | CWBackPixel | CWBorderPixel, &attr
);
XSelectInput(dpy, pet.bubble_window, ExposureMask);
}
int text_width = 0;
int text_height = 0;
if (font_set) {
XRectangle ink, log;
XmbTextExtents(font_set, s, (int)strlen(s), &ink, &log);
text_width = log.width;
text_height = log.height;
}
else {
GContext gcontext = XGContextFromGC(DefaultGC(dpy, scr));
XFontStruct* font = XQueryFont(dpy, gcontext);
if (font) {
text_width = XTextWidth(font, s, (int)strlen(s));
text_height = font->ascent + font->descent;
XFreeFontInfo(NULL, font, 1);
}
}
int bw = CLAMP(text_width + 2 * SPEECH_PAD_X, SPEECH_MIN_W, 1 << 15);
int bh = CLAMP(text_height + 2 * SPEECH_PAD_Y, SPEECH_MIN_H, 1 << 15);
int bx = CLAMP(pet.x + 32 - bw / 2, 10, scr_width - 10 - bw);
int by = CLAMP(pet.y - bh - 10, 10, scr_height - 10 - bh);
XMoveResizeWindow(dpy, pet.bubble_window, bx, by, bw, bh);
XMapWindow(dpy, pet.bubble_window);
draw_bubble();
XRaiseWindow(dpy, pet.bubble_window);
}
void step(enum state d, double* step_x, double* step_y)
{
const double s = PET_SPEED;
const double ds = PET_SPEED * 0.70710678118654752440; /* sqrt(2)^-1 */
switch (d) {
case E: *step_x = s; *step_y = 0; break;
case W: *step_x = -s; *step_y = 0; break;
case N: *step_x = 0; *step_y = -s; break;
case S: *step_x = 0; *step_y = s; break;
case NE: *step_x = ds; *step_y = -ds; break;
case SE: *step_x = ds; *step_y = ds; break;
case NW: *step_x = -ds; *step_y = -ds; break;
case SW: *step_x = -ds; *step_y = ds; break;
default: *step_x = 0; *step_y = 0; break;
}
}
void update_animation(void)
{
if (!pet.current_animation || pet.current_animation->n_frames <= 0) {
if (pet.state != IDLE) {
set_pet_state(IDLE);
}
return;
}
pet.frame_time += PET_REFRESH;
struct frame* frame_ptr = &pet.current_animation->frames[pet.current_frame];
if (pet.frame_time >= frame_ptr->duration) {
pet.frame_time = 0;
pet.current_frame++;
if (pet.current_frame >= pet.current_animation->n_frames) {
pet.current_frame = pet.current_animation->loop ?
0 : pet.current_animation->n_frames - 1;
}
frame_ptr = &pet.current_animation->frames[pet.current_frame];
XShapeCombineMask(dpy, pet.window, ShapeBounding, 0, 0, frame_ptr->mask, ShapeSet);
XSetWindowBackgroundPixmap(dpy, pet.window, frame_ptr->pix);
XClearWindow(dpy, pet.window);
}
}
void wander(void)
{
double delta_x = (double)pet.target_x - pet.subpixel_x;
double delta_y = (double)pet.target_y - pet.subpixel_y;
double distance_squared = delta_x * delta_x + delta_y * delta_y;
if (distance_squared < 4.0) {
if (pet.wander_wait <= 0) {
pet.wander_wait =
WANDER_MIN_WAIT + (rand() % (WANDER_MAX_WAIT - WANDER_MIN_WAIT));
set_pet_state(IDLE);
}
else {
pet.wander_wait -= PET_REFRESH;
if (pet.wander_wait <= 0) {
pick_random_destination();
}
}
return;
}
move_to(pet.target_x, pet.target_y);
}
Bool walking(enum state s)
{
return s == N || s == S || s == E || s == W ||
s == NW || s == NE || s == SW || s == SE;
}
void xsleep(long ms)
{
struct timeval tv = {ms / 1000, (int)((ms % 1000) * 1000)};
select(0, 0, 0, 0, &tv);
}
int main(int argc, char** argv)
{
(void)argv;
if (argc > 1) {
printf("xpets " VERSION "\n> uint 2025\n");
return 0;
}
setup();
run();
return 0;
}