Celebration animations for purchases, XP gains & achievement unlocks
- New global celebration system: celebration-store (queue) + CelebrationOverlay (animated: count-up XP, filling bar, level-up pop, achievement cards; plays levelUp/award sounds; tap or auto-dismiss). Rendered in page.tsx. - Shop: every purchase now celebrates — XP packs animate XP gain + level-up, cosmetics show a "purchased!" pop. Newly-unlocked achievements (diffed from the profile before/after) animate too. - XP purchases now actually evaluate achievements: gamification.evaluateAchievements (client) + Gamification.EvaluateAchievements (server, called in ShopBuy xp path) unlock level milestones + grant their coins. Verified live: buying XP took L1→L5, unlocked level_5 server-side and credited its reward. tsc + dotnet + next build clean; images rebuilt :1500/:1505. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { AchievementUnlock } from "./online/types";
|
||||
|
||||
/** A queued celebration shown by <CelebrationOverlay/>. */
|
||||
export interface Celebration {
|
||||
id: number;
|
||||
variant: "xp" | "purchase";
|
||||
title?: string;
|
||||
/** emoji/glyph shown big at the top */
|
||||
icon?: string;
|
||||
xpGained?: number;
|
||||
levelBefore?: number;
|
||||
levelAfter?: number;
|
||||
achievements?: AchievementUnlock[];
|
||||
}
|
||||
|
||||
interface CelebrationStore {
|
||||
current: Celebration | null;
|
||||
queue: Celebration[];
|
||||
celebrate: (c: Omit<Celebration, "id">) => void;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
let _id = 1;
|
||||
|
||||
export const useCelebrationStore = create<CelebrationStore>((set, get) => ({
|
||||
current: null,
|
||||
queue: [],
|
||||
celebrate: (c) => {
|
||||
const item: Celebration = { ...c, id: _id++ };
|
||||
if (get().current) set({ queue: [...get().queue, item] });
|
||||
else set({ current: item });
|
||||
},
|
||||
dismiss: () => {
|
||||
const [next, ...rest] = get().queue;
|
||||
set({ current: next ?? null, queue: rest });
|
||||
},
|
||||
}));
|
||||
|
||||
/** Convenience helper usable from anywhere (no hook needed). */
|
||||
export function celebrate(c: Omit<Celebration, "id">) {
|
||||
useCelebrationStore.getState().celebrate(c);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const fa: Dict = {
|
||||
"resume.cta": "بازگشت به بازی",
|
||||
"resume.matchEnded": "بازی به پایان رسید",
|
||||
"resume.matchEndedBody": "نتیجه و جایزه را ببینید",
|
||||
"celebrate.purchased": "خرید با موفقیت انجام شد!",
|
||||
|
||||
"achv.title": "دستاوردها",
|
||||
"achv.unlocked": "باز شده",
|
||||
@@ -299,6 +300,7 @@ const en: Dict = {
|
||||
"resume.cta": "Return to game",
|
||||
"resume.matchEnded": "Match ended",
|
||||
"resume.matchEndedBody": "See the result and reward",
|
||||
"celebrate.purchased": "Purchase complete!",
|
||||
|
||||
"achv.title": "Achievements",
|
||||
"achv.unlocked": "Unlocked",
|
||||
|
||||
@@ -298,6 +298,40 @@ export function achievementById(id: string): AchievementDef | undefined {
|
||||
return ACHIEVEMENTS.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-evaluate all achievements against the profile's current state (used outside
|
||||
* matches, e.g. after an XP-pack purchase crosses a level milestone). Unlocks new
|
||||
* ones, grants their coin rewards, and returns the newly-unlocked list.
|
||||
*/
|
||||
export function evaluateAchievements(profile: UserProfile): {
|
||||
profile: UserProfile;
|
||||
newAchievements: AchievementUnlock[];
|
||||
} {
|
||||
const achievements = { ...profile.achievements };
|
||||
const unlocked = [...profile.unlocked];
|
||||
const newAchievements: AchievementUnlock[] = [];
|
||||
let coins = 0;
|
||||
for (const def of ACHIEVEMENTS) {
|
||||
const prog = achievementProgress(def, profile.stats, profile.rating, profile.level);
|
||||
achievements[def.id] = prog;
|
||||
if (prog >= def.goal && !unlocked.includes(def.id)) {
|
||||
unlocked.push(def.id);
|
||||
coins += def.coinReward;
|
||||
newAchievements.push({
|
||||
id: def.id,
|
||||
nameFa: def.nameFa,
|
||||
nameEn: def.nameEn,
|
||||
icon: def.icon,
|
||||
coinReward: def.coinReward,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
profile: { ...profile, achievements, unlocked, coins: profile.coins + coins },
|
||||
newAchievements,
|
||||
};
|
||||
}
|
||||
|
||||
/** The sticker pack (if any) that unlocking this achievement grants. */
|
||||
export function stickerPackForAchievement(achId: string): StickerPackDef | undefined {
|
||||
return STICKER_PACKS.find((p) => p.unlockAchievement === achId);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
addXp,
|
||||
applyMatchResult,
|
||||
dailyRewardFor,
|
||||
evaluateAchievements,
|
||||
faNum,
|
||||
xpNeededForLevel,
|
||||
} from "./gamification";
|
||||
@@ -898,7 +899,10 @@ export class MockOnlineService implements OnlineService {
|
||||
return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" };
|
||||
const pack = XP_PACKS.find((x) => x.id === id)!;
|
||||
const lvl = addXp(p.level, p.xp, pack.xp);
|
||||
this.profile = { ...p, coins: p.coins - item.price, level: lvl.level, xp: lvl.xp };
|
||||
const leveled = { ...p, coins: p.coins - item.price, level: lvl.level, xp: lvl.xp };
|
||||
// unlock any level milestones the new level reaches
|
||||
const { profile: evaluated } = evaluateAchievements(leveled);
|
||||
this.profile = evaluated;
|
||||
this.saveProfile();
|
||||
return { ok: true, profile: this.profile, messageFa: "امتیاز اضافه شد", messageEn: "XP added" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user