[UI/UX] [Bug] Fix ModifierSelectPhase animation delay (#6121)

* Rework promise handling to ensure no races

* Add delay to ensure pokeball opening animation can be seen

* Remove leftover debug statements.

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Add tween bouncing pokeball to tweens that must complete for promise to resolve

* Fix typo in tsdoc

Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>
This commit is contained in:
Sirz Benjie 2025-07-25 14:36:21 -06:00 committed by GitHub
parent fc128a2f4c
commit ffa3d1cfe3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -273,12 +273,23 @@ export class ModifierSelectUiHandler extends AwaitableUiHandler {
// causing errors if reroll is selected // causing errors if reroll is selected
this.awaitingActionInput = false; this.awaitingActionInput = false;
// TODO: Replace with `Promise.withResolvers` when possible. const { promise: tweenPromise, resolve: tweenResolve } = Promise.withResolvers<void>();
let tweenResolve: () => void;
const tweenPromise = new Promise<void>(resolve => (tweenResolve = resolve));
let i = 0; let i = 0;
// TODO: Rework this bespoke logic for animating the modifier options. // #region: animation
/** Holds promises that resolve once each reward's *upgrade animation* has finished playing */
const rewardAnimPromises: Promise<void>[] = [];
/** Holds promises that resolves once *all* animations for a reward have finished playing */
const rewardAnimAllSettledPromises: Promise<void>[] = [];
/*
* A counter here is used instead of a loop to "stagger" the apperance of each reward,
* using `sine.easeIn` to speed up the appearance of the rewards as each animation progresses.
*
* The `onComplete` callback for this tween is set to resolve once the upgrade animations
* for each reward has finished playing, allowing for the next set of animations to
* start to appear.
*/
globalScene.tweens.addCounter({ globalScene.tweens.addCounter({
ease: "Sine.easeIn", ease: "Sine.easeIn",
duration: 1250, duration: 1250,
@ -288,30 +299,35 @@ export class ModifierSelectUiHandler extends AwaitableUiHandler {
const index = Math.floor(value * typeOptions.length); const index = Math.floor(value * typeOptions.length);
if (index > i && index <= typeOptions.length) { if (index > i && index <= typeOptions.length) {
const option = this.options[i]; const option = this.options[i];
option?.show( if (option) {
Math.floor((1 - value) * 1250) * 0.325 + 2000 * maxUpgradeCount, rewardAnimPromises.push(
-(maxUpgradeCount - typeOptions[i].upgradeCount), option.show(
); Math.floor((1 - value) * 1250) * 0.325 + 2000 * maxUpgradeCount,
-(maxUpgradeCount - typeOptions[i].upgradeCount),
rewardAnimAllSettledPromises,
),
);
}
i++; i++;
} }
}, },
onComplete: () => { onComplete: () => {
tweenResolve(); Promise.allSettled(rewardAnimPromises).then(() => tweenResolve());
}, },
}); });
let shopResolve: () => void; /** Holds promises that resolve once each shop item has finished animating */
const shopPromise = new Promise<void>(resolve => (shopResolve = resolve)); const shopAnimPromises: Promise<void>[] = [];
tweenPromise.then(() => { globalScene.time.delayedCall(1000 + maxUpgradeCount * 2000, () => {
globalScene.time.delayedCall(1000, () => { for (const shopOption of this.shopOptionsRows.flat()) {
for (const shopOption of this.shopOptionsRows.flat()) { // It is safe to skip awaiting the `show` method here,
shopOption.show(0, 0); // as the promise it returns is also part of the promise appended to `shopAnimPromises`,
} // which is awaited later on.
shopResolve(); shopOption.show(0, 0, shopAnimPromises, false);
}); }
}); });
shopPromise.then(() => { tweenPromise.then(() => {
globalScene.time.delayedCall(500, () => { globalScene.time.delayedCall(500, () => {
if (partyHasHeldItem) { if (partyHasHeldItem) {
this.transferButtonContainer.setAlpha(0); this.transferButtonContainer.setAlpha(0);
@ -344,31 +360,39 @@ export class ModifierSelectUiHandler extends AwaitableUiHandler {
duration: 250, duration: 250,
}); });
const updateCursorTarget = () => { // Ensure that the reward animations have completed before allowing input to proceed.
if (globalScene.shopCursorTarget === ShopCursorTarget.CHECK_TEAM) { // Required to ensure that the user cannot interact with the UI before the animations
this.setRowCursor(0); // have completed, (which, among other things, would allow the GameObjects to be destroyed
this.setCursor(2); // before the animations have completed, causing errors).
} else if (globalScene.shopCursorTarget === ShopCursorTarget.SHOP && globalScene.gameMode.hasNoShop) { Promise.allSettled([...shopAnimPromises, ...rewardAnimAllSettledPromises]).then(() => {
this.setRowCursor(ShopCursorTarget.REWARDS); const updateCursorTarget = () => {
this.setCursor(0); if (globalScene.shopCursorTarget === ShopCursorTarget.CHECK_TEAM) {
} else { this.setRowCursor(0);
this.setRowCursor(globalScene.shopCursorTarget); this.setCursor(2);
this.setCursor(0); } else if (globalScene.shopCursorTarget === ShopCursorTarget.SHOP && globalScene.gameMode.hasNoShop) {
} this.setRowCursor(ShopCursorTarget.REWARDS);
}; this.setCursor(0);
} else {
this.setRowCursor(globalScene.shopCursorTarget);
this.setCursor(0);
}
};
updateCursorTarget(); updateCursorTarget();
handleTutorial(Tutorial.Select_Item).then(res => { handleTutorial(Tutorial.Select_Item).then(res => {
if (res) { if (res) {
updateCursorTarget(); updateCursorTarget();
} }
this.awaitingActionInput = true; this.awaitingActionInput = true;
this.onActionInput = args[2]; this.onActionInput = args[2];
});
}); });
}); });
}); });
// #endregion: animation
return true; return true;
} }
@ -820,14 +844,45 @@ class ModifierOption extends Phaser.GameObjects.Container {
} }
} }
show(remainingDuration: number, upgradeCountOffset: number) { /**
if (!this.modifierTypeOption.cost) { * Start the tweens responsible for animating the option's appearance
*
* @privateremarks
* This method is unusual. It "returns" (one via the actual return, one by via appending to the `promiseHolder`
* parameter) two promises. The promise returned by the method resolves once the option's appearance animations have
* completed, and is meant to allow callers to synchronize with the completion of the option's appearance animations.
* The promise appended to `promiseHolder` resolves once *all* animations started by this method have completed,
* and should be used by callers to ensure that all animations have completed before proceeding.
*
* @param remainingDuration - The duration in milliseconds that the animation can play for
* @param upgradeCountOffset - The offset to apply to the upgrade count for options whose rarity is being upgraded
* @param promiseHolder - A promise that resolves once all tweens started by this method have completed will be pushed to this array.
* @param isReward - Whether the option being shown is a reward, meaning it should show pokeball and upgrade animations.
* @returns A promise that resolves once the *option's apperance animations* have completed. This promise will resolve _before_ all
* promises that are initiated in this method complete. Instead, the `promiseHolder` array will contain a new promise
* that will resolve once all animations have completed.
*
*/
async show(
remainingDuration: number,
upgradeCountOffset: number,
promiseHolder: Promise<void>[],
isReward = true,
): Promise<void> {
/** Promises for the pokeball and upgrade animations */
const animPromises: Promise<void>[] = [];
if (isReward) {
const { promise: bouncePromise, resolve: resolveBounce } = Promise.withResolvers<void>();
globalScene.tweens.add({ globalScene.tweens.add({
targets: this.pb, targets: this.pb,
y: 0, y: 0,
duration: 1250, duration: 1250,
ease: "Bounce.Out", ease: "Bounce.Out",
onComplete: () => {
resolveBounce();
},
}); });
animPromises.push(bouncePromise);
let lastValue = 1; let lastValue = 1;
let bounceCount = 0; let bounceCount = 0;
@ -857,7 +912,9 @@ class ModifierOption extends Phaser.GameObjects.Container {
// TODO: Figure out proper delay between chains and then convert this into a single tween chain // TODO: Figure out proper delay between chains and then convert this into a single tween chain
// rather than starting multiple tween chains. // rather than starting multiple tween chains.
for (let u = 0; u < this.modifierTypeOption.upgradeCount; u++) { for (let u = 0; u < this.modifierTypeOption.upgradeCount; u++) {
const { resolve, promise } = Promise.withResolvers<void>();
globalScene.tweens.chain({ globalScene.tweens.chain({
tweens: [ tweens: [
{ {
@ -883,65 +940,99 @@ class ModifierOption extends Phaser.GameObjects.Container {
ease: "Sine.easeOut", ease: "Sine.easeOut",
onComplete: () => { onComplete: () => {
this.pbTint.setVisible(false); this.pbTint.setVisible(false);
resolve();
}, },
}, },
], ],
}); });
animPromises.push(promise);
} }
} }
const finalPromises: Promise<void>[] = [];
globalScene.time.delayedCall(remainingDuration + 2000, () => { globalScene.time.delayedCall(remainingDuration + 2000, () => {
if (!globalScene) { if (isReward) {
return;
}
if (!this.modifierTypeOption.cost) {
this.pb.setTexture("pb", `${this.getPbAtlasKey(0)}_open`); this.pb.setTexture("pb", `${this.getPbAtlasKey(0)}_open`);
globalScene.playSound("se/pb_rel"); globalScene.playSound("se/pb_rel");
const { resolve: pbResolve, promise: pbPromise } = Promise.withResolvers<void>();
globalScene.tweens.add({ globalScene.tweens.add({
targets: this.pb, targets: this.pb,
duration: 500, duration: 500,
delay: 250,
ease: "Sine.easeIn", ease: "Sine.easeIn",
alpha: 0, alpha: 0,
onComplete: () => this.pb.destroy(), onComplete: () => {
Promise.allSettled(animPromises).then(() => this.pb.destroy());
pbResolve();
},
}); });
finalPromises.push(pbPromise);
} }
/** Delay for the rest of the tweens to ensure they show after the pokeball animation begins to appear */
const delay = isReward ? 250 : 0;
const { resolve: itemResolve, promise: itemPromise } = Promise.withResolvers<void>();
globalScene.tweens.add({ globalScene.tweens.add({
targets: this.itemContainer, targets: this.itemContainer,
delay,
duration: 500, duration: 500,
ease: "Elastic.Out", ease: "Elastic.Out",
scale: 2, scale: 2,
alpha: 1, alpha: 1,
onComplete: () => {
itemResolve();
},
}); });
if (!this.modifierTypeOption.cost) { finalPromises.push(itemPromise);
if (isReward) {
const { resolve: itemTintResolve, promise: itemTintPromise } = Promise.withResolvers<void>();
globalScene.tweens.add({ globalScene.tweens.add({
targets: this.itemTint, targets: this.itemTint,
alpha: 0, alpha: 0,
delay,
duration: 500, duration: 500,
ease: "Sine.easeIn", ease: "Sine.easeIn",
onComplete: () => this.itemTint.destroy(), onComplete: () => {
this.itemTint.destroy();
itemTintResolve();
},
}); });
finalPromises.push(itemTintPromise);
} }
const { resolve: itemTextResolve, promise: itemTextPromise } = Promise.withResolvers<void>();
globalScene.tweens.add({ globalScene.tweens.add({
targets: this.itemText, targets: this.itemText,
delay,
duration: 500, duration: 500,
alpha: 1, alpha: 1,
y: 25, y: 25,
ease: "Cubic.easeInOut", ease: "Cubic.easeInOut",
onComplete: () => itemTextResolve(),
}); });
finalPromises.push(itemTextPromise);
if (this.itemCostText) { if (this.itemCostText) {
const { resolve: itemCostResolve, promise: itemCostPromise } = Promise.withResolvers<void>();
globalScene.tweens.add({ globalScene.tweens.add({
targets: this.itemCostText, targets: this.itemCostText,
delay,
duration: 500, duration: 500,
alpha: 1, alpha: 1,
y: 35, y: 35,
ease: "Cubic.easeInOut", ease: "Cubic.easeInOut",
onComplete: () => itemCostResolve(),
}); });
finalPromises.push(itemCostPromise);
} }
}); });
// The `.then` suppresses the return type for the Promise.allSettled so that it returns void.
promiseHolder.push(Promise.allSettled([...animPromises, ...finalPromises]).then());
await Promise.allSettled(animPromises);
} }
getPbAtlasKey(tierOffset = 0) { getPbAtlasKey(tierOffset = 0) {