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