Files
TNCcat/src/catcontroller.cpp
T
JulianandClaude Opus 5 403ac30b3a Add a coffee break
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>
2026-09-10 23:00:22 +02:00

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 &center)
{
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));
}
}