// Copyright (C) 2026 Danilo M. // // 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 } } }