feat: 每日任务进度添加动画
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"last-module-event-record-time": 1788857110960,
|
||||
"last-module-event-record-time": 1790648876696,
|
||||
"group-list": [
|
||||
"default",
|
||||
"Map"
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user