diff --git a/src/ReceiveWidget.cpp b/src/ReceiveWidget.cpp index 23b3674c..140bba41 100644 --- a/src/ReceiveWidget.cpp +++ b/src/ReceiveWidget.cpp @@ -23,7 +23,7 @@ ReceiveWidget::ReceiveWidget(Wallet *wallet, QWidget *parent) // wowlet: ambient scene as a short banner under the address table. A layout // item, never an overlay over the table. - auto *scene = new SceneWidget(this); + auto *scene = new SceneWidget(this, SceneWidget::Dawn); scene->setMinimumHeight(110); scene->setMaximumHeight(130); ui->verticalLayout->addWidget(scene); diff --git a/src/SendWidget.cpp b/src/SendWidget.cpp index c51b6bc2..0650fa0c 100644 --- a/src/SendWidget.cpp +++ b/src/SendWidget.cpp @@ -29,7 +29,7 @@ SendWidget::SendWidget(Wallet *wallet, QWidget *parent) // wowlet: the form only needs the top of the tab — give the rest of it to // the ambient scene. A layout item under the form, never an overlay: the // send form itself stays untouched. - auto *scene = new SceneWidget(this); + auto *scene = new SceneWidget(this, SceneWidget::Night); scene->setMinimumHeight(160); scene->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); ui->verticalLayoutOuter->addWidget(scene, 1); diff --git a/src/assets/qml/scene.qml b/src/assets/qml/scene.qml index 6d88716b..974d2673 100644 --- a/src/assets/qml/scene.qml +++ b/src/assets/qml/scene.qml @@ -1,14 +1,20 @@ import QtQuick -// wowlet ambient scene — dusk sky, tiled grass, and a doge that runs the field -// collecting WOW coins. All motion gates on `scene.reducedMotion` (exposed by -// the host SceneBackend); frozen it is the reduced-motion still. +// wowlet ambient scene — a doge running a field of WOW coins under a big sky. +// All motion gates on `scene.reducedMotion` (exposed by the host SceneBackend); +// frozen it is the reduced-motion still. // // One cycle is three beats, so the scene breathes instead of running flat out: // -// 1. empty field for a few seconds (nothing but sky and grass) +// 1. an empty field for 3-5s (nothing but sky and grass) // 2. the coins pop in, one at a time -// 3. the doge and the money go by; the doge collects the coins +// 3. the doge and the money go by, and the doge collects the coins +// +// Every lap re-rolls its own timing — how long the field stays empty, when the +// money enters relative to the doge and how fast it crosses — so the loop does +// not read as the same eight seconds forever. Roughly one lap in six, the doge +// eats it halfway down the field and the lap ends in GAME OVER with the rest of +// the coins uncollected. // // 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 @@ -18,12 +24,29 @@ Rectangle { id: scene property bool reducedMotion: (typeof sceneCtx !== 'undefined' && sceneCtx) ? sceneCtx.reducedMotion : false + // 0 dusk · 1 dawn · 2 night — so the tabs are not all the same field. + property int variant: (typeof sceneCtx !== 'undefined' && sceneCtx) ? sceneCtx.variant : 0 - // --- cycle timing ------------------------------------------------------- - readonly property int emptyBeat: 4000 // just background, between laps + // --- sky ---------------------------------------------------------------- + readonly property var skies: [ + ["#160d2b", "#5b2f6e", "#c96aa0", "#f2adc9"], // dusk + ["#123a63", "#2f6fa8", "#e8a06a", "#ffd9a8"], // dawn + ["#04060f", "#0d1330", "#1d2450", "#39406e"] // night + ] + readonly property var sky: skies[Math.max(0, Math.min(skies.length - 1, variant))] + readonly property bool night: variant === 2 + + // --- cycle timing (re-rolled every lap) --------------------------------- + // emptyBeat / moneyDelay / moneyBeat are rolled onto the animations directly + // (see the ScriptAction at the foot of the file). readonly property int revealStep: 320 // per coin, popping in one by one - readonly property int settleBeat: 500 // coins all out, before the run - readonly property int runBeat: 8000 // doge + money crossing + readonly property int settleBeat: 500 // coins all out, before the run + readonly property int runBeat: 8000 // the doge crossing + + // --- the trip ----------------------------------------------------------- + property bool tripLap: false // does the doge eat it this lap? + property real tripAt: 0.55 // ...and how far along + readonly property bool tripped: tripLap && runProgress >= tripAt // --- ground plane and the doge's run ------------------------------------ readonly property int grassHeight: 46 @@ -34,7 +57,10 @@ Rectangle { readonly property real runEnd: scene.width + 10 property real runProgress: 0.0 - readonly property real dogX: runStart + runProgress * (runEnd - runStart) + property real moneyProgress: 0.0 + // A tripped doge stops where he fell instead of sliding on to the far edge. + readonly property real dogProgress: tripped ? tripAt : runProgress + readonly property real dogX: runStart + dogProgress * (runEnd - runStart) // The doge collects a coin once its snout reaches the coin's centre. readonly property real snoutX: dogX + dogWidth * 0.78 @@ -46,27 +72,46 @@ Rectangle { property real coinReveal: 0.0 gradient: Gradient { - GradientStop { position: 0.0; color: "#160d2b" } - GradientStop { position: 0.5; color: "#5b2f6e" } - GradientStop { position: 0.82; color: "#c96aa0" } - GradientStop { position: 1.0; color: "#f2adc9" } + GradientStop { position: 0.0; color: scene.sky[0] } + GradientStop { position: 0.5; color: scene.sky[1] } + GradientStop { position: 0.82; color: scene.sky[2] } + GradientStop { position: 1.0; color: scene.sky[3] } } - // Sun, low in the dusk sky. + // Stars, night only. Positions are a fixed hash of the index so they do not + // jump around on every repaint. + Repeater { + model: scene.night ? 26 : 0 + Rectangle { + width: (index % 4 === 0) ? 3 : 2 + height: width + radius: 1 + color: "#fdf6d8" + opacity: 0.45 + ((index * 37) % 50) / 100 + x: scene.width * (((index * 73) % 100) / 100) + y: scene.height * 0.06 + scene.height * 0.42 * (((index * 41) % 100) / 100) + } + } + + // Sun, or a paler moon at night. Rectangle { - x: scene.width * 0.74; y: scene.height * 0.14 - width: 62; height: 62; radius: 31 - color: "#ffe9b0"; opacity: 0.9 + x: scene.width * 0.74 + y: scene.height * (scene.night ? 0.10 : 0.14) + width: scene.night ? 46 : 62 + height: width + radius: width / 2 + color: scene.night ? "#e8ecff" : "#ffe9b0" + opacity: scene.night ? 0.85 : 0.9 } - // Money crosses with the doge, a little quicker, so it leads him off screen. + // Money crosses on its own roll, so it does not shadow the doge every lap. AnimatedImage { source: "qrc:/scene/flyingmoney.gif" playing: !scene.reducedMotion width: 60; height: 55 y: Math.max(4, Math.min(scene.height * 0.10, 60)) x: scene.reducedMotion ? scene.width * 0.30 - : -80 + scene.runProgress * (scene.width + 240) + : -80 + scene.moneyProgress * (scene.width + 240) opacity: 0.9 } @@ -90,7 +135,8 @@ Rectangle { // Both states are derived from the cycle, so a lap needs no reset // bookkeeping: `coinReveal` dropping to 0 hides them, and the doge - // returning to the left edge un-collects them. + // returning to the left edge un-collects them. A tripped doge never + // reaches the rest, so they are still sitting there at GAME OVER. readonly property bool revealed: scene.reducedMotion || scene.coinReveal >= index + 1 readonly property bool collected: revealed && !scene.reducedMotion && scene.snoutX >= x + width / 2 @@ -115,7 +161,6 @@ Rectangle { // Pickup pop. Text { - id: popText anchors.horizontalCenter: parent.horizontalCenter text: "+1" color: "#ffd257" @@ -137,36 +182,109 @@ Rectangle { } AnimatedImage { + id: doge source: "qrc:/scene/dog.gif" - playing: !scene.reducedMotion + playing: !scene.reducedMotion && !scene.tripped width: scene.dogWidth; height: scene.dogHeight y: scene.groundY - scene.dogHeight + 21 x: scene.reducedMotion ? 8 : scene.dogX + // Face-plant: tips forward over his own feet rather than sinking + // through the grass, so he ends up nose-down on the ground. + transformOrigin: Item.Bottom + rotation: scene.tripped ? 58 : 0 + Behavior on rotation { NumberAnimation { duration: 260; easing.type: Easing.OutQuad } } } - // The three beats, on a loop. - SequentialAnimation { - running: !scene.reducedMotion - loops: Animation.Infinite + Text { + anchors.centerIn: parent + text: "GAME OVER" + color: "#ff6b6b" + style: Text.Outline + styleColor: "#1a0b1f" + font.bold: true + font.pixelSize: Math.max(20, Math.min(scene.height * 0.20, 34)) + opacity: scene.tripped ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 320 } } + } - // 1. empty field — doge and money are parked off the left edge by - // runProgress 0, and every coin is hidden. - ScriptAction { script: { scene.runProgress = 0.0; scene.coinReveal = 0.0; } } - PauseAnimation { duration: scene.emptyBeat } + // The three beats, on a loop, re-rolled each time round. + // + // This is a timer-driven state machine rather than one big + // SequentialAnimation on purpose: each lap rolls its own durations, and + // assigning a duration to a child of a *running* SequentialAnimation makes + // the group recompute and re-enter its own ScriptAction, which recurses + // until the JS stack blows. + property int beat: 0 - // 2. coins pop in, one at a time - NumberAnimation { - target: scene; property: "coinReveal" - from: 0.0; to: scene.coinCount - duration: scene.coinCount * scene.revealStep + NumberAnimation { + id: revealAnim + target: scene; property: "coinReveal" + from: 0.0; to: scene.coinCount + duration: scene.coinCount * scene.revealStep + } + NumberAnimation { + id: runAnim + target: scene; property: "runProgress" + from: 0.0; to: 1.0 + duration: scene.runBeat + } + NumberAnimation { + id: moneyAnim + target: scene; property: "moneyProgress" + from: 0.0; to: 1.0 + duration: 6000 + } + + Timer { id: beatTimer; repeat: false; onTriggered: scene.advance() } + Timer { id: moneyTimer; repeat: false; onTriggered: moneyAnim.start() } + + // Beat 0 is the empty field, 1 is the coins coming out, 2 is the run. + function startLap() { + beatTimer.stop(); moneyTimer.stop(); + revealAnim.stop(); runAnim.stop(); moneyAnim.stop(); + + scene.beat = 0; + scene.runProgress = 0.0; + scene.moneyProgress = 0.0; + scene.coinReveal = 0.0; + + moneyAnim.duration = 5000 + Math.floor(Math.random() * 2000); + moneyTimer.interval = Math.floor(Math.random() * Math.max(1, scene.runBeat - moneyAnim.duration)); + scene.tripLap = Math.random() < 0.17; + scene.tripAt = 0.34 + Math.random() * 0.34; + + beatTimer.interval = 3000 + Math.floor(Math.random() * 2000); // 3-5s of empty field + beatTimer.start(); + } + + function advance() { + if (scene.reducedMotion) + return; + if (scene.beat === 0) { + scene.beat = 1; + revealAnim.start(); + beatTimer.interval = scene.coinCount * scene.revealStep + scene.settleBeat; + beatTimer.start(); + } else if (scene.beat === 1) { + scene.beat = 2; + runAnim.start(); + moneyTimer.start(); + beatTimer.interval = scene.runBeat + 900; // 900 holds on GAME OVER + beatTimer.start(); + } else { + scene.startLap(); } - PauseAnimation { duration: scene.settleBeat } + } - // 3. the run - NumberAnimation { - target: scene; property: "runProgress" - from: 0.0; to: 1.0 - duration: scene.runBeat + Component.onCompleted: if (!scene.reducedMotion) scene.startLap(); + + onReducedMotionChanged: { + if (scene.reducedMotion) { + beatTimer.stop(); moneyTimer.stop(); + revealAnim.stop(); runAnim.stop(); moneyAnim.stop(); + scene.tripLap = false; + } else { + scene.startLap(); } } } diff --git a/src/widgets/SceneWidget.cpp b/src/widgets/SceneWidget.cpp index 32c7c98c..e2739f79 100644 --- a/src/widgets/SceneWidget.cpp +++ b/src/widgets/SceneWidget.cpp @@ -7,11 +7,12 @@ #include "utils/config.h" -SceneWidget::SceneWidget(QWidget *parent) +SceneWidget::SceneWidget(QWidget *parent, Variant variant) : QQuickWidget(parent) , m_backend(new SceneBackend(this)) { m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool()); + m_backend->setVariant(static_cast(variant)); this->rootContext()->setContextProperty("sceneCtx", m_backend); this->setResizeMode(QQuickWidget::SizeRootObjectToView); this->setSource(QUrl("qrc:/qml/scene.qml")); diff --git a/src/widgets/SceneWidget.h b/src/widgets/SceneWidget.h index 635be1b9..2ba1a275 100644 --- a/src/widgets/SceneWidget.h +++ b/src/widgets/SceneWidget.h @@ -8,18 +8,24 @@ #include // Context object exposed to scene.qml as `sceneCtx`. Carries the reduced-motion -// setting so the scene freezes into its still (reduced-motion) form. +// setting so the scene freezes into its still (reduced-motion) form, and the +// sky variant so the tabs are not all showing the same field. class SceneBackend : public QObject { Q_OBJECT Q_PROPERTY(bool reducedMotion READ reducedMotion NOTIFY reducedMotionChanged) + Q_PROPERTY(int variant READ variant NOTIFY variantChanged) public: explicit SceneBackend(QObject *parent = nullptr) : QObject(parent) {} bool reducedMotion() const { return m_reducedMotion; } void setReducedMotion(bool r) { if (m_reducedMotion != r) { m_reducedMotion = r; emit reducedMotionChanged(); } } + int variant() const { return m_variant; } + void setVariant(int v) { if (m_variant != v) { m_variant = v; emit variantChanged(); } } signals: void reducedMotionChanged(); + void variantChanged(); private: bool m_reducedMotion = false; + int m_variant = 0; }; // wowlet: reusable ambient-scene surface. Hosts scene.qml and reads the @@ -28,7 +34,11 @@ private: class SceneWidget : public QQuickWidget { Q_OBJECT public: - explicit SceneWidget(QWidget *parent = nullptr); + // Sky variants, one per host, so History/Send/Receive do not all show the + // same field. Must match the `skies` table in scene.qml. + enum Variant { Dusk = 0, Dawn = 1, Night = 2 }; + + explicit SceneWidget(QWidget *parent = nullptr, Variant variant = Dusk); void refreshReducedMotion(); private: SceneBackend *m_backend;