fix(scene): money no longer vanishes mid-flight, and the quiet tabs have something to watch

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
This commit is contained in:
jwinterm
2026-09-03 14:17:54 -04:00
parent f1144c483e
commit 068e4c2aa5
+159 -10
View File
@@ -130,6 +130,16 @@ Rectangle {
// One roll per lap: how many blow through, when, how fast, how high, how big. // One roll per lap: how many blow through, when, how fast, how high, how big.
property var moneyRolls: [] 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() { function rollMoney() {
var n = [0, 1, 1, 2, 2, 3][Math.floor(Math.random() * 6)]; var n = [0, 1, 1, 2, 2, 3][Math.floor(Math.random() * 6)];
var out = []; var out = [];
@@ -141,7 +151,8 @@ Rectangle {
"dur": 4200 + Math.floor(Math.random() * 3600), "dur": 4200 + Math.floor(Math.random() * 3600),
"band": Math.random(), "band": Math.random(),
"size": 0.72 + Math.random() * 0.55, "size": 0.72 + Math.random() * 0.55,
"bob": 6 + Math.random() * 12 "bob": 6 + Math.random() * 12,
"tumble": Math.random() * 2.5
}); });
} }
return out; return out;
@@ -247,7 +258,7 @@ Rectangle {
// Two birds, rolled per lap, daytime skies only. // Two birds, rolled per lap, daytime skies only.
Repeater { Repeater {
model: (scene.night || !scene.birdLap) ? 0 : 2 model: (scene.night || !(scene.birdLap || scene.sitMode)) ? 0 : 2
Item { Item {
id: bird id: bird
property real p: 0 property real p: 0
@@ -267,7 +278,7 @@ Rectangle {
rotation: 22 - Math.sin(bird.p * 46 + index) * 16 rotation: 22 - Math.sin(bird.p * 46 + index) * 16
} }
NumberAnimation on p { NumberAnimation on p {
running: scene.live && scene.birdLap running: scene.live && (scene.birdLap || scene.sitMode)
loops: Animation.Infinite loops: Animation.Infinite
from: 0; to: 1 from: 0; to: 1
duration: 21000 + index * 7000 duration: 21000 + index * 7000
@@ -297,11 +308,25 @@ Rectangle {
var span = Math.max(10, scene.height * 0.42 - height * 0.4); 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); 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 opacity: 0.92
NumberAnimation { id: flyAnim; target: bill; property: "p"; from: 0; to: 1; duration: bill.roll ? bill.roll.dur : 6000 } NumberAnimation {
Timer { id: flyTimer; repeat: false; interval: bill.roll ? bill.roll.delay : 0; onTriggered: if (bill.roll && bill.roll.active) flyAnim.restart() } 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) { function armFor(beat) {
flyAnim.stop(); flyAnim.stop();
@@ -312,6 +337,20 @@ Rectangle {
&& bill.roll && bill.roll.active) && bill.roll && bill.roll.active)
flyTimer.restart(); 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 { Connections {
target: scene target: scene
function onBeatChanged() { bill.armFor(scene.beat) } function onBeatChanged() { bill.armFor(scene.beat) }
@@ -439,6 +478,115 @@ Rectangle {
property real sparkT: 1.0 property real sparkT: 1.0
NumberAnimation { id: sparkAnim; target: scene; property: "sparkT"; from: 0; to: 1; duration: 520 } 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. // The doge, sitting this one out. Beat 0 only, and only on some laps.
AnimatedImage { AnimatedImage {
id: sitter id: sitter
@@ -446,7 +594,7 @@ Rectangle {
playing: scene.live && visible playing: scene.live && visible
width: 84; height: 67 width: 84; height: 67
x: scene.width * (scene.sitMode ? 0.5 : 0.16) - (scene.sitMode ? width / 2 : 0) 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 visible: !scene.reducedMotion
&& (scene.sitMode || (scene.runMode && scene.idleLap && scene.beat === 0)) && (scene.sitMode || (scene.runMode && scene.idleLap && scene.beat === 0))
opacity: visible ? 1.0 : 0.0 opacity: visible ? 1.0 : 0.0
@@ -728,6 +876,9 @@ Rectangle {
scene.picked = 0; scene.picked = 0;
scene.hopY = 0.0; scene.hopY = 0.0;
scene.sparkT = 1.0; scene.sparkT = 1.0;
scene.sitterHop = 0.0;
flyingCoin.t = 1.0;
giftCoin.t = 1.0;
if (!scene.live) if (!scene.live)
return; return;
@@ -739,9 +890,7 @@ Rectangle {
if (scene.moneyMode) { if (scene.moneyMode) {
scene.moneyRolls = scene.rollMoney(); scene.moneyRolls = scene.rollMoney();
scene.beat = 2; // arms the bills through armFor() scene.beat = 2; // arms the bills through armFor()
beatTimer.interval = 9000 + Math.floor(Math.random() * 5000); return; // each bill reschedules itself
beatTimer.start();
return;
} }
scene.runBeat = 6800 + Math.floor(Math.random() * 3000); scene.runBeat = 6800 + Math.floor(Math.random() * 3000);