feat(scene): per-lap variety, the occasional face-plant, and a sky per tab

Every tab was running the identical eight seconds forever. Three changes:

- Each lap rolls its own timing: 3-5s of empty field, and the money now enters
  on its own delay at its own pace instead of shadowing the doge every time.
- Roughly one lap in six the doge eats it partway down the field: he tips
  nose-first onto the grass, GAME OVER comes up, and the coins he never reached
  are still sitting there. Costs no new art — it is the running sprite pivoted
  on its own feet.
- A `variant` on SceneWidget picks the sky: dusk (History, lock screen), dawn
  (Receive), night (Send, with stars and a moon instead of the sun).

The cycle is now a timer-driven state machine rather than one SequentialAnimation.
That is not stylistic: a lap that rolls its own durations has to assign them
somewhere, and assigning a duration to a child of a *running* SequentialAnimation
makes the group recompute and re-enter its own ScriptAction until the JS stack
blows. Qt flags the binding form of the same mistake as a binding loop.

Verified by headless qml6 render: a forced trip lap, and a clean run with no QML
warnings.
This commit is contained in:
jwinterm
2026-08-31 13:23:41 -04:00
parent dd76a7c4bf
commit 252b358baa
5 changed files with 176 additions and 47 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ ReceiveWidget::ReceiveWidget(Wallet *wallet, QWidget *parent)
// wowlet: ambient scene as a short banner under the address table. A layout // wowlet: ambient scene as a short banner under the address table. A layout
// item, never an overlay over the table. // item, never an overlay over the table.
auto *scene = new SceneWidget(this); auto *scene = new SceneWidget(this, SceneWidget::Dawn);
scene->setMinimumHeight(110); scene->setMinimumHeight(110);
scene->setMaximumHeight(130); scene->setMaximumHeight(130);
ui->verticalLayout->addWidget(scene); ui->verticalLayout->addWidget(scene);
+1 -1
View File
@@ -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 // 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 // the ambient scene. A layout item under the form, never an overlay: the
// send form itself stays untouched. // send form itself stays untouched.
auto *scene = new SceneWidget(this); auto *scene = new SceneWidget(this, SceneWidget::Night);
scene->setMinimumHeight(160); scene->setMinimumHeight(160);
scene->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); scene->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
ui->verticalLayoutOuter->addWidget(scene, 1); ui->verticalLayoutOuter->addWidget(scene, 1);
+160 -42
View File
@@ -1,14 +1,20 @@
import QtQuick import QtQuick
// wowlet ambient scene — dusk sky, tiled grass, and a doge that runs the field // wowlet ambient scene — a doge running a field of WOW coins under a big sky.
// collecting WOW coins. All motion gates on `scene.reducedMotion` (exposed by // All motion gates on `scene.reducedMotion` (exposed by the host SceneBackend);
// the host SceneBackend); frozen it is the reduced-motion still. // frozen it is the reduced-motion still.
// //
// One cycle is three beats, so the scene breathes instead of running flat out: // 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 // 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 // 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 // straight to `scene.width`: an animation whose `to:` is bound to the width
@@ -18,12 +24,29 @@ Rectangle {
id: scene id: scene
property bool reducedMotion: (typeof sceneCtx !== 'undefined' && sceneCtx) ? sceneCtx.reducedMotion : false 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 ------------------------------------------------------- // --- sky ----------------------------------------------------------------
readonly property int emptyBeat: 4000 // just background, between laps 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 revealStep: 320 // per coin, popping in one by one
readonly property int settleBeat: 500 // coins all out, before the run readonly property int settleBeat: 500 // coins all out, before the run
readonly property int runBeat: 8000 // doge + money crossing 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 ------------------------------------ // --- ground plane and the doge's run ------------------------------------
readonly property int grassHeight: 46 readonly property int grassHeight: 46
@@ -34,7 +57,10 @@ Rectangle {
readonly property real runEnd: scene.width + 10 readonly property real runEnd: scene.width + 10
property real runProgress: 0.0 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. // The doge collects a coin once its snout reaches the coin's centre.
readonly property real snoutX: dogX + dogWidth * 0.78 readonly property real snoutX: dogX + dogWidth * 0.78
@@ -46,27 +72,46 @@ Rectangle {
property real coinReveal: 0.0 property real coinReveal: 0.0
gradient: Gradient { gradient: Gradient {
GradientStop { position: 0.0; color: "#160d2b" } GradientStop { position: 0.0; color: scene.sky[0] }
GradientStop { position: 0.5; color: "#5b2f6e" } GradientStop { position: 0.5; color: scene.sky[1] }
GradientStop { position: 0.82; color: "#c96aa0" } GradientStop { position: 0.82; color: scene.sky[2] }
GradientStop { position: 1.0; color: "#f2adc9" } 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 { Rectangle {
x: scene.width * 0.74; y: scene.height * 0.14 x: scene.width * 0.74
width: 62; height: 62; radius: 31 y: scene.height * (scene.night ? 0.10 : 0.14)
color: "#ffe9b0"; opacity: 0.9 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 { AnimatedImage {
source: "qrc:/scene/flyingmoney.gif" source: "qrc:/scene/flyingmoney.gif"
playing: !scene.reducedMotion playing: !scene.reducedMotion
width: 60; height: 55 width: 60; height: 55
y: Math.max(4, Math.min(scene.height * 0.10, 60)) y: Math.max(4, Math.min(scene.height * 0.10, 60))
x: scene.reducedMotion ? scene.width * 0.30 x: scene.reducedMotion ? scene.width * 0.30
: -80 + scene.runProgress * (scene.width + 240) : -80 + scene.moneyProgress * (scene.width + 240)
opacity: 0.9 opacity: 0.9
} }
@@ -90,7 +135,8 @@ Rectangle {
// Both states are derived from the cycle, so a lap needs no reset // Both states are derived from the cycle, so a lap needs no reset
// bookkeeping: `coinReveal` dropping to 0 hides them, and the doge // 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 revealed: scene.reducedMotion || scene.coinReveal >= index + 1
readonly property bool collected: revealed && !scene.reducedMotion readonly property bool collected: revealed && !scene.reducedMotion
&& scene.snoutX >= x + width / 2 && scene.snoutX >= x + width / 2
@@ -115,7 +161,6 @@ Rectangle {
// Pickup pop. // Pickup pop.
Text { Text {
id: popText
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: "+1" text: "+1"
color: "#ffd257" color: "#ffd257"
@@ -137,36 +182,109 @@ Rectangle {
} }
AnimatedImage { AnimatedImage {
id: doge
source: "qrc:/scene/dog.gif" source: "qrc:/scene/dog.gif"
playing: !scene.reducedMotion playing: !scene.reducedMotion && !scene.tripped
width: scene.dogWidth; height: scene.dogHeight width: scene.dogWidth; height: scene.dogHeight
y: scene.groundY - scene.dogHeight + 21 y: scene.groundY - scene.dogHeight + 21
x: scene.reducedMotion ? 8 : scene.dogX 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. Text {
SequentialAnimation { anchors.centerIn: parent
running: !scene.reducedMotion text: "GAME OVER"
loops: Animation.Infinite 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 // The three beats, on a loop, re-rolled each time round.
// runProgress 0, and every coin is hidden. //
ScriptAction { script: { scene.runProgress = 0.0; scene.coinReveal = 0.0; } } // This is a timer-driven state machine rather than one big
PauseAnimation { duration: scene.emptyBeat } // 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 {
NumberAnimation { id: revealAnim
target: scene; property: "coinReveal" target: scene; property: "coinReveal"
from: 0.0; to: scene.coinCount from: 0.0; to: scene.coinCount
duration: scene.coinCount * scene.revealStep 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 Component.onCompleted: if (!scene.reducedMotion) scene.startLap();
NumberAnimation {
target: scene; property: "runProgress" onReducedMotionChanged: {
from: 0.0; to: 1.0 if (scene.reducedMotion) {
duration: scene.runBeat beatTimer.stop(); moneyTimer.stop();
revealAnim.stop(); runAnim.stop(); moneyAnim.stop();
scene.tripLap = false;
} else {
scene.startLap();
} }
} }
} }
+2 -1
View File
@@ -7,11 +7,12 @@
#include "utils/config.h" #include "utils/config.h"
SceneWidget::SceneWidget(QWidget *parent) SceneWidget::SceneWidget(QWidget *parent, Variant variant)
: QQuickWidget(parent) : QQuickWidget(parent)
, m_backend(new SceneBackend(this)) , m_backend(new SceneBackend(this))
{ {
m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool()); m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool());
m_backend->setVariant(static_cast<int>(variant));
this->rootContext()->setContextProperty("sceneCtx", m_backend); this->rootContext()->setContextProperty("sceneCtx", m_backend);
this->setResizeMode(QQuickWidget::SizeRootObjectToView); this->setResizeMode(QQuickWidget::SizeRootObjectToView);
this->setSource(QUrl("qrc:/qml/scene.qml")); this->setSource(QUrl("qrc:/qml/scene.qml"));
+12 -2
View File
@@ -8,18 +8,24 @@
#include <QQuickWidget> #include <QQuickWidget>
// Context object exposed to scene.qml as `sceneCtx`. Carries the reduced-motion // 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 { class SceneBackend : public QObject {
Q_OBJECT Q_OBJECT
Q_PROPERTY(bool reducedMotion READ reducedMotion NOTIFY reducedMotionChanged) Q_PROPERTY(bool reducedMotion READ reducedMotion NOTIFY reducedMotionChanged)
Q_PROPERTY(int variant READ variant NOTIFY variantChanged)
public: public:
explicit SceneBackend(QObject *parent = nullptr) : QObject(parent) {} explicit SceneBackend(QObject *parent = nullptr) : QObject(parent) {}
bool reducedMotion() const { return m_reducedMotion; } bool reducedMotion() const { return m_reducedMotion; }
void setReducedMotion(bool r) { if (m_reducedMotion != r) { m_reducedMotion = r; emit reducedMotionChanged(); } } 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: signals:
void reducedMotionChanged(); void reducedMotionChanged();
void variantChanged();
private: private:
bool m_reducedMotion = false; bool m_reducedMotion = false;
int m_variant = 0;
}; };
// wowlet: reusable ambient-scene surface. Hosts scene.qml and reads the // wowlet: reusable ambient-scene surface. Hosts scene.qml and reads the
@@ -28,7 +34,11 @@ private:
class SceneWidget : public QQuickWidget { class SceneWidget : public QQuickWidget {
Q_OBJECT Q_OBJECT
public: 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(); void refreshReducedMotion();
private: private:
SceneBackend *m_backend; SceneBackend *m_backend;