Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,582 @@
|
||||
"""The pet itself: a frameless, translucent, always-on-top window that
|
||||
renders the current sprite animation, wanders the desktop on its own while
|
||||
idle, walks/emotes on command from the server (see pet_actions.py), can be
|
||||
dragged around, dims when napping, and turns a plain (non-drag) click into a
|
||||
"talk now" request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QPoint, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (
|
||||
QColor, QCursor, QFont, QFontMetrics, QPainter, QPainterPath, QPixmap, QRegion, QTransform,
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from .. import config
|
||||
from ..state import PetState
|
||||
from .sprite import SpriteSet
|
||||
|
||||
_DRAG_THRESHOLD_PX = 4
|
||||
# Movement runs on its own ~30fps timer, independent of the (slower) sprite
|
||||
# animation timer, so a stroll looks smooth even at IDLE_ANIMATION_FPS=6.
|
||||
_WANDER_TICK_MS = 33
|
||||
_EMOTE_TICKS = 36 # ~1.2s per emote at the tick rate above
|
||||
_NAP_OPACITY = 0.35
|
||||
|
||||
|
||||
def emote_transform(emote: str, progress: float) -> tuple[float, float, float, float]:
|
||||
"""(dx, dy, rotation_degrees, scale) for an emote at *progress* 0..1.
|
||||
|
||||
Pure maths, deliberately separate from paintEvent so the motion curves can
|
||||
be unit tested (and so adding an emote doesn't mean touching painting
|
||||
code). Every emote must return to (0, 0, 0, 1) at progress 1.0, otherwise
|
||||
the pet ends up permanently askew.
|
||||
"""
|
||||
progress = min(max(progress, 0.0), 1.0)
|
||||
fade = math.sin(math.pi * progress) # 0 -> 1 -> 0, so it always lands home
|
||||
tau = 2 * math.pi
|
||||
if emote == "wave":
|
||||
return 0.0, 0.0, 14.0 * fade * math.sin(tau * 2 * progress), 1.0
|
||||
if emote in ("hop", "bounce"):
|
||||
hops = 2 if emote == "hop" else 3
|
||||
return 0.0, -22.0 * fade * abs(math.sin(math.pi * hops * progress)), 0.0, 1.0
|
||||
if emote == "spin":
|
||||
return 0.0, 0.0, 360.0 * progress % 360.0, 1.0
|
||||
if emote == "nod":
|
||||
return 0.0, 10.0 * fade * math.sin(tau * 2 * progress), 0.0, 1.0 - 0.05 * fade
|
||||
if emote in ("shake", "wiggle"):
|
||||
return 14.0 * fade * math.sin(tau * 3 * progress), 0.0, 0.0, 1.0
|
||||
return 0.0, 0.0, 0.0, 1.0
|
||||
|
||||
|
||||
class SpeechBubble(QWidget):
|
||||
"""Small translucent word-bubble shown above the pet while it talks."""
|
||||
|
||||
_MAX_WIDTH = 260
|
||||
_PADDING = 10
|
||||
|
||||
copied = Signal(str)
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent, Qt.FramelessWindowHint | Qt.Tool)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
self._text = ""
|
||||
self._font = QFont()
|
||||
self._font.setPointSize(10)
|
||||
self._flash = "" # transient overlay ("Copied") drawn over the text
|
||||
self._hide_timer = QTimer(self)
|
||||
self._hide_timer.setSingleShot(True)
|
||||
self._hide_timer.timeout.connect(self.hide)
|
||||
self._flash_timer = QTimer(self)
|
||||
self._flash_timer.setSingleShot(True)
|
||||
self._flash_timer.timeout.connect(self._clear_flash)
|
||||
self.setToolTip("Click to copy")
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.hide()
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self._text
|
||||
|
||||
def show_text(self, text: str, duration_ms: int = 6000) -> None:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
self.hide()
|
||||
return
|
||||
self._text = text
|
||||
self._relayout()
|
||||
self.show()
|
||||
self.raise_()
|
||||
self._hide_timer.start(duration_ms)
|
||||
|
||||
# ── click to copy ────────────────────────────────────────────────────
|
||||
# The bubble hides itself after a few seconds, which is fine for chat and
|
||||
# awful for anything you needed to keep (a path, a number, a command's
|
||||
# output). One click puts it on the clipboard; the tray's History window
|
||||
# has the rest.
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
if event.button() != Qt.LeftButton or not self._text:
|
||||
return
|
||||
QApplication.clipboard().setText(self._text)
|
||||
self.copied.emit(self._text)
|
||||
self._flash = "Copied to clipboard"
|
||||
self.update()
|
||||
self._flash_timer.start(900)
|
||||
self._hide_timer.start(2500) # linger a moment so the flash is visible
|
||||
|
||||
def _clear_flash(self) -> None:
|
||||
self._flash = ""
|
||||
self.update()
|
||||
|
||||
def _wrapped_lines(self) -> list[str]:
|
||||
metrics = QFontMetrics(self._font)
|
||||
words = self._text.split()
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
max_text_width = self._MAX_WIDTH - 2 * self._PADDING
|
||||
for word in words:
|
||||
candidate = f"{current} {word}".strip()
|
||||
if metrics.horizontalAdvance(candidate) <= max_text_width or not current:
|
||||
current = candidate
|
||||
else:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines[:6] # don't let a huge reply turn into a wall of bubble
|
||||
|
||||
def _relayout(self) -> None:
|
||||
metrics = QFontMetrics(self._font)
|
||||
lines = self._wrapped_lines()
|
||||
text_width = max((metrics.horizontalAdvance(line) for line in lines), default=0)
|
||||
width = min(self._MAX_WIDTH, text_width + 2 * self._PADDING)
|
||||
height = metrics.height() * len(lines) + 2 * self._PADDING
|
||||
self.resize(width, height)
|
||||
|
||||
def paintEvent(self, _event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(0, 0, self.width(), self.height(), 10, 10)
|
||||
painter.fillPath(path, QColor(30, 30, 35, 230))
|
||||
painter.setFont(self._font)
|
||||
metrics = QFontMetrics(self._font)
|
||||
if self._flash:
|
||||
painter.setPen(QColor(150, 230, 170))
|
||||
painter.drawText(self.rect(), Qt.AlignCenter, self._flash)
|
||||
return
|
||||
painter.setPen(QColor(240, 240, 245))
|
||||
y = self._PADDING + metrics.ascent()
|
||||
for line in self._wrapped_lines():
|
||||
painter.drawText(self._PADDING, y, line)
|
||||
y += metrics.height()
|
||||
|
||||
|
||||
class PetWindow(QWidget):
|
||||
talk_requested = Signal()
|
||||
copied = Signal(str) # bubble text the user just put on the clipboard
|
||||
|
||||
def __init__(self, sprite_dir: Optional[Path] = None, size: Optional[int] = None):
|
||||
super().__init__()
|
||||
flags = Qt.FramelessWindowHint | Qt.Tool
|
||||
if config.PET_ALWAYS_ON_TOP:
|
||||
flags |= Qt.WindowStaysOnTopHint
|
||||
self.setWindowFlags(flags)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||
|
||||
self.sprites = SpriteSet(sprite_dir or (Path(__file__).resolve().parent.parent / "assets" / "sprites"),
|
||||
size or config.PET_SIZE)
|
||||
self.resize(self.sprites.size, self.sprites.size)
|
||||
self._current_state = PetState.IDLE
|
||||
|
||||
self._drag_offset: Optional[QPoint] = None
|
||||
self._press_pos: Optional[QPoint] = None
|
||||
self._dragged = False
|
||||
|
||||
self._bubble = SpeechBubble()
|
||||
self._bubble.copied.connect(self.copied)
|
||||
|
||||
self._napping = False
|
||||
self._emote: Optional[str] = None
|
||||
self._emote_tick = 0
|
||||
self._mask_key = None
|
||||
|
||||
self._anim_timer = QTimer(self)
|
||||
self._anim_timer.timeout.connect(self._advance_frame)
|
||||
fps = max(1.0, config.IDLE_ANIMATION_FPS)
|
||||
self._anim_timer.start(int(1000 / fps))
|
||||
|
||||
self._wander_enabled = config.PET_WANDER
|
||||
self._wander_target: Optional[QPoint] = None
|
||||
self._commanded_move = False # a petctl move — happens even mid-conversation
|
||||
self._next_wander_at = 0.0
|
||||
self._bob_offset = 0
|
||||
self._bob_phase = 0.0
|
||||
self._schedule_next_wander()
|
||||
self._wander_timer = QTimer(self)
|
||||
self._wander_timer.timeout.connect(self._movement_tick)
|
||||
self._wander_timer.start(_WANDER_TICK_MS)
|
||||
|
||||
self._click_through = False
|
||||
self.set_click_through(config.PET_CLICK_THROUGH)
|
||||
self._place_start_position()
|
||||
|
||||
# ── placement ────────────────────────────────────────────────────────
|
||||
|
||||
def _place_start_position(self) -> None:
|
||||
screen = QApplication.primaryScreen()
|
||||
geo = screen.availableGeometry() if screen else None
|
||||
try:
|
||||
x = int(config.PET_START_X) if config.PET_START_X else None
|
||||
y = int(config.PET_START_Y) if config.PET_START_Y else None
|
||||
except ValueError:
|
||||
x = y = None
|
||||
if geo is not None:
|
||||
x = geo.right() - self.width() - 40 if x is None else x
|
||||
y = geo.bottom() - self.height() - 60 if y is None else y
|
||||
self.move(x or 0, y or 0)
|
||||
self._reposition_bubble()
|
||||
|
||||
def _reposition_bubble(self) -> None:
|
||||
top_left = self.geometry().topLeft()
|
||||
self._bubble.move(
|
||||
top_left.x() + self.width() // 2 - self._bubble.width() // 2,
|
||||
top_left.y() - self._bubble.height() - 8,
|
||||
)
|
||||
|
||||
# ── server-driven actions (petctl) ───────────────────────────────────
|
||||
|
||||
def apply_action(self, action: dict) -> None:
|
||||
"""Perform one parsed petctl action (see pet_actions.py). Called on
|
||||
the UI thread via a queued signal from the controller."""
|
||||
kind = action.get("action")
|
||||
if kind == "move":
|
||||
target = self._resolve_move_target(action)
|
||||
if target is not None:
|
||||
self._wander_target = target
|
||||
self._commanded_move = True # overrides the idle-only rule
|
||||
elif kind == "emote":
|
||||
self.start_emote(action["emote"])
|
||||
elif kind == "say":
|
||||
self.say(action["text"])
|
||||
elif kind == "wander":
|
||||
self.set_wander_enabled(bool(action["enabled"]))
|
||||
elif kind == "nap":
|
||||
self.set_napping(bool(action["enabled"]))
|
||||
|
||||
def _resolve_move_target(self, action: dict) -> Optional[QPoint]:
|
||||
geo = self._screen_geometry()
|
||||
if "x" in action and "y" in action:
|
||||
point = QPoint(int(action["x"]), int(action["y"]))
|
||||
return self._clamp_to_screen(point, geo)
|
||||
anchor = action.get("anchor")
|
||||
if anchor == "cursor":
|
||||
cursor = QCursor.pos()
|
||||
return self._clamp_to_screen(
|
||||
QPoint(cursor.x() - self.width() // 2, cursor.y() - self.height() // 2), geo
|
||||
)
|
||||
if geo is None:
|
||||
return None
|
||||
if anchor == "random":
|
||||
return self._pick_wander_target()
|
||||
margin = config.PET_WANDER_MARGIN
|
||||
left, right = geo.left() + margin, geo.right() - self.width() - margin
|
||||
top, bottom = geo.top() + margin, geo.bottom() - self.height() - margin
|
||||
middle_x = geo.left() + (geo.width() - self.width()) // 2
|
||||
middle_y = geo.top() + (geo.height() - self.height()) // 2
|
||||
positions = {
|
||||
"top-left": (left, top), "top": (middle_x, top), "top-right": (right, top),
|
||||
"left": (left, middle_y), "center": (middle_x, middle_y), "right": (right, middle_y),
|
||||
"bottom-left": (left, bottom), "bottom": (middle_x, bottom), "bottom-right": (right, bottom),
|
||||
}
|
||||
if anchor not in positions:
|
||||
return None
|
||||
return QPoint(*positions[anchor])
|
||||
|
||||
def _clamp_to_screen(self, point: QPoint, geo) -> QPoint:
|
||||
if geo is None:
|
||||
return point
|
||||
x = min(max(point.x(), geo.left()), max(geo.left(), geo.right() - self.width()))
|
||||
y = min(max(point.y(), geo.top()), max(geo.top(), geo.bottom() - self.height()))
|
||||
return QPoint(x, y)
|
||||
|
||||
# ── emotes ───────────────────────────────────────────────────────────
|
||||
|
||||
def start_emote(self, emote: str) -> None:
|
||||
self._emote = emote
|
||||
self._emote_tick = 0
|
||||
self.update()
|
||||
|
||||
def _advance_emote(self) -> None:
|
||||
if self._emote is None:
|
||||
return
|
||||
self._emote_tick += 1
|
||||
if self._emote_tick > _EMOTE_TICKS:
|
||||
self._emote = None
|
||||
self._emote_tick = 0
|
||||
self.update()
|
||||
|
||||
def _emote_transform(self) -> tuple[float, float, float, float]:
|
||||
if self._emote is None:
|
||||
return 0.0, 0.0, 0.0, 1.0
|
||||
return emote_transform(self._emote, self._emote_tick / _EMOTE_TICKS)
|
||||
|
||||
# ── napping (quiet hours / do-not-disturb) ───────────────────────────
|
||||
|
||||
def set_napping(self, napping: bool) -> None:
|
||||
"""Dim and stand still. Purely cosmetic here — the controller is what
|
||||
actually suppresses proactive speech."""
|
||||
if napping == self._napping:
|
||||
return
|
||||
self._napping = napping
|
||||
self.setWindowOpacity(_NAP_OPACITY if napping else 1.0)
|
||||
if napping:
|
||||
self._stop_walking()
|
||||
self.update()
|
||||
|
||||
@property
|
||||
def napping(self) -> bool:
|
||||
return self._napping
|
||||
|
||||
# ── mouse transparency ───────────────────────────────────────────────
|
||||
|
||||
def set_click_through(self, enabled: bool) -> None:
|
||||
"""When on, the pet ignores the mouse entirely (tray-only control) —
|
||||
for when it's parked over something you need to click a lot."""
|
||||
self._click_through = enabled
|
||||
self.setAttribute(Qt.WA_TransparentForMouseEvents, enabled)
|
||||
if enabled:
|
||||
self.clearMask()
|
||||
self._mask_key = None
|
||||
else:
|
||||
self._mask_key = None # force the shaped mask to be rebuilt
|
||||
|
||||
@property
|
||||
def click_through(self) -> bool:
|
||||
return self._click_through
|
||||
|
||||
def _apply_input_mask(self, pixmap: Optional[QPixmap], x: int, y: int) -> None:
|
||||
"""Restrict the window to the sprite's opaque pixels, so the square
|
||||
window's transparent corners stop swallowing clicks meant for what's
|
||||
underneath. Rebuilt only when the frame actually changes — the mask
|
||||
is derived from the pixmap's alpha, which isn't free."""
|
||||
if self._click_through or not config.PET_SHAPED_INPUT:
|
||||
return
|
||||
if pixmap is None:
|
||||
if self._mask_key is not None:
|
||||
self.clearMask()
|
||||
self._mask_key = None
|
||||
return
|
||||
key = (pixmap.cacheKey(), x, y)
|
||||
if key == self._mask_key:
|
||||
return
|
||||
self._mask_key = key
|
||||
try:
|
||||
region = QRegion(pixmap.mask())
|
||||
region.translate(x, y)
|
||||
self.setMask(region)
|
||||
except Exception:
|
||||
self.clearMask() # a sprite without an alpha channel — never mind
|
||||
|
||||
# ── wandering ────────────────────────────────────────────────────────
|
||||
|
||||
def set_wander_enabled(self, enabled: bool) -> None:
|
||||
self._wander_enabled = enabled
|
||||
if not enabled:
|
||||
self._stop_walking()
|
||||
|
||||
def wander_now(self) -> None:
|
||||
"""Stroll immediately (tray menu / anything that wants a nudge)."""
|
||||
self._next_wander_at = 0.0
|
||||
|
||||
def _screen_geometry(self):
|
||||
# screenAt() so a multi-monitor setup keeps the pet on the screen
|
||||
# it's currently standing on rather than yanking it to the primary.
|
||||
screen = QApplication.screenAt(self.frameGeometry().center()) or QApplication.primaryScreen()
|
||||
return screen.availableGeometry() if screen else None
|
||||
|
||||
def _schedule_next_wander(self) -> None:
|
||||
base = max(1.0, config.PET_WANDER_INTERVAL_SECONDS)
|
||||
self._next_wander_at = time.monotonic() + random.uniform(0.5 * base, 1.5 * base)
|
||||
|
||||
def _stop_walking(self) -> None:
|
||||
if self._wander_target is None and not self._bob_offset:
|
||||
return
|
||||
self._wander_target = None
|
||||
self._commanded_move = False
|
||||
self._bob_phase = 0.0
|
||||
self._bob_offset = 0
|
||||
self.update()
|
||||
|
||||
def snap_to_edge(self) -> bool:
|
||||
"""If the pet has come to rest near a screen edge, tuck it flush
|
||||
against it — a desktop pet parked 11px off the taskbar looks like a
|
||||
bug. Returns True if it moved."""
|
||||
geo = self._screen_geometry()
|
||||
if geo is None or not config.PET_EDGE_SNAP:
|
||||
return False
|
||||
margin = config.PET_SNAP_MARGIN
|
||||
here = self.pos()
|
||||
x, y = here.x(), here.y()
|
||||
if abs(x - geo.left()) <= margin:
|
||||
x = geo.left()
|
||||
elif abs(geo.right() - (x + self.width())) <= margin:
|
||||
x = geo.right() - self.width() + 1
|
||||
if abs(y - geo.top()) <= margin:
|
||||
y = geo.top()
|
||||
elif abs(geo.bottom() - (y + self.height())) <= margin:
|
||||
y = geo.bottom() - self.height() + 1
|
||||
if (x, y) == (here.x(), here.y()):
|
||||
return False
|
||||
self.move(x, y)
|
||||
self._reposition_bubble()
|
||||
return True
|
||||
|
||||
def _pick_wander_target(self) -> Optional[QPoint]:
|
||||
geo = self._screen_geometry()
|
||||
if geo is None:
|
||||
return None
|
||||
margin = config.PET_WANDER_MARGIN
|
||||
min_x, max_x = geo.left() + margin, geo.right() - self.width() - margin
|
||||
min_y, max_y = geo.top() + margin, geo.bottom() - self.height() - margin
|
||||
if max_x <= min_x or max_y <= min_y: # pet bigger than the screen
|
||||
return None
|
||||
here = self.pos()
|
||||
target = QPoint(random.randint(min_x, max_x), random.randint(min_y, max_y))
|
||||
dx, dy = target.x() - here.x(), target.y() - here.y()
|
||||
distance = math.hypot(dx, dy)
|
||||
limit = max(1.0, config.PET_WANDER_MAX_DISTANCE)
|
||||
if distance > limit: # shorten the trip rather than sprinting the diagonal
|
||||
scale = limit / distance
|
||||
target = QPoint(round(here.x() + dx * scale), round(here.y() + dy * scale))
|
||||
elif distance < 8: # already there — not worth a stroll
|
||||
return None
|
||||
return target
|
||||
|
||||
def _movement_tick(self) -> None:
|
||||
"""One timer, two jobs — emotes play whatever the pet is doing, while
|
||||
wandering only happens when it's otherwise unoccupied."""
|
||||
self._advance_emote()
|
||||
self._wander_tick()
|
||||
|
||||
def _wander_tick(self) -> None:
|
||||
# Only stroll while genuinely idle: not mid-drag, not napping, not
|
||||
# talking/listening, and not while a speech bubble is up (it would walk
|
||||
# out from under it). A commanded `petctl move` ignores all of that
|
||||
# except the drag — if Bolt says go, it goes.
|
||||
busy = (
|
||||
not self._wander_enabled
|
||||
or self._napping
|
||||
or self._current_state != PetState.IDLE
|
||||
or self._bubble.isVisible()
|
||||
)
|
||||
if self._drag_offset is not None or (busy and not self._commanded_move):
|
||||
self._stop_walking()
|
||||
self._schedule_next_wander() # settle first, then wander
|
||||
return
|
||||
|
||||
if self._wander_target is None:
|
||||
if time.monotonic() < self._next_wander_at:
|
||||
return
|
||||
self._wander_target = self._pick_wander_target()
|
||||
if self._wander_target is None:
|
||||
self._schedule_next_wander()
|
||||
return
|
||||
|
||||
here = self.pos()
|
||||
dx = self._wander_target.x() - here.x()
|
||||
dy = self._wander_target.y() - here.y()
|
||||
distance = math.hypot(dx, dy)
|
||||
step = max(1.0, config.PET_WANDER_SPEED * _WANDER_TICK_MS / 1000.0)
|
||||
if distance <= step:
|
||||
self.move(self._wander_target)
|
||||
self._stop_walking()
|
||||
self._schedule_next_wander()
|
||||
self.snap_to_edge()
|
||||
else:
|
||||
self.move(round(here.x() + dx / distance * step), round(here.y() + dy / distance * step))
|
||||
self._bob_phase += 0.45 # little walk-cycle hop
|
||||
self._bob_offset = int(round(-2.5 * abs(math.sin(self._bob_phase))))
|
||||
self.update()
|
||||
self._reposition_bubble()
|
||||
|
||||
# ── state / speech ──────────────────────────────────────────────────
|
||||
|
||||
def set_state(self, state: PetState) -> None:
|
||||
if state == self._current_state:
|
||||
return
|
||||
self._current_state = state
|
||||
self.sprites.get(state).reset()
|
||||
if state != PetState.IDLE and not self._commanded_move:
|
||||
# Stand still while listening/thinking/talking — but not if Bolt
|
||||
# just told it to walk somewhere: that command arrives mid-turn,
|
||||
# and the reply (-> TALKING) lands a moment later.
|
||||
self._stop_walking()
|
||||
self.update()
|
||||
|
||||
def say(self, text: str, duration_ms: int = 6000) -> None:
|
||||
self._reposition_bubble()
|
||||
self._bubble.show_text(text, duration_ms)
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
self._bubble.close()
|
||||
super().closeEvent(event)
|
||||
|
||||
# ── animation ────────────────────────────────────────────────────────
|
||||
|
||||
def _advance_frame(self) -> None:
|
||||
self.sprites.get(self._current_state).advance()
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
painter.setRenderHint(QPainter.SmoothPixmapTransform)
|
||||
pixmap: Optional[QPixmap] = self.sprites.get(self._current_state).current()
|
||||
if pixmap is None:
|
||||
self._apply_input_mask(None, 0, 0)
|
||||
return
|
||||
# Non-square source art (e.g. the Kenney robot sprites) keeps its
|
||||
# aspect ratio when scaled in SpriteSet, so it may be narrower or
|
||||
# shorter than the (square) window — center it either way.
|
||||
x = (self.width() - pixmap.width()) // 2
|
||||
y = (self.height() - pixmap.height()) // 2 + self._bob_offset
|
||||
# The input mask tracks the resting position, not the emote/bob
|
||||
# offset: rebuilding it every frame of a spin would be both expensive
|
||||
# and visibly janky, and the offsets are only a few pixels.
|
||||
self._apply_input_mask(pixmap, x, (self.height() - pixmap.height()) // 2)
|
||||
|
||||
dx, dy, angle, scale = self._emote_transform()
|
||||
if (dx, dy, angle, scale) == (0.0, 0.0, 0.0, 1.0):
|
||||
painter.drawPixmap(x, y, pixmap)
|
||||
return
|
||||
# Rotate/scale about the sprite's own center so a spin doesn't orbit
|
||||
# the window's corner.
|
||||
center_x = x + pixmap.width() / 2
|
||||
center_y = y + pixmap.height() / 2
|
||||
transform = QTransform()
|
||||
transform.translate(center_x + dx, center_y + dy)
|
||||
transform.rotate(angle)
|
||||
transform.scale(scale, scale)
|
||||
transform.translate(-pixmap.width() / 2, -pixmap.height() / 2)
|
||||
painter.setTransform(transform)
|
||||
painter.drawPixmap(0, 0, pixmap)
|
||||
|
||||
# ── drag / click-to-talk ─────────────────────────────────────────────
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
if event.button() == Qt.LeftButton:
|
||||
global_pos = event.globalPosition().toPoint()
|
||||
self._drag_offset = global_pos - self.frameGeometry().topLeft()
|
||||
self._press_pos = global_pos
|
||||
self._dragged = False
|
||||
self._stop_walking() # grabbing it interrupts a stroll at once
|
||||
|
||||
def mouseMoveEvent(self, event) -> None:
|
||||
if self._drag_offset is None:
|
||||
return
|
||||
global_pos = event.globalPosition().toPoint()
|
||||
self.move(global_pos - self._drag_offset)
|
||||
self._reposition_bubble()
|
||||
if (global_pos - self._press_pos).manhattanLength() > _DRAG_THRESHOLD_PX:
|
||||
self._dragged = True
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None:
|
||||
if event.button() != Qt.LeftButton:
|
||||
return
|
||||
was_click = not self._dragged
|
||||
self._drag_offset = None
|
||||
self._press_pos = None
|
||||
if was_click:
|
||||
self.talk_requested.emit()
|
||||
else:
|
||||
self.snap_to_edge() # dropped near an edge -> tuck it flush
|
||||
Reference in New Issue
Block a user