diff options
Diffstat (limited to 'desktop/companion/Figure.qml')
| -rw-r--r-- | desktop/companion/Figure.qml | 147 |
1 files changed, 147 insertions, 0 deletions
diff --git a/desktop/companion/Figure.qml b/desktop/companion/Figure.qml new file mode 100644 index 0000000..0b3b671 --- /dev/null +++ b/desktop/companion/Figure.qml @@ -0,0 +1,147 @@ +// 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, 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(); + } + 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 { 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 } + } +} |
