(() => { const normalizeColor = (c) => (c || "").trim().toLowerCase(); const create = ({ config, getCurrentScene, getScore, getClearedCount, getClearedByColor, getTimerEndMs, ui, }) => { const goalMilestoneThresholds = [0.5, 0.75, 0.9]; let announcedGoalMilestones = new Set(); const getGoalState = () => { const scene = getCurrentScene(); const winCond = scene?.config?.winCondition; if (!winCond) return null; if (winCond.type === "timer") { const duration = winCond.durationSec ?? 120; const now = Date.now(); const end = getTimerEndMs() || now + duration * 1000; const remainingMs = Math.max(0, end - now); const remainingSec = Math.ceil(remainingMs / 1000); const elapsed = Math.max(0, duration - remainingSec); return { label: `${String(Math.floor(remainingSec / 60)).padStart(2, "0")}:${String(remainingSec % 60).padStart(2, "0")}`, progress: duration > 0 ? (100 * elapsed) / duration : 0, met: remainingMs <= 0, hint: "Survive until the timer ends", summary: `Survive for ${duration} seconds`, }; } if (winCond.type === "clearCount") { const target = winCond.target ?? 0; const cleared = getClearedCount(); const remaining = Math.max(0, target - cleared); return { label: `Clear ${target} balls (${remaining} left)`, progress: target > 0 ? (100 * cleared) / target : 0, met: cleared >= target, hint: "Clear the required balls", summary: `Clear ${target} balls`, }; } if (winCond.type === "score") { const target = winCond.target ?? 0; const currentScore = getScore(); const remaining = Math.max(0, target - currentScore); return { label: `Score ${target} (${remaining} left)`, progress: target > 0 ? (100 * currentScore) / target : 0, met: currentScore >= target, hint: "Reach the score target", summary: `Score ${target} points`, }; } if (winCond.type === "colorClear" && Array.isArray(winCond.targets)) { const clearedByColor = getClearedByColor(); const targets = winCond.targets.map((t) => ({ color: normalizeColor(t.color), count: t.count || 0, })); const totalTarget = targets.reduce((sum, t) => sum + t.count, 0); let totalAchieved = 0; const parts = targets.map((t) => { const achieved = Math.max(0, clearedByColor[normalizeColor(t.color)] || 0); const got = Math.min(t.count, achieved); totalAchieved += got; const remaining = Math.max(0, t.count - got); return `${got}/${t.count} (${remaining} left)`; }); return { label: parts.join(" • "), progress: totalTarget > 0 ? (100 * totalAchieved) / totalTarget : 0, met: targets.every( (t) => (clearedByColor[normalizeColor(t.color)] || 0) >= (t.count || 0), ), colors: targets.map((t) => t.color), hint: "Clear the target colors", summary: `Clear target colors`, }; } return null; }; const formatGoalMessage = (goal, { includeProgress = true } = {}) => { if (!goal) return null; const title = goal.summary || goal.hint || goal.label || "Goal"; const pieces = [`Goal: ${title}`]; if (includeProgress && Number.isFinite(goal.progress)) { const pct = Math.max(0, Math.min(100, Math.round(goal.progress))); pieces.push(`${pct}% complete`); } return pieces.join(" • "); }; const maybeAnnounceGoalProgress = (goal) => { if (!goal || !Number.isFinite(goal.progress)) return; const fraction = Math.max(0, Math.min(1, goal.progress / 100)); for (const threshold of goalMilestoneThresholds) { if (fraction >= threshold && !announcedGoalMilestones.has(threshold)) { announcedGoalMilestones.add(threshold); const text = config.messages?.text || formatGoalMessage(goal); if (!text) return; const colors = (Array.isArray(config.messages?.colors) && config.messages.colors) || goal?.colors || null; ui.showFloatingMessage( { text, colors }, { durationMs: config.messages.durationMs, position: config.messages.position, }, ); break; } } }; const showGoalIntro = () => { const goal = getGoalState(); const text = config.messages?.text || formatGoalMessage(goal, { includeProgress: false }); if (!text) return; const colors = (Array.isArray(config.messages?.colors) && config.messages.colors) || goal?.colors || null; ui.showFloatingMessage( { text, colors }, { durationMs: config.messages.durationMs, position: config.messages.position, }, ); }; const resetMilestones = () => { announcedGoalMilestones = new Set(); }; return { getGoalState, formatGoalMessage, maybeAnnounceGoalProgress, showGoalIntro, resetMilestones, normalizeColor, }; }; window.PhysilinksGoals = { create }; })();