aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/companion/Figure.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/companion/Figure.qml')
-rw-r--r--desktop/companion/Figure.qml159
1 files changed, 159 insertions, 0 deletions
diff --git a/desktop/companion/Figure.qml b/desktop/companion/Figure.qml
new file mode 100644
index 0000000..3401168
--- /dev/null
+++ b/desktop/companion/Figure.qml
@@ -0,0 +1,159 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import ".."
+
+// One frame of the companion, drawn at half its pixel size and standing on
+// its bottom edge: a crossfade between frames, a slow bob, a tremble, the
+// drop out of view and back, and the self-destruct flash. Which frame is Mood.js's
+// business; this only draws it.
+Item {
+ id: s
+
+ // The frame, and what to show when it is missing: the same outfit's
+ // base, never the other outfit's, so a gap in the art cannot put the
+ // skirt back mid-scene.
+ property string source
+ property string fallback
+ // Dropped below the bottom edge and faded out.
+ property bool out: false
+ signal settled(bool out)
+
+ // Two images: the front one stays until the back one has loaded, then
+ // they swap, so a frame change never flashes empty.
+ property bool flip: false
+ readonly property Image front: s.flip ? b : a
+ width: s.front.implicitWidth / 2
+ height: s.front.implicitHeight / 2
+
+ function load(url) {
+ const front = s.flip ? b : a, back = s.flip ? a : b;
+ if (String(front.source) === url) { back.source = ""; return; }
+ if (String(back.source) === url && back.status === Image.Ready) { s.flip = !s.flip; return; }
+ back.source = url;
+ }
+ onSourceChanged: s.load(s.source)
+ Component.onCompleted: s.load(s.source)
+
+ function ready(img) {
+ if (img.status === Image.Error && String(img.source) !== s.fallback) {
+ img.source = s.fallback;
+ return;
+ }
+ if (img.status === Image.Ready && img !== s.front) s.flip = !s.flip;
+ }
+
+ property real drop: 0
+ property real bob: 0
+ NumberAnimation {
+ id: dropAnim
+ target: s; property: "drop"; duration: 320
+ onFinished: s.settled(s.out)
+ }
+ onOutChanged: {
+ dropAnim.stop();
+ dropAnim.to = s.out ? s.height + 24 : 0;
+ dropAnim.easing.type = s.out ? Easing.InCubic : Easing.OutBack;
+ dropAnim.start();
+ }
+ // A quick side to side shiver, for the wallpaper blush. Stopping can
+ // leave it mid-swing, so it is put back to rest.
+ property bool trembling: false
+ property real shake: 0
+ onTremblingChanged: if (!s.trembling) s.shake = 0
+ SequentialAnimation on shake {
+ running: s.trembling && !s.out
+ loops: Animation.Infinite
+ NumberAnimation { to: 1.5; duration: 40 }
+ NumberAnimation { to: -1.5; duration: 80 }
+ NumberAnimation { to: 0; duration: 40 }
+ }
+ SequentialAnimation on bob {
+ running: !s.out
+ loops: Animation.Infinite
+ NumberAnimation { to: 2; duration: 1900; easing.type: Easing.InOutSine }
+ NumberAnimation { to: 0; duration: 2300; easing.type: Easing.InOutSine }
+ }
+
+ Item {
+ anchors.fill: parent
+ opacity: 1 - s.drop / (s.height + 24)
+ transform: Translate { x: s.shake; y: s.drop - s.bob }
+
+ Image {
+ id: a
+ width: implicitWidth / 2; height: implicitHeight / 2
+ anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter }
+ smooth: true; mipmap: true; asynchronous: true
+ opacity: s.flip ? 0 : 1
+ Behavior on opacity { NumberAnimation { duration: 120 } }
+ onStatusChanged: s.ready(a)
+ }
+ Image {
+ id: b
+ width: implicitWidth / 2; height: implicitHeight / 2
+ anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter }
+ smooth: true; mipmap: true; asynchronous: true
+ opacity: s.flip ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 120 } }
+ onStatusChanged: s.ready(b)
+ }
+ }
+
+ // --- self-destruct: the glow swells into a flash, then a ring and
+ // sparks burst out. About 1.1 s, matching Mood.T.destruct, during which
+ // the frame swaps to the leotard under the flash.
+
+ function explode() { boom.restart(); }
+
+ property real burst: 0
+ Rectangle {
+ id: flash
+ anchors.centerIn: parent
+ width: s.height * 1.6; height: width; radius: width / 2
+ color: "white"; opacity: 0; scale: 0
+ }
+ Rectangle {
+ id: ring
+ anchors.centerIn: parent
+ width: s.height * 2; height: width; radius: width / 2
+ color: "transparent"
+ border { width: 3; color: Theme.peach }
+ opacity: 0; scale: 0
+ }
+ Repeater {
+ model: 8
+ Rectangle {
+ required property int index
+ readonly property real angle: index * Math.PI / 4
+ width: 6; height: 6; radius: 3
+ color: index % 2 ? Theme.peach : Theme.yellow
+ x: s.width / 2 - 3 + Math.cos(angle) * s.burst * s.height
+ y: s.height / 2 - 3 + Math.sin(angle) * s.burst * s.height
+ opacity: s.burst > 0 && s.burst < 1 ? 1 - s.burst : 0
+ }
+ }
+ SequentialAnimation {
+ id: boom
+ ParallelAnimation {
+ NumberAnimation { target: flash; property: "scale"; from: 0; to: 1; duration: 600; easing.type: Easing.InQuad }
+ NumberAnimation { target: flash; property: "opacity"; from: 0; to: 0.95; duration: 600 }
+ }
+ ParallelAnimation {
+ NumberAnimation { target: flash; property: "opacity"; to: 0; duration: 400 }
+ NumberAnimation { target: ring; property: "scale"; from: 0.2; to: 1.4; duration: 400; easing.type: Easing.OutCubic }
+ NumberAnimation { target: ring; property: "opacity"; from: 1; to: 0; duration: 400 }
+ NumberAnimation { target: s; property: "burst"; from: 0; to: 1; duration: 500; easing.type: Easing.OutCubic }
+ }
+ PropertyAction { target: s; property: "burst"; value: 0 }
+ }
+}