390 lines
12 KiB
Python
390 lines
12 KiB
Python
from gi.repository import GdkPixbuf, GLib
|
|
from fabric.widgets.box import Box
|
|
from fabric.widgets.image import Image
|
|
from fabric.widgets.button import Button
|
|
|
|
from sims.modules.stylix import resolve_colors
|
|
from sims.services.theme import get_theme_service
|
|
|
|
|
|
# 16x16 sprite grid, displayed at SCALE x scale (32x32) with crisp pixels.
|
|
SPRITE_SIZE = 16
|
|
SCALE = 2
|
|
|
|
# Palette character → semantic role. Resolved to RGBA from stylix at render time.
|
|
# . transparent
|
|
# B body main
|
|
# H body highlight (lighter)
|
|
# b body shadow (darker)
|
|
# e eye dark
|
|
# w eye sparkle / white
|
|
# m mouth / closed-eye line
|
|
# c blush
|
|
# s sweat drop
|
|
# z z text
|
|
# * sparkle / excitement
|
|
# _ semi-transparent ground shadow
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sprite construction: layered grid with strict 16x16 validation.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _grid(rows: list[str]) -> list[str]:
|
|
if len(rows) != SPRITE_SIZE:
|
|
raise ValueError(f"sprite must be {SPRITE_SIZE} rows, got {len(rows)}")
|
|
for i, r in enumerate(rows):
|
|
if len(r) != SPRITE_SIZE:
|
|
raise ValueError(f"row {i} has {len(r)} chars (want {SPRITE_SIZE}): {r!r}")
|
|
return rows
|
|
|
|
|
|
def _overlay(base: list[str], *layers: list[tuple[int, int, str]]) -> list[str]:
|
|
grid = [list(r) for r in base]
|
|
for layer in layers:
|
|
for (y, x, ch) in layer:
|
|
if 0 <= y < SPRITE_SIZE and 0 <= x < SPRITE_SIZE:
|
|
grid[y][x] = ch
|
|
return ["".join(r) for r in grid]
|
|
|
|
|
|
def _shift(base: list[str], dy: int = 0, dx: int = 0) -> list[str]:
|
|
out = []
|
|
for y in range(SPRITE_SIZE):
|
|
src_y = y - dy
|
|
if 0 <= src_y < SPRITE_SIZE:
|
|
row = base[src_y]
|
|
else:
|
|
row = "." * SPRITE_SIZE
|
|
if dx > 0:
|
|
row = "." * dx + row[:-dx]
|
|
elif dx < 0:
|
|
row = row[-dx:] + "." * (-dx)
|
|
out.append(row)
|
|
return out
|
|
|
|
|
|
def _shift_overlay(overlay: list[tuple[int, int, str]], dy: int = 0, dx: int = 0) -> list[tuple[int, int, str]]:
|
|
return [(y + dy, x + dx, ch) for (y, x, ch) in overlay]
|
|
|
|
|
|
# Body shapes ---------------------------------------------------------------
|
|
|
|
BODY_REST = _grid([
|
|
"................",
|
|
"................",
|
|
"................",
|
|
".......BB.......",
|
|
"......BBBB......",
|
|
".....BBBBBB.....",
|
|
"....HBBBBBBB....",
|
|
"...HBBBBBBBBB...",
|
|
"..HBBBBBBBBBBB..",
|
|
"..BBBBBBBBBBBB..",
|
|
".BBBBBBBBBBBBBb.",
|
|
".BBBBBBBBBBBBBb.",
|
|
".BBBBBBBBBBBBbb.",
|
|
"BBBBBBBBBBBBBbbb",
|
|
"BBBBBBBBBBBBBbbb",
|
|
"................",
|
|
])
|
|
|
|
BODY_BOB = _shift(BODY_REST, dy=-1) # bobbed up by 1 row
|
|
|
|
BODY_SQUISH = _grid([
|
|
"................",
|
|
"................",
|
|
"................",
|
|
"................",
|
|
"......BBBB......",
|
|
".....BBBBBB.....",
|
|
"....BBBBBBBB....",
|
|
"...HBBBBBBBBB...",
|
|
"..HBBBBBBBBBBB..",
|
|
"..BBBBBBBBBBBB..",
|
|
".BBBBBBBBBBBBBb.",
|
|
".BBBBBBBBBBBBBb.",
|
|
"BBBBBBBBBBBBBbbb",
|
|
"BBBBBBBBBBBBBbbb",
|
|
"BBBBBBBBBBBBBbbb",
|
|
"................",
|
|
])
|
|
|
|
BODY_LEAN_L = _shift(BODY_REST, dx=-1)
|
|
BODY_LEAN_R = _shift(BODY_REST, dx=1)
|
|
|
|
|
|
# Face overlays. Eye row defaults to row 8 (rest pose); pass r= for bob/squish.
|
|
|
|
def eyes_open(r: int = 8) -> list[tuple[int, int, str]]:
|
|
return [(r, 5, "e"), (r, 10, "e")]
|
|
|
|
|
|
def eyes_blink(r: int = 8) -> list[tuple[int, int, str]]:
|
|
return [(r, 4, "m"), (r, 5, "m"), (r, 9, "m"), (r, 10, "m")]
|
|
|
|
|
|
def eyes_closed_happy(r: int = 8) -> list[tuple[int, int, str]]:
|
|
# ^^ shaped happy eyes
|
|
return [(r - 1, 4, "m"), (r - 1, 9, "m"),
|
|
(r, 5, "m"), (r, 10, "m"),
|
|
(r, 3, "m"), (r, 8, "m")]
|
|
|
|
|
|
def eyes_wide(r: int = 8) -> list[tuple[int, int, str]]:
|
|
return [(r - 1, 5, "w"), (r - 1, 10, "w"),
|
|
(r, 5, "e"), (r, 10, "e")]
|
|
|
|
|
|
def mouth_neutral(r: int = 11) -> list[tuple[int, int, str]]:
|
|
return [(r, 6, "m"), (r, 7, "m"), (r, 8, "m"), (r, 9, "m")]
|
|
|
|
|
|
def mouth_smile(r: int = 11) -> list[tuple[int, int, str]]:
|
|
return [(r, 6, "m"), (r, 9, "m"),
|
|
(r + 1, 7, "m"), (r + 1, 8, "m")]
|
|
|
|
|
|
def mouth_frown(r: int = 11) -> list[tuple[int, int, str]]:
|
|
return [(r, 7, "m"), (r, 8, "m"),
|
|
(r - 1, 6, "m"), (r - 1, 9, "m")]
|
|
|
|
|
|
def mouth_o(r: int = 11) -> list[tuple[int, int, str]]:
|
|
return [(r, 7, "m"), (r, 8, "m"),
|
|
(r + 1, 7, "m"), (r + 1, 8, "m")]
|
|
|
|
|
|
def blush(r: int = 9) -> list[tuple[int, int, str]]:
|
|
return [(r, 3, "c"), (r, 12, "c")]
|
|
|
|
|
|
# Accent overlays
|
|
SWEAT_HIGH = [(2, 13, "s"), (3, 13, "s")]
|
|
SWEAT_LOW = [(5, 13, "s"), (6, 13, "s")]
|
|
Z_SMALL = [(3, 12, "z"), (3, 13, "z")]
|
|
Z_LARGE = [(1, 13, "z"), (1, 14, "z"),
|
|
(2, 13, "z"), (2, 14, "z"),
|
|
(3, 12, "z"), (3, 13, "z")]
|
|
SPARKLE_LEFT = [(3, 2, "*"), (5, 1, "*")]
|
|
SPARKLE_RIGHT = [(3, 14, "*"), (5, 14, "*")]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frame compositions per mood
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Idle: bob + occasional blink. Most frames are eyes-open at rest.
|
|
IDLE_REST = _overlay(BODY_REST, eyes_open(8), mouth_neutral(11))
|
|
IDLE_BOB = _overlay(BODY_BOB, eyes_open(7), mouth_neutral(10))
|
|
IDLE_BLINK = _overlay(BODY_REST, eyes_blink(8), mouth_neutral(11))
|
|
|
|
# Petted: closed-happy eyes + smile + blush. Squish lowers face by 1 row.
|
|
PETTED_FLAT = _overlay(BODY_SQUISH, eyes_closed_happy(9), mouth_smile(12), blush(10))
|
|
PETTED_RECOVER = _overlay(BODY_REST, eyes_closed_happy(8), mouth_smile(11), blush(9))
|
|
|
|
# Excited: wide eyes, open mouth, sparkles, bouncing
|
|
EXCITED_LOW = _overlay(BODY_REST, eyes_wide(8), mouth_o(11),
|
|
SPARKLE_LEFT)
|
|
EXCITED_HIGH = _overlay(BODY_BOB, eyes_wide(7), mouth_o(10),
|
|
SPARKLE_LEFT, SPARKLE_RIGHT)
|
|
|
|
# Bopping: sway in time with music. Shift face with the body.
|
|
BOP_LEFT = _overlay(BODY_LEAN_L,
|
|
_shift_overlay(eyes_open(8), dx=-1),
|
|
_shift_overlay(mouth_smile(11), dx=-1))
|
|
BOP_RIGHT = _overlay(BODY_LEAN_R,
|
|
_shift_overlay(eyes_open(8), dx=1),
|
|
_shift_overlay(mouth_smile(11), dx=1))
|
|
|
|
# Busy: frown + sweat drops
|
|
BUSY_DROP_HIGH = _overlay(BODY_REST, eyes_open(8), mouth_frown(11), SWEAT_HIGH)
|
|
BUSY_DROP_LOW = _overlay(BODY_REST, eyes_open(8), mouth_frown(11), SWEAT_LOW)
|
|
|
|
# Sleepy: closed eyes, z floats, no mouth
|
|
SLEEPY_NO_Z = _overlay(BODY_REST, eyes_blink(8))
|
|
SLEEPY_Z_SMALL = _overlay(BODY_REST, eyes_blink(8), Z_SMALL)
|
|
SLEEPY_Z_LARGE = _overlay(BODY_REST, eyes_blink(8), Z_LARGE)
|
|
|
|
|
|
# (frames, frame_duration_ms, loop)
|
|
MOOD_FRAMES: dict[str, tuple[list[list[str]], int, bool]] = {
|
|
"idle": (
|
|
[IDLE_REST, IDLE_REST, IDLE_BOB, IDLE_REST, IDLE_REST, IDLE_BLINK],
|
|
300, True,
|
|
),
|
|
"petted": (
|
|
[PETTED_FLAT, PETTED_FLAT, PETTED_RECOVER, PETTED_RECOVER],
|
|
180, False,
|
|
),
|
|
"excited": (
|
|
[EXCITED_LOW, EXCITED_HIGH],
|
|
180, True,
|
|
),
|
|
"bopping": (
|
|
[BOP_LEFT, IDLE_REST, BOP_RIGHT, IDLE_REST],
|
|
220, True,
|
|
),
|
|
"busy": (
|
|
[BUSY_DROP_HIGH, BUSY_DROP_LOW],
|
|
260, True,
|
|
),
|
|
"sleepy": (
|
|
[SLEEPY_NO_Z, SLEEPY_NO_Z, SLEEPY_Z_SMALL, SLEEPY_Z_LARGE],
|
|
500, True,
|
|
),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pixbuf rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _hex_to_rgba(hex_str: str, alpha: int = 255) -> tuple[int, int, int, int]:
|
|
h = hex_str.lstrip("#")
|
|
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), alpha)
|
|
|
|
|
|
def _mix(c1: tuple[int, int, int], c2: tuple[int, int, int], t: float) -> tuple[int, int, int]:
|
|
return (int(c1[0] * (1 - t) + c2[0] * t),
|
|
int(c1[1] * (1 - t) + c2[1] * t),
|
|
int(c1[2] * (1 - t) + c2[2] * t))
|
|
|
|
|
|
def _resolve_palette(mode: str | None = None) -> dict[str, tuple[int, int, int, int]]:
|
|
colors = resolve_colors(mode)
|
|
|
|
body_rgba = _hex_to_rgba(colors["base0D"])
|
|
body_rgb = body_rgba[:3]
|
|
light_rgb = _mix(body_rgb, (255, 255, 255), 0.35)
|
|
dark_rgb = _mix(body_rgb, (0, 0, 0), 0.30)
|
|
|
|
return {
|
|
".": (0, 0, 0, 0),
|
|
"B": body_rgba,
|
|
"H": light_rgb + (255,),
|
|
"b": dark_rgb + (255,),
|
|
"e": _hex_to_rgba(colors["base00"]),
|
|
"w": _hex_to_rgba(colors["base05"]),
|
|
"m": _hex_to_rgba(colors["base00"]),
|
|
"c": _hex_to_rgba(colors["base08"]),
|
|
"s": _hex_to_rgba(colors["base0C"]),
|
|
"z": _hex_to_rgba(colors["base05"]),
|
|
"*": _hex_to_rgba(colors["base0A"]),
|
|
"_": _hex_to_rgba(colors["base00"], alpha=80),
|
|
}
|
|
|
|
|
|
def _frame_to_pixbuf(rows: list[str], palette: dict[str, tuple[int, int, int, int]]) -> GdkPixbuf.Pixbuf:
|
|
out_size = SPRITE_SIZE * SCALE
|
|
rowstride = out_size * 4
|
|
buf = bytearray(rowstride * out_size)
|
|
for y in range(SPRITE_SIZE):
|
|
for x in range(SPRITE_SIZE):
|
|
ch = rows[y][x]
|
|
r, g, b, a = palette.get(ch, (0, 0, 0, 0))
|
|
for sy in range(SCALE):
|
|
for sx in range(SCALE):
|
|
o = (y * SCALE + sy) * rowstride + (x * SCALE + sx) * 4
|
|
buf[o] = r
|
|
buf[o + 1] = g
|
|
buf[o + 2] = b
|
|
buf[o + 3] = a
|
|
return GdkPixbuf.Pixbuf.new_from_bytes(
|
|
GLib.Bytes.new(bytes(buf)),
|
|
GdkPixbuf.Colorspace.RGB,
|
|
True, 8,
|
|
out_size, out_size, rowstride,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Widget
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BuddyWidget(Box):
|
|
def __init__(self, on_pet=None, **kwargs):
|
|
super().__init__(name="buddy", v_align="center", **kwargs)
|
|
theme = get_theme_service()
|
|
self._palette = _resolve_palette(theme.mode)
|
|
theme.connect("changed", self._on_theme_changed)
|
|
self._cache: dict[int, GdkPixbuf.Pixbuf] = {}
|
|
self._mood = "idle"
|
|
self._frame_idx = 0
|
|
self._timer_id = None
|
|
self._on_pet = on_pet
|
|
|
|
out = SPRITE_SIZE * SCALE
|
|
self._image = Image(name="buddy-image")
|
|
self._image.set_size_request(out, out)
|
|
self._button = Button(
|
|
name="buddy-button",
|
|
child=self._image,
|
|
on_clicked=self._handle_click,
|
|
style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;",
|
|
)
|
|
self.children = [self._button]
|
|
|
|
self._set_mood("idle")
|
|
self.show_all()
|
|
|
|
def _pixbuf_for(self, frame_rows: list[str]) -> GdkPixbuf.Pixbuf:
|
|
key = id(frame_rows)
|
|
if key not in self._cache:
|
|
self._cache[key] = _frame_to_pixbuf(frame_rows, self._palette)
|
|
return self._cache[key]
|
|
|
|
def _set_mood(self, mood: str):
|
|
if mood not in MOOD_FRAMES:
|
|
mood = "idle"
|
|
self._mood = mood
|
|
self._frame_idx = 0
|
|
if self._timer_id is not None:
|
|
GLib.source_remove(self._timer_id)
|
|
self._timer_id = None
|
|
_frames, dur, _loop = MOOD_FRAMES[mood]
|
|
self._render_current()
|
|
self._timer_id = GLib.timeout_add(dur, self._tick)
|
|
|
|
def _tick(self):
|
|
frames, _dur, loop = MOOD_FRAMES[self._mood]
|
|
self._frame_idx += 1
|
|
if self._frame_idx >= len(frames):
|
|
if loop:
|
|
self._frame_idx = 0
|
|
else:
|
|
self._frame_idx = len(frames) - 1
|
|
self._timer_id = None
|
|
if callable(self._on_pet) and self._mood == "petted":
|
|
GLib.timeout_add(120, self._notify_petted_done)
|
|
return False
|
|
self._render_current()
|
|
return True
|
|
|
|
def _render_current(self):
|
|
frames, _dur, _loop = MOOD_FRAMES[self._mood]
|
|
rows = frames[self._frame_idx]
|
|
self._image.set_from_pixbuf(self._pixbuf_for(rows))
|
|
|
|
def _notify_petted_done(self):
|
|
if callable(self._on_pet):
|
|
self._on_pet(done=True)
|
|
return False
|
|
|
|
def _handle_click(self, *_args):
|
|
if callable(self._on_pet):
|
|
self._on_pet(petted=True)
|
|
|
|
def _on_theme_changed(self, _theme, mode):
|
|
self._palette = _resolve_palette(mode)
|
|
self._cache.clear()
|
|
self._render_current()
|
|
|
|
def update_mood(self, mood: str):
|
|
if mood == self._mood:
|
|
return
|
|
self._set_mood(mood)
|