feat: 每日任务进度添加动画

This commit is contained in:
guanchao
2026-09-29 11:18:17 +08:00
parent af624bcb5f
commit 94cc9e2ad1
4 changed files with 1490 additions and 1313 deletions
File diff suppressed because it is too large Load Diff
+76 -9
View File
@@ -50,6 +50,7 @@ export default class DailyQuests extends cc.Component {
private rewardEffectLayer: cc.Node = null;
private rewardEffectComplete: (() => void) = null;
private dailyTaskOpeningPlayed = false;
private dailyTaskProgressTween: cc.Tween<{ ratio: number }> = null;
private dailyTaskStateComplete: (() => void) = null;
private dailyTaskStateAnimations: {
node: cc.Node, task: any, index: number, scaleX: number, scaleY: number,
@@ -73,6 +74,7 @@ export default class DailyQuests extends cc.Component {
// 当前这次打开跨期后保留原界面,关闭再打开才接收新周期数据。
if (this.dailyTaskOpeningPlayed && this.hasExpiredDisplayedTasks()) return;
this.cacheRefreshPending = false;
this.clearDailyTaskProgressAnimation();
this.clearDailyTaskStateAnimations();
data = cc.fx.GameTool.getCachedDailyQuests() || data;
this.sceneValue = cc.fx.GameConfig.GM_INFO.sceneValue;
@@ -98,13 +100,13 @@ export default class DailyQuests extends cc.Component {
if (finishNode) finishNode.active = data.dailyTasks.every(task => task.state === 2);
const layout = this.content.getComponent(cc.Layout);
if (layout) layout.updateLayout();
if (animateOpening) this.playDailyTaskStateAnimations(data.dailyTasks);
if (animateOpening) this.playDailyTaskProgressAnimation(data.dailyTasks, this.cacheOpenedTaskProgress(data));
}
refreshFromCache() {
if (this.hasExpiredDisplayedTasks()) return;
if (!this.touchStart || this.dailyTaskStateAnimations.length || this.rewardEffectLayer) {
if (!this.touchStart || this.dailyTaskProgressTween || this.dailyTaskStateAnimations.length || this.rewardEffectLayer) {
this.cacheRefreshPending = true;
return;
}
@@ -124,7 +126,7 @@ export default class DailyQuests extends cc.Component {
private updateCountdowns(): void {
if (this.cacheRefreshPending && this.touchStart
&& !this.dailyTaskStateAnimations.length && !this.rewardEffectLayer) {
&& !this.dailyTaskProgressTween && !this.dailyTaskStateAnimations.length && !this.rewardEffectLayer) {
this.refreshFromCache();
}
const bg = this.node.getChildByName("bg");
@@ -177,12 +179,8 @@ export default class DailyQuests extends cc.Component {
descNode.active = !claimable;
descNode.y = claimed ? 0 : 32;
const processNode = child.getChildByName("process");
const value = Math.max(0, Math.min(Number(task.value) || 0, task.target));
processNode.getComponent(cc.ProgressBar).progress = task.target > 0 ? value / task.target : 0;
this.renderDailyTaskProgress(child, task, Number(task.value) || 0);
const processLabel = child.getChildByName("processLabel");
const progressText = task.metric === "online_seconds"
? `${this.formatClock(value)}/${this.formatClock(task.target)}` : `${value}/${task.target}`;
this.setNodeText(processLabel, progressText);
processLabel.active = state === 0;
const rewardsNode = child.getChildByName("rewards");
rewardsNode.active = !claimed;
@@ -197,11 +195,78 @@ export default class DailyQuests extends cc.Component {
getNode.active = claimable;
this.bindButtonData(getNode, task.taskId);
const button = getNode.getComponent(cc.Button);
if (button) button.interactable = claimable && this.dailyTaskStateAnimations.length === 0;
if (button) button.interactable = claimable && !this.dailyTaskProgressTween && this.dailyTaskStateAnimations.length === 0;
}
if (completeNode) completeNode.active = claimed;
}
private renderDailyTaskProgress(node: cc.Node, task: any, value: number): void {
const target = Math.max(0, Number(task.target) || 0);
value = Math.max(0, Math.min(value, target));
node.getChildByName("process").getComponent(cc.ProgressBar).progress = target > 0 ? value / target : 0;
// 在线任务仍按秒累计和填充进度条,只将显示单位转换为整分钟。
const unit = task.metric === "online_seconds" ? 60 : 1;
this.setNodeText(node.getChildByName("processLabel"), `${Math.floor(value / unit)}/${Math.floor(target / unit)}`);
}
private cacheOpenedTaskProgress(data: any): number[] {
const current = data.dailyTasks.map(task => Math.max(0, Math.min(Number(task.value) || 0, Number(task.target) || 0)));
const day = data.dayKey || data.dayEndsAt;
if (!day) return current;
// taskId 可能复用;将目标和随机参数一起匹配,避免调试重置后沿用另一任务的进度。
const keys = data.dailyTasks.map(task => JSON.stringify([task.taskId, task.metric, task.target, task.params || {}]));
try {
const uid = cc.fx.StorageMessage.getStorage("uid") || cc.fx.GameConfig.GM_INFO.uid;
if (!uid) return current;
const storageKey = `dailyTaskOpenedProgress:${uid}`;
const previous = cc.fx.StorageMessage.getStorage(storageKey);
const values = previous && previous.day === day && previous.values || {};
const starts = keys.map((key, index) => typeof values[key] === "number" && Number.isFinite(values[key])
? Math.max(0, Math.min(values[key], current[index])) : 0);
const snapshot = {};
keys.forEach((key, index) => snapshot[key] = current[index]);
// 保存打开时的最终进度;后续刷新、领取以及关闭不覆盖这份快照。
cc.fx.StorageMessage.setStorage(storageKey, { day, values: snapshot });
return starts;
} catch (error) {
// 存储不可用时正常展示任务,不影响领取。
return current;
}
}
private playDailyTaskProgressAnimation(tasks: any[], starts: number[]): void {
const rows = tasks.map((task, index) => ({ task, index, node: this.content.getChildByName(task.taskId),
start: starts[index], end: Math.max(0, Math.min(Number(task.value) || 0, Number(task.target) || 0)) }))
.filter(row => cc.isValid(row.node) && row.task.state !== 2);
if (!this.node.activeInHierarchy || !rows.some(row => row.end > row.start)) {
this.playDailyTaskStateAnimations(tasks);
return;
}
this.touchStart = false;
rows.forEach(({ node, task, index, start }) => {
this.renderDailyTask(node, task, index, 0);
this.renderDailyTaskProgress(node, task, start);
});
// 共用一个补间,让所有行同时开始和结束,且不改动 GM_INFO.tasks。
this.dailyTaskProgressTween = cc.tween({ ratio: 0 })
.to(0.6, { ratio: 1 }, { easing: "quadOut", onUpdate: (value: { ratio: number }) => {
rows.forEach(row => this.renderDailyTaskProgress(row.node, row.task, row.start + (row.end - row.start) * value.ratio));
} })
.call(() => {
this.dailyTaskProgressTween = null;
rows.forEach(row => this.renderDailyTaskProgress(row.node, row.task, row.end));
this.touchStart = true;
this.playDailyTaskStateAnimations(tasks);
}).start();
}
private clearDailyTaskProgressAnimation(): void {
if (!this.dailyTaskProgressTween) return;
this.dailyTaskProgressTween.stop();
this.dailyTaskProgressTween = null;
this.touchStart = true;
}
private playDailyTaskStateAnimations(tasks: any[], claimedTaskId = "", onComplete?: () => void): void {
if (!cc.isValid(this.node) || !this.node.activeInHierarchy) {
if (onComplete) onComplete();
@@ -962,6 +1027,7 @@ export default class DailyQuests extends cc.Component {
}
onDisable() {
this.clearDailyTaskProgressAnimation();
this.clearDailyTaskStateAnimations();
this.dailyTaskOpeningPlayed = false;
this.clearRewardAnimation();
@@ -969,6 +1035,7 @@ export default class DailyQuests extends cc.Component {
}
onDestroy() {
this.clearDailyTaskProgressAnimation();
this.clearDailyTaskStateAnimations();
this.clearRewardAnimation();
this.stopWeeklyChestAnimations();
+1 -1
View File
@@ -1,5 +1,5 @@
{
"last-module-event-record-time": 1788857110960,
"last-module-event-record-time": 1790648876696,
"group-list": [
"default",
"Map"
+111 -1
View File
@@ -69,6 +69,7 @@ function clone(node) {
function setup(states = [0, 1, 1, 2], options = {}) {
const tweens = [], requests = [], timers = [], grants = [], flights = [];
const storage = new Map();
// Advance real durations without waiting on a wall clock; callbacks run only
// after their preceding segment, and stopped tweens cannot change nodes.
class Tween {
@@ -88,6 +89,7 @@ function setup(states = [0, 1, 1, 2], options = {}) {
this.elapsed += consumed; seconds -= consumed;
const progress = Math.min(1, this.elapsed / step.duration);
for (const [key, end] of Object.entries(step.properties || {})) this.target[key] = this.initial[key] + (end - this.initial[key]) * progress;
if (step.easing?.onUpdate) step.easing.onUpdate(this.target, progress);
if (this.elapsed + 1e-9 < step.duration) return;
if (step.properties) Object.assign(this.target, step.properties);
this.index++; this.elapsed = 0; this.initial = null;
@@ -108,6 +110,10 @@ function setup(states = [0, 1, 1, 2], options = {}) {
MiniGameSdk: { API: { showToast() {} } } };
cc.fx.GameTool = {getDailyTaskServerTime: () => Date.now(), claimDailyQuestReward: context.Utils.getDailyQuestReward};
cc.fx.GameTool.getCachedDailyQuests = () => null;
cc.fx.StorageMessage = {
getStorage: key => storage.has(key) ? structuredClone(storage.get(key)) : null,
setStorage: (key, value) => storage.set(key, structuredClone(value))
};
vm.runInNewContext(code, context);
const daily = new context.DailyQuests(); daily.node = build(1);
daily.content = daily.node.getChildByName('bg').getChildByName('content');
@@ -126,11 +132,115 @@ function setup(states = [0, 1, 1, 2], options = {}) {
cc.fx.GameConfig.GM_INFO.tasks = data;
daily.onLoad();
if (options.initialize !== false) daily.init(data);
return { daily, data, cc, tweens, requests, timers, grants, flights, rows: daily.content.children,
return { daily, data, cc, storage, tweens, requests, timers, grants, flights, rows: daily.content.children,
advance(seconds) { [...tweens].forEach(tween => tween.advance(seconds)); } };
}
function button(row) { return row.getChildByName('btnGet').getComponent('cc.Button'); }
function progress(row) { return row.getChildByName('process').getComponent('cc.ProgressBar').progress; }
function progressText(row) { return row.getChildByName('processLabel').getComponent('cc.Label').string; }
function progressSetup() {
const h = setup([0, 0], { initialize: false });
h.storage.set('uid', 'player-a'); h.data.dayKey = '2026-09-29';
Object.assign(h.data.dailyTasks[0], { target: 10, value: 2 });
Object.assign(h.data.dailyTasks[1], { target: 1800, value: 360, metric: 'online_seconds' });
return h;
}
test('opening animates all task progress together, caches the final values, and displays online minutes', () => {
const h = progressSetup(); h.daily.init(h.data);
assert.equal(h.tweens.length, 1);
assert.equal(progressText(h.rows[0]), '0/10'); assert.equal(progressText(h.rows[1]), '0/30');
assert.equal(h.daily.touchStart, false);
const snapshot = h.storage.get('dailyTaskOpenedProgress:player-a');
assert.deepEqual(Object.values(snapshot.values), [2, 360]);
h.advance(0.3);
assert.ok(progress(h.rows[0]) > 0 && progress(h.rows[0]) < 0.2);
assert.equal(progress(h.rows[0]), progress(h.rows[1]));
h.advance(0.3);
assert.equal(progressText(h.rows[0]), '2/10'); assert.equal(progressText(h.rows[1]), '6/30');
assert.equal(h.daily.touchStart, true);
assert.deepEqual(h.data.dailyTasks.map(task => task.value), [2, 360]);
h.daily.onDisable();
h.data.dailyTasks[0].value = 8; h.data.dailyTasks[1].value = 900;
h.daily.init(h.data);
assert.equal(progressText(h.rows[0]), '2/10'); assert.equal(progressText(h.rows[1]), '6/30');
h.advance(0.3);
assert.equal(progressText(h.rows[0]), '5/10');
assert.ok(progress(h.rows[1]) > 0.2 && progress(h.rows[1]) < 0.5);
h.advance(0.3);
assert.equal(progressText(h.rows[0]), '8/10'); assert.equal(progressText(h.rows[1]), '15/30');
});
test('progress reaches the goal before completion jelly animations and claims stay locked through both stages', () => {
const h = progressSetup();
h.data.dailyTasks.forEach(task => { task.value = task.target; task.state = 1; });
h.daily.init(h.data);
h.daily.getReward(null, 'daily_0'); assert.equal(h.requests.length, 0);
h.advance(0.3); assert.equal(h.rows[0].scale, 1); assert.equal(h.tweens.length, 1);
h.advance(0.3);
assert.equal(h.tweens.length, 3);
assert.ok(h.rows.every(row => progress(row) === 1));
h.daily.getReward(null, 'daily_0'); assert.equal(h.requests.length, 0);
h.advance(0.14);
assert.ok(h.rows.every(row => !button(row).interactable));
h.advance(0.34);
assert.ok(h.rows.every(row => button(row).interactable));
h.daily.getReward(null, 'daily_0'); assert.equal(h.requests.length, 1);
});
test('background refresh is deferred during progress and never overwrites the opening snapshot', () => {
const h = progressSetup(); h.daily.init(h.data);
const updated = structuredClone(h.data); updated.dailyTasks[0].value = 7;
h.cc.fx.GameTool.getCachedDailyQuests = () => updated;
h.daily.refreshFromCache();
assert.equal(h.daily.cacheRefreshPending, true);
assert.equal(progressText(h.rows[0]), '0/10');
h.advance(0.6); h.daily.updateCountdowns();
assert.equal(progressText(h.rows[0]), '7/10');
assert.deepEqual(Object.values(h.storage.get('dailyTaskOpenedProgress:player-a').values), [2, 360]);
assert.equal(h.tweens.length, 1);
h.daily.onDisable(); h.daily.init(updated);
assert.equal(progressText(h.rows[0]), '2/10');
});
test('closing cancels progress without starting completion animations or changing a reopened panel', () => {
const h = progressSetup(); h.data.dailyTasks[0].value = 10; h.data.dailyTasks[0].state = 1;
h.daily.init(h.data); h.advance(0.2);
const oldTween = h.tweens[0];
h.daily.node.active = false; h.daily.onDisable();
assert.equal(oldTween.cancelled, true); assert.equal(h.tweens.length, 1);
h.daily.node.active = true; h.daily.init(h.data);
const count = h.tweens.length;
oldTween.advance(10); assert.equal(h.tweens.length, count);
assert.equal(progress(h.rows[0]), 1);
h.advance(1); assertClaimable(h.rows[0], true);
});
test('snapshots separate players, dates and task configurations, and never animate progress backwards', () => {
for (const change of [
h => h.storage.set('uid', 'player-b'),
h => h.data.dayKey = '2026-09-30',
h => h.data.dailyTasks[0].params = { colorId: 3 }
]) {
const h = progressSetup(); h.daily.init(h.data); h.advance(1); h.daily.onDisable();
change(h); h.daily.init(h.data);
assert.equal(progress(h.rows[0]), 0);
}
const h = progressSetup(); h.daily.init(h.data); h.advance(1); h.daily.onDisable();
h.data.dailyTasks[0].value = 1; h.daily.init(h.data);
assert.equal(progressText(h.rows[0]), '1/10'); assert.equal(h.daily.dailyTaskProgressTween, null);
});
test('online progress keeps second precision in the bar while labels use whole minutes; storage failure is harmless', () => {
const h = progressSetup(); h.data.dailyTasks[1].value = 390;
h.cc.fx.StorageMessage.setStorage = () => { throw new Error('storage full'); };
h.daily.init(h.data);
assert.equal(progressText(h.rows[1]), '6/30');
assert.equal(progress(h.rows[1]), 390 / 1800);
assert.equal(h.daily.touchStart, true);
});
function assertClaimable(row, interactable) {
assert.equal(row.getComponent('cc.Sprite').spriteFrame, 'claimable-frame');
assert.equal(row.getChildByName('btnGet').active, true);