Files
TNCcat/qml/Cat.qml
T
JulianandClaude Opus 5 f28dfec978 Settle the eyes down while walking
Two things made the eyes jump. The body bobbed two units per step and the head
rode along with it, and each eye ran its own random blink timer, so the cat
winked one eye at a time at intervals of its own choosing.

The bob is now a property on the cat rather than an animation owning torso.y,
the amplitude is softer, and the head translates against it by 75 percent, the
way a walking cat keeps its head level. Measured on a 240 px cat, the eye's
vertical travel per step went from 4.0 px to 0.8 px.

Both eyes now share one eyelid, driven by a single timer and a single
animation. That also removes the Behavior that was fighting the blink animation
for the same property, and the mid-blink flicker to the drawn closed-eye arch,
which now only appears when the eyes are really shut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:04:56 +02:00

645 lines
27 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
// A chibi cat drawn from primitives and vector paths.
// Design space is 100 x 90 units, ground line at y = 84, cat faces right.
Item {
id: cat
property color coat: "#f4b183"
property bool facingRight: true
property string mood: "walking" // walking running sitting grooming stretching sleeping loving alert
property bool lifted: false
property bool petted: false
property point look: Qt.point(0, 0)
property bool showCodes: true
property real sipDip: 0
// Vertical travel of the body during a step. The head cancels most of it
// again, because a walking cat keeps its head almost level and the eyes
// are what the eye follows.
property real bob: 0
// The cat announces what it is doing in the machine's own language.
readonly property string moodCode: mood === "running" || mood === "chasing" ? "G00"
: mood === "walking" ? "G01"
: mood === "sitting" ? "M00"
: mood === "grooming" ? "M08"
: mood === "stretching" ? "G28"
: mood === "sleeping" ? "M30"
: mood === "drinking" ? "M01"
: ""
readonly property bool dark: coat.hslLightness < 0.45
readonly property color coatShade: Qt.darker(coat, 1.12)
readonly property color coatLight: Qt.lighter(coat, dark ? 1.35 : 1.16)
readonly property color ink: dark ? Qt.lighter(coat, 1.9) : Qt.darker(coat, 1.7)
readonly property color eyeColor: dark ? "#f7d67c" : "#2f2a36"
readonly property color pink: "#f2929f"
readonly property color blush: "#f77d95"
readonly property bool walking: mood === "walking"
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"
|| mood === "drinking" || petted
// The cat is drawn in a 100 x 90 box. The window box is larger so whiskers,
// tail and the glyphs above the head stay inside it in every pose. The slack
// has to be symmetric: facing left mirrors the art about the box centre.
// Widest reach is the right whiskers while stretching, art x 109.
readonly property real designWidth: 120
readonly property real designHeight: 106
readonly property real artX: 7
readonly property real artY: 14
readonly property real unit: width / designWidth
// tail pose (design units); swish is added on top by the idle animation
property real tailC1X: 14
property real tailC1Y: 66
property real tailC2X: 8
property real tailC2Y: 46
property real tailX: 13
property real tailY: 34
property real tailSwish: 0
Behavior on tailC1X { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailC1Y { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailC2X { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailC2Y { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailX { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
Behavior on tailY { NumberAnimation { duration: 500; easing.type: Easing.InOutQuad } }
implicitWidth: designWidth
implicitHeight: designHeight
transform: Scale {
origin.x: cat.width / 2
xScale: cat.facingRight ? 1 : -1
}
Item {
id: art
x: cat.artX * cat.unit
y: cat.artY * cat.unit
width: 100
height: 90
transformOrigin: Item.TopLeft
scale: cat.unit
// ground shadow
Rectangle {
id: shadow
x: 24; y: 79
width: 56; height: 8
radius: 4
color: "#000000"
opacity: 0.16
antialiasing: true
scale: 1 - torso.y * -0.02
}
Item {
id: torso
width: 100
height: 90
y: cat.bob
// tail
Shape {
id: tailShape
z: -1
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: tail
strokeColor: cat.coatShade
strokeWidth: 7
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 30; startY: 60
PathCubic {
control1X: cat.tailC1X; control1Y: cat.tailC1Y
control2X: cat.tailC2X + cat.tailSwish * 0.5; control2Y: cat.tailC2Y
x: cat.tailX + cat.tailSwish; y: cat.tailY
}
}
SequentialAnimation {
running: !cat.sleeping && !cat.lifted
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { target: cat; property: "tailSwish"; to: 6; duration: 900; easing.type: Easing.InOutSine }
NumberAnimation { target: cat; property: "tailSwish"; to: -4; duration: 900; easing.type: Easing.InOutSine }
}
}
// legs — the far pair sits behind the body, the near pair in front
Repeater {
id: legs
model: 4
Rectangle {
id: leg
required property int index
readonly property bool front: index >= 2
readonly property bool far: index % 2 === 0
readonly property real swing: cat.running ? 38 : 24
readonly property real restRotation: front ? (cat.mood === "stretching" ? (far ? 48 : 40)
: cat.lifted ? (far ? -12 : -18) : 0)
: (cat.mood === "stretching" ? -6 : 0)
z: far ? 0 : 2
x: front ? (cat.seated && !cat.lifted ? 60 : 56) + (far ? 0 : 8)
: (cat.seated && !cat.lifted ? 36 : 30) + (far ? 0 : 8)
y: cat.sleeping && !cat.lifted ? 74
: (front ? (cat.seated && !cat.lifted ? 60 : 63)
: (cat.seated && !cat.lifted ? 72 : 63))
width: 10
height: cat.sleeping && !cat.lifted ? 8
: (front ? (cat.seated && !cat.lifted ? 24 : 20)
: (cat.seated && !cat.lifted ? 12 : 20))
radius: 5
color: far ? cat.coatShade : cat.coat
antialiasing: true
transformOrigin: Item.Top
Behavior on x { NumberAnimation { duration: 400; easing.type: Easing.InOutQuad } }
Behavior on y { NumberAnimation { duration: 400; easing.type: Easing.InOutQuad } }
Behavior on height { NumberAnimation { duration: 400; easing.type: Easing.InOutQuad } }
// paw
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
width: parent.width
height: 6.5
radius: 3.25
color: cat.coatLight
antialiasing: true
Rectangle { // toe line
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 1.2
width: 4.5
height: 1
radius: 0.5
color: cat.coatShade
opacity: 0.7
visible: !leg.far && !cat.sleeping
}
}
// While walking the gait animation owns rotation; otherwise the pose does.
states: State {
name: "resting"
when: !cat.moving
PropertyChanges { leg.rotation: leg.restRotation }
}
transitions: Transition {
NumberAnimation { property: "rotation"; duration: 400; easing.type: Easing.OutBack }
}
SequentialAnimation on rotation {
running: cat.moving
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: leg.far ? leg.swing : -leg.swing; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
NumberAnimation { to: leg.far ? -leg.swing : leg.swing; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
}
}
}
// body
Rectangle {
id: body
z: 1
x: 24; y: 46
width: 50; height: 32
radius: 16
color: cat.coat
antialiasing: true
Behavior on x { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on y { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on width { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on height { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on rotation { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Rectangle { // belly
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 3
width: parent.width * 0.5
height: parent.height * 0.42
radius: height / 2
color: cat.coatLight
antialiasing: true
}
}
// head
Item {
id: head
z: 3
x: 46; y: 12
width: 50; height: 44
transformOrigin: Item.Bottom
transform: Translate { y: -cat.bob * 0.75 }
Behavior on x { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on y { NumberAnimation { duration: 450; easing.type: Easing.InOutQuad } }
Behavior on rotation { NumberAnimation { duration: 450; easing.type: Easing.InOutBack } }
// ears
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath { // left ear
fillColor: cat.coat
strokeColor: "transparent"
startX: 6; startY: 14
PathQuad { controlX: 2; controlY: -4; x: 8; y: -4 }
PathQuad { controlX: 14; controlY: -2; x: 24; y: 5 }
PathLine { x: 6; y: 14 }
}
ShapePath { // left inner
fillColor: cat.pink
strokeColor: "transparent"
startX: 9; startY: 11
PathQuad { controlX: 6; controlY: 2; x: 9; y: 1 }
PathQuad { controlX: 14; controlY: 3; x: 19; y: 6 }
PathLine { x: 9; y: 11 }
}
ShapePath { // right ear
fillColor: cat.coat
strokeColor: "transparent"
startX: 44; startY: 14
PathQuad { controlX: 48; controlY: -4; x: 42; y: -4 }
PathQuad { controlX: 36; controlY: -2; x: 26; y: 5 }
PathLine { x: 44; y: 14 }
}
ShapePath { // right inner
fillColor: cat.pink
strokeColor: "transparent"
startX: 41; startY: 11
PathQuad { controlX: 44; controlY: 2; x: 41; y: 1 }
PathQuad { controlX: 36; controlY: 3; x: 31; y: 6 }
PathLine { x: 41; y: 11 }
}
}
Rectangle { // face
x: 0; y: 4
width: 50; height: 40
radius: 21
color: cat.coat
antialiasing: true
}
// cheeks
Repeater {
model: 2
Rectangle {
required property int index
x: index === 0 ? 5 : 35
y: 30
width: 10; height: 5.5
radius: 3
color: cat.blush
opacity: cat.happy ? 0.75 : 0.4
antialiasing: true
Behavior on opacity { NumberAnimation { duration: 300 } }
}
}
// eyes
Repeater {
model: 2
Item {
id: eye
required property int index
x: (index === 0 ? 12 : 29) + cat.look.x * 2.2
y: 17 + cat.look.y * 1.6
width: 9.5; height: 12
Rectangle {
id: eyeBall
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: eye.width * (cat.mood === "alert" || cat.mood === "batting" ? 1.15 : 1)
height: eye.height * cat.eyeOpen * (cat.mood === "alert" || cat.mood === "batting" ? 1.15 : 1)
radius: width / 2
color: cat.eyeColor
visible: !cat.eyesShut && cat.eyeOpen > 0.02
antialiasing: true
Rectangle { // highlight
x: parent.width * 0.5; y: parent.height * 0.14
width: 3.4; height: 3.4 * cat.eyeOpen
radius: 1.7
color: "white"
antialiasing: true
}
Rectangle {
x: parent.width * 0.22; y: parent.height * 0.6
width: 1.6; height: 1.6 * cat.eyeOpen
radius: 0.8
color: "white"
antialiasing: true
}
}
Shape { // closed eye: happy arch or sleepy curve
anchors.fill: parent
visible: cat.eyesShut
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: cat.ink
strokeWidth: 1.8
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 0.5; startY: 7
PathQuad {
controlX: 4.75
controlY: cat.happy ? 2 : 10
x: 9; y: 7
}
}
}
}
}
// nose
Rectangle {
x: 22.5; y: 28
width: 5; height: 3.6
radius: 1.8
color: cat.pink
antialiasing: true
}
// mouth "ω"
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: cat.ink
strokeWidth: 1.1
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: 20.5; startY: 32
PathQuad { controlX: 22.5; controlY: 35.2; x: 25; y: 32.4 }
PathQuad { controlX: 27.5; controlY: 35.2; x: 29.5; y: 32 }
}
}
// whiskers
Repeater {
model: 4
Rectangle {
required property int index
readonly property bool leftSide: index < 2
x: leftSide ? -7 : 45
y: 25 + (index % 2) * 4.5
width: 12
height: 1
radius: 0.5
color: cat.ink
opacity: 0.55
rotation: (leftSide ? 1 : -1) * ((index % 2) === 0 ? 7 : -7)
antialiasing: true
}
}
// raised paw while grooming
Rectangle {
x: 8; y: 26
width: 10; height: 16
radius: 5
color: cat.coat
antialiasing: true
visible: cat.mood === "grooming"
transformOrigin: Item.Bottom
SequentialAnimation on rotation {
running: cat.mood === "grooming"
loops: Animation.Infinite
NumberAnimation { to: 14; duration: 260; easing.type: Easing.InOutSine }
NumberAnimation { to: -6; duration: 260; easing.type: Easing.InOutSine }
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: 0
width: parent.width; height: 6
radius: 3
color: cat.coatLight
}
}
}
// purr wiggle while petted / loving
SequentialAnimation on rotation {
running: cat.petted || cat.mood === "loving"
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: 2.5; duration: 180; easing.type: Easing.InOutSine }
NumberAnimation { to: -2.5; duration: 180; easing.type: Easing.InOutSine }
}
}
// Machine code tag, flashed in the headroom whenever the mood changes
Item {
id: codeTag
x: 16; y: -12
width: codeBg.width
height: codeBg.height
opacity: 0
visible: cat.showCodes && opacity > 0.01
transform: Scale { xScale: cat.facingRight ? 1 : -1; origin.x: codeTag.width / 2 }
z: 10
Accessible.ignored: true
Rectangle {
id: codeBg
width: codeText.implicitWidth + 8
height: codeText.implicitHeight + 4
radius: 3
color: "#242833"
opacity: 0.92
antialiasing: true
Text {
id: codeText
anchors.centerIn: parent
text: cat.moodCode
color: "#8fd6ff"
font.pixelSize: 10
font.bold: true
font.family: "Menlo"
}
}
SequentialAnimation {
id: codeFlash
NumberAnimation { target: codeTag; property: "opacity"; to: 1; duration: 160 }
PauseAnimation { duration: 1500 }
NumberAnimation { target: codeTag; property: "opacity"; to: 0; duration: 500 }
}
}
// speech bubble: "!" / "z" / "♥"
Text {
id: bubble
readonly property string glyph: cat.mood === "alert" || cat.mood === "batting" ? "!"
: cat.sleeping ? "z"
: (cat.mood === "loving" || cat.petted) ? "♥" : ""
x: 82; y: 4
text: glyph
visible: glyph.length > 0
font.pixelSize: cat.mood === "alert" || cat.mood === "batting" ? 22 : 16
font.bold: true
font.family: "Helvetica Neue"
color: cat.mood === "alert" || cat.mood === "batting" ? "#f2a33c"
: (glyph === "♥" ? "#ff5b7f" : cat.ink)
transform: Scale { xScale: cat.facingRight ? 1 : -1; origin.x: bubble.width / 2 }
Accessible.ignored: true
SequentialAnimation on y {
running: bubble.visible
loops: Animation.Infinite
NumberAnimation { from: 10; to: -8; duration: cat.sleeping ? 1800 : 900; easing.type: Easing.OutQuad }
}
SequentialAnimation on opacity {
running: bubble.visible
loops: Animation.Infinite
NumberAnimation { from: 0; to: 1; duration: cat.sleeping ? 500 : 250 }
NumberAnimation { to: 0; duration: cat.sleeping ? 1300 : 650 }
}
}
}
onMoodCodeChanged: if (showCodes && moodCode.length > 0) codeFlash.restart()
SequentialAnimation {
running: cat.moving
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { target: cat; property: "bob"; to: cat.running ? -3 : -1.6; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
NumberAnimation { target: cat; property: "bob"; to: 0; duration: cat.running ? 130 : 240; easing.type: Easing.InOutSine }
}
// One pair of eyelids for both eyes. Two independent blink timers made the
// cat wink at random, which read as the eyes jittering.
readonly property bool eyesShut: sleeping || happy
property real eyeOpen: 1
onEyesShutChanged: {
blinkSeq.stop()
lidAnim.to = eyesShut ? 0 : 1
lidAnim.restart()
}
NumberAnimation {
id: lidAnim
target: cat
property: "eyeOpen"
duration: 140
easing.type: Easing.InOutQuad
}
SequentialAnimation {
id: blinkSeq
NumberAnimation { target: cat; property: "eyeOpen"; to: 0.06; duration: 90; easing.type: Easing.InQuad }
NumberAnimation { target: cat; property: "eyeOpen"; to: 1; duration: 140; easing.type: Easing.OutQuad }
}
Timer {
id: blinkTimer
interval: 3600
running: !cat.eyesShut && !cat.lifted
repeat: true
onTriggered: { interval = 3200 + Math.random() * 4200; blinkSeq.start() }
Component.onCompleted: interval = 2000 + Math.random() * 4000
}
// 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"
when: cat.running && !cat.lifted
PropertyChanges { body.rotation: -5; head.y: 15; head.x: 48 }
PropertyChanges { cat.tailC1X: 8; cat.tailC1Y: 60; cat.tailC2X: 4; cat.tailC2Y: 56; cat.tailX: 0; cat.tailY: 52 }
},
State {
name: "sitting"
when: (cat.mood === "sitting" || cat.mood === "loving" || cat.mood === "alert") && !cat.lifted
PropertyChanges { body.x: 34; body.y: 40; body.width: 40; body.height: 40 }
PropertyChanges { head.y: 8; head.x: 40 }
PropertyChanges { cat.tailC1X: 26; cat.tailC1Y: 84; cat.tailC2X: 40; cat.tailC2Y: 88; cat.tailX: 52; cat.tailY: 80 }
},
State {
name: "grooming"
when: cat.mood === "grooming" && !cat.lifted
PropertyChanges { body.x: 34; body.y: 40; body.width: 40; body.height: 40 }
PropertyChanges { head.y: 10; head.x: 38; head.rotation: -10 }
PropertyChanges { cat.tailC1X: 26; cat.tailC1Y: 84; cat.tailC2X: 40; cat.tailC2Y: 88; cat.tailX: 52; cat.tailY: 80 }
},
State {
name: "stretching"
when: cat.mood === "stretching" && !cat.lifted
PropertyChanges { body.x: 16; body.y: 50; body.width: 64; body.height: 26; body.rotation: -8 }
PropertyChanges { head.y: 32; head.x: 52; head.rotation: 7 }
PropertyChanges { cat.tailC1X: 10; cat.tailC1Y: 48; cat.tailC2X: 6; cat.tailC2Y: 30; cat.tailX: 14; cat.tailY: 22 }
},
State {
name: "sleeping"
when: cat.sleeping && !cat.lifted
PropertyChanges { body.x: 22; body.y: 54; body.width: 56; body.height: 26 }
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
PropertyChanges { body.rotation: -10; body.x: 26 }
PropertyChanges { head.y: 10; head.x: 50 }
},
State {
name: "lifted"
when: cat.lifted
PropertyChanges { torso.rotation: 8; torso.y: -6 }
PropertyChanges { body.x: 28; body.y: 40; body.width: 42; body.height: 40 }
PropertyChanges { head.y: 8; head.x: 40 }
PropertyChanges { cat.tailC1X: 22; cat.tailC1Y: 74; cat.tailC2X: 18; cat.tailC2Y: 86; cat.tailX: 24; cat.tailY: 92 }
PropertyChanges { shadow.opacity: 0.06; shadow.scale: 0.7 }
}
]
transitions: Transition {
NumberAnimation { target: torso; properties: "rotation,y"; duration: 260; easing.type: Easing.OutBack }
NumberAnimation { target: shadow; properties: "opacity,scale"; duration: 260 }
}
readonly property bool seated: mood === "sitting" || mood === "loving" || mood === "alert"
|| mood === "grooming" || mood === "drinking"
}