Skip to content

Commit 7072ff8

Browse files
Replace 19-badge system with 200 badges across Building & Exploration tracks
Two tracks: Building (100 badges, edits 1→1M cubic curve) and Exploration (100 badges, cells completed 1→50K cubic curve). Badge images served as external PNGs from center-art/ instead of base64. Profile, leaderboard, weekly recap, and achievement toast all updated for dual-track display. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1e27f3b commit 7072ff8

File tree

7 files changed

+451
-253
lines changed

7 files changed

+451
-253
lines changed

scripts/deploy-gh-pages.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ git clone --depth 1 "$DEPLOY_REPO" "$DEPLOY_DIR"
1515
rm -rf "$DEPLOY_DIR"/*
1616
cp -r dist/min/* "$DEPLOY_DIR/"
1717

18+
# Copy badge center-art PNGs
19+
BADGE_ART="../Documents/New project/static/badges/pyr/center-art"
20+
if [ -d "$BADGE_ART" ]; then
21+
echo "Copying badge center-art..."
22+
mkdir -p "$DEPLOY_DIR/center-art/building" "$DEPLOY_DIR/center-art/exploration"
23+
cp "$BADGE_ART/building/"*.png "$DEPLOY_DIR/center-art/building/" 2>/dev/null || true
24+
cp "$BADGE_ART/exploration/"*.png "$DEPLOY_DIR/center-art/exploration/" 2>/dev/null || true
25+
fi
26+
1827
cd "$DEPLOY_DIR"
1928
git add -A
2029
git commit -m "Deploy $(date +%Y-%m-%d\ %H:%M)" --allow-empty

src/components/AchievementToast.vue

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import { ref, watch, onMounted, nextTick } from 'vue';
1111
import { storeToRefs } from 'pinia';
1212
import { useUserStatsStore, useProofreadingQueueStore } from '../store';
13-
import { BADGE_DEFINITIONS, BadgeDefinition } from '../widgets/badge_definitions';
13+
import { BUILDING_BADGES, EXPLORATION_BADGES, BadgeDefinition, statKeyForTrack } from '../widgets/badge_definitions';
1414
import { BADGE_IMAGE_MAP } from '../widgets/badge_images';
1515
import ConfettiCelebration from 'components/ConfettiCelebration.vue';
1616
@@ -76,14 +76,14 @@ onMounted(() => {
7676
setTimeout(() => { confettiRef.value?.sparkle(2); }, 800);
7777
});
7878
79-
// Watch for badge unlocks
79+
// Watch for building badge unlocks (edits)
8080
watch(() => stats.value.editsAllTime, (newEdits) => {
8181
if (!initialized) { prevEdits = newEdits; return; }
8282
// First real update after init — just capture baseline, don't celebrate
8383
if (!statsSeenNonZero && newEdits > 0) { statsSeenNonZero = true; prevEdits = newEdits; return; }
84-
for (const badge of BADGE_DEFINITIONS) {
85-
if (badge.editThreshold <= 0) continue;
86-
if (prevEdits < badge.editThreshold && newEdits >= badge.editThreshold) {
84+
for (const badge of BUILDING_BADGES) {
85+
if (badge.threshold <= 0) continue;
86+
if (prevEdits < badge.threshold && newEdits >= badge.threshold) {
8787
const imgUrl = BADGE_IMAGE_MAP[badge.imageKey] ?? '';
8888
addToast({
8989
type: 'badge',
@@ -92,8 +92,7 @@ watch(() => stats.value.editsAllTime, (newEdits) => {
9292
icon: imgUrl || '🏅',
9393
isImage: !!imgUrl,
9494
});
95-
// 🎊 Confetti for badge unlocks!
96-
fireConfetti('purple', badge.editThreshold >= 1000 ? 2 : 1);
95+
fireConfetti('purple', badge.threshold >= 1000 ? 2 : 1);
9796
}
9897
}
9998
// Edit milestones (round numbers) — confetti scales with milestone size
@@ -107,7 +106,6 @@ watch(() => stats.value.editsAllTime, (newEdits) => {
107106
icon: '',
108107
isImage: false,
109108
});
110-
// 🎊 Confetti intensity scales with milestone
111109
const intensity = m >= 10000 ? 3 : m >= 1000 ? 2 : 1;
112110
const palette = m >= 10000 ? 'rainbow' : m >= 1000 ? 'gold' : 'cyan';
113111
fireConfetti(palette, intensity);
@@ -116,9 +114,25 @@ watch(() => stats.value.editsAllTime, (newEdits) => {
116114
prevEdits = newEdits;
117115
});
118116
119-
// Watch for cell milestones
117+
// Watch for exploration badge unlocks + cell milestones
120118
watch(() => stats.value.cellsSubmitted, (newCells) => {
121119
if (!initialized) { prevCells = newCells; return; }
120+
// Exploration badge unlocks
121+
for (const badge of EXPLORATION_BADGES) {
122+
if (badge.threshold <= 0) continue;
123+
if (prevCells < badge.threshold && newCells >= badge.threshold) {
124+
const imgUrl = BADGE_IMAGE_MAP[badge.imageKey] ?? '';
125+
addToast({
126+
type: 'badge',
127+
title: `Badge Unlocked: ${badge.name}`,
128+
subtitle: badge.description,
129+
icon: imgUrl || '🏅',
130+
isImage: !!imgUrl,
131+
});
132+
fireConfetti('purple', badge.threshold >= 500 ? 2 : 1);
133+
}
134+
}
135+
// Cell milestones
122136
for (const m of CELL_MILESTONES) {
123137
if (prevCells < m && newCells >= m) {
124138
addToast({

src/components/LeaderboardPanel.vue

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {ref, computed, onMounted} from 'vue';
33
import {storeToRefs} from 'pinia';
44
import ModalOverlay from 'components/ModalOverlay.vue';
55
import {DEMO_USERS, DemoUser} from '../data/demo-users';
6-
import {BADGE_DEFINITIONS} from '../widgets/badge_definitions';
6+
import {BADGE_DEFINITIONS, BUILDING_BADGES, EXPLORATION_BADGES, BadgeDefinition} from '../widgets/badge_definitions';
77
import {BADGE_IMAGE_MAP} from '../widgets/badge_images';
88
import {useUserPreferencesStore, useProofreadingBackendStore} from '../store';
99
@@ -60,6 +60,13 @@ function editCountForTab(user: DemoUser): number {
6060
}
6161
6262
// Badges helpers
63+
function isBadgeEarnedByUser(badge: BadgeDefinition, user: DemoUser): boolean {
64+
if (badge.threshold === 0) return false;
65+
const stat = badge.track === 'building' ? user.stats.editsAllTime : user.stats.cellsSubmitted;
66+
return stat >= badge.threshold;
67+
}
68+
69+
// Legacy compat wrapper
6370
function isBadgeEarned(editThreshold: number, editsAllTime: number): boolean {
6471
if (editThreshold === 0) return false;
6572
return editsAllTime >= editThreshold;
@@ -69,11 +76,11 @@ function getBadgeUrl(imageKey: string): string {
6976
return BADGE_IMAGE_MAP[imageKey] ?? '';
7077
}
7178
72-
// Top earned badge for the leaderboard row (highest threshold)
79+
// Top earned badge for the leaderboard row (highest threshold from either track)
7380
function topBadge(user: DemoUser) {
7481
return BADGE_DEFINITIONS
75-
.filter(b => b.editThreshold > 0 && user.stats.editsAllTime >= b.editThreshold)
76-
.sort((a, b) => b.editThreshold - a.editThreshold)[0] ?? null;
82+
.filter(b => b.threshold > 0 && isBadgeEarnedByUser(b, user))
83+
.sort((a, b) => b.threshold - a.threshold)[0] ?? null;
7784
}
7885
7986
function selectUser(user: DemoUser) {
@@ -238,25 +245,60 @@ const emit = defineEmits({hide: null});
238245
</span>
239246
</div>
240247

241-
<!-- Badges -->
242-
<div class="nge-lb-detail-badges-label">Badges</div>
248+
<!-- Building Badges -->
249+
<div class="nge-lb-detail-badges-label" style="color: #ffd08a;">Building Badges <span style="font-size: 0.75em; opacity: 0.6;">(edits)</span></div>
250+
<div class="nge-lb-detail-badges-grid">
251+
<div
252+
v-for="badge in BUILDING_BADGES"
253+
:key="badge.id"
254+
class="nge-lb-detail-badge"
255+
:class="{
256+
'nge-lb-detail-badge--locked': !isBadgeEarnedByUser(badge, selectedUser),
257+
'nge-lb-detail-badge--selected': selectedBadgeId === badge.id,
258+
}"
259+
:title="isBadgeEarnedByUser(badge, selectedUser)
260+
? badge.name + ' — click for details'
261+
: '??? (locked)'"
262+
@click="isBadgeEarnedByUser(badge, selectedUser)
263+
? onDetailBadgeClick(badge.id)
264+
: undefined"
265+
>
266+
<template v-if="isBadgeEarnedByUser(badge, selectedUser)">
267+
<div class="nge-lb-detail-badge-img">
268+
<img :src="getBadgeUrl(badge.imageKey)" :alt="badge.name" class="nge-lb-detail-badge-icon" />
269+
</div>
270+
<div class="nge-lb-detail-badge-name">{{ badge.name }}</div>
271+
</template>
272+
<template v-else>
273+
<div class="nge-lb-detail-badge-img">
274+
<div class="nge-lb-detail-badge-mystery">
275+
<span class="nge-lb-detail-badge-mystery-q">?</span>
276+
</div>
277+
</div>
278+
<div class="nge-lb-detail-badge-name nge-lb-detail-badge-name--locked">???</div>
279+
</template>
280+
</div>
281+
</div>
282+
283+
<!-- Exploration Badges -->
284+
<div class="nge-lb-detail-badges-label" style="color: #90fff2;">Exploration Badges <span style="font-size: 0.75em; opacity: 0.6;">(cells)</span></div>
243285
<div class="nge-lb-detail-badges-grid">
244286
<div
245-
v-for="badge in BADGE_DEFINITIONS"
287+
v-for="badge in EXPLORATION_BADGES"
246288
:key="badge.id"
247289
class="nge-lb-detail-badge"
248290
:class="{
249-
'nge-lb-detail-badge--locked': !isBadgeEarned(badge.editThreshold, selectedUser.stats.editsAllTime),
291+
'nge-lb-detail-badge--locked': !isBadgeEarnedByUser(badge, selectedUser),
250292
'nge-lb-detail-badge--selected': selectedBadgeId === badge.id,
251293
}"
252-
:title="isBadgeEarned(badge.editThreshold, selectedUser.stats.editsAllTime)
294+
:title="isBadgeEarnedByUser(badge, selectedUser)
253295
? badge.name + ' — click for details'
254296
: '??? (locked)'"
255-
@click="isBadgeEarned(badge.editThreshold, selectedUser.stats.editsAllTime)
297+
@click="isBadgeEarnedByUser(badge, selectedUser)
256298
? onDetailBadgeClick(badge.id)
257299
: undefined"
258300
>
259-
<template v-if="isBadgeEarned(badge.editThreshold, selectedUser.stats.editsAllTime)">
301+
<template v-if="isBadgeEarnedByUser(badge, selectedUser)">
260302
<div class="nge-lb-detail-badge-img">
261303
<img :src="getBadgeUrl(badge.imageKey)" :alt="badge.name" class="nge-lb-detail-badge-icon" />
262304
</div>
@@ -285,7 +327,7 @@ const emit = defineEmits({hide: null});
285327
<div class="nge-lb-detail-badge-card-name">{{ selectedBadgeDef()?.name }}</div>
286328
<div class="nge-lb-detail-badge-card-desc">{{ selectedBadgeDef()?.description }}</div>
287329
<div class="nge-lb-detail-badge-card-thresh">
288-
Unlocked at {{ selectedBadgeDef()?.editThreshold.toLocaleString() }} edits
330+
Unlocked at {{ selectedBadgeDef()?.threshold.toLocaleString() }} {{ selectedBadgeDef()?.track === 'building' ? 'edits' : 'cells completed' }}
289331
</div>
290332
</div>
291333
<button class="nge-lb-detail-badge-card-close" @click.stop="selectedBadgeId = null">×</button>

src/components/UserProfilePanel.vue

Lines changed: 81 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {storeToRefs} from 'pinia';
44
import ModalOverlay from 'components/ModalOverlay.vue';
55
66
import {useLoginStore, useUserStatsStore, useUserPreferencesStore, useCellHistoryStore, useProofreadingBackendStore, CellHistoryEntry} from '../store';
7-
import {BADGE_DEFINITIONS, BadgeDefinition} from '../widgets/badge_definitions';
7+
import {BADGE_DEFINITIONS, BUILDING_BADGES, EXPLORATION_BADGES, BadgeDefinition, BadgeTrack, statKeyForTrack} from '../widgets/badge_definitions';
88
import {BADGE_IMAGE_MAP} from '../widgets/badge_images';
99
import {DEMO_USERS, DEMO_COMMUNITY_EDITS_WEEK, DEMO_COMMUNITY_EDITS_MONTH} from '../data/demo-users';
1010
@@ -272,27 +272,47 @@ function getBadgeUrl(imageKey: string): string {
272272
return BADGE_IMAGE_MAP[imageKey] ?? '';
273273
}
274274
275-
function isBadgeEarned(editThreshold: number): boolean {
276-
if (editThreshold === 0) return false;
277-
return (stats.value.editsAllTime ?? 0) >= editThreshold;
275+
/** Get the player's current count for a given track. */
276+
function statForTrack(track: BadgeTrack): number {
277+
return track === 'building'
278+
? (stats.value.editsAllTime ?? 0)
279+
: (stats.value.cellsSubmitted ?? 0);
280+
}
281+
282+
function isBadgeEarned(badge: BadgeDefinition): boolean {
283+
if (badge.threshold === 0) return false;
284+
return statForTrack(badge.track) >= badge.threshold;
278285
}
279286
280287
function onBadgeClick(badge: BadgeDefinition) {
281-
if (!isBadgeEarned(badge.editThreshold)) return;
288+
if (!isBadgeEarned(badge)) return;
282289
// Toggle: click same badge again to return to cells canvas
283290
selectedBadge.value = selectedBadge.value?.id === badge.id ? null : badge;
284291
}
285292
286-
// ── Achievement countdown ─────────────────────────────────────────────────────
293+
/** Label for the threshold in badge detail. */
294+
function thresholdLabel(badge: BadgeDefinition): string {
295+
return badge.track === 'building' ? 'edits' : 'cells completed';
296+
}
297+
298+
// ── Achievement countdown (shows next badge across both tracks) ──────────────
287299
const nextAchievement = computed(() => {
288-
const allTime = stats.value.editsAllTime ?? 0;
289-
const sorted = [...BADGE_DEFINITIONS].filter(b => b.editThreshold > 0).sort((a, b) => a.editThreshold - b.editThreshold);
290-
const next = sorted.find(b => allTime < b.editThreshold);
291-
if (!next) return null;
292-
const prev = sorted[sorted.indexOf(next) - 1]?.editThreshold ?? 0;
293-
const remaining = next.editThreshold - allTime;
294-
const pct = Math.min(100, Math.round(((allTime - prev) / (next.editThreshold - prev)) * 100));
295-
return { name: next.name, threshold: next.editThreshold, remaining, pct };
300+
// Find next unearned badge from each track, show whichever is closer
301+
function nextFor(badges: BadgeDefinition[], current: number) {
302+
const sorted = [...badges].filter(b => b.threshold > 0).sort((a, b) => a.threshold - b.threshold);
303+
const next = sorted.find(b => current < b.threshold);
304+
if (!next) return null;
305+
const prev = sorted[sorted.indexOf(next) - 1]?.threshold ?? 0;
306+
const remaining = next.threshold - current;
307+
const pct = Math.min(100, Math.round(((current - prev) / (next.threshold - prev)) * 100));
308+
return { name: next.name, threshold: next.threshold, remaining, pct, track: next.track as BadgeTrack };
309+
}
310+
const building = nextFor(BUILDING_BADGES, stats.value.editsAllTime ?? 0);
311+
const exploration = nextFor(EXPLORATION_BADGES, stats.value.cellsSubmitted ?? 0);
312+
// Prefer whichever has fewer remaining (closer to unlock)
313+
if (!building) return exploration;
314+
if (!exploration) return building;
315+
return building.remaining <= exploration.remaining ? building : exploration;
296316
});
297317
298318
// ── Current dataset helper (for filtering cells) ────────────────────────────
@@ -571,7 +591,7 @@ const emit = defineEmits({hide: null, 'open-settings': null});
571591
<div class="nge-profile-section-label nge-profile-section-label--green">▌ Next Achievement</div>
572592
<div class="nge-profile-countdown-row">
573593
<div class="nge-profile-countdown-name">{{ nextAchievement.name }}</div>
574-
<div class="nge-profile-countdown-remaining">{{ nextAchievement.remaining.toLocaleString() }} edits to go</div>
594+
<div class="nge-profile-countdown-remaining">{{ nextAchievement.remaining.toLocaleString() }} {{ nextAchievement.track === 'building' ? 'edits' : 'cells' }} to go</div>
575595
</div>
576596
<div class="nge-profile-countdown-track">
577597
<div class="nge-profile-countdown-fill" :style="{ width: nextAchievement.pct + '%' }"></div>
@@ -583,27 +603,65 @@ const emit = defineEmits({hide: null, 'open-settings': null});
583603
</div>
584604
</div>
585605

