fix(scene): the doge collects the coins, and no more slow first lap

Three things you spotted on the History banner:

- Slow first lap that restarted at speed. The dog and the money animated
  `to: scene.width`, and the QQuickWidget starts at a placeholder width: the
  first lap crawled across the wrong distance, then the real width arrived,
  re-bound `to`, and restarted the animation. Motion is now driven by a
  unitless 0..1 progress and mapped onto the current width each frame, so a
  resize repositions without restarting.

- Coins sat mid-sky and clipped the top border. They now rest on the grass
  with a low bob, at the doge's pickup height.

- The doge collects them. A coin is collected once the doge's snout reaches
  its centre: it pops, fades, and floats a "+1". The collected state is
  derived from the run position, so the coins simply come back when the lap
  restarts, with nothing to reset.

Reduced motion still gets a static field: doge parked at the left, every coin
present, money parked in the sky.

Verified by headless qml6 render at banner (1000x140, 620x120) and lock-screen
(900x380) sizes, motion and reduced-motion, with no QML warnings.
This commit is contained in:
jwinterm
2026-08-31 12:14:46 -04:00
parent 6942cbb459
commit 2a54663f76
3 changed files with 116 additions and 91 deletions
+116 -20
View File
@@ -1,12 +1,37 @@
import QtQuick
// wowlet ambient scene — dusk sky, tiled grass, a running doge, bouncing WOW
// coins, drifting money. All motion gates on `scene.reducedMotion` (exposed by
// the host SceneBackend); frozen it is the reduced-motion still.
// wowlet ambient scene — dusk sky, tiled grass, and a doge that runs the length
// of the field collecting WOW coins, then loops. All motion gates on
// `scene.reducedMotion` (exposed by the host SceneBackend); frozen it is the
// reduced-motion still.
//
// Motion is driven by a unitless 0..1 `runProgress` rather than by animating
// straight to `scene.width`: an animation whose `to:` is bound to the width
// restarts every time the host widget resizes, which showed up as a slow first
// lap that snapped back to the start once the real width arrived.
Rectangle {
id: scene
property bool reducedMotion: (typeof sceneCtx !== 'undefined' && sceneCtx) ? sceneCtx.reducedMotion : false
// Ground plane and the doge's run.
readonly property int grassHeight: 46
readonly property int dogWidth: 104
readonly property int dogHeight: 83
readonly property real groundY: scene.height - grassHeight
readonly property real runStart: -dogWidth - 10
readonly property real runEnd: scene.width + 10
property real runProgress: 0.0
readonly property real dogX: runStart + runProgress * (runEnd - runStart)
// The doge collects a coin once its snout reaches the coin's centre.
readonly property real snoutX: dogX + dogWidth * 0.78
// Coins rest on the grass so the run reads as a pickup, not a fly-by.
readonly property int coinSize: 34
readonly property real coinRestY: groundY - coinSize + 8
readonly property int coinCount: Math.max(3, Math.min(7, Math.floor(scene.width / 190)))
gradient: Gradient {
GradientStop { position: 0.0; color: "#160d2b" }
GradientStop { position: 0.5; color: "#5b2f6e" }
@@ -14,36 +39,107 @@ Rectangle {
GradientStop { position: 1.0; color: "#f2adc9" }
}
Rectangle { x: parent.width*0.74; y: parent.height*0.14; width: 62; height: 62; radius: 31; color: "#ffe9b0"; opacity: 0.9 }
// Sun, low in the dusk sky.
Rectangle {
x: scene.width * 0.74; y: scene.height * 0.14
width: 62; height: 62; radius: 31
color: "#ffe9b0"; opacity: 0.9
}
// Money drifting across the sky, on its own unitless lap.
property real moneyProgress: 0.0
AnimatedImage {
source: "qrc:/scene/flyingmoney.gif"; playing: !scene.reducedMotion
width: 70; height: 64; y: 30; x: 120; opacity: 0.9
NumberAnimation on x { running: !scene.reducedMotion; from: -80; to: scene.width; duration: 11000; loops: Animation.Infinite }
source: "qrc:/scene/flyingmoney.gif"
playing: !scene.reducedMotion
width: 60; height: 55
y: 4
x: scene.reducedMotion ? scene.width * 0.30 : -80 + scene.moneyProgress * (scene.width + 160)
opacity: 0.9
}
Row {
anchors.bottom: parent.bottom
Repeater { model: Math.ceil(scene.width/180)+1
Image { source: "qrc:/scene/grass.png"; width: 180; height: 46; smooth: false } }
Repeater {
model: Math.ceil(scene.width / 180) + 1
Image { source: "qrc:/scene/grass.png"; width: 180; height: scene.grassHeight; smooth: false }
}
}
Repeater {
model: 3
AnimatedImage {
source: "qrc:/scene/goldcoin.gif"; playing: !scene.reducedMotion
width: 34; height: 34; x: 150 + index*170; y: scene.height*0.46
SequentialAnimation on y {
running: !scene.reducedMotion; loops: Animation.Infinite
NumberAnimation { to: scene.height*0.36; duration: 1050 + index*170; easing.type: Easing.OutQuad }
NumberAnimation { to: scene.height*0.5; duration: 1050 + index*170; easing.type: Easing.InQuad }
model: scene.coinCount
Item {
id: coin
width: scene.coinSize; height: scene.coinSize
// Spread the coins over the field, clear of both ends of the run.
x: scene.width * (0.22 + 0.66 * (scene.coinCount > 1 ? index / (scene.coinCount - 1) : 0.5)) - width / 2
y: scene.coinRestY
// Purely derived from the run, so a lap reset restores every coin
// with no bookkeeping.
readonly property bool collected: !scene.reducedMotion && scene.snoutX >= x + width / 2
AnimatedImage {
id: coinImage
width: parent.width; height: parent.height
source: "qrc:/scene/goldcoin.gif"
playing: !scene.reducedMotion && !coin.collected
opacity: coin.collected ? 0.0 : 1.0
scale: coin.collected ? 1.6 : 1.0
// Idle bob, low over the grass.
SequentialAnimation on y {
running: !scene.reducedMotion && !coin.collected
loops: Animation.Infinite
NumberAnimation { to: -11; duration: 950 + index * 130; easing.type: Easing.OutQuad }
NumberAnimation { to: 0; duration: 950 + index * 130; easing.type: Easing.InQuad }
}
Behavior on opacity { NumberAnimation { duration: 260 } }
Behavior on scale { NumberAnimation { duration: 260; easing.type: Easing.OutBack } }
}
// Pickup pop.
Text {
id: popText
anchors.horizontalCenter: parent.horizontalCenter
text: "+1"
color: "#ffd257"
style: Text.Outline
styleColor: "#2a1636"
font.bold: true
font.pixelSize: 18
opacity: 0.0
y: coin.collected ? -44 : -6
Behavior on y { NumberAnimation { duration: 900; easing.type: Easing.OutQuad } }
SequentialAnimation on opacity {
running: coin.collected
NumberAnimation { to: 1.0; duration: 90 }
PauseAnimation { duration: 420 }
NumberAnimation { to: 0.0; duration: 340 }
}
}
}
}
AnimatedImage {
source: "qrc:/scene/dog.gif"; playing: !scene.reducedMotion
width: 104; height: 83; y: scene.height - 46 - 62; x: 70
NumberAnimation on x { running: !scene.reducedMotion; from: -110; to: scene.width; duration: 6500; loops: Animation.Infinite }
source: "qrc:/scene/dog.gif"
playing: !scene.reducedMotion
width: scene.dogWidth; height: scene.dogHeight
y: scene.groundY - scene.dogHeight + 21
x: scene.reducedMotion ? 8 : scene.dogX
}
// One lap: run the field, then start over with the coins restored.
NumberAnimation on runProgress {
running: !scene.reducedMotion
from: 0.0; to: 1.0
duration: 8000
loops: Animation.Infinite
}
NumberAnimation on moneyProgress {
running: !scene.reducedMotion
from: 0.0; to: 1.0
duration: 13000
loops: Animation.Infinite
}
}
-46
View File
@@ -1,46 +0,0 @@
// SPDX-License-Identifier: BSD-3-Clause
// SPDX-FileCopyrightText: The Monero Project
#include "IdleDoge.h"
#include <QEvent>
#include <QMovie>
#include "utils/config.h"
IdleDoge::IdleDoge(QWidget *parent, int height)
: QLabel(parent)
, m_movie(new QMovie(":/assets/images/dog_sitting.gif", QByteArray(), this))
{
m_movie->jumpToFrame(0);
const QSize frame = m_movie->currentImage().size();
if (frame.height() > 0)
m_movie->setScaledSize(QSize(frame.width() * height / frame.height(), height));
this->setMovie(m_movie);
this->setAttribute(Qt::WA_TransparentForMouseEvents); // never block the form
this->adjustSize();
if (conf()->get(Config::reducedMotion).toBool())
m_movie->jumpToFrame(0); // still frame
else
m_movie->start();
if (parent) {
parent->installEventFilter(this);
this->reposition();
this->raise();
}
}
void IdleDoge::reposition() {
if (auto *p = parentWidget())
this->move(p->width() - this->width() - 14, p->height() - this->height() - 14);
}
bool IdleDoge::eventFilter(QObject *o, QEvent *e) {
if (o == parentWidget() && (e->type() == QEvent::Resize || e->type() == QEvent::Show)) {
this->reposition();
this->raise();
}
return QLabel::eventFilter(o, e);
}
-25
View File
@@ -1,25 +0,0 @@
// SPDX-License-Identifier: BSD-3-Clause
// SPDX-FileCopyrightText: The Monero Project
#ifndef WOWLET_IDLEDOGE_H
#define WOWLET_IDLEDOGE_H
#include <QLabel>
class QMovie;
// wowlet: a small sitting doge that floats, click-through, in the bottom-right
// corner of its parent — the "most quiet" ambient idle for task screens (Send,
// Receive). Freezes to a still frame when "Prefer reduced motion" is set.
class IdleDoge : public QLabel {
Q_OBJECT
public:
explicit IdleDoge(QWidget *parent, int height = 44);
protected:
bool eventFilter(QObject *o, QEvent *e) override;
private:
void reposition();
QMovie *m_movie;
};
#endif //WOWLET_IDLEDOGE_H