mirror of
https://github.com/wownero/wowlet.git
synced 2026-10-17 15:21:30 +02:00
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:
@@ -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
@@ -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);
|
||||||
|
|||||||
+152
-34
@@ -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 {
|
Rectangle {
|
||||||
x: scene.width * 0.74; y: scene.height * 0.14
|
width: (index % 4 === 0) ? 3 : 2
|
||||||
width: 62; height: 62; radius: 31
|
height: width
|
||||||
color: "#ffe9b0"; opacity: 0.9
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Money crosses with the doge, a little quicker, so it leads him off screen.
|
// Sun, or a paler moon at night.
|
||||||
|
Rectangle {
|
||||||
|
x: scene.width * 0.74
|
||||||
|
y: scene.height * (scene.night ? 0.10 : 0.14)
|
||||||
|
width: scene.night ? 46 : 62
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: scene.night ? "#e8ecff" : "#ffe9b0"
|
||||||
|
opacity: scene.night ? 0.85 : 0.9
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money crosses 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
|
||||||
}
|
}
|
||||||
PauseAnimation { duration: scene.settleBeat }
|
|
||||||
|
|
||||||
// 3. the run
|
|
||||||
NumberAnimation {
|
NumberAnimation {
|
||||||
|
id: runAnim
|
||||||
target: scene; property: "runProgress"
|
target: scene; property: "runProgress"
|
||||||
from: 0.0; to: 1.0
|
from: 0.0; to: 1.0
|
||||||
duration: scene.runBeat
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: if (!scene.reducedMotion) scene.startLap();
|
||||||
|
|
||||||
|
onReducedMotionChanged: {
|
||||||
|
if (scene.reducedMotion) {
|
||||||
|
beatTimer.stop(); moneyTimer.stop();
|
||||||
|
revealAnim.stop(); runAnim.stop(); moneyAnim.stop();
|
||||||
|
scene.tripLap = false;
|
||||||
|
} else {
|
||||||
|
scene.startLap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user