186 lines
6.1 KiB
JavaScript
186 lines
6.1 KiB
JavaScript
(() => {
|
|
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 logGoalEvent = (event, data = {}) => {
|
|
const sceneId = getCurrentScene()?.id || "unknown";
|
|
console.log("[Goals]", event, { sceneId, ...data });
|
|
};
|
|
|
|
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) {
|
|
logGoalEvent("skip-progress-announcement", {
|
|
threshold,
|
|
progress: goal.progress,
|
|
reason: "missing text",
|
|
});
|
|
return;
|
|
}
|
|
const colors =
|
|
(Array.isArray(config.messages?.colors) &&
|
|
config.messages.colors) ||
|
|
goal?.colors ||
|
|
null;
|
|
logGoalEvent("announce-progress", {
|
|
threshold,
|
|
progress: goal.progress,
|
|
colors,
|
|
text,
|
|
});
|
|
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;
|
|
logGoalEvent("goal-intro", {
|
|
progress: goal?.progress,
|
|
colors,
|
|
text,
|
|
});
|
|
ui.showFloatingMessage(
|
|
{ text, colors },
|
|
{
|
|
durationMs: config.messages.durationMs,
|
|
position: config.messages.position,
|
|
},
|
|
);
|
|
};
|
|
|
|
const resetMilestones = () => {
|
|
announcedGoalMilestones = new Set();
|
|
logGoalEvent("reset-milestones");
|
|
};
|
|
|
|
return {
|
|
getGoalState,
|
|
formatGoalMessage,
|
|
maybeAnnounceGoalProgress,
|
|
showGoalIntro,
|
|
resetMilestones,
|
|
normalizeColor,
|
|
};
|
|
};
|
|
|
|
window.PhysilinksGoals = { create };
|
|
})();
|