diff --git a/CMakeLists.txt b/CMakeLists.txt index cc2d837..45f54d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,7 +22,7 @@ qt_add_executable(tncat target_include_directories(tncat PRIVATE src) # QML files live in qml/ but register at the module root (TNCat.Main etc.) -set(TNCAT_QML Main Cat Chip CatWindow ChipWindow OverlayWindow SettingsWindow CatMoods) +set(TNCAT_QML Main Cat Chip Cup CatWindow ChipWindow CupWindow OverlayWindow SettingsWindow CatMoods) foreach(qmlfile IN LISTS TNCAT_QML) set_source_files_properties(qml/${qmlfile}.qml PROPERTIES QT_RESOURCE_ALIAS ${qmlfile}.qml) list(APPEND TNCAT_QML_FILES qml/${qmlfile}.qml) diff --git a/README.md b/README.md index ba57890..86fab54 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ A little cat that wanders along the bottom edge of the screen, sits down, grooms itself, stretches, dashes off and takes naps. Every now and then a curled steel -chip skitters past and the cat goes after it. Drag it around, tap it to pet it, -and it purrs. +chip skitters past and the cat goes after it, or a mug appears and the cat takes +a coffee break. Drag it around, tap it to pet it, and it purrs. It announces what it is doing in the machine's own language: `G00` when it sprints, `G01` when it strolls, `M08` while it washes, `G28` when it stretches -back to reference, `M30` when it goes to sleep. +back to reference, `M30` when it goes to sleep, `M01` when it stops for coffee. The name is `TNC` plus `cat`, because the intended home is a HEIDENHAIN control (TNC7, TNC 640). Primary target is therefore **Linux / X11**; development happens @@ -22,16 +22,17 @@ plain Qt 6 / QML project. |---|---| | `CMakeLists.txt` | `qt_add_executable` + `qt_add_qml_module` (URI `TNCat`), macOS bundle, `.desktop` file on Linux | | `src/main.cpp` | Wires `AppSettings` and `CatController` into the QML engine, warns on Wayland | -| `src/catcontroller.*` | Mood machine (walk, run, sit, groom, stretch, sleep, love, alert, chase, bat), movement tick, chip chase, cursor tracking | +| `src/catcontroller.*` | Mood machine (walk, run, sit, groom, stretch, sleep, love, alert, chase, bat, drink), movement tick, chip chase, coffee break, cursor tracking | | `src/appsettings.*` | `QSettings`-backed coat, size, pace, ground line, eye tracking, pet counter | | `src/platforminfo.*` | QML singleton `Platform`: platform name plus the X11 escape hatches | | `src/traycontroller.*` | Tray icon and menu, silently skipped when the desktop has no tray | | `qml/Main.qml` | Non-visual root that picks the window layout for the platform | -| `qml/CatWindow.qml`, `qml/ChipWindow.qml` | One small window each, positioned by the app. X11, macOS, Windows | +| `qml/CatWindow.qml`, `qml/ChipWindow.qml`, `qml/CupWindow.qml` | One small window each, positioned by the app. X11, macOS, Windows | | `qml/OverlayWindow.qml` | One screen-filling click-through window holding both. Wayland | | `qml/Cat.qml` | The drawing: rounded primitives plus `Shape` paths for tail, ears and mouth | | `qml/Chip.qml` | The curled chip, a stroked `Shape` spiral | -| `qml/SettingsWindow.qml` | Coat swatches, size, pace, ground line, eye tracking, codes, chips, tricks | +| `qml/Cup.qml` | The coffee mug, with `Shape` steam wisps | +| `qml/SettingsWindow.qml` | Coat swatches, size, pace, ground line, eye tracking, codes, chips, coffee, tricks | The cat is drawn in a fixed 100 x 90 design space and scaled by `width / 100`, so every size setting stays crisp. There are no image assets, which keeps the @@ -71,7 +72,7 @@ context menu. ## Two window layouts -On X11, macOS and Windows the cat and the chip each get their own small, +On X11, macOS and Windows the cat, the chip and the mug each get their own small, frameless, always-on-top window, and the app moves those windows around. Nothing else on screen is covered and no clicks are intercepted. diff --git a/qml/Cat.qml b/qml/Cat.qml index a2ffb75..1a87a9c 100644 --- a/qml/Cat.qml +++ b/qml/Cat.qml @@ -14,6 +14,7 @@ Item { property bool petted: false property point look: Qt.point(0, 0) property bool showCodes: true + property real sipDip: 0 // The cat announces what it is doing in the machine's own language. readonly property string moodCode: mood === "running" || mood === "chasing" ? "G00" @@ -22,6 +23,7 @@ Item { : mood === "grooming" ? "M08" : mood === "stretching" ? "G28" : mood === "sleeping" ? "M30" + : mood === "drinking" ? "M01" : "" readonly property bool dark: coat.hslLightness < 0.45 @@ -36,7 +38,8 @@ Item { readonly property bool running: mood === "running" || mood === "chasing" readonly property bool moving: (walking || running) && !lifted readonly property bool sleeping: mood === "sleeping" - readonly property bool happy: mood === "loving" || mood === "grooming" || mood === "stretching" || petted + readonly property bool happy: mood === "loving" || mood === "grooming" || mood === "stretching" + || mood === "drinking" || petted readonly property real unit: width / 100 // tail pose (design units); swish is added on top by the idle animation @@ -531,6 +534,17 @@ Item { onMoodCodeChanged: if (showCodes && moodCode.length > 0) codeFlash.restart() + // Head dips into the mug, holds, comes back up. + SequentialAnimation { + running: cat.mood === "drinking" && !cat.lifted + loops: Animation.Infinite + alwaysRunToEnd: true + NumberAnimation { target: cat; property: "sipDip"; to: 13; duration: 700; easing.type: Easing.InOutQuad } + PauseAnimation { duration: 900 } + NumberAnimation { target: cat; property: "sipDip"; to: 0; duration: 600; easing.type: Easing.InOutQuad } + PauseAnimation { duration: 1400 } + } + states: [ State { name: "running" @@ -566,6 +580,13 @@ Item { PropertyChanges { head.y: 24; head.x: 44; head.rotation: 4 } PropertyChanges { cat.tailC1X: 24; cat.tailC1Y: 86; cat.tailC2X: 60; cat.tailC2Y: 90; cat.tailX: 76; cat.tailY: 78 } }, + State { + name: "drinking" + when: cat.mood === "drinking" && !cat.lifted + PropertyChanges { body.x: 32; body.y: 42; body.width: 42; body.height: 38 } + PropertyChanges { head.x: 50; head.y: 20 + cat.sipDip; head.rotation: 16 } + PropertyChanges { cat.tailC1X: 26; cat.tailC1Y: 84; cat.tailC2X: 40; cat.tailC2Y: 88; cat.tailX: 52; cat.tailY: 80 } + }, State { name: "batting" when: cat.mood === "batting" && !cat.lifted @@ -588,5 +609,6 @@ Item { NumberAnimation { target: shadow; properties: "opacity,scale"; duration: 260 } } - readonly property bool seated: mood === "sitting" || mood === "loving" || mood === "alert" || mood === "grooming" + readonly property bool seated: mood === "sitting" || mood === "loving" || mood === "alert" + || mood === "grooming" || mood === "drinking" } diff --git a/qml/CatMoods.qml b/qml/CatMoods.qml index f8575b0..74e2f87 100644 --- a/qml/CatMoods.qml +++ b/qml/CatMoods.qml @@ -5,7 +5,7 @@ import TNCat // Maps CatController::Mood onto the strings Cat.qml understands. QtObject { readonly property var names: ["walking", "running", "sitting", "grooming", "stretching", - "sleeping", "loving", "alert", "chasing", "batting"] + "sleeping", "loving", "alert", "chasing", "batting", "drinking"] function name(mood) { return names[mood] !== undefined ? names[mood] : "sitting" diff --git a/qml/Cup.qml b/qml/Cup.qml new file mode 100644 index 0000000..e9d5a92 --- /dev/null +++ b/qml/Cup.qml @@ -0,0 +1,109 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Shapes + +// A mug of coffee with steam. Design space is 44 x 52 units, mug on the ground. +Item { + id: cup + + readonly property real unit: width / 44 + + implicitWidth: 44 + implicitHeight: 52 + + Item { + width: 44 + height: 52 + transformOrigin: Item.TopLeft + scale: cup.unit + + Rectangle { // ground shadow + x: 8; y: 46 + width: 26; height: 5 + radius: 2.5 + color: "#000000" + opacity: 0.18 + antialiasing: true + } + + // steam + Repeater { + model: 3 + Shape { + id: wisp + required property int index + x: 10 + index * 7 + y: 2 + width: 8 + height: 16 + opacity: 0 + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeColor: "#ffffff" + strokeWidth: 1.8 + capStyle: ShapePath.RoundCap + fillColor: "transparent" + startX: 4; startY: 15 + PathQuad { controlX: 0; controlY: 10; x: 4; y: 7 } + PathQuad { controlX: 8; controlY: 4; x: 4; y: 0 } + } + + SequentialAnimation { + running: true + loops: Animation.Infinite + PauseAnimation { duration: wisp.index * 600 } + ParallelAnimation { + NumberAnimation { target: wisp; property: "opacity"; from: 0; to: 0.55; duration: 700 } + NumberAnimation { target: wisp; property: "y"; from: 8; to: 2; duration: 700 } + } + ParallelAnimation { + NumberAnimation { target: wisp; property: "opacity"; to: 0; duration: 1000 } + NumberAnimation { target: wisp; property: "y"; to: -6; duration: 1000 } + } + PauseAnimation { duration: 1800 - wisp.index * 600 } + } + } + } + + // handle + Shape { + x: 28; y: 24 + width: 14; height: 18 + preferredRendererType: Shape.CurveRenderer + ShapePath { + strokeColor: "#e8e3da" + strokeWidth: 3.5 + capStyle: ShapePath.RoundCap + fillColor: "transparent" + startX: 1; startY: 2 + PathCubic { control1X: 12; control1Y: 0; control2X: 12; control2Y: 14; x: 1; y: 13 } + } + } + + // mug + Rectangle { + x: 9; y: 20 + width: 22; height: 27 + radius: 4 + color: "#f7f4ee" + antialiasing: true + + Rectangle { // band + x: 0; y: 13 + width: parent.width + height: 5 + color: "#4d7ea8" + } + } + + // coffee surface + Rectangle { + x: 10.5; y: 19 + width: 19; height: 5 + radius: 2.5 + color: "#4a2c17" + antialiasing: true + } + } +} diff --git a/qml/CupWindow.qml b/qml/CupWindow.qml new file mode 100644 index 0000000..06af0df --- /dev/null +++ b/qml/CupWindow.qml @@ -0,0 +1,28 @@ +pragma ComponentBehavior: Bound +import QtQuick +import TNCat + +// The mug stands on the ground next to the cat, click-through like the chip. +Window { + id: win + + required property CatController cat + required property AppSettings settings + + readonly property int cupSize: Math.round(settings.catSize * 0.38) + + width: cupSize + height: Math.round(cupSize * 1.18) + x: cat.cupX - width / 2 + y: Screen.desktopAvailableHeight - height - settings.bottomMargin + color: "transparent" + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + | Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus + | Qt.WindowTransparentForInput + | (Platform.bypassWindowManager ? Qt.X11BypassWindowManagerHint : 0) + visible: cat.cupVisible && !Platform.opaqueBackground + + Cup { + anchors.fill: parent + } +} diff --git a/qml/Main.qml b/qml/Main.qml index d5ccaa7..b590384 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -28,10 +28,16 @@ QtObject { visible: !Platform.overlayMode && app.cat.chipVisible } + readonly property CupWindow cupWindow: CupWindow { + cat: app.cat + settings: app.settings + visible: !Platform.overlayMode && app.cat.cupVisible + } + readonly property OverlayWindow overlayWindow: OverlayWindow { cat: app.cat settings: app.settings - active: Platform.overlayMode + overlayActive: Platform.overlayMode onSettingsRequested: app.openSettings() } diff --git a/qml/OverlayWindow.qml b/qml/OverlayWindow.qml index 36c3a17..a746434 100644 --- a/qml/OverlayWindow.qml +++ b/qml/OverlayWindow.qml @@ -15,7 +15,7 @@ Window { // Fullscreen is a visibility, not a visible flag, so the mode switch has to // go through visibility or the two fight each other. - property bool active: false + property bool overlayActive: false readonly property int catW: settings.catSize readonly property int catH: Math.round(settings.catSize * 0.9) @@ -26,14 +26,14 @@ Window { Platform.setInputRegion(win, Qt.rect(Math.round(cat.x), Math.round(groundY), catW, catH)) } - visibility: active ? Window.FullScreen : Window.Hidden + visibility: overlayActive ? Window.FullScreen : Window.Hidden color: "transparent" flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool | Qt.NoDropShadowWindowHint | Qt.WindowDoesNotAcceptFocus title: qsTr("TNCat") - onActiveChanged: { - if (!active) + onOverlayActiveChanged: { + if (!overlayActive) return cat.stageWidth = width cat.catWidth = catW @@ -51,7 +51,7 @@ Window { // Coalesce mask updates: one X11 round trip per frame of walking is plenty. Timer { interval: 33 - running: win.active + running: win.overlayActive repeat: true onTriggered: win.refreshInputRegion() } @@ -69,6 +69,14 @@ Window { } } + Cup { + x: win.cat.cupX - width / 2 + y: win.height - height - win.settings.bottomMargin + width: Math.round(win.settings.catSize * 0.38) + height: Math.round(width * 1.18) + visible: win.cat.cupVisible + } + Item { id: catItem x: win.cat.x diff --git a/qml/SettingsWindow.qml b/qml/SettingsWindow.qml index 60c6598..c92905a 100644 --- a/qml/SettingsWindow.qml +++ b/qml/SettingsWindow.qml @@ -163,6 +163,13 @@ Window { onToggled: win.settings.chipsEnabled = checked } + Label { text: qsTr("Coffee") } + Switch { + text: qsTr("Take a coffee break now and then") + checked: win.settings.coffeeEnabled + onToggled: win.settings.coffeeEnabled = checked + } + Label { text: qsTr("Cat") } Switch { text: checked ? qsTr("Napping") : qsTr("Awake") @@ -183,6 +190,7 @@ Window { Button { text: qsTr("Dash"); onClicked: win.cat.doTrick(CatController.Running) } Button { text: qsTr("Nap"); onClicked: win.cat.doTrick(CatController.Sleeping) } Button { text: qsTr("Toss a chip"); onClicked: win.cat.tossChip() } + Button { text: qsTr("Coffee"); onClicked: win.cat.coffeeBreak() } } } diff --git a/src/appsettings.cpp b/src/appsettings.cpp index 32dac54..3b424db 100644 --- a/src/appsettings.cpp +++ b/src/appsettings.cpp @@ -99,6 +99,19 @@ void AppSettings::setChipsEnabled(bool enabled) emit chipsEnabledChanged(); } +bool AppSettings::coffeeEnabled() const +{ + return m_settings.value("coffeeEnabled", true).toBool(); +} + +void AppSettings::setCoffeeEnabled(bool enabled) +{ + if (enabled == coffeeEnabled()) + return; + m_settings.setValue("coffeeEnabled", enabled); + emit coffeeEnabledChanged(); +} + int AppSettings::petCount() const { return m_settings.value("petCount", 0).toInt(); diff --git a/src/appsettings.h b/src/appsettings.h index 21072ef..d76def8 100644 --- a/src/appsettings.h +++ b/src/appsettings.h @@ -18,6 +18,7 @@ class AppSettings : public QObject Q_PROPERTY(int bottomMargin READ bottomMargin WRITE setBottomMargin NOTIFY bottomMarginChanged) Q_PROPERTY(bool showCodes READ showCodes WRITE setShowCodes NOTIFY showCodesChanged) Q_PROPERTY(bool chipsEnabled READ chipsEnabled WRITE setChipsEnabled NOTIFY chipsEnabledChanged) + Q_PROPERTY(bool coffeeEnabled READ coffeeEnabled WRITE setCoffeeEnabled NOTIFY coffeeEnabledChanged) Q_PROPERTY(int petCount READ petCount NOTIFY petCountChanged) public: @@ -44,6 +45,9 @@ public: bool chipsEnabled() const; void setChipsEnabled(bool enabled); + bool coffeeEnabled() const; + void setCoffeeEnabled(bool enabled); + int petCount() const; void incrementPetCount(); @@ -55,6 +59,7 @@ signals: void bottomMarginChanged(); void showCodesChanged(); void chipsEnabledChanged(); + void coffeeEnabledChanged(); void petCountChanged(); private: diff --git a/src/catcontroller.cpp b/src/catcontroller.cpp index 1c91ca4..6f131d1 100644 --- a/src/catcontroller.cpp +++ b/src/catcontroller.cpp @@ -95,6 +95,7 @@ void CatController::setPaused(bool paused) m_moodTimer.stop(); m_chipTimer.stop(); endChase(); + endCoffee(); setMood(Mood::Sleeping); } else { pickNextMood(); @@ -112,6 +113,7 @@ void CatController::setDragging(bool dragging) return; if (dragging) { m_moodTimer.stop(); + endCoffee(); setMood(Mood::Alert); } else if (!m_paused) { holdMood(Mood::Sitting, 1500); @@ -132,6 +134,7 @@ void CatController::pet() return; m_settings->incrementPetCount(); endChase(); + endCoffee(); holdMood(Mood::Loving, 1800); } @@ -140,7 +143,13 @@ 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); } @@ -155,6 +164,10 @@ 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; @@ -173,6 +186,40 @@ void CatController::tossChip() 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(); @@ -306,9 +353,15 @@ void CatController::pickNextMood() 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); diff --git a/src/catcontroller.h b/src/catcontroller.h index eb5132d..1bba9a2 100644 --- a/src/catcontroller.h +++ b/src/catcontroller.h @@ -30,9 +30,11 @@ class CatController : public QObject Q_PROPERTY(bool chipVisible READ chipVisible NOTIFY chipVisibleChanged) Q_PROPERTY(qreal chipX READ chipX NOTIFY chipXChanged) Q_PROPERTY(int chipKick READ chipKick NOTIFY chipKickChanged) + Q_PROPERTY(bool cupVisible READ cupVisible NOTIFY cupVisibleChanged) + Q_PROPERTY(qreal cupX READ cupX NOTIFY cupXChanged) public: - enum class Mood { Walking, Running, Sitting, Grooming, Stretching, Sleeping, Loving, Alert, Chasing, Batting }; + enum class Mood { Walking, Running, Sitting, Grooming, Stretching, Sleeping, Loving, Alert, Chasing, Batting, Drinking }; Q_ENUM(Mood) explicit CatController(AppSettings *settings, QObject *parent = nullptr); @@ -67,10 +69,13 @@ public: bool chipVisible() const { return m_chipVisible; } qreal chipX() const { return m_chipX; } int chipKick() const { return m_chipKick; } + bool cupVisible() const { return m_cupVisible; } + qreal cupX() const { return m_cupX; } Q_INVOKABLE void pet(); Q_INVOKABLE void doTrick(Mood mood); Q_INVOKABLE void tossChip(); + Q_INVOKABLE void coffeeBreak(); signals: void xChanged(); @@ -86,6 +91,8 @@ signals: void chipVisibleChanged(); void chipXChanged(); void chipKickChanged(); + void cupVisibleChanged(); + void cupXChanged(); private: void tick(); @@ -97,6 +104,7 @@ private: void holdMood(Mood mood, int ms); void batChip(); void endChase(); + void endCoffee(); void scheduleChip(); qreal catNose() const; @@ -124,4 +132,7 @@ private: qreal m_chipX = 0; int m_chipKick = 0; int m_batsLeft = 0; + + bool m_cupVisible = false; + qreal m_cupX = 0; }; diff --git a/src/traycontroller.cpp b/src/traycontroller.cpp index a223e73..134d116 100644 --- a/src/traycontroller.cpp +++ b/src/traycontroller.cpp @@ -17,6 +17,7 @@ TrayController::TrayController(CatController *cat, QObject *parent) { QT_TR_NOOP("Stretch"), CatController::Mood::Stretching }, { QT_TR_NOOP("Dash"), CatController::Mood::Running }, { QT_TR_NOOP("Nap"), CatController::Mood::Sleeping }, + { QT_TR_NOOP("Coffee break"), CatController::Mood::Drinking }, }; for (const auto &e : entries) { const auto mood = e.mood;