From e5b8ef6a00db9263b3cba9a4a5feb1f621f5ec25 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Wed, 2 Sep 2026 22:34:23 -0400 Subject: [PATCH 1/5] fix(scene): park hidden tabs, and put some weight behind the doge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The +1s raining out of an empty sky came from a scene running on a tab that had never been shown. An unshown QQuickWidget is 0px wide, and at 0px every coin sits at x=0, so the doge "collected" the whole field in one step; the pickup pops from that phantom lap were still mid-flight when the tab was finally selected and the real width arrived. Their `SequentialAnimation on opacity { running: }` was cut off by the width change, which leaves the property wherever it stopped — so a +1 hung in the air for the rest of the lap, next to a coin nobody had taken. Both halves are fixed: * SceneWidget parks its scene on hide and starts a fresh lap on show, and the QML refuses to run at all until it has been laid out. A background tab now costs nothing, too. * Every one-shot flourish (+1, bark, dust, GAME OVER) is started and reset from an explicit handler, so an interrupted lap cannot strand one on screen. The rest is the ambient scene earning its keep: * The face-plant has weight. He tips over his front paws — pivoting on the paw at the grass line, not the middle of the sprite, which used to swing his snout through the ground — squashes at the nose, bends through the body, throws dust off the impact, and lies there breathing. GAME OVER now lands a beat later so the crash reads on its own, and the lap ends shortly after instead of running out the clock on a wreck. * He has opinions: HRNMMROOF, BONK, SNOOT.EXE and friends on a wipeout; MUCH COIN, AMAZE, VERY WIN when he clears the field, with a victory hop to go with it. * More variety per lap: zero to three bills at their own speeds and heights, a 1-in-3 fat +5 coin with a halo, a doge sitting in the field during the empty beat, drifting clouds, a soft corona on the sun, blinking stars and the odd shooting star at night, birds by day, and a run speed that is no longer always eight seconds. Verified by rendering scene.qml headless (qml6 + xvfb, frame grabs) at the real widget sizes, and by driving two scenes through a QTabWidget to watch the hidden-tab case before and after. Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF --- src/assets/qml/scene.qml | 644 +++++++++++++++++++++++++++++++----- src/widgets/SceneWidget.cpp | 23 ++ src/widgets/SceneWidget.h | 17 + 3 files changed, 601 insertions(+), 83 deletions(-) diff --git a/src/assets/qml/scene.qml b/src/assets/qml/scene.qml index 974d2673..d5dce147 100644 --- a/src/assets/qml/scene.qml +++ b/src/assets/qml/scene.qml @@ -1,25 +1,38 @@ import QtQuick // 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. +// All motion gates on `scene.live` (reduced motion off, the widget laid out and +// on screen); frozen it is the reduced-motion still. // // One cycle is three beats, so the scene breathes instead of running flat out: // -// 1. an empty field for 3-5s (nothing but sky and grass) +// 1. an empty field for 3-5s (sky, grass, and sometimes a doge sitting in it) // 2. the coins pop in, one at a time // 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. +// Every lap re-rolls its own timing and its own cast — how long the field stays +// empty, how fast the doge crosses, how many bills blow through (0-3), whether +// one coin is a fat +5, which bark he uses — so the loop does not read as the +// same eight seconds forever. Roughly one lap in six he eats it halfway down the +// field: he face-plants, deforms, throws up dust, barks something undignified, +// and the lap ends in GAME OVER with the rest of the coins uncollected. Clear a +// whole field instead and he hops and gloats. // // 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. +// +// Two rules keep the lap honest, both learned from bugs: +// +// * Nothing runs until the scene is actually laid out and visible. A hidden +// tab is 0px wide, and at 0px every coin sits at x=0, so the doge "collects" +// the whole field at once; the leftovers of that phantom lap then showed up +// on the real one as +1s raining out of an empty sky. +// * Every one-shot flourish (+1 pop, bark, dust) is started and *reset* from +// an explicit handler. A `SequentialAnimation on { running: }` +// that gets interrupted leaves the property wherever it stopped, which is +// how a +1 could hang in the air for the rest of the lap. Rectangle { id: scene @@ -27,6 +40,12 @@ Rectangle { // 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 + // The host sets this false while its tab is hidden (see SceneWidget). + property bool onScreen: true + // Below this the widget has not been laid out yet and the field is nonsense. + readonly property bool laidOut: width > 60 && height > 40 + readonly property bool live: !reducedMotion && laidOut && onScreen + // --- sky ---------------------------------------------------------------- readonly property var skies: [ ["#160d2b", "#5b2f6e", "#c96aa0", "#f2adc9"], // dusk @@ -36,17 +55,29 @@ Rectangle { 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). + // --- cycle timing ------------------------------------------------------- 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 // the doge crossing + property int runBeat: 8000 // the doge crossing, re-rolled per lap - // --- the trip ----------------------------------------------------------- + // --- the lap ------------------------------------------------------------ + // Bumped by startLap(). Anything holding one-shot state resets on the change + // rather than trying to work out for itself that a new lap has begun. + property int lapId: 0 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 + property bool idleLap: false // doge sitting in the field during beat 0 + property bool birdLap: false + property bool starLap: false // shooting star, night only + property int bonusIndex: -1 // which coin is worth +5, or -1 + + property string barkText: "" + readonly property var crashBarks: ["HRNMMROOF", "BONK", "OOF", "SUCH OW", + "MUCH FALL", "AAARF", "SNOOT.EXE", "WOW... OW", + "HECKIN OW", "BORK BONK"] + readonly property var winBarks: ["MUCH COIN", "SUCH SPEED", "VERY WIN", "WOW", + "SO RICH", "AMAZE", "MANY WOW", "GIB MORE"] // --- ground plane and the doge's run ------------------------------------ readonly property int grassHeight: 46 @@ -57,12 +88,19 @@ Rectangle { readonly property real runEnd: scene.width + 10 property real runProgress: 0.0 - 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 his snout reaches the coin's centre. readonly property real snoutX: dogX + dogWidth * 0.78 + property real hopY: 0.0 // victory hop + + // Crash rig. Driven by crashAnim, reset by resetCrash(). + property real crashRot: 0.0 + property real crashSquashX: 1.0 + property real crashSquashY: 1.0 + property real crashBend: 0.0 + property real dustT: 1.0 // 0..1 sweep; 1 is spent // --- the coins ---------------------------------------------------------- readonly property int coinSize: 34 @@ -70,6 +108,27 @@ Rectangle { readonly property int coinCount: Math.max(3, Math.min(7, Math.floor(scene.width / 190))) // Rises 0 -> coinCount during beat 2; coin `index` is out once it passes index+1. property real coinReveal: 0.0 + property int picked: 0 // coins taken this lap + + // --- the bills ---------------------------------------------------------- + // One roll per lap: how many blow through, when, how fast, how high, how big. + property var moneyRolls: [] + + function rollMoney() { + var n = [0, 1, 1, 2, 2, 3][Math.floor(Math.random() * 6)]; + var out = []; + for (var i = 0; i < 3; ++i) { + out.push({ + "active": i < n, + "delay": Math.floor(Math.random() * Math.max(1, scene.runBeat - 2500)), + "dur": 4200 + Math.floor(Math.random() * 3600), + "band": Math.random(), + "size": 0.72 + Math.random() * 0.55, + "bob": 6 + Math.random() * 12 + }); + } + return out; + } gradient: Gradient { GradientStop { position: 0.0; color: scene.sky[0] } @@ -90,29 +149,155 @@ Rectangle { 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) + SequentialAnimation on opacity { + running: scene.live && index % 3 === 0 + loops: Animation.Infinite + NumberAnimation { to: 0.25; duration: 1400 + (index * 211) % 1800 } + NumberAnimation { to: 0.95; duration: 1400 + (index * 173) % 1600 } + } } } - // Sun, or a paler moon at night. + // A shooting star, rolled per lap, night only. Rectangle { + id: shootingStar + property real p: 1.0 + readonly property real originX: scene.width * 0.62 + readonly property real originY: scene.height * 0.10 + width: 34; height: 2; radius: 1; rotation: 28 + color: "#fff6d0" + visible: scene.night && scene.starLap && p < 1.0 + opacity: Math.sin(Math.PI * Math.min(1, Math.max(0, p))) * 0.9 + x: originX - 120 + p * (scene.width * 0.5) + y: originY + p * (scene.height * 0.28) + NumberAnimation { id: starAnim; target: shootingStar; property: "p"; from: 0; to: 1; duration: 1100 } + Timer { + id: starTimer; repeat: false + onTriggered: if (scene.live && scene.starLap) starAnim.restart() + } + } + + // Sun, or a paler moon at night, with a soft corona. + Item { + id: sunDisc 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 + // Stacked rings rather than one disc: a soft falloff instead of a hard + // circle drawn around the sun. + Repeater { + model: 6 + Rectangle { + anchors.centerIn: parent + width: sunDisc.width * (2.4 - index * 0.26); height: width; radius: width / 2 + color: scene.night ? "#8fa0ff" : "#ffd07a" + opacity: 0.028 + index * 0.012 + } + } + Rectangle { + anchors.fill: parent + radius: width / 2 + color: scene.night ? "#e8ecff" : "#ffe9b0" + opacity: scene.night ? 0.85 : 0.9 + } } - // 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.moneyProgress * (scene.width + 240) - opacity: 0.9 + // Clouds, drifting on their own clock. Each is three blobs so it reads as a + // cloud and not a pill. + Repeater { + model: scene.night ? 2 : 3 + Item { + id: cloud + readonly property real seed: ((index * 61) % 100) / 100 + property real p: seed + readonly property real w: 54 + seed * 46 + width: w; height: w * 0.42 + x: -w - 20 + p * (scene.width + w * 2 + 40) + y: scene.height * (0.08 + 0.30 * (((index * 43) % 100) / 100)) + opacity: scene.night ? 0.16 : 0.30 + Rectangle { x: 0; y: parent.height * 0.36; width: parent.width * 0.52; height: parent.height * 0.62; radius: height / 2; color: "#ffffff" } + Rectangle { x: parent.width*0.24; y: 0; width: parent.width * 0.50; height: parent.height; radius: height / 2; color: "#ffffff" } + Rectangle { x: parent.width*0.50; y: parent.height * 0.30; width: parent.width * 0.50; height: parent.height * 0.68; radius: height / 2; color: "#ffffff" } + NumberAnimation on p { + running: scene.live + loops: Animation.Infinite + from: cloud.seed; to: 1.0 + duration: (1.0 - cloud.seed) * (52000 + index * 14000) + } + } + } + + // Two birds, rolled per lap, daytime skies only. + Repeater { + model: (scene.night || !scene.birdLap) ? 0 : 2 + Item { + id: bird + property real p: 0 + readonly property real lift: 5 + index * 3 + width: 13; height: 7 + x: -20 + p * (scene.width + 60) + y: scene.height * (0.16 + 0.12 * index) + Math.sin(p * 9 + index) * lift + opacity: 0.75 + Rectangle { + x: 0; y: 3; width: 8; height: 1.6; radius: 1; color: "#2b1a3a" + transformOrigin: Item.Right + rotation: -22 + Math.sin(bird.p * 46 + index) * 16 + } + Rectangle { + x: 6; y: 3; width: 8; height: 1.6; radius: 1; color: "#2b1a3a" + transformOrigin: Item.Left + rotation: 22 - Math.sin(bird.p * 46 + index) * 16 + } + NumberAnimation on p { + running: scene.live && scene.birdLap + loops: Animation.Infinite + from: 0; to: 1 + duration: 21000 + index * 7000 + } + } + } + + // The bills. Zero to three per lap, each on its own delay, speed and band, + // so the sky is not metronomic. + Repeater { + model: 3 + AnimatedImage { + id: bill + readonly property var roll: (scene.moneyRolls.length > index) ? scene.moneyRolls[index] : null + property real p: 0 + source: "qrc:/scene/flyingmoney.gif" + playing: scene.live && bill.visible + visible: scene.reducedMotion ? index === 0 + : (roll !== null && roll.active && p > 0 && p < 1) + width: 60 * (roll ? roll.size : 1); height: 55 * (roll ? roll.size : 1) + x: scene.reducedMotion ? scene.width * 0.30 + : -width - 20 + p * (scene.width + width * 2 + 40) + y: { + var band = roll ? roll.band : 0.4; + var top = Math.max(2, scene.height * 0.04); + var span = Math.max(10, scene.height * 0.42 - height * 0.4); + return top + band * span + (roll ? Math.sin(p * 7.5) * roll.bob : 0); + } + rotation: roll ? Math.sin(p * 6 + index) * 7 : 0 + opacity: 0.92 + + NumberAnimation { id: flyAnim; target: bill; property: "p"; from: 0; to: 1; duration: bill.roll ? bill.roll.dur : 6000 } + Timer { id: flyTimer; repeat: false; interval: bill.roll ? bill.roll.delay : 0; onTriggered: if (bill.roll && bill.roll.active) flyAnim.restart() } + + function armFor(beat) { + flyAnim.stop(); + bill.p = 0; + flyTimer.stop(); + if (beat === 2 && scene.live && bill.roll && bill.roll.active) + flyTimer.restart(); + } + Connections { + target: scene + function onBeatChanged() { bill.armFor(scene.beat) } + function onLapIdChanged() { bill.armFor(-1) } + } + } } Row { @@ -128,29 +313,61 @@ Rectangle { Item { id: coin - width: scene.coinSize; height: scene.coinSize + readonly property bool bonus: scene.bonusIndex === index + readonly property int worth: bonus ? 5 : 1 + width: scene.coinSize * (bonus ? 1.34 : 1.0) + height: width // 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 + // The last coin sits clear of the right edge so the gloat that + // follows it plays out on screen instead of half off it. + x: scene.width * (0.20 + 0.60 * (scene.coinCount > 1 ? index / (scene.coinCount - 1) : 0.5)) - width / 2 + y: scene.coinRestY - (bonus ? 6 : 0) // 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. 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 + readonly property bool collected: revealed && scene.live && scene.beat === 2 && scene.snoutX >= x + width / 2 + // One-shot, so it is started and cleared by hand — a bound `running:` + // leaves the +1 stranded wherever an interrupted lap dropped it. + onCollectedChanged: { + popAnim.stop(); + plus.opacity = 0.0; + plus.y = -6; + if (coin.collected) { + popAnim.start(); + scene.pick(coin.worth); + } + } + + // The fat coin gets a slow halo so it reads as worth chasing. + Rectangle { + anchors.centerIn: coinImage + width: coin.width * 1.5; height: width; radius: width / 2 + color: "#ffd257" + visible: coin.bonus && coin.revealed && !coin.collected + opacity: 0.18 + SequentialAnimation on scale { + running: coin.bonus && scene.live && coin.revealed && !coin.collected + loops: Animation.Infinite + NumberAnimation { to: 1.18; duration: 780; easing.type: Easing.InOutQuad } + NumberAnimation { to: 0.92; duration: 780; easing.type: Easing.InOutQuad } + } + } + AnimatedImage { id: coinImage width: parent.width; height: parent.height source: "qrc:/scene/goldcoin.gif" - playing: !scene.reducedMotion && coin.revealed && !coin.collected + playing: scene.live && coin.revealed && !coin.collected opacity: (coin.revealed && !coin.collected) ? 1.0 : 0.0 scale: !coin.revealed ? 0.2 : (coin.collected ? 1.6 : 1.0) // Idle bob, low over the grass. SequentialAnimation on y { - running: !scene.reducedMotion && coin.revealed && !coin.collected + running: scene.live && coin.revealed && !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 } @@ -161,53 +378,282 @@ Rectangle { // Pickup pop. Text { + id: plus anchors.horizontalCenter: parent.horizontalCenter - text: "+1" - color: "#ffd257" + text: "+" + coin.worth + color: coin.bonus ? "#ffe9a3" : "#ffd257" style: Text.Outline styleColor: "#2a1636" font.bold: true - font.pixelSize: 18 + font.pixelSize: coin.bonus ? 24 : 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 } + y: -6 + } + SequentialAnimation { + id: popAnim + ParallelAnimation { + NumberAnimation { target: plus; property: "opacity"; to: 1.0; duration: 90 } + NumberAnimation { target: plus; property: "y"; from: -6; to: -46; duration: 820; easing.type: Easing.OutQuad } + } + PauseAnimation { duration: 260 } + NumberAnimation { target: plus; property: "opacity"; to: 0.0; duration: 320 } + } + + // Sparkle burst on pickup. + Repeater { + model: 5 + Rectangle { + readonly property real ang: (index / 5) * Math.PI * 2 + index + width: 3; height: 3; radius: 1.5 + color: "#ffe9a3" + visible: scene.sparkT < 1 && coin.collected + opacity: (1.0 - scene.sparkT) * 0.9 + x: coin.width / 2 - 1.5 + Math.cos(ang) * scene.sparkT * 26 + y: coin.height / 2 - 1.5 + Math.sin(ang) * scene.sparkT * 26 - scene.sparkT * 8 } } } } + // Sparkle sweep, shared by whichever coin was last taken. + property real sparkT: 1.0 + NumberAnimation { id: sparkAnim; target: scene; property: "sparkT"; from: 0; to: 1; duration: 520 } + + // The doge, sitting this one out. Beat 0 only, and only on some laps. AnimatedImage { - id: doge - source: "qrc:/scene/dog.gif" - playing: !scene.reducedMotion && !scene.tripped - width: scene.dogWidth; height: scene.dogHeight - y: scene.groundY - scene.dogHeight + 21 + id: sitter + source: "qrc:/scene/dog2.gif" + playing: scene.live && visible + width: 84; height: 67 + x: scene.width * 0.16 + y: scene.groundY - height + 14 + visible: !scene.reducedMotion && scene.idleLap && scene.beat === 0 + opacity: visible ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 400 } } + } + + Item { + id: rig + width: scene.dogWidth + height: scene.dogHeight 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 } } + y: scene.groundY - scene.dogHeight + 21 - scene.hopY + + AnimatedImage { + id: doge + anchors.fill: parent + source: "qrc:/scene/dog.gif" + playing: scene.live && !scene.tripped + // Face-plant: he tips over his own front paws — the pivot is the paw + // on the grass line, not the middle of the sprite, or the snout + // swings straight down through the ground. Then the face squashes + // against the dirt and the body bends over it. + readonly property real pivotX: rig.width * 0.80 + readonly property real pivotY: rig.height - 21 + transform: [ + Matrix4x4 { + matrix: Qt.matrix4x4(1, -scene.crashBend, 0, scene.crashBend * doge.pivotY, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1) + }, + Scale { + origin.x: doge.pivotX; origin.y: doge.pivotY + xScale: scene.crashSquashX; yScale: scene.crashSquashY + }, + Rotation { + origin.x: doge.pivotX; origin.y: doge.pivotY + angle: scene.crashRot + } + ] + } + + // Dust off the snout on impact: thrown up and forward, then falling. + Repeater { + model: 12 + Rectangle { + readonly property real ang: -Math.PI * (0.08 + 0.84 * (((index * 31) % 12) / 11)) + readonly property real spd: 46 + ((index * 37) % 42) + width: 4 + (index % 5); height: width; radius: width / 2 + color: index % 3 === 0 ? "#fff6e4" : (index % 3 === 1 ? "#e8d6b4" : "#c9a878") + visible: scene.dustT < 1.0 + opacity: (1.0 - scene.dustT) * 0.95 + x: rig.width * 0.92 - width / 2 + Math.cos(ang) * scene.dustT * spd + y: rig.height - 24 + Math.sin(ang) * scene.dustT * spd * 0.62 + + scene.dustT * scene.dustT * 34 + } + } + } + + // What he has to say about it. One bubble, used for both the wipeout and the + // gloat; started and cleared by hand for the same reason as the +1. + Item { + id: bark + readonly property real headX: rig.x + rig.width * 0.86 + width: barkText.implicitWidth + 18 + height: barkText.implicitHeight + 12 + x: Math.max(4, Math.min(scene.width - width - 4, headX - width * 0.35)) + y: Math.max(2, rig.y - height + 6) + opacity: 0.0 + scale: 0.6 + transformOrigin: Item.BottomLeft + visible: opacity > 0.01 + + Rectangle { + anchors.fill: parent + radius: 9 + color: "#fff6e2" + border.color: "#2a1636" + border.width: 2 + } + // Tail, pointing back down at the doge. + Rectangle { + width: 10; height: 10 + color: "#fff6e2" + border.color: "#2a1636" + border.width: 2 + rotation: 45 + x: bark.width * 0.28 + y: bark.height - 6 + Rectangle { // hide the seam the border draws across the bubble + x: -2; y: -3; width: 14; height: 6; rotation: 0 + color: "#fff6e2" + } + } + Text { + id: barkText + anchors.centerIn: parent + text: scene.barkText + color: "#2a1636" + font.bold: true + font.pixelSize: 15 + } + + SequentialAnimation { + id: barkAnim + ParallelAnimation { + NumberAnimation { target: bark; property: "opacity"; to: 1.0; duration: 110 } + NumberAnimation { target: bark; property: "scale"; from: 0.6; to: 1.0; duration: 300; easing.type: Easing.OutBack } + } + // A little shake, so it lands like a shout. + SequentialAnimation { + loops: 2 + NumberAnimation { target: bark; property: "rotation"; to: -3.5; duration: 70 } + NumberAnimation { target: bark; property: "rotation"; to: 3.5; duration: 70 } + } + NumberAnimation { target: bark; property: "rotation"; to: 0; duration: 70 } + PauseAnimation { duration: 900 } + ParallelAnimation { + NumberAnimation { target: bark; property: "opacity"; to: 0.0; duration: 300 } + NumberAnimation { target: bark; property: "scale"; to: 0.9; duration: 300 } + } + } + } + + function say(text) { + barkAnim.stop(); + bark.rotation = 0; + scene.barkText = text; + bark.opacity = 0.0; + bark.scale = 0.6; + barkAnim.start(); + } + + function hushBark() { + barkAnim.stop(); + bark.opacity = 0.0; + bark.scale = 0.6; + bark.rotation = 0; } Text { - anchors.centerIn: parent + id: gameOver + anchors.horizontalCenter: parent.horizontalCenter + y: scene.height * 0.16 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 + opacity: scene.gameOverShown ? 1.0 : 0.0 + scale: scene.gameOverShown ? 1.0 : 1.35 Behavior on opacity { NumberAnimation { duration: 320 } } + Behavior on scale { NumberAnimation { duration: 420; easing.type: Easing.OutBack } } } - // The three beats, on a loop, re-rolled each time round. + // --- the crash ---------------------------------------------------------- + SequentialAnimation { + id: crashAnim + // Impact: over the nose, squashed into the ground, bent through the body. + ParallelAnimation { + NumberAnimation { target: scene; property: "crashRot"; to: 66; duration: 160; easing.type: Easing.InQuad } + NumberAnimation { target: scene; property: "crashSquashX"; to: 1.26; duration: 170 } + NumberAnimation { target: scene; property: "crashSquashY"; to: 0.74; duration: 170 } + NumberAnimation { target: scene; property: "crashBend"; to: 0.30; duration: 170 } + NumberAnimation { target: scene; property: "dustT"; from: 0; to: 1; duration: 780; easing.type: Easing.OutQuad } + } + // Settle: he does not bounce all the way back out of it. + ParallelAnimation { + NumberAnimation { target: scene; property: "crashRot"; to: 52; duration: 420; easing.type: Easing.OutBack } + NumberAnimation { target: scene; property: "crashSquashX"; to: 1.10; duration: 420; easing.type: Easing.OutBack } + NumberAnimation { target: scene; property: "crashSquashY"; to: 0.92; duration: 420; easing.type: Easing.OutBack } + NumberAnimation { target: scene; property: "crashBend"; to: 0.12; duration: 420; easing.type: Easing.OutQuad } + } + // ...and lies there breathing, so the wreck is not a frozen frame. + SequentialAnimation { + loops: Animation.Infinite + NumberAnimation { target: scene; property: "crashSquashY"; to: 0.97; duration: 900; easing.type: Easing.InOutQuad } + NumberAnimation { target: scene; property: "crashSquashY"; to: 0.90; duration: 900; easing.type: Easing.InOutQuad } + } + } + + function resetCrash() { + crashAnim.stop(); + scene.crashRot = 0.0; + scene.crashSquashX = 1.0; + scene.crashSquashY = 1.0; + scene.crashBend = 0.0; + scene.dustT = 1.0; + } + + // GAME OVER lands a beat after the wipeout so the crash reads on its own. + property bool gameOverShown: false + Timer { id: gameOverTimer; interval: 460; repeat: false; onTriggered: scene.gameOverShown = true } + + onTrippedChanged: { + if (scene.tripped) { + crashAnim.restart(); + scene.say(scene.crashBarks[Math.floor(Math.random() * scene.crashBarks.length)]); + gameOverTimer.restart(); + // No point running out the rest of a lap he is not going to finish. + beatTimer.interval = 3200; + beatTimer.restart(); + } else { + scene.resetCrash(); + } + } + + // --- the gloat ---------------------------------------------------------- + SequentialAnimation { + id: hopAnim + NumberAnimation { target: scene; property: "hopY"; to: 26; duration: 220; easing.type: Easing.OutQuad } + NumberAnimation { target: scene; property: "hopY"; to: 0; duration: 200; easing.type: Easing.InQuad } + NumberAnimation { target: scene; property: "hopY"; to: 14; duration: 170; easing.type: Easing.OutQuad } + NumberAnimation { target: scene; property: "hopY"; to: 0; duration: 150; easing.type: Easing.InQuad } + } + + function pick(worth) { + scene.picked += worth; + sparkAnim.restart(); + // Cleared the field: hop and gloat about it. + if (scene.picked >= scene.coinCount + (scene.bonusIndex >= 0 ? 4 : 0)) { + hopAnim.restart(); + scene.say(scene.winBarks[Math.floor(Math.random() * scene.winBarks.length)]); + } + } + + // --- the three beats ---------------------------------------------------- // // This is a timer-driven state machine rather than one big // SequentialAnimation on purpose: each lap rolls its own durations, and @@ -228,37 +674,63 @@ Rectangle { 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() } + Timer { id: beatTimer; repeat: false; onTriggered: scene.advance() } // 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.stopEverything(); + scene.lapId += 1; scene.beat = 0; scene.runProgress = 0.0; - scene.moneyProgress = 0.0; scene.coinReveal = 0.0; + scene.picked = 0; + scene.hopY = 0.0; + scene.sparkT = 1.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; + if (!scene.live) + return; + + scene.runBeat = 6800 + Math.floor(Math.random() * 3000); + scene.tripLap = Math.random() < 0.17; + scene.tripAt = 0.34 + Math.random() * 0.34; + scene.idleLap = Math.random() < 0.45; + scene.birdLap = !scene.night && Math.random() < 0.5; + scene.starLap = scene.night && Math.random() < 0.4; + scene.bonusIndex = (Math.random() < 0.3) + ? Math.floor(Math.random() * scene.coinCount) : -1; + scene.moneyRolls = scene.rollMoney(); beatTimer.interval = 3000 + Math.floor(Math.random() * 2000); // 3-5s of empty field beatTimer.start(); } + function stopEverything() { + beatTimer.stop(); + revealAnim.stop(); + runAnim.stop(); + hopAnim.stop(); + sparkAnim.stop(); + starTimer.stop(); + starAnim.stop(); + shootingStar.p = 1.0; + // Order matters: park the run first. Clearing `tripLap` while the doge + // is still frozen mid-field un-freezes him onto `runProgress`, which by + // the end of a lap is 1.0 — he would teleport past every coin left + // standing and "win" the lap he just lost. + scene.beat = 0; + scene.runProgress = 0.0; + scene.coinReveal = 0.0; + scene.tripLap = false; + gameOverTimer.stop(); + scene.gameOverShown = false; + scene.hushBark(); + scene.resetCrash(); + } + function advance() { - if (scene.reducedMotion) + if (!scene.live) return; if (scene.beat === 0) { scene.beat = 1; @@ -268,23 +740,29 @@ Rectangle { } else if (scene.beat === 1) { scene.beat = 2; runAnim.start(); - moneyTimer.start(); - beatTimer.interval = scene.runBeat + 900; // 900 holds on GAME OVER + if (scene.starLap) { + shootingStar.p = 1.0; + starTimer.interval = 600 + Math.floor(Math.random() * (scene.runBeat - 1800)); + starTimer.restart(); + } + beatTimer.interval = scene.runBeat + 1400; // holds on GAME OVER / the gloat beatTimer.start(); } else { scene.startLap(); } } - Component.onCompleted: if (!scene.reducedMotion) scene.startLap(); + // Called from the host when its tab is shown or hidden. A hidden scene is + // parked at the start of a lap rather than left running against a 0px field. + function restart() { scene.onScreen = true } + function suspend() { scene.onScreen = false } - onReducedMotionChanged: { - if (scene.reducedMotion) { - beatTimer.stop(); moneyTimer.stop(); - revealAnim.stop(); runAnim.stop(); moneyAnim.stop(); - scene.tripLap = false; - } else { + onLiveChanged: { + if (scene.live) scene.startLap(); - } + else + scene.stopEverything(); } + + Component.onCompleted: if (scene.live) scene.startLap(); } diff --git a/src/widgets/SceneWidget.cpp b/src/widgets/SceneWidget.cpp index e2739f79..886ef2cd 100644 --- a/src/widgets/SceneWidget.cpp +++ b/src/widgets/SceneWidget.cpp @@ -4,6 +4,7 @@ #include "SceneWidget.h" #include +#include #include "utils/config.h" @@ -16,6 +17,28 @@ SceneWidget::SceneWidget(QWidget *parent, Variant variant) this->rootContext()->setContextProperty("sceneCtx", m_backend); this->setResizeMode(QQuickWidget::SizeRootObjectToView); this->setSource(QUrl("qrc:/qml/scene.qml")); + this->callScene("suspend"); +} + +void SceneWidget::showEvent(QShowEvent *event) { + QQuickWidget::showEvent(event); + this->callScene("restart"); +} + +void SceneWidget::hideEvent(QHideEvent *event) { + this->callScene("suspend"); + QQuickWidget::hideEvent(event); +} + +void SceneWidget::resizeEvent(QResizeEvent *event) { + QQuickWidget::resizeEvent(event); + if (this->isVisible()) + this->callScene("restart"); +} + +void SceneWidget::callScene(const char *method) { + if (auto *root = this->rootObject()) + QMetaObject::invokeMethod(root, method); } void SceneWidget::refreshReducedMotion() { diff --git a/src/widgets/SceneWidget.h b/src/widgets/SceneWidget.h index 2ba1a275..7154e91d 100644 --- a/src/widgets/SceneWidget.h +++ b/src/widgets/SceneWidget.h @@ -40,7 +40,24 @@ public: explicit SceneWidget(QWidget *parent = nullptr, Variant variant = Dusk); void refreshReducedMotion(); + +protected: + // A scene on a hidden tab is parked rather than left running: an unshown + // QQuickWidget is 0px wide, and a lap run against a 0px field collects every + // coin at once and leaves its litter (+1s, barks) to surface on the real lap + // when the tab is finally shown. Parking also keeps a background tab from + // burning CPU on animation nobody is looking at. + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + // Belt and braces: a scene that is on screen and has just been given a real + // size is running, whether or not a show event was the thing that got it + // there. `restart` only sets a flag, so re-arming an already-running scene + // costs nothing and does not interrupt its lap. + void resizeEvent(QResizeEvent *event) override; + private: + void callScene(const char *method); + SceneBackend *m_backend; }; From f1144c483ed541111729fffd8f2564dd58952b3b Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 3 Sep 2026 13:07:04 -0400 Subject: [PATCH 2/5] fix(scene): the wipeout is a skid, the win says so, and each tab gets its own amount of scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crash launched him. Rotating 66 degrees about a pivot inside the sprite, shearing him through the middle and scaling him about the same point left him diagonal in mid-air, elongated, nowhere near the ground — a stunt, not a trip. It is now a face-plant and a skid: 26 degrees, a 21px drop so the snout reaches the grass, and 26px of forward slide along the ground with the dust coming up. The shear is gone entirely. The pivot is the snout at the grass line rather than a point inside the body. The other half of looking airborne was the frozen frame: stopping a run cycle leaves whatever mid-stride pose the gif was on, legs extended. He now lands on frame 4, the one with the legs tucked under him. Clearing the field and getting across now says WIN, with the tally, the way eating it says GAME OVER. Before, a won lap just ended and the next one opened with a doge sitting down, which reads as nothing having happened. And the scene comes in three amounts, because a form should not have to compete with a dog: History runs the whole thing, Receive is a doge sitting in the field, Send is money blowing past and nothing else. One QML file, gated by mode; the beat machine only runs where there is a run. Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF --- src/ReceiveWidget.cpp | 2 +- src/SendWidget.cpp | 2 +- src/assets/qml/scene.qml | 139 +++++++++++++++++++++++++----------- src/widgets/SceneWidget.cpp | 5 +- src/widgets/SceneWidget.h | 14 +++- 5 files changed, 117 insertions(+), 45 deletions(-) diff --git a/src/ReceiveWidget.cpp b/src/ReceiveWidget.cpp index 140bba41..87a4533d 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, SceneWidget::Dawn); + auto *scene = new SceneWidget(this, SceneWidget::Dawn, SceneWidget::Sit); scene->setMinimumHeight(110); scene->setMaximumHeight(130); ui->verticalLayout->addWidget(scene); diff --git a/src/SendWidget.cpp b/src/SendWidget.cpp index 0650fa0c..b9ec4291 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, SceneWidget::Night); + auto *scene = new SceneWidget(this, SceneWidget::Night, SceneWidget::Money); 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 d5dce147..2d75a7b2 100644 --- a/src/assets/qml/scene.qml +++ b/src/assets/qml/scene.qml @@ -39,6 +39,14 @@ Rectangle { 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 + // What this surface is doing. History gets the whole run; Receive gets a + // doge sitting in the field; Send gets money blowing past and nothing else. + // One scene, three amounts of it, so a form does not have to compete with a + // dog for attention. + property string mode: (typeof sceneCtx !== 'undefined' && sceneCtx) ? sceneCtx.mode : "run" + readonly property bool runMode: mode === "run" + readonly property bool sitMode: mode === "sit" + readonly property bool moneyMode: mode === "money" // The host sets this false while its tab is hidden (see SceneWidget). property bool onScreen: true @@ -91,15 +99,23 @@ Rectangle { // 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) + + scene.crashSlide // The doge collects a coin once his snout reaches the coin's centre. readonly property real snoutX: dogX + dogWidth * 0.78 property real hopY: 0.0 // victory hop // Crash rig. Driven by crashAnim, reset by resetCrash(). + // + // The wipeout is a face-plant and a skid, not a stunt: he pitches nose-down + // by a small angle, drops onto his snout, and slides forward along the + // ground while the dust goes up. An earlier version rotated him 66 degrees + // about a pivot inside the sprite and sheared him at the same time, which + // launched him into the air at a diagonal and stretched him on the way. property real crashRot: 0.0 property real crashSquashX: 1.0 property real crashSquashY: 1.0 - property real crashBend: 0.0 + property real crashDrop: 0.0 // px down, so the face reaches the ground + property real crashSlide: 0.0 // px forward along the ground property real dustT: 1.0 // 0..1 sweep; 1 is spent // --- the coins ---------------------------------------------------------- @@ -120,7 +136,8 @@ Rectangle { for (var i = 0; i < 3; ++i) { out.push({ "active": i < n, - "delay": Math.floor(Math.random() * Math.max(1, scene.runBeat - 2500)), + "delay": Math.floor(Math.random() * (scene.moneyMode ? 7000 + : Math.max(1, scene.runBeat - 2500))), "dur": 4200 + Math.floor(Math.random() * 3600), "band": Math.random(), "size": 0.72 + Math.random() * 0.55, @@ -268,8 +285,9 @@ Rectangle { property real p: 0 source: "qrc:/scene/flyingmoney.gif" playing: scene.live && bill.visible - visible: scene.reducedMotion ? index === 0 - : (roll !== null && roll.active && p > 0 && p < 1) + visible: scene.sitMode ? false + : scene.reducedMotion ? index === 0 + : (roll !== null && roll.active && p > 0 && p < 1) width: 60 * (roll ? roll.size : 1); height: 55 * (roll ? roll.size : 1) x: scene.reducedMotion ? scene.width * 0.30 : -width - 20 + p * (scene.width + width * 2 + 40) @@ -289,7 +307,9 @@ Rectangle { flyAnim.stop(); bill.p = 0; flyTimer.stop(); - if (beat === 2 && scene.live && bill.roll && bill.roll.active) + // In money mode every lap is the money beat. + if ((beat === 2 || scene.moneyMode) && scene.live + && bill.roll && bill.roll.active) flyTimer.restart(); } Connections { @@ -309,7 +329,7 @@ Rectangle { } Repeater { - model: scene.coinCount + model: scene.runMode ? scene.coinCount : 0 Item { id: coin @@ -425,38 +445,39 @@ Rectangle { source: "qrc:/scene/dog2.gif" playing: scene.live && visible width: 84; height: 67 - x: scene.width * 0.16 + x: scene.width * (scene.sitMode ? 0.5 : 0.16) - (scene.sitMode ? width / 2 : 0) y: scene.groundY - height + 14 - visible: !scene.reducedMotion && scene.idleLap && scene.beat === 0 + visible: !scene.reducedMotion + && (scene.sitMode || (scene.runMode && scene.idleLap && scene.beat === 0)) opacity: visible ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: 400 } } } Item { id: rig + visible: scene.runMode width: scene.dogWidth height: scene.dogHeight x: scene.reducedMotion ? 8 : scene.dogX - y: scene.groundY - scene.dogHeight + 21 - scene.hopY + y: scene.groundY - scene.dogHeight + 21 - scene.hopY + scene.crashDrop AnimatedImage { id: doge anchors.fill: parent source: "qrc:/scene/dog.gif" playing: scene.live && !scene.tripped + // Freezing a run cycle leaves him in whatever mid-stride pose the + // gif happened to be on, legs extended, which reads as airborne no + // matter how he is rotated. Frame 4 has the legs tucked under the + // body — the closest thing this sprite sheet has to a heap. + onPlayingChanged: if (!playing && scene.tripped) currentFrame = 4 // Face-plant: he tips over his own front paws — the pivot is the paw // on the grass line, not the middle of the sprite, or the snout // swings straight down through the ground. Then the face squashes // against the dirt and the body bends over it. - readonly property real pivotX: rig.width * 0.80 - readonly property real pivotY: rig.height - 21 + readonly property real pivotX: rig.width * 0.86 // the snout + readonly property real pivotY: rig.height - 14 // on the grass transform: [ - Matrix4x4 { - matrix: Qt.matrix4x4(1, -scene.crashBend, 0, scene.crashBend * doge.pivotY, - 0, 1, 0, 0, - 0, 0, 1, 0, - 0, 0, 0, 1) - }, Scale { origin.x: doge.pivotX; origin.y: doge.pivotY xScale: scene.crashSquashX; yScale: scene.crashSquashY @@ -473,14 +494,14 @@ Rectangle { model: 12 Rectangle { readonly property real ang: -Math.PI * (0.08 + 0.84 * (((index * 31) % 12) / 11)) - readonly property real spd: 46 + ((index * 37) % 42) + readonly property real spd: 30 + ((index * 37) % 26) width: 4 + (index % 5); height: width; radius: width / 2 color: index % 3 === 0 ? "#fff6e4" : (index % 3 === 1 ? "#e8d6b4" : "#c9a878") visible: scene.dustT < 1.0 opacity: (1.0 - scene.dustT) * 0.95 - x: rig.width * 0.92 - width / 2 + Math.cos(ang) * scene.dustT * spd - y: rig.height - 24 + Math.sin(ang) * scene.dustT * spd * 0.62 - + scene.dustT * scene.dustT * 34 + x: rig.width * 0.90 - width / 2 + Math.cos(ang) * scene.dustT * spd + y: rig.height - 12 + Math.sin(ang) * scene.dustT * spd * 0.45 + + scene.dustT * scene.dustT * 26 } } } @@ -497,7 +518,7 @@ Rectangle { opacity: 0.0 scale: 0.6 transformOrigin: Item.BottomLeft - visible: opacity > 0.01 + visible: scene.runMode && opacity > 0.01 Rectangle { anchors.fill: parent @@ -566,6 +587,24 @@ Rectangle { bark.rotation = 0; } + // Cleared the field and got across: say so, rather than letting the lap + // just end and the next one start with a doge sitting down. + Text { + id: winner + anchors.horizontalCenter: parent.horizontalCenter + y: scene.height * 0.16 + text: scene.picked > 0 ? "WIN +" + scene.picked : "WIN" + color: "#8ff08a" + style: Text.Outline + styleColor: "#10240f" + font.bold: true + font.pixelSize: Math.max(20, Math.min(scene.height * 0.20, 34)) + opacity: (scene.runMode && scene.winShown) ? 1.0 : 0.0 + scale: scene.winShown ? 1.0 : 1.35 + Behavior on opacity { NumberAnimation { duration: 280 } } + Behavior on scale { NumberAnimation { duration: 420; easing.type: Easing.OutBack } } + } + Text { id: gameOver anchors.horizontalCenter: parent.horizontalCenter @@ -576,7 +615,7 @@ Rectangle { styleColor: "#1a0b1f" font.bold: true font.pixelSize: Math.max(20, Math.min(scene.height * 0.20, 34)) - opacity: scene.gameOverShown ? 1.0 : 0.0 + opacity: (scene.runMode && scene.gameOverShown) ? 1.0 : 0.0 scale: scene.gameOverShown ? 1.0 : 1.35 Behavior on opacity { NumberAnimation { duration: 320 } } Behavior on scale { NumberAnimation { duration: 420; easing.type: Easing.OutBack } } @@ -585,26 +624,21 @@ Rectangle { // --- the crash ---------------------------------------------------------- SequentialAnimation { id: crashAnim - // Impact: over the nose, squashed into the ground, bent through the body. + // Impact: he pitches onto his nose and the dust comes up. Small angle + // on purpose — a shiba tripping is a face on the ground, not a cartwheel. ParallelAnimation { - NumberAnimation { target: scene; property: "crashRot"; to: 66; duration: 160; easing.type: Easing.InQuad } - NumberAnimation { target: scene; property: "crashSquashX"; to: 1.26; duration: 170 } - NumberAnimation { target: scene; property: "crashSquashY"; to: 0.74; duration: 170 } - NumberAnimation { target: scene; property: "crashBend"; to: 0.30; duration: 170 } - NumberAnimation { target: scene; property: "dustT"; from: 0; to: 1; duration: 780; easing.type: Easing.OutQuad } + NumberAnimation { target: scene; property: "crashRot"; to: 26; duration: 150; easing.type: Easing.InQuad } + NumberAnimation { target: scene; property: "crashDrop"; to: 21; duration: 150; easing.type: Easing.InQuad } + NumberAnimation { target: scene; property: "crashSquashX"; to: 1.07; duration: 150 } + NumberAnimation { target: scene; property: "crashSquashY"; to: 0.93; duration: 150 } + NumberAnimation { target: scene; property: "dustT"; from: 0; to: 1; duration: 700; easing.type: Easing.OutQuad } } - // Settle: he does not bounce all the way back out of it. + // The skid: his face carries on along the ground and stops. ParallelAnimation { - NumberAnimation { target: scene; property: "crashRot"; to: 52; duration: 420; easing.type: Easing.OutBack } - NumberAnimation { target: scene; property: "crashSquashX"; to: 1.10; duration: 420; easing.type: Easing.OutBack } - NumberAnimation { target: scene; property: "crashSquashY"; to: 0.92; duration: 420; easing.type: Easing.OutBack } - NumberAnimation { target: scene; property: "crashBend"; to: 0.12; duration: 420; easing.type: Easing.OutQuad } - } - // ...and lies there breathing, so the wreck is not a frozen frame. - SequentialAnimation { - loops: Animation.Infinite - NumberAnimation { target: scene; property: "crashSquashY"; to: 0.97; duration: 900; easing.type: Easing.InOutQuad } - NumberAnimation { target: scene; property: "crashSquashY"; to: 0.90; duration: 900; easing.type: Easing.InOutQuad } + NumberAnimation { target: scene; property: "crashSlide"; to: 26; duration: 620; easing.type: Easing.OutQuad } + NumberAnimation { target: scene; property: "crashRot"; to: 21; duration: 380; easing.type: Easing.OutQuad } + NumberAnimation { target: scene; property: "crashSquashX"; to: 1.03; duration: 380; easing.type: Easing.OutQuad } + NumberAnimation { target: scene; property: "crashSquashY"; to: 0.97; duration: 380; easing.type: Easing.OutQuad } } } @@ -613,10 +647,13 @@ Rectangle { scene.crashRot = 0.0; scene.crashSquashX = 1.0; scene.crashSquashY = 1.0; - scene.crashBend = 0.0; + scene.crashDrop = 0.0; + scene.crashSlide = 0.0; scene.dustT = 1.0; } + // He got across with the field cleared. + property bool winShown: false // GAME OVER lands a beat after the wipeout so the crash reads on its own. property bool gameOverShown: false Timer { id: gameOverTimer; interval: 460; repeat: false; onTriggered: scene.gameOverShown = true } @@ -673,11 +710,14 @@ Rectangle { target: scene; property: "runProgress" from: 0.0; to: 1.0 duration: scene.runBeat + onFinished: if (scene.live && !scene.tripped) scene.winShown = true } Timer { id: beatTimer; repeat: false; onTriggered: scene.advance() } // Beat 0 is the empty field, 1 is the coins coming out, 2 is the run. + // Only "run" mode walks all three: money mode loops the money beat, and a + // sitting doge needs no cycle at all. function startLap() { scene.stopEverything(); @@ -692,6 +732,18 @@ Rectangle { if (!scene.live) return; + if (scene.sitMode) { + scene.beat = 0; + return; // nothing to schedule; he just sits + } + if (scene.moneyMode) { + scene.moneyRolls = scene.rollMoney(); + scene.beat = 2; // arms the bills through armFor() + beatTimer.interval = 9000 + Math.floor(Math.random() * 5000); + beatTimer.start(); + return; + } + scene.runBeat = 6800 + Math.floor(Math.random() * 3000); scene.tripLap = Math.random() < 0.17; scene.tripAt = 0.34 + Math.random() * 0.34; @@ -725,6 +777,7 @@ Rectangle { scene.tripLap = false; gameOverTimer.stop(); scene.gameOverShown = false; + scene.winShown = false; scene.hushBark(); scene.resetCrash(); } @@ -732,6 +785,10 @@ Rectangle { function advance() { if (!scene.live) return; + if (!scene.runMode) { + scene.startLap(); + return; + } if (scene.beat === 0) { scene.beat = 1; revealAnim.start(); diff --git a/src/widgets/SceneWidget.cpp b/src/widgets/SceneWidget.cpp index 886ef2cd..2294130f 100644 --- a/src/widgets/SceneWidget.cpp +++ b/src/widgets/SceneWidget.cpp @@ -8,12 +8,15 @@ #include "utils/config.h" -SceneWidget::SceneWidget(QWidget *parent, Variant variant) +SceneWidget::SceneWidget(QWidget *parent, Variant variant, Mode mode) : QQuickWidget(parent) , m_backend(new SceneBackend(this)) { m_backend->setReducedMotion(conf()->get(Config::reducedMotion).toBool()); m_backend->setVariant(static_cast(variant)); + m_backend->setMode(mode == Sit ? QStringLiteral("sit") + : mode == Money ? QStringLiteral("money") + : QStringLiteral("run")); 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 7154e91d..7b2d8255 100644 --- a/src/widgets/SceneWidget.h +++ b/src/widgets/SceneWidget.h @@ -5,6 +5,7 @@ #define WOWLET_SCENEWIDGET_H #include +#include #include // Context object exposed to scene.qml as `sceneCtx`. Carries the reduced-motion @@ -14,18 +15,23 @@ class SceneBackend : public QObject { Q_OBJECT Q_PROPERTY(bool reducedMotion READ reducedMotion NOTIFY reducedMotionChanged) Q_PROPERTY(int variant READ variant NOTIFY variantChanged) + Q_PROPERTY(QString mode READ mode NOTIFY modeChanged) 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(); } } + QString mode() const { return m_mode; } + void setMode(const QString &m) { if (m_mode != m) { m_mode = m; emit modeChanged(); } } signals: void reducedMotionChanged(); void variantChanged(); + void modeChanged(); private: bool m_reducedMotion = false; int m_variant = 0; + QString m_mode = QStringLiteral("run"); }; // wowlet: reusable ambient-scene surface. Hosts scene.qml and reads the @@ -38,7 +44,13 @@ public: // 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); + // How much scene a surface wants. History has room for the whole run; + // Receive gets a doge sitting in the field; Send gets money blowing past and + // nothing else, so the form it sits under is not competing with a dog. + enum Mode { Run, Sit, Money }; + + explicit SceneWidget(QWidget *parent = nullptr, Variant variant = Dusk, + Mode mode = Run); void refreshReducedMotion(); protected: From 068e4c2aa53ac0edbbcb3d5f368ce569d749c96b Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 3 Sep 2026 14:17:54 -0400 Subject: [PATCH 3/5] fix(scene): money no longer vanishes mid-flight, and the quiet tabs have something to watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send's bills were being cut off halfway across. Money mode reused the run's lap machinery, so every 9-14 seconds the cycle restarted and reset every bill to the start of its flight — and a roll whose delay plus duration ran past the cycle simply disappeared wherever it had got to. Bills now keep their own clock: cross, re-roll, wait, cross again, with the re-roll deciding afresh whether that bill flies at all. Nothing resets them from outside, so nothing can cut them short, and the sky thins and fills instead of carrying identical traffic for ever. 26 seconds of frames now contain no mid-screen disappearance. Both quiet tabs get an ambient clock, because a form is a long time to look at a dog breathing: * Send: a gold coin arcs across on its own trajectory now and then, bills tumble in proportion to their speed, and at night the odd shooting star. * Receive: a coin drops out of the sky, bounces, and settles in the grass beside the doge with a small sparkle burst — a coin arriving is the entire point of that tab — and he perks up when it lands. Birds fly by in daylight. It lands to one side of him and clear of the sun, which is where the first version put it. Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF --- src/assets/qml/scene.qml | 169 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 159 insertions(+), 10 deletions(-) diff --git a/src/assets/qml/scene.qml b/src/assets/qml/scene.qml index 2d75a7b2..7aef7ae6 100644 --- a/src/assets/qml/scene.qml +++ b/src/assets/qml/scene.qml @@ -130,6 +130,16 @@ Rectangle { // One roll per lap: how many blow through, when, how fast, how high, how big. property var moneyRolls: [] + function rerollMoney(index) { + var rolls = scene.moneyRolls.slice(); + var fresh = scene.rollMoney(); + rolls[index] = fresh[Math.floor(Math.random() * fresh.length)]; + // Re-roll whether this bill flies at all, so the sky thins and fills + // instead of carrying the same traffic for ever. + rolls[index].active = Math.random() < 0.72; + scene.moneyRolls = rolls; + } + function rollMoney() { var n = [0, 1, 1, 2, 2, 3][Math.floor(Math.random() * 6)]; var out = []; @@ -141,7 +151,8 @@ Rectangle { "dur": 4200 + Math.floor(Math.random() * 3600), "band": Math.random(), "size": 0.72 + Math.random() * 0.55, - "bob": 6 + Math.random() * 12 + "bob": 6 + Math.random() * 12, + "tumble": Math.random() * 2.5 }); } return out; @@ -247,7 +258,7 @@ Rectangle { // Two birds, rolled per lap, daytime skies only. Repeater { - model: (scene.night || !scene.birdLap) ? 0 : 2 + model: (scene.night || !(scene.birdLap || scene.sitMode)) ? 0 : 2 Item { id: bird property real p: 0 @@ -267,7 +278,7 @@ Rectangle { rotation: 22 - Math.sin(bird.p * 46 + index) * 16 } NumberAnimation on p { - running: scene.live && scene.birdLap + running: scene.live && (scene.birdLap || scene.sitMode) loops: Animation.Infinite from: 0; to: 1 duration: 21000 + index * 7000 @@ -297,11 +308,25 @@ Rectangle { var span = Math.max(10, scene.height * 0.42 - height * 0.4); return top + band * span + (roll ? Math.sin(p * 7.5) * roll.bob : 0); } - rotation: roll ? Math.sin(p * 6 + index) * 7 : 0 + rotation: roll ? Math.sin(p * (5 + roll.tumble) + index) * (6 + roll.tumble * 4) : 0 opacity: 0.92 - NumberAnimation { id: flyAnim; target: bill; property: "p"; from: 0; to: 1; duration: bill.roll ? bill.roll.dur : 6000 } - Timer { id: flyTimer; repeat: false; interval: bill.roll ? bill.roll.delay : 0; onTriggered: if (bill.roll && bill.roll.active) flyAnim.restart() } + NumberAnimation { + id: flyAnim + target: bill; property: "p"; from: 0; to: 1 + duration: bill.roll ? bill.roll.dur : 6000 + onFinished: bill.rescheduleSelf() + } + Timer { + id: flyTimer; repeat: false + interval: bill.roll ? bill.roll.delay : 0 + onTriggered: { + if (bill.roll && bill.roll.active) + flyAnim.restart(); + else + bill.rescheduleSelf(); // dormant this pass; try again later + } + } function armFor(beat) { flyAnim.stop(); @@ -312,6 +337,20 @@ Rectangle { && bill.roll && bill.roll.active) flyTimer.restart(); } + + // Money mode has no lap to hide inside, so each bill runs its own + // clock: cross, re-roll, wait, cross again. A shared cycle was + // cutting bills off mid-screen whenever a roll's delay plus its + // duration ran past the cycle length, which looked exactly like + // money vanishing halfway across. + function rescheduleSelf() { + if (!scene.moneyMode || !scene.live) + return; + scene.rerollMoney(index); + bill.p = 0; + flyTimer.interval = 900 + Math.floor(Math.random() * 5200); + flyTimer.restart(); + } Connections { target: scene function onBeatChanged() { bill.armFor(scene.beat) } @@ -439,6 +478,115 @@ Rectangle { property real sparkT: 1.0 NumberAnimation { id: sparkAnim; target: scene; property: "sparkT"; from: 0; to: 1; duration: 520 } + // A coin sails past on the Send screen — money going somewhere. + AnimatedImage { + id: flyingCoin + property real t: 1.0 + property real band: 0.3 + source: "qrc:/scene/goldcoin.gif" + playing: scene.live && visible + visible: !scene.reducedMotion && scene.moneyMode && t < 1.0 + width: 26; height: 26 + x: -width + t * (scene.width + width * 2) + // A shallow arc rather than a flat line, so it reads as thrown. + y: scene.height * band - Math.sin(t * Math.PI) * scene.height * 0.16 + rotation: t * 540 + opacity: Math.min(1, Math.sin(t * Math.PI) * 3) + NumberAnimation { id: flyingCoinAnim; target: flyingCoin; property: "t" + from: 0; to: 1; duration: 2600 } + } + + // ...and one lands in the field on the Receive screen. A coin arriving is + // the whole point of that tab, so it is worth more than a doge panting. + Item { + id: giftCoin + property real t: 1.0 + property real spotX: 0.5 + readonly property real landAt: 0.55 // t at which it touches down + readonly property bool landed: t >= landAt && t < 1.0 + visible: !scene.reducedMotion && scene.sitMode && t < 1.0 + width: scene.coinSize; height: width + x: scene.width * spotX - width / 2 + y: { + if (t >= landAt) + return scene.coinRestY - Math.abs(Math.sin((t - landAt) * 9)) * 10 + * (1 - (t - landAt) / (1 - landAt)); // settle bounce + var fall = t / landAt; + return -height + fall * fall * (scene.coinRestY + height); + } + AnimatedImage { + anchors.fill: parent + source: "qrc:/scene/goldcoin.gif" + playing: scene.live && giftCoin.visible + opacity: giftCoin.t > 0.9 ? (1 - giftCoin.t) * 10 : 1 + } + Repeater { + model: 6 + Rectangle { + readonly property real ang: (index / 6) * Math.PI * 2 + readonly property real burst: Math.max(0, Math.min(1, + (giftCoin.t - giftCoin.landAt) * 4)) + width: 3; height: 3; radius: 1.5; color: "#ffe9a3" + visible: giftCoin.landed && burst < 1 + opacity: 1 - burst + x: giftCoin.width / 2 - 1.5 + Math.cos(ang) * burst * 22 + y: giftCoin.height / 2 - 1.5 + Math.sin(ang) * burst * 22 + } + } + NumberAnimation { id: giftCoinAnim; target: giftCoin; property: "t" + from: 0; to: 1; duration: 3400 } + } + + // The quiet screens get their own clock: every few seconds something small + // happens, so the tab is never just a dog breathing. + property real sitterHop: 0.0 + SequentialAnimation { + id: sitterHopAnim + NumberAnimation { target: scene; property: "sitterHop"; to: 9; duration: 180; easing.type: Easing.OutQuad } + NumberAnimation { target: scene; property: "sitterHop"; to: 0; duration: 220; easing.type: Easing.InQuad } + NumberAnimation { target: scene; property: "sitterHop"; to: 4; duration: 140; easing.type: Easing.OutQuad } + NumberAnimation { target: scene; property: "sitterHop"; to: 0; duration: 120; easing.type: Easing.InQuad } + } + + Timer { + id: ambientTimer + repeat: true + running: scene.live && !scene.runMode + interval: 5000 + onTriggered: { + scene.ambientEvent(); + interval = 4200 + Math.floor(Math.random() * 7000); + } + } + + function ambientEvent() { + var roll = Math.random(); + if (scene.moneyMode) { + if (roll < 0.62) { + flyingCoin.band = 0.16 + Math.random() * 0.30; + flyingCoinAnim.duration = 2100 + Math.floor(Math.random() * 2200); + flyingCoinAnim.restart(); + } else if (scene.night) { + shootingStar.p = 1.0; + starAnim.restart(); + } + return; + } + // sit mode + if (roll < 0.5) { + // Beside him, on whichever side, and clear of the sun at 0.74. + giftCoin.spotX = (Math.random() < 0.5) ? 0.30 + Math.random() * 0.12 + : 0.58 + Math.random() * 0.10; + giftCoinAnim.restart(); + sitterHopAnim.restart(); // he notices + } else if (roll < 0.72) { + sitterHopAnim.restart(); + } else if (scene.night) { + shootingStar.p = 1.0; + starAnim.restart(); + } + } + // The doge, sitting this one out. Beat 0 only, and only on some laps. AnimatedImage { id: sitter @@ -446,7 +594,7 @@ Rectangle { playing: scene.live && visible width: 84; height: 67 x: scene.width * (scene.sitMode ? 0.5 : 0.16) - (scene.sitMode ? width / 2 : 0) - y: scene.groundY - height + 14 + y: scene.groundY - height + 14 - scene.sitterHop visible: !scene.reducedMotion && (scene.sitMode || (scene.runMode && scene.idleLap && scene.beat === 0)) opacity: visible ? 1.0 : 0.0 @@ -728,6 +876,9 @@ Rectangle { scene.picked = 0; scene.hopY = 0.0; scene.sparkT = 1.0; + scene.sitterHop = 0.0; + flyingCoin.t = 1.0; + giftCoin.t = 1.0; if (!scene.live) return; @@ -739,9 +890,7 @@ Rectangle { if (scene.moneyMode) { scene.moneyRolls = scene.rollMoney(); scene.beat = 2; // arms the bills through armFor() - beatTimer.interval = 9000 + Math.floor(Math.random() * 5000); - beatTimer.start(); - return; + return; // each bill reschedules itself } scene.runBeat = 6800 + Math.floor(Math.random() * 3000); From b1e751a6b7f7f7de5b1ff8d356442f802ef38bf2 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 3 Sep 2026 15:04:41 -0400 Subject: [PATCH 4/5] fix(scene): the sitting doge belongs to Receive only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit He was filling History's empty beat as well, left over from before Receive had a scene of its own. Two tabs showing the same sitting doge reads as one tab's scene leaking into the other, and History is meant to be the tab where he runs. History's opening beat is now sky, grass, clouds and the sun — the scene breathing before the coins come out — and the sitting doge appears on Receive and nowhere else. The per-lap idle roll goes with him. Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF --- src/assets/qml/scene.qml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/assets/qml/scene.qml b/src/assets/qml/scene.qml index 7aef7ae6..ae4591e5 100644 --- a/src/assets/qml/scene.qml +++ b/src/assets/qml/scene.qml @@ -6,7 +6,7 @@ import QtQuick // // One cycle is three beats, so the scene breathes instead of running flat out: // -// 1. an empty field for 3-5s (sky, grass, and sometimes a doge sitting in it) +// 1. an empty field for 3-5s (sky, grass, clouds — the scene breathing) // 2. the coins pop in, one at a time // 3. the doge and the money go by, and the doge collects the coins // @@ -75,7 +75,6 @@ Rectangle { 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 - property bool idleLap: false // doge sitting in the field during beat 0 property bool birdLap: false property bool starLap: false // shooting star, night only property int bonusIndex: -1 // which coin is worth +5, or -1 @@ -593,10 +592,11 @@ Rectangle { source: "qrc:/scene/dog2.gif" playing: scene.live && visible width: 84; height: 67 - x: scene.width * (scene.sitMode ? 0.5 : 0.16) - (scene.sitMode ? width / 2 : 0) + x: scene.width * 0.5 - width / 2 y: scene.groundY - height + 14 - scene.sitterHop - visible: !scene.reducedMotion - && (scene.sitMode || (scene.runMode && scene.idleLap && scene.beat === 0)) + // Receive only. He used to fill History's empty beat too, which now + // reads as the wrong tab's scene leaking into this one. + visible: !scene.reducedMotion && scene.sitMode opacity: visible ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: 400 } } } @@ -896,7 +896,6 @@ Rectangle { scene.runBeat = 6800 + Math.floor(Math.random() * 3000); scene.tripLap = Math.random() < 0.17; scene.tripAt = 0.34 + Math.random() * 0.34; - scene.idleLap = Math.random() < 0.45; scene.birdLap = !scene.night && Math.random() < 0.5; scene.starLap = scene.night && Math.random() < 0.4; scene.bonusIndex = (Math.random() < 0.3) From 79daae67962e3be294f2f826e2444251b959ec48 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 3 Sep 2026 15:10:48 -0400 Subject: [PATCH 5/5] fix(send): the scene sits in the middle of the space below the form It was butted straight up against the Clear/Send row, so a band of night sky touched a button and read as part of the form. Now there is a fixed 14px gap under the buttons and a stretch either side of the scene, which centres it in whatever space the form leaves. And the coins come in handfuls. One at a time reads as a metronome, so Send now has three of them: usually one goes past, sometimes two, and about one time in seven all three, staggered by a beat so they arrive together rather than in a rank. Each rolls its own height, size, duration and spin direction. Claude-Session: https://claude.ai/code/session_01VLQ5DsgKv23osGANoFT3zF --- src/SendWidget.cpp | 11 ++++-- src/assets/qml/scene.qml | 72 ++++++++++++++++++++++++++++------------ 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/src/SendWidget.cpp b/src/SendWidget.cpp index b9ec4291..d27e49a6 100644 --- a/src/SendWidget.cpp +++ b/src/SendWidget.cpp @@ -29,10 +29,17 @@ 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. + // + // Centred in the space below the form rather than stacked straight under + // it: butted up against the Clear/Send row the scene reads as part of the + // form, and a band of night sky touching a button looks like a mistake. auto *scene = new SceneWidget(this, SceneWidget::Night, SceneWidget::Money); scene->setMinimumHeight(160); - scene->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - ui->verticalLayoutOuter->addWidget(scene, 1); + scene->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + ui->verticalLayoutOuter->addSpacing(14); // never touches the Send row + ui->verticalLayoutOuter->addStretch(1); + ui->verticalLayoutOuter->addWidget(scene); + ui->verticalLayoutOuter->addStretch(1); QString amount_rx = R"(^\d{0,8}[\.,]\d{0,12}|(all)$)"; QRegularExpression rx; diff --git a/src/assets/qml/scene.qml b/src/assets/qml/scene.qml index ae4591e5..195d7eb5 100644 --- a/src/assets/qml/scene.qml +++ b/src/assets/qml/scene.qml @@ -477,22 +477,41 @@ Rectangle { property real sparkT: 1.0 NumberAnimation { id: sparkAnim; target: scene; property: "sparkT"; from: 0; to: 1; duration: 520 } - // A coin sails past on the Send screen — money going somewhere. - AnimatedImage { - id: flyingCoin - property real t: 1.0 - property real band: 0.3 - source: "qrc:/scene/goldcoin.gif" - playing: scene.live && visible - visible: !scene.reducedMotion && scene.moneyMode && t < 1.0 - width: 26; height: 26 - x: -width + t * (scene.width + width * 2) - // A shallow arc rather than a flat line, so it reads as thrown. - y: scene.height * band - Math.sin(t * Math.PI) * scene.height * 0.16 - rotation: t * 540 - opacity: Math.min(1, Math.sin(t * Math.PI) * 3) - NumberAnimation { id: flyingCoinAnim; target: flyingCoin; property: "t" - from: 0; to: 1; duration: 2600 } + // Coins sail past on the Send screen — money going somewhere. Three of + // them, because one at a time reads as a metronome; a handful crossing + // together with a beat between them reads as a payment going out. + Repeater { + id: flyingCoins + model: 3 + AnimatedImage { + id: coinFly + property real t: 1.0 + property real band: 0.3 + property real spin: 540 + source: "qrc:/scene/goldcoin.gif" + playing: scene.live && visible + visible: !scene.reducedMotion && scene.moneyMode && t < 1.0 + width: 22 + index * 5; height: width + x: -width + t * (scene.width + width * 2) + // A shallow arc rather than a flat line, so it reads as thrown. + y: scene.height * band - Math.sin(t * Math.PI) * scene.height * 0.16 + rotation: t * spin + opacity: Math.min(1, Math.sin(t * Math.PI) * 3) + + NumberAnimation { id: coinFlyAnim; target: coinFly; property: "t" + from: 0; to: 1; duration: 2600 } + Timer { id: coinFlyTimer; repeat: false; onTriggered: coinFlyAnim.restart() } + + function launch(delayMs) { + coinFlyAnim.stop(); + coinFly.t = 1.0; // hidden until it starts + coinFly.band = 0.14 + Math.random() * 0.32; + coinFly.spin = (Math.random() < 0.5 ? -1 : 1) * (360 + Math.random() * 540); + coinFlyAnim.duration = 2000 + Math.floor(Math.random() * 2400); + coinFlyTimer.interval = Math.max(1, delayMs); + coinFlyTimer.restart(); + } + } } // ...and one lands in the field on the Receive screen. A coin arriving is @@ -561,10 +580,18 @@ Rectangle { function ambientEvent() { var roll = Math.random(); if (scene.moneyMode) { - if (roll < 0.62) { - flyingCoin.band = 0.16 + Math.random() * 0.30; - flyingCoinAnim.duration = 2100 + Math.floor(Math.random() * 2200); - flyingCoinAnim.restart(); + if (roll < 0.68) { + // One coin most of the time, two now and then, occasionally + // three — staggered by a beat so they arrive as a handful + // rather than a rank. + var n = 1; + if (Math.random() < 0.38) n = 2; + if (Math.random() < 0.14) n = 3; + for (var i = 0; i < n; ++i) { + var item = flyingCoins.itemAt(i); + if (item) + item.launch(i * (120 + Math.floor(Math.random() * 380))); + } } else if (scene.night) { shootingStar.p = 1.0; starAnim.restart(); @@ -877,7 +904,10 @@ Rectangle { scene.hopY = 0.0; scene.sparkT = 1.0; scene.sitterHop = 0.0; - flyingCoin.t = 1.0; + for (var ci = 0; ci < 3; ++ci) { + var c = flyingCoins.itemAt(ci); + if (c) c.t = 1.0; + } giftCoin.t = 1.0; if (!scene.live)