From f1144c483ed541111729fffd8f2564dd58952b3b Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 3 Sep 2026 13:07:04 -0400 Subject: [PATCH] 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: