aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/companion/Figure.qml
blob: 0b3b67152911da0f80192ad3dc770ca756c4929c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
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 }
    }
}