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
148
149
150
151
152
153
154
155
156
157
158
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 }
}
}
|