Every so often a mug appears at the cat's nose, it sits down, dips its head in and takes a few slow sips while steam rises. M01, optional stop. The mug lives in its own click-through window like the chip, or inside the overlay on Wayland. It goes away as soon as the cat is petted, dragged, sent to sleep or moves on to something else, so no mug is ever left standing. Also renames OverlayWindow's mode flag, which shadowed QWindow::active. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
381 lines
9.3 KiB
C++
381 lines
9.3 KiB
C++
#include "catcontroller.h"
|
|
#include "appsettings.h"
|
|
|
|
#include <QCursor>
|
|
#include <QMetaEnum>
|
|
#include <QRandomGenerator>
|
|
|
|
namespace {
|
|
constexpr qreal kChipReach = 26.0; // how close the nose has to get
|
|
constexpr int kChipMinDelayMs = 45000;
|
|
constexpr int kChipMaxDelayMs = 150000;
|
|
}
|
|
|
|
CatController::CatController(AppSettings *settings, QObject *parent)
|
|
: QObject(parent)
|
|
, m_settings(settings)
|
|
{
|
|
m_clock.start();
|
|
|
|
m_tickTimer.setInterval(16);
|
|
connect(&m_tickTimer, &QTimer::timeout, this, &CatController::tick);
|
|
m_tickTimer.start();
|
|
|
|
m_moodTimer.setSingleShot(true);
|
|
connect(&m_moodTimer, &QTimer::timeout, this, &CatController::pickNextMood);
|
|
|
|
m_cursorTimer.setInterval(40);
|
|
connect(&m_cursorTimer, &QTimer::timeout, this, &CatController::trackCursor);
|
|
m_cursorTimer.start();
|
|
|
|
m_chipTimer.setSingleShot(true);
|
|
connect(&m_chipTimer, &QTimer::timeout, this, &CatController::tossChip);
|
|
|
|
// TNCAT_POSE=Sleeping freezes the cat in one mood (handy for screenshots).
|
|
const QByteArray pose = qgetenv("TNCAT_POSE");
|
|
if (!pose.isEmpty()) {
|
|
const QMetaEnum me = QMetaEnum::fromType<Mood>();
|
|
bool ok = false;
|
|
const int value = me.keyToValue(pose.constData(), &ok);
|
|
if (ok) {
|
|
m_frozen = true;
|
|
setMood(static_cast<Mood>(value));
|
|
}
|
|
}
|
|
if (!m_frozen) {
|
|
pickNextMood();
|
|
scheduleChip();
|
|
}
|
|
}
|
|
|
|
qreal CatController::catNose() const
|
|
{
|
|
return m_facingRight ? m_x + m_catWidth * 0.78 : m_x + m_catWidth * 0.22;
|
|
}
|
|
|
|
void CatController::setX(qreal x)
|
|
{
|
|
if (qFuzzyCompare(x, m_x))
|
|
return;
|
|
m_x = x;
|
|
emit xChanged();
|
|
}
|
|
|
|
void CatController::setStageWidth(qreal width)
|
|
{
|
|
if (qFuzzyCompare(width, m_stageWidth))
|
|
return;
|
|
m_stageWidth = width;
|
|
emit stageWidthChanged();
|
|
}
|
|
|
|
void CatController::setCatWidth(qreal width)
|
|
{
|
|
if (qFuzzyCompare(width, m_catWidth))
|
|
return;
|
|
m_catWidth = width;
|
|
emit catWidthChanged();
|
|
}
|
|
|
|
void CatController::setCatCenter(const QPointF ¢er)
|
|
{
|
|
if (center == m_catCenter)
|
|
return;
|
|
m_catCenter = center;
|
|
emit catCenterChanged();
|
|
}
|
|
|
|
void CatController::setPaused(bool paused)
|
|
{
|
|
if (paused == m_paused)
|
|
return;
|
|
m_paused = paused;
|
|
emit pausedChanged();
|
|
if (paused) {
|
|
m_moodTimer.stop();
|
|
m_chipTimer.stop();
|
|
endChase();
|
|
endCoffee();
|
|
setMood(Mood::Sleeping);
|
|
} else {
|
|
pickNextMood();
|
|
scheduleChip();
|
|
}
|
|
}
|
|
|
|
void CatController::setDragging(bool dragging)
|
|
{
|
|
if (dragging == m_dragging)
|
|
return;
|
|
m_dragging = dragging;
|
|
emit draggingChanged();
|
|
if (m_frozen)
|
|
return;
|
|
if (dragging) {
|
|
m_moodTimer.stop();
|
|
endCoffee();
|
|
setMood(Mood::Alert);
|
|
} else if (!m_paused) {
|
|
holdMood(Mood::Sitting, 1500);
|
|
}
|
|
}
|
|
|
|
void CatController::setHovered(bool hovered)
|
|
{
|
|
if (hovered == m_hovered)
|
|
return;
|
|
m_hovered = hovered;
|
|
emit hoveredChanged();
|
|
}
|
|
|
|
void CatController::pet()
|
|
{
|
|
if (m_paused || m_frozen)
|
|
return;
|
|
m_settings->incrementPetCount();
|
|
endChase();
|
|
endCoffee();
|
|
holdMood(Mood::Loving, 1800);
|
|
}
|
|
|
|
void CatController::doTrick(Mood mood)
|
|
{
|
|
if (m_paused)
|
|
return;
|
|
m_frozen = false;
|
|
if (mood == Mood::Drinking) {
|
|
coffeeBreak();
|
|
return;
|
|
}
|
|
endChase();
|
|
if (mood != Mood::Drinking)
|
|
endCoffee();
|
|
holdMood(mood, mood == Mood::Sleeping ? 15000 : 3500);
|
|
}
|
|
|
|
void CatController::holdMood(Mood mood, int ms)
|
|
{
|
|
setMood(mood);
|
|
m_moodTimer.start(ms);
|
|
}
|
|
|
|
// A curled metal chip skitters in from one side and the cat goes after it.
|
|
void CatController::tossChip()
|
|
{
|
|
if (m_paused || m_frozen || m_chipVisible)
|
|
return;
|
|
if (m_cupVisible) { // finish the coffee first
|
|
scheduleChip();
|
|
return;
|
|
}
|
|
if (!m_settings->chipsEnabled()) {
|
|
scheduleChip();
|
|
return;
|
|
}
|
|
|
|
auto *rng = QRandomGenerator::global();
|
|
const bool fromLeft = rng->bounded(2) == 0;
|
|
const qreal margin = qMin(200.0, m_stageWidth * 0.2);
|
|
setChipX(fromLeft ? margin : m_stageWidth - margin);
|
|
|
|
m_batsLeft = rng->bounded(2, 5);
|
|
m_chipVisible = true;
|
|
emit chipVisibleChanged();
|
|
|
|
m_moodTimer.stop();
|
|
setMood(Mood::Chasing);
|
|
}
|
|
|
|
// A mug appears at the cat's nose, it sits down and takes its time.
|
|
void CatController::coffeeBreak()
|
|
{
|
|
if (m_paused || m_frozen)
|
|
return;
|
|
|
|
endChase();
|
|
const qreal reach = m_catWidth * 1.08; // just past the whiskers
|
|
const qreal edge = m_catWidth * 0.5;
|
|
|
|
// Put the mug on the side the cat faces, unless that runs off the screen.
|
|
qreal wanted = m_facingRight ? m_x + reach : m_x + m_catWidth - reach;
|
|
if (wanted < edge || wanted > m_stageWidth - edge) {
|
|
setFacingRight(!m_facingRight);
|
|
wanted = m_facingRight ? m_x + reach : m_x + m_catWidth - reach;
|
|
}
|
|
m_cupX = qBound(edge, wanted, m_stageWidth - edge);
|
|
emit cupXChanged();
|
|
|
|
if (!m_cupVisible) {
|
|
m_cupVisible = true;
|
|
emit cupVisibleChanged();
|
|
}
|
|
holdMood(Mood::Drinking, QRandomGenerator::global()->bounded(7000, 12000));
|
|
}
|
|
|
|
void CatController::endCoffee()
|
|
{
|
|
if (!m_cupVisible)
|
|
return;
|
|
m_cupVisible = false;
|
|
emit cupVisibleChanged();
|
|
}
|
|
|
|
void CatController::batChip()
|
|
{
|
|
auto *rng = QRandomGenerator::global();
|
|
++m_chipKick;
|
|
emit chipKickChanged();
|
|
|
|
const qreal distance = rng->bounded(140, 380);
|
|
qreal next = m_chipX + (m_facingRight ? distance : -distance);
|
|
const qreal edge = 40.0;
|
|
if (next < edge || next > m_stageWidth - edge) {
|
|
next = qBound(edge, next, m_stageWidth - edge);
|
|
--m_batsLeft; // a chip against the wall is nearly done for
|
|
}
|
|
setChipX(next);
|
|
|
|
if (--m_batsLeft <= 0) {
|
|
endChase();
|
|
holdMood(Mood::Sitting, 2500); // pleased with itself
|
|
scheduleChip();
|
|
} else {
|
|
holdMood(Mood::Batting, 420);
|
|
}
|
|
}
|
|
|
|
void CatController::endChase()
|
|
{
|
|
if (!m_chipVisible)
|
|
return;
|
|
m_chipVisible = false;
|
|
m_batsLeft = 0;
|
|
emit chipVisibleChanged();
|
|
}
|
|
|
|
void CatController::scheduleChip()
|
|
{
|
|
m_chipTimer.start(QRandomGenerator::global()->bounded(kChipMinDelayMs, kChipMaxDelayMs));
|
|
}
|
|
|
|
void CatController::setChipX(qreal x)
|
|
{
|
|
if (qFuzzyCompare(x, m_chipX))
|
|
return;
|
|
m_chipX = x;
|
|
emit chipXChanged();
|
|
}
|
|
|
|
void CatController::trackCursor()
|
|
{
|
|
if (m_catCenter.isNull() || !m_settings->followCursor()) {
|
|
if (!m_look.isNull()) {
|
|
m_look = {};
|
|
emit lookChanged();
|
|
}
|
|
return;
|
|
}
|
|
QPointF delta = QCursor::pos() - m_catCenter;
|
|
if (!m_facingRight)
|
|
delta.setX(-delta.x());
|
|
const qreal range = 400.0;
|
|
const QPointF look(qBound(-1.0, delta.x() / range, 1.0), qBound(-1.0, delta.y() / range, 1.0));
|
|
if ((look - m_look).manhattanLength() > 0.005) {
|
|
m_look = look;
|
|
emit lookChanged();
|
|
}
|
|
}
|
|
|
|
void CatController::tick()
|
|
{
|
|
const qint64 now = m_clock.elapsed();
|
|
const qreal dt = qMin(now - m_lastTickMs, qint64(100)) / 1000.0;
|
|
m_lastTickMs = now;
|
|
|
|
if (m_dragging || m_paused || m_frozen)
|
|
return;
|
|
|
|
const qreal maxX = qMax(0.0, m_stageWidth - m_catWidth);
|
|
|
|
if (m_mood == Mood::Chasing) {
|
|
const qreal delta = m_chipX - catNose();
|
|
setFacingRight(delta >= 0);
|
|
if (qAbs(delta) <= kChipReach) {
|
|
batChip();
|
|
return;
|
|
}
|
|
const qreal step = m_settings->walkSpeed() * 4.0 * dt;
|
|
setX(qBound(0.0, m_x + (delta > 0 ? step : -step), maxX));
|
|
return;
|
|
}
|
|
|
|
if (m_mood != Mood::Walking && m_mood != Mood::Running)
|
|
return;
|
|
|
|
const qreal speed = m_settings->walkSpeed() * (m_mood == Mood::Running ? 4.5 : 1.0);
|
|
qreal next = m_x + (m_facingRight ? speed * dt : -speed * dt);
|
|
if (next >= maxX) {
|
|
next = maxX;
|
|
setFacingRight(false);
|
|
if (m_mood == Mood::Running)
|
|
holdMood(Mood::Sitting, 2000);
|
|
} else if (next <= 0) {
|
|
next = 0;
|
|
setFacingRight(true);
|
|
if (m_mood == Mood::Running)
|
|
holdMood(Mood::Sitting, 2000);
|
|
}
|
|
setX(next);
|
|
}
|
|
|
|
void CatController::setMood(Mood mood)
|
|
{
|
|
if (mood == m_mood)
|
|
return;
|
|
m_mood = mood;
|
|
emit moodChanged();
|
|
}
|
|
|
|
void CatController::setFacingRight(bool right)
|
|
{
|
|
if (right == m_facingRight)
|
|
return;
|
|
m_facingRight = right;
|
|
emit facingRightChanged();
|
|
}
|
|
|
|
void CatController::pickNextMood()
|
|
{
|
|
if (m_paused || m_dragging || m_frozen)
|
|
return;
|
|
if (m_chipVisible) { // a chase is still on, keep after the chip
|
|
setMood(Mood::Chasing);
|
|
return;
|
|
}
|
|
|
|
endCoffee();
|
|
|
|
auto *rng = QRandomGenerator::global();
|
|
const int roll = rng->bounded(100);
|
|
|
|
if (roll < 8 && m_settings->coffeeEnabled()) {
|
|
coffeeBreak();
|
|
return;
|
|
}
|
|
if (roll < 45) {
|
|
if (rng->bounded(100) < 20)
|
|
setFacingRight(!m_facingRight);
|
|
holdMood(Mood::Walking, rng->bounded(3000, 9000));
|
|
} else if (roll < 55) {
|
|
holdMood(Mood::Running, rng->bounded(1200, 2600));
|
|
} else if (roll < 72) {
|
|
holdMood(Mood::Sitting, rng->bounded(2500, 6000));
|
|
} else if (roll < 82) {
|
|
holdMood(Mood::Grooming, rng->bounded(2500, 4500));
|
|
} else if (roll < 90) {
|
|
holdMood(Mood::Stretching, rng->bounded(1800, 3000));
|
|
} else {
|
|
holdMood(Mood::Sleeping, rng->bounded(8000, 20000));
|
|
}
|
|
}
|