586-
<!-- Badges -->
606+
<!-- Building Badges (edits) -->
587607
<div class="nge-profile-section nge-profile-section--badges">
588-
<div class="nge-profile-section-label">▌ Badges</div>
589-
<div class="nge-profile-badges-hint" v-if="selectedBadge">
608+
<div class="nge-profile-section-label" style="color: #ffd08a;">▌ Building Badges <span style="font-size: 0.75em; opacity: 0.6;">(edits)</span></div>
609+
<div class="nge-profile-badges-hint" v-if="selectedBadge?.track === 'building'">
590610
badge detail on the right · click again to dismiss
591611
</div>
592612
<div class="nge-profile-badges-grid">
593613
<div
594-
v-for="badge in BADGE_DEFINITIONS"
614+
v-for="badge in BUILDING_BADGES"
595615
:key="badge.id"
596616
class="nge-profile-badge"
597617
:class="{
598-
'nge-profile-badge--locked': !isBadgeEarned(badge.editThreshold),
618+
'nge-profile-badge--locked': !isBadgeEarned(badge),
599619
'nge-profile-badge--selected': selectedBadge?.id === badge.id,
600620
}"
601-
:title="isBadgeEarned(badge.editThreshold)
621+
:title="isBadgeEarned(badge)
602622
? badge.name + ' — click to see detail'
603623
: '??? — keep editing to unlock!'"
604624
@click="onBadgeClick(badge)"
605625
>
606-
<template v-if="isBadgeEarned(badge.editThreshold)">
626+
<template v-if="isBadgeEarned(badge)">
627+
<div class="nge-profile-badge-img">
628+
<img :src="getBadgeUrl(badge.imageKey)" :alt="badge.name" class="nge-profile-badge-icon" />
629+
</div>
630+
<div class="nge-profile-badge-name">{{ badge.name }}</div>
631+
</template>
632+
<template v-else>
633+
<div class="nge-profile-badge-img">
634+
<div class="nge-profile-badge-mystery">
635+
<span class="nge-profile-badge-mystery-q">?</span>
636+
</div>
637+
</div>
638+
<div class="nge-profile-badge-name nge-profile-badge-name--locked">???</div>
639+
</template>
640+
</div>
641+
</div>
642+
</div>
643+
644+
<!-- Exploration Badges (cells completed) -->
645+
<div class="nge-profile-section nge-profile-section--badges">
646+
<div class="nge-profile-section-label" style="color: #90fff2;">▌ Exploration Badges <span style="font-size: 0.75em; opacity: 0.6;">(cells)</span></div>
647+
<div class="nge-profile-badges-hint" v-if="selectedBadge?.track === 'exploration'">
648+
badge detail on the right · click again to dismiss
649+
</div>
650+
<div class="nge-profile-badges-grid">
651+
<div
652+
v-for="badge in EXPLORATION_BADGES"
653+
:key="badge.id"
654+
class="nge-profile-badge"
655+
:class="{
656+
'nge-profile-badge--locked': !isBadgeEarned(badge),
657+
'nge-profile-badge--selected': selectedBadge?.id === badge.id,
658+
}"
659+
:title="isBadgeEarned(badge)
660+
? badge.name + ' — click to see detail'
661+
: '??? — complete more cells to unlock!'"
662+
@click="onBadgeClick(badge)"
663+
>
664+
<template v-if="isBadgeEarned(badge)">
607665
<div class="nge-profile-badge-img">
608666
<img :src="getBadgeUrl(badge.imageKey)" :alt="badge.name" class="nge-profile-badge-icon" />
609667
</div>
@@ -708,7 +766,7 @@ const emit = defineEmits({hide: null, 'open-settings': null});
708766
<div class="nge-profile-viz-badge-desc">{{ selectedBadge.description }}</div>
709767
<div class="nge-profile-viz-badge-threshold">
710768
Unlocked at<br>
711-
<strong>{{ selectedBadge.editThreshold.toLocaleString() }}</strong> edits
769+
<strong>{{ selectedBadge.threshold.toLocaleString() }}</strong> {{ thresholdLabel(selectedBadge) }}
712770
</div>
713771
<button class="nge-profile-viz-badge-back" @click="selectedBadge = null">
714772
← Back to cells map

0 commit comments

Comments
 (0)