@docyrus/animaguru 0.19.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/dist/animaguru.js +366 -261
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +9 -1
- package/skills/animaguru/references/sharing.md +3 -0
- package/skills/animaguru-branching-story/SKILL.md +72 -0
- package/skills/animaguru-general-video/SKILL.md +1 -0
- package/skills/animaguru-general-video/references/branching-story.md +13 -0
- package/skills/animaguru-quiz/SKILL.md +80 -0
- package/skills/animaguru-video/SKILL.md +9 -1
- package/templates/video/AGENTS.md +2 -0
- package/templates/video/README.md +8 -0
- package/templates/video/WORKSPACE-AGENTS.md +3 -1
- package/templates/video/scripts/build.mjs +4 -1
- package/templates/video/scripts/export-mp4.mjs +2 -0
- package/templates/video/scripts/html-export.mjs +12 -4
- package/templates/video/scripts/project-context.mjs +20 -3
- package/templates/video/scripts/quiz.d.mts +31 -0
- package/templates/video/scripts/quiz.mjs +126 -0
- package/templates/video/scripts/review.mjs +9 -1
- package/templates/video/scripts/story.d.mts +36 -0
- package/templates/video/scripts/story.mjs +139 -0
- package/templates/video/scripts/workspace-plugin.mjs +9 -4
- package/templates/video/shared/locales/en/player.json +29 -1
- package/templates/video/src/kit/index.ts +1 -0
- package/templates/video/src/kit/story.ts +19 -0
- package/templates/video/src/main.ts +6 -1
- package/templates/video/src/player-types.ts +6 -0
- package/templates/video/src/player.ts +213 -17
- package/templates/video/src/quiz.ts +315 -0
- package/templates/video/src/story.ts +112 -0
- package/templates/video/src/style.css +106 -3
|
@@ -5,9 +5,11 @@ import { publicAsset } from "./assets";
|
|
|
5
5
|
import { downloadHtml, downloadUrl } from "./download";
|
|
6
6
|
import { loadFonts } from "./kit/fonts";
|
|
7
7
|
import type { ResourceTimeline } from "./resource-timeline";
|
|
8
|
+
import { StoryMap, StoryOverlay, type StorySegment, type StoryView } from "./story";
|
|
9
|
+
import { QuizOverlay, QuizRun, quizCopy } from "./quiz";
|
|
8
10
|
import "./style.css";
|
|
9
11
|
|
|
10
|
-
export async function mountPlayer({ config, Film, chapters, branding, exportEndpoint, templateLabel }: PlayerOptions) {
|
|
12
|
+
export async function mountPlayer({ config, Film, chapters, branding, exportEndpoint, templateLabel, story: storyGraph, quiz: quizData }: PlayerOptions) {
|
|
11
13
|
const root = document.querySelector<HTMLDivElement>("#app")!;
|
|
12
14
|
// All variable copy is inserted as text, including agent-authored film titles.
|
|
13
15
|
root.innerHTML = `<header class="masthead"><a class="brand" href="https://animaguru.com" target="_blank" rel="noreferrer"><img src="${publicAsset("brand/logo-mark.svg")}" alt=""/><strong>AnimaGuru</strong><span data-copy="studio"></span></a><span class="template" data-copy="template"></span></header>
|
|
@@ -31,6 +33,24 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
31
33
|
const capture = params.has("capture");
|
|
32
34
|
const exporting = !embed && params.has("export");
|
|
33
35
|
const autoplay = embed && params.get("autoplay") === "1" && !matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
36
|
+
// A branching story plays one segment at a time and asks the viewer at its choices. Capture and review see its whole
|
|
37
|
+
// timeline as one film; an MP4 has one path, so a story never exports one.
|
|
38
|
+
let story: StoryMap | null = null;
|
|
39
|
+
// A quiz pauses the film at its checkpoints and follows its end with a final round and the score. Capture and review
|
|
40
|
+
// see the film without it; its questions wait for a viewer's answers, so a quiz never exports an MP4. visited: the story segments played since the last restart, whose questions count.
|
|
41
|
+
let quiz: QuizRun | null = null;
|
|
42
|
+
const visited = new Set<string>();
|
|
43
|
+
if (storyGraph || quizData) {
|
|
44
|
+
document.querySelector<HTMLElement>('[data-copy="exportHint"]')!.textContent = storyGraph ? copy.storyHint : copy.quizHint;
|
|
45
|
+
try {
|
|
46
|
+
if (exporting) throw new Error(storyGraph ? copy.storyNoMp4 : copy.quizNoMp4);
|
|
47
|
+
if (storyGraph && !capture && !params.has("review")) story = new StoryMap(storyGraph, config.duration);
|
|
48
|
+
if (quizData && !capture && !params.has("review")) quiz = new QuizRun(quizData, config.duration, story ? { segments: story.graph.segments, visited } : undefined);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
status.textContent = error instanceof Error ? error.message : copy.failed; element("loading")?.remove(); console.error(error);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
34
54
|
const loop = embed && params.get("loop") === "1";
|
|
35
55
|
if (capture) document.body.classList.add("capture");
|
|
36
56
|
if (embed) document.body.classList.add("embed");
|
|
@@ -53,6 +73,26 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
53
73
|
type WebkitDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => void };
|
|
54
74
|
type WebkitElement = HTMLElement & { webkitRequestFullscreen?: () => void };
|
|
55
75
|
const fullscreenElement = () => document.fullscreenElement ?? (document as WebkitDocument).webkitFullscreenElement ?? null;
|
|
76
|
+
// The page's player fits the window (style.css). This measures the page above it and the controls under its picture,
|
|
77
|
+
// which change as the window resizes and the transport wraps. Full screen keeps the last measures for its way back; an
|
|
78
|
+
// embed or a capture fills its frame instead.
|
|
79
|
+
if (!embed && !capture) {
|
|
80
|
+
const main = theater.parentElement!, picture = element("canvas-host");
|
|
81
|
+
let frame = 0;
|
|
82
|
+
const measure = () => {
|
|
83
|
+
frame = 0;
|
|
84
|
+
if (fullscreenElement() || theater.classList.contains("expanded")) return;
|
|
85
|
+
const box = theater.getBoundingClientRect();
|
|
86
|
+
main.style.setProperty("--player-above", `${Math.round(box.top + scrollY)}px`);
|
|
87
|
+
main.style.setProperty("--player-controls", `${Math.ceil(box.height - picture.getBoundingClientRect().height)}px`);
|
|
88
|
+
};
|
|
89
|
+
main.style.setProperty("--film-aspect", `${config.width} / ${config.height}`);
|
|
90
|
+
main.classList.add("fit-window");
|
|
91
|
+
measure();
|
|
92
|
+
// A measure waits for the next frame, so the resize it causes is not reported as a loop.
|
|
93
|
+
const observer = new ResizeObserver(() => { frame ||= requestAnimationFrame(measure); });
|
|
94
|
+
for (const target of [theater, document.querySelector(".masthead")!]) observer.observe(target);
|
|
95
|
+
}
|
|
56
96
|
|
|
57
97
|
async function start() {
|
|
58
98
|
await document.fonts.ready;
|
|
@@ -71,6 +111,34 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
71
111
|
const initialMuted = autoplay || capture || exporting;
|
|
72
112
|
if (audio) { audio.crossOrigin = "anonymous"; audio.src = publicAsset(String(config.audio)); audio.preload = "auto"; audio.muted = initialMuted; audio.addEventListener("error", audioFailure); }
|
|
73
113
|
let time = Math.max(0, Math.min(Number(params.get("t")) || 0, config.duration));
|
|
114
|
+
// ?segment=ID starts a story at that segment; ?t= at that time of the timeline.
|
|
115
|
+
let segment: StorySegment | null = story ? story.graph.segments.find((item) => item.id === params.get("segment")) ?? (params.has("t") ? story.at(time) : story.first) : null;
|
|
116
|
+
if (segment && !params.has("t")) time = segment.start;
|
|
117
|
+
if (segment) visited.add(segment.id);
|
|
118
|
+
/** The choice taken while its segment still plays; the story follows it when the segment ends. */
|
|
119
|
+
let picked: number | null = null;
|
|
120
|
+
/** Seconds a timed choice has been on screen. */
|
|
121
|
+
let waited = 0;
|
|
122
|
+
/** The story is at an ending. */
|
|
123
|
+
let finished = false;
|
|
124
|
+
/** The segments the viewer chose at, for going back. */
|
|
125
|
+
const path: string[] = [];
|
|
126
|
+
/** A story's final quiz and score have shown at an ending in this playthrough. */
|
|
127
|
+
let quizEnded = false;
|
|
128
|
+
const overlay = story ? new StoryOverlay(element("canvas-host"), story.graph, {
|
|
129
|
+
theEnd: story.graph.ui?.theEnd ?? copy.storyEnd, playAgain: story.graph.ui?.playAgain ?? copy.storyPlayAgain,
|
|
130
|
+
back: story.graph.ui?.back ?? copy.storyBack, choose: story.graph.ui?.choose ?? copy.storyChoose,
|
|
131
|
+
}, branding, { choose, restart: () => { void restart(); }, back: () => { void back(); } }) : null;
|
|
132
|
+
const quizOverlay = quiz ? new QuizOverlay(element("canvas-host"), quiz.quiz, quizCopy(copy, quiz.quiz.ui), branding, {
|
|
133
|
+
pick, check: checkAnswer, next: () => { void nextQuestion(); }, watchAgain: () => { void watchAgain(); }, retake: retakeQuiz, finish: () => { void finishQuiz(); },
|
|
134
|
+
}) : null;
|
|
135
|
+
const span = () => segment ? { start: segment.start, end: segment.end } : { start: 0, end: config.duration };
|
|
136
|
+
const atEnd = () => time >= span().end - 0.0005;
|
|
137
|
+
const choosing = () => Boolean(story && segment?.choices && time >= story.choiceAt(segment) - 0.0005);
|
|
138
|
+
// A quiz card covers the story's panels until it closes.
|
|
139
|
+
const storyView = (): StoryView => !segment || quiz?.panel ? { kind: "none" }
|
|
140
|
+
: finished ? { kind: "ending", segment, canGoBack: path.length > 0 }
|
|
141
|
+
: choosing() ? { kind: "choices", segment, picked, remaining: segment.timeout ? 1 - waited / segment.timeout : null } : { kind: "none" };
|
|
74
142
|
let playing = false;
|
|
75
143
|
let muted = initialMuted;
|
|
76
144
|
let last = performance.now();
|
|
@@ -78,32 +146,53 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
78
146
|
let playbackRequest = 0;
|
|
79
147
|
let resources: ResourceTimeline | undefined;
|
|
80
148
|
const update = () => {
|
|
81
|
-
|
|
149
|
+
// A story's transport covers the segment playing, not the whole timeline of every path.
|
|
150
|
+
const { start: from, end: to } = span();
|
|
151
|
+
playButton.textContent = playing ? copy.pause : (segment ? finished : time >= config.duration) ? copy.replay : copy.play;
|
|
152
|
+
// A question showing holds the film: it continues once the viewer answers.
|
|
153
|
+
const asking = Boolean(quiz?.asking);
|
|
154
|
+
playButton.disabled = asking || Boolean(segment?.choices && !playing && atEnd() && picked === null);
|
|
155
|
+
timeline.disabled = asking;
|
|
82
156
|
playButton.setAttribute("aria-label", playButton.textContent);
|
|
83
|
-
element("time").textContent = `${format(time)} / ${format(
|
|
84
|
-
timeline.
|
|
85
|
-
timeline.
|
|
157
|
+
element("time").textContent = `${format(time - from)} / ${format(to - from)}`;
|
|
158
|
+
timeline.max = String(to - from);
|
|
159
|
+
timeline.value = String(time - from);
|
|
160
|
+
timeline.setAttribute("aria-valuetext", `${format(time - from)} / ${format(to - from)}`);
|
|
86
161
|
soundButton.textContent = !audio ? copy.noAudio : muted ? copy.unmute : copy.mute;
|
|
87
162
|
soundButton.disabled = !audio;
|
|
88
163
|
soundButton.setAttribute("aria-pressed", String(Boolean(audio) && !muted));
|
|
89
164
|
captionsButton.setAttribute("aria-pressed", String(film.captions));
|
|
90
165
|
resources?.update({ time, playing, captions: film.captions, muted });
|
|
166
|
+
overlay?.render(storyView());
|
|
167
|
+
if (quiz) quizOverlay?.render(quiz.view());
|
|
91
168
|
};
|
|
92
169
|
const render = (seconds: number) => {
|
|
170
|
+
// A story holding at a segment's end shows that segment's last frame, not the next segment's first.
|
|
171
|
+
const at = segment ? Math.min(seconds, segment.end - 0.001) : seconds;
|
|
93
172
|
// Seeking and frame capture are serialized so decoded video frames cannot race.
|
|
94
|
-
renderQueue = renderQueue.then(() => film.render(
|
|
173
|
+
renderQueue = renderQueue.then(() => film.render(at));
|
|
95
174
|
return renderQueue;
|
|
96
175
|
};
|
|
97
176
|
function pause() { playbackRequest++; playing = false; audio?.pause(); update(); }
|
|
98
177
|
async function seek(seconds: number) {
|
|
99
178
|
if (!Number.isFinite(seconds)) throw new Error("Seek time must be finite.");
|
|
100
179
|
time = Math.max(0, Math.min(seconds, config.duration)); pause();
|
|
180
|
+
// A seek closes a quiz card unanswered; its checkpoint asks again when the film next reaches it.
|
|
181
|
+
quiz?.close();
|
|
182
|
+
// A seek stays in the story's segment while it can, up to its end; another time of the timeline enters its segment.
|
|
183
|
+
if (story && segment && !(time >= segment.start && time <= segment.end)) { segment = story.at(time); visited.add(segment.id); }
|
|
184
|
+
if (segment) { picked = null; waited = 0; finished = segment.ending !== undefined && atEnd(); }
|
|
101
185
|
if (audio) audio.currentTime = time;
|
|
102
186
|
await render(time); update();
|
|
103
187
|
}
|
|
104
188
|
async function play() {
|
|
105
|
-
if (exporting) return;
|
|
106
|
-
|
|
189
|
+
if (exporting || quiz?.asking) return;
|
|
190
|
+
// Play over the score replays the film, or the story from its start.
|
|
191
|
+
quiz?.close();
|
|
192
|
+
if (segment) {
|
|
193
|
+
if (finished) { await restart(); return; }
|
|
194
|
+
if (segment.choices && picked === null && atEnd()) return;
|
|
195
|
+
} else if (time >= config.duration) await seek(0);
|
|
107
196
|
const request = ++playbackRequest;
|
|
108
197
|
if (audio) {
|
|
109
198
|
audio.currentTime = time;
|
|
@@ -117,6 +206,82 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
117
206
|
muted = value; audio.muted = muted; update();
|
|
118
207
|
}
|
|
119
208
|
function toggleSound() { setMuted(!muted); }
|
|
209
|
+
/** Plays a story segment from a time (its start by default); a choice shown at the segment's very end waits there. */
|
|
210
|
+
async function enter(target: StorySegment, at = target.start) {
|
|
211
|
+
segment = target; time = at; picked = null; waited = 0; finished = false; visited.add(target.id);
|
|
212
|
+
if (audio) audio.currentTime = time;
|
|
213
|
+
await render(time);
|
|
214
|
+
if (atEnd()) { pause(); return; }
|
|
215
|
+
if (playing) { last = performance.now(); update(); } else await play();
|
|
216
|
+
}
|
|
217
|
+
/** What a segment does at its end: continue, go where the viewer chose, wait for a choice, or end the story. */
|
|
218
|
+
async function arrive() {
|
|
219
|
+
if (!story || !segment) return;
|
|
220
|
+
if (segment.next) await enter(story.get(segment.next));
|
|
221
|
+
else if (segment.choices) {
|
|
222
|
+
if (picked !== null) await take(picked);
|
|
223
|
+
else { playbackRequest++; playing = false; audio?.pause(); }
|
|
224
|
+
} else {
|
|
225
|
+
finished = true; pause();
|
|
226
|
+
// A quiz's final round and score come before the story's ending card, once a playthrough.
|
|
227
|
+
if (quiz && !quizEnded) { quizEnded = true; if (quizEnd()) update(); }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function choose(index: number) {
|
|
231
|
+
if (!segment?.choices?.[index] || picked !== null || !choosing() || quiz?.panel) return;
|
|
232
|
+
picked = index;
|
|
233
|
+
// A choice made while the segment plays waits for its end, so the segment finishes as it was cut.
|
|
234
|
+
if (!playing || atEnd()) void take(index); else update();
|
|
235
|
+
}
|
|
236
|
+
async function take(index: number) {
|
|
237
|
+
path.push(segment!.id);
|
|
238
|
+
await enter(story!.get(segment!.choices![index].to));
|
|
239
|
+
}
|
|
240
|
+
async function restart() { path.length = 0; quiz?.reset(); visited.clear(); quizEnded = false; await enter(story!.first); }
|
|
241
|
+
/** Back to the last choice the viewer made, with its choices showing. */
|
|
242
|
+
async function back() {
|
|
243
|
+
const id = path.pop();
|
|
244
|
+
if (!id) { await restart(); return; }
|
|
245
|
+
const decision = story!.get(id);
|
|
246
|
+
await enter(decision, story!.choiceAt(decision));
|
|
247
|
+
}
|
|
248
|
+
/** A quiz event for a page hosting the film: each answer, and the score when the quiz is complete. */
|
|
249
|
+
const emit = (type: "answer" | "complete", detail: object | null) => window.dispatchEvent(new CustomEvent("animaguru:quiz", { detail: { type, ...detail } }));
|
|
250
|
+
function pick(index: number) {
|
|
251
|
+
if (!quiz?.pick(index)) return;
|
|
252
|
+
if (quiz.answered) emit("answer", quiz.lastAnswer);
|
|
253
|
+
update();
|
|
254
|
+
}
|
|
255
|
+
function checkAnswer() { if (quiz?.check()) { emit("answer", quiz.lastAnswer); update(); } }
|
|
256
|
+
/** The final quiz, or the score, at the film's end or a story's ending; false when the viewer met no question. */
|
|
257
|
+
function quizEnd() {
|
|
258
|
+
if (!quiz?.openEnd()) return false;
|
|
259
|
+
if (quiz.panel?.kind === "results") emit("complete", quiz.score());
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
/** After an answer: the next question, the score, or back to the film where the checkpoint paused it. */
|
|
263
|
+
async function nextQuestion() {
|
|
264
|
+
const step = quiz?.advance();
|
|
265
|
+
if (step === "results") emit("complete", quiz!.score());
|
|
266
|
+
update();
|
|
267
|
+
if (step !== "closed") return;
|
|
268
|
+
if (segment) { if (atEnd()) await arrive(); else await play(); }
|
|
269
|
+
else if (time >= config.duration - 0.0005) { if (quizEnd()) update(); }
|
|
270
|
+
else await play();
|
|
271
|
+
}
|
|
272
|
+
/** Back to the part a checkpoint covers, which asks again when the film reaches it. */
|
|
273
|
+
async function watchAgain() {
|
|
274
|
+
const panel = quiz?.panel;
|
|
275
|
+
if (panel?.kind !== "checkpoint") return;
|
|
276
|
+
quiz!.arm(panel.checkpoint);
|
|
277
|
+
await seek(quiz!.reviewTime(panel.checkpoint)); await play();
|
|
278
|
+
}
|
|
279
|
+
function retakeQuiz() { quiz?.retake(); update(); }
|
|
280
|
+
/** The score's last button: a story's ending card, or the film again from the start with every question fresh. */
|
|
281
|
+
async function finishQuiz() {
|
|
282
|
+
if (segment) { quiz?.close(); update(); return; }
|
|
283
|
+
quiz?.reset(); await seek(0); await play();
|
|
284
|
+
}
|
|
120
285
|
function setCaptions(value: boolean) { film.captions = value; void render(time); update(); }
|
|
121
286
|
const fullscreenButton = element<HTMLButtonElement>("fullscreen");
|
|
122
287
|
function showFullscreen() {
|
|
@@ -141,8 +306,8 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
141
306
|
let resumeAfterScrub = false;
|
|
142
307
|
async function scrub(seconds: number, done = true) {
|
|
143
308
|
resumeAfterScrub ||= playing;
|
|
144
|
-
await seek(seconds);
|
|
145
|
-
if (done && resumeAfterScrub) { resumeAfterScrub = false; await play(); }
|
|
309
|
+
await seek(segment ? Math.max(segment.start, Math.min(segment.end, seconds)) : seconds);
|
|
310
|
+
if (done && resumeAfterScrub) { resumeAfterScrub = false; if (!finished) await play(); }
|
|
146
311
|
}
|
|
147
312
|
async function saveFrame() {
|
|
148
313
|
pause(); await render(time);
|
|
@@ -211,11 +376,21 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
211
376
|
app.canvas.addEventListener("dblclick", () => { void fullscreen(); });
|
|
212
377
|
}
|
|
213
378
|
element("save").addEventListener("click", () => { closeDownloads(true); void saveFrame(); });
|
|
214
|
-
timeline.addEventListener("input", () => { void scrub(Number(timeline.value), false); });
|
|
215
|
-
timeline.addEventListener("change", () => { void scrub(Number(timeline.value)); });
|
|
379
|
+
timeline.addEventListener("input", () => { void scrub(span().start + Number(timeline.value), false); });
|
|
380
|
+
timeline.addEventListener("change", () => { void scrub(span().start + Number(timeline.value)); });
|
|
216
381
|
document.addEventListener("visibilitychange", () => { if (document.hidden && !exporting) pause(); });
|
|
217
382
|
document.addEventListener("keydown", (event) => {
|
|
218
|
-
|
|
383
|
+
const target = event.target instanceof HTMLElement ? event.target : null;
|
|
384
|
+
if (exporting || target?.closest("input,textarea,select,[contenteditable]")) return;
|
|
385
|
+
// Number keys answer a question or pick a story's choice, also with focus on one of their buttons.
|
|
386
|
+
if (quiz?.asking && /^[1-6]$/.test(event.key)) { event.preventDefault(); pick(Number(event.key) - 1); return; }
|
|
387
|
+
if (segment && !quiz?.panel && /^[1-4]$/.test(event.key)) { choose(Number(event.key) - 1); return; }
|
|
388
|
+
if (target?.closest("button,a,summary")) return;
|
|
389
|
+
if (quiz?.asking) {
|
|
390
|
+
if (event.key === "Enter") { event.preventDefault(); if (quiz.answered) void nextQuestion(); else checkAnswer(); }
|
|
391
|
+
// The film waits for the answer: the play and seek keys do nothing.
|
|
392
|
+
if (["Space", "ArrowRight", "ArrowLeft"].includes(event.code)) { event.preventDefault(); return; }
|
|
393
|
+
}
|
|
219
394
|
if (event.code === "Space") { event.preventDefault(); if (playing) pause(); else void play(); }
|
|
220
395
|
if (event.code === "ArrowRight") { event.preventDefault(); void scrub(time + 5); }
|
|
221
396
|
if (event.code === "ArrowLeft") { event.preventDefault(); void scrub(time - 5); }
|
|
@@ -227,11 +402,22 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
227
402
|
async function tick(now: number) {
|
|
228
403
|
try {
|
|
229
404
|
if (playing) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
405
|
+
const end = span().end, from = time;
|
|
406
|
+
time = Math.min(end, audio && !audio.paused ? audio.currentTime : time + (now - last) / 1000);
|
|
407
|
+
// A checkpoint the film passes stops it on its own frame and asks.
|
|
408
|
+
const due = quiz?.due(from, time, segment ? span() : undefined);
|
|
409
|
+
if (due) { time = due.at; pause(); quiz!.open(due); await render(time); }
|
|
410
|
+
else {
|
|
411
|
+
await render(time);
|
|
412
|
+
if (time >= end) { if (segment) await arrive(); else if (quizEnd()) pause(); else if (loop) { await seek(0); await play(); } else pause(); }
|
|
413
|
+
}
|
|
233
414
|
update();
|
|
234
415
|
}
|
|
416
|
+
// A timed choice counts down while the film plays on and while it holds on the segment's last frame.
|
|
417
|
+
if (segment?.timeout !== undefined && picked === null && choosing() && (playing || atEnd()) && !quiz?.panel && !document.hidden) {
|
|
418
|
+
waited += (now - last) / 1000;
|
|
419
|
+
if (waited >= segment.timeout) choose(segment.default ?? 0); else if (!playing) update();
|
|
420
|
+
}
|
|
235
421
|
last = now;
|
|
236
422
|
requestAnimationFrame((next) => { void tick(next); });
|
|
237
423
|
} catch (error) { pause(); status.textContent = copy.failed; console.error(error); }
|
|
@@ -241,8 +427,18 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
241
427
|
element("loading")?.remove();
|
|
242
428
|
const api = {
|
|
243
429
|
ready: true, width: config.width, height: config.height, duration: config.duration, fps: config.fps, language: config.language, languages: [{ code: config.language, name: config.language }], canvas: app.canvas,
|
|
244
|
-
get state() {
|
|
430
|
+
get state() {
|
|
431
|
+
return { time, playing, muted, captions: film.captions, audio: { configured: Boolean(audio), readyState: audio?.readyState ?? 0, error: audio?.error?.code ?? null },
|
|
432
|
+
// waiting: the segment has ended and only a choice continues the story, so play does nothing.
|
|
433
|
+
...(segment ? { story: { segment: segment.id, start: segment.start, end: segment.end, choosing: choosing() && picked === null, waiting: Boolean(segment.choices && !playing && atEnd() && picked === null), ended: finished } } : {}),
|
|
434
|
+
// open: a quiz card shows; asking: it asks a question, so play and seeking wait for the answer.
|
|
435
|
+
...(quiz ? { quiz: { open: Boolean(quiz.panel), asking: quiz.asking, card: quiz.panel?.kind ?? null, score: quiz.score() } } : {}) };
|
|
436
|
+
},
|
|
245
437
|
resources: film.resources,
|
|
438
|
+
// A branching story: its graph, the path taken, and the viewer's actions.
|
|
439
|
+
...(story ? { story: { graph: story.graph, get path() { return [...path]; }, choose, back, restart, async goTo(id: string) { path.length = 0; await enter(story!.get(id)); } } } : {}),
|
|
440
|
+
// A quiz: its questions, the viewer's actions on the card, and the score so far.
|
|
441
|
+
...(quiz ? { quiz: { data: quiz.quiz, pick, check: checkAnswer, next: nextQuestion, retake: retakeQuiz, reset() { quiz!.reset(); update(); }, get score() { return quiz!.score(); } } } : {}),
|
|
246
442
|
get texts() { return film.texts; },
|
|
247
443
|
get cues() { return film.cues; },
|
|
248
444
|
seek, play, pause, setMuted, setCaptions, saveFrame,
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { checkpointSegment, QUIZ_UI_KEYS, validateQuiz, type Quiz, type QuizCheckpoint, type QuizQuestion, type QuizUiKey } from "../scripts/quiz.mjs";
|
|
2
|
+
import type { StorySegment } from "./story";
|
|
3
|
+
import type { Branding } from "./player-types";
|
|
4
|
+
|
|
5
|
+
export type { Quiz, QuizCheckpoint, QuizQuestion };
|
|
6
|
+
export type QuizCopy = Record<QuizUiKey, string>;
|
|
7
|
+
type Answer = { chosen: number[]; correct: boolean };
|
|
8
|
+
/** Where the quiz is: a checkpoint's question, the final quiz's question, or the score. */
|
|
9
|
+
export type QuizPanel = { kind: "checkpoint"; checkpoint: QuizCheckpoint; index: number } | { kind: "final"; index: number } | { kind: "results" };
|
|
10
|
+
export type QuizMark = "right" | "wrong" | "skipped";
|
|
11
|
+
export type QuizScore = { correct: number; total: number; skipped: number; marks: QuizMark[]; pass: number | null; passed: boolean | null };
|
|
12
|
+
/** What the card over the picture shows. */
|
|
13
|
+
export type QuizView =
|
|
14
|
+
| { kind: "none" }
|
|
15
|
+
| { kind: "question"; key: string; title: string | undefined; final: boolean; number: number; count: number; question: QuizQuestion; selection: number[]; answer: Answer | null; watchAgain: boolean }
|
|
16
|
+
| { kind: "results"; score: QuizScore; retake: boolean; story: boolean };
|
|
17
|
+
|
|
18
|
+
const EPSILON = 1e-6;
|
|
19
|
+
const fill = (template: string, values: Record<string, string | number>) => template.replace(/\{(\w+)\}/g, (match, name: string) => name in values ? String(values[name]) : match);
|
|
20
|
+
|
|
21
|
+
/** The cards' words: quiz.json's ui where it sets them, the player's (quizCheck, quizFinal…) otherwise. */
|
|
22
|
+
export function quizCopy(defaults: Record<string, string>, ui: Quiz["ui"] = {}): QuizCopy {
|
|
23
|
+
return Object.fromEntries(QUIZ_UI_KEYS.map((key) => [key, ui[key] ?? defaults[`quiz${key[0].toUpperCase()}${key.slice(1)}`]])) as QuizCopy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A viewer's way through the quiz: which checkpoint is due as the film plays, the question showing, the answers and
|
|
28
|
+
* the score. A checkpoint asks once; Watch again asks it again, and its new answers replace the old. The score covers
|
|
29
|
+
* the questions on the viewer's path: every checkpoint of a linear film, those of the story segments visited, then the
|
|
30
|
+
* final quiz once it is done.
|
|
31
|
+
*/
|
|
32
|
+
export class QuizRun {
|
|
33
|
+
panel: QuizPanel | null = null;
|
|
34
|
+
private readonly selection = new Set<number>();
|
|
35
|
+
private readonly answers = new Map<string, Answer>();
|
|
36
|
+
private readonly armed = new Set<string>();
|
|
37
|
+
private finalDone = false;
|
|
38
|
+
readonly checkpoints: QuizCheckpoint[];
|
|
39
|
+
private readonly segmentOf = new Map<string, StorySegment>();
|
|
40
|
+
|
|
41
|
+
constructor(readonly quiz: Quiz, duration: number, private readonly story?: { segments: StorySegment[]; visited: Set<string> }) {
|
|
42
|
+
validateQuiz(quiz, { duration }, story ? { segments: story.segments } : null);
|
|
43
|
+
this.checkpoints = quiz.checkpoints ?? [];
|
|
44
|
+
if (story) for (const checkpoint of this.checkpoints) this.segmentOf.set(checkpoint.id, checkpointSegment(checkpoint, story.segments)!);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Each question's id: its own, or its place ("orbit/1", "final/2"; ids cannot contain a slash). */
|
|
48
|
+
private keys(where: QuizCheckpoint | "final") {
|
|
49
|
+
const [name, questions] = where === "final" ? ["final", this.quiz.final?.questions ?? []] as const : [where.id, where.questions] as const;
|
|
50
|
+
return questions.map((question, index) => question.id ?? `${name}/${index + 1}`);
|
|
51
|
+
}
|
|
52
|
+
private get current() {
|
|
53
|
+
const panel = this.panel;
|
|
54
|
+
if (!panel || panel.kind === "results") return null;
|
|
55
|
+
const [questions, keys] = panel.kind === "final" ? [this.quiz.final!.questions, this.keys("final")] : [panel.checkpoint.questions, this.keys(panel.checkpoint)];
|
|
56
|
+
return { question: questions[panel.index], key: keys[panel.index], count: questions.length };
|
|
57
|
+
}
|
|
58
|
+
/** A question is showing: the film waits for its answer. */
|
|
59
|
+
get asking() { return this.current !== null; }
|
|
60
|
+
/** The question showing has its answer. */
|
|
61
|
+
get answered() { const current = this.current; return Boolean(current && this.answers.has(current.key)); }
|
|
62
|
+
/** The answer to the question showing, as the player's quiz events report it. */
|
|
63
|
+
get lastAnswer() {
|
|
64
|
+
const current = this.current; const answer = current && this.answers.get(current.key);
|
|
65
|
+
return current && answer ? { id: current.key, prompt: current.question.prompt, chosen: answer.chosen, correct: answer.correct } : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The checkpoint the film reaches playing from `from` to `to`, while it still has to ask; within a story segment's span. */
|
|
69
|
+
due(from: number, to: number, span?: { start: number; end: number }) {
|
|
70
|
+
return this.checkpoints.find((checkpoint) => checkpoint.at > from + EPSILON && checkpoint.at <= to + EPSILON
|
|
71
|
+
&& (!span || (checkpoint.at > span.start + EPSILON && checkpoint.at <= span.end + EPSILON))
|
|
72
|
+
&& (this.armed.has(checkpoint.id) || !this.keys(checkpoint).every((key) => this.answers.has(key)))) ?? null;
|
|
73
|
+
}
|
|
74
|
+
open(checkpoint: QuizCheckpoint) {
|
|
75
|
+
this.armed.delete(checkpoint.id);
|
|
76
|
+
for (const key of this.keys(checkpoint)) this.answers.delete(key);
|
|
77
|
+
this.show({ kind: "checkpoint", checkpoint, index: 0 });
|
|
78
|
+
}
|
|
79
|
+
/** At the film's end: the final quiz until it is done, then the score; false when no question was on the viewer's path. */
|
|
80
|
+
openEnd() {
|
|
81
|
+
if (this.quiz.final && !this.finalDone) {
|
|
82
|
+
for (const key of this.keys("final")) this.answers.delete(key);
|
|
83
|
+
this.show({ kind: "final", index: 0 });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (!this.score().total) return false;
|
|
87
|
+
this.show({ kind: "results" });
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/** Picks an option: answers a one-answer question at once, ticks or unticks one of a "select all that apply". */
|
|
91
|
+
pick(index: number) {
|
|
92
|
+
const current = this.current;
|
|
93
|
+
if (!current || this.answers.has(current.key) || !Number.isInteger(index) || index < 0 || index >= current.question.options.length) return false;
|
|
94
|
+
if (Array.isArray(current.question.answer)) { if (!this.selection.delete(index)) this.selection.add(index); }
|
|
95
|
+
else this.record([index]);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
/** Answers a "select all that apply" with the options ticked. */
|
|
99
|
+
check() {
|
|
100
|
+
const current = this.current;
|
|
101
|
+
if (!current || !Array.isArray(current.question.answer) || this.answers.has(current.key) || !this.selection.size) return false;
|
|
102
|
+
this.record([...this.selection].sort((a, b) => a - b));
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
private record(chosen: number[]) {
|
|
106
|
+
const { question, key } = this.current!;
|
|
107
|
+
const right = Array.isArray(question.answer) ? question.answer : [question.answer];
|
|
108
|
+
this.answers.set(key, { chosen, correct: chosen.length === right.length && chosen.every((value, index) => value === right[index]) });
|
|
109
|
+
}
|
|
110
|
+
/** After an answer: the next question, the score after the final quiz's last, or closed after a checkpoint's last. */
|
|
111
|
+
advance(): "question" | "results" | "closed" | null {
|
|
112
|
+
const panel = this.panel; const current = this.current;
|
|
113
|
+
if (!panel || panel.kind === "results" || !current || !this.answers.has(current.key)) return null;
|
|
114
|
+
if (panel.index + 1 < current.count) { this.show({ ...panel, index: panel.index + 1 }); return "question"; }
|
|
115
|
+
if (panel.kind === "final") { this.finalDone = true; this.show({ kind: "results" }); return "results"; }
|
|
116
|
+
this.show(null);
|
|
117
|
+
return "closed";
|
|
118
|
+
}
|
|
119
|
+
/** Asks a checkpoint again the next time the film reaches it. */
|
|
120
|
+
arm(checkpoint: QuizCheckpoint) { this.armed.add(checkpoint.id); }
|
|
121
|
+
close() { this.show(null); }
|
|
122
|
+
retake() { if (!this.quiz.final) return; this.finalDone = false; this.openEnd(); }
|
|
123
|
+
/** Forgets every answer, for a fresh viewing. */
|
|
124
|
+
reset() { this.answers.clear(); this.armed.clear(); this.finalDone = false; this.show(null); }
|
|
125
|
+
private show(panel: QuizPanel | null) { this.panel = panel; this.selection.clear(); }
|
|
126
|
+
|
|
127
|
+
/** Where Watch again goes back to: the checkpoint's review, else the previous checkpoint (in its story segment) or the start. */
|
|
128
|
+
reviewTime(checkpoint: QuizCheckpoint) {
|
|
129
|
+
if (checkpoint.review !== undefined) return checkpoint.review;
|
|
130
|
+
const segment = this.segmentOf.get(checkpoint.id);
|
|
131
|
+
const earlier = this.checkpoints.filter((other) => other.at < checkpoint.at - EPSILON && (!segment || this.segmentOf.get(other.id) === segment));
|
|
132
|
+
return earlier.at(-1)?.at ?? segment?.start ?? 0;
|
|
133
|
+
}
|
|
134
|
+
score(): QuizScore {
|
|
135
|
+
const marks: QuizMark[] = [];
|
|
136
|
+
const mark = (key: string) => { const answer = this.answers.get(key); marks.push(!answer ? "skipped" : answer.correct ? "right" : "wrong"); };
|
|
137
|
+
for (const checkpoint of this.checkpoints) if (!this.story || this.story.visited.has(this.segmentOf.get(checkpoint.id)!.id)) this.keys(checkpoint).forEach(mark);
|
|
138
|
+
if (this.finalDone) this.keys("final").forEach(mark);
|
|
139
|
+
const correct = marks.filter((value) => value === "right").length;
|
|
140
|
+
const pass = this.quiz.final?.pass ?? null;
|
|
141
|
+
return { correct, total: marks.length, skipped: marks.filter((value) => value === "skipped").length, marks, pass, passed: pass === null || !marks.length ? null : correct * 100 >= pass * marks.length };
|
|
142
|
+
}
|
|
143
|
+
view(): QuizView {
|
|
144
|
+
const panel = this.panel; const current = this.current;
|
|
145
|
+
if (!panel) return { kind: "none" };
|
|
146
|
+
if (panel.kind === "results" || !current) return { kind: "results", score: this.score(), retake: Boolean(this.quiz.final), story: Boolean(this.story) };
|
|
147
|
+
return {
|
|
148
|
+
kind: "question", key: current.key, final: panel.kind === "final", title: panel.kind === "final" ? this.quiz.final!.title : panel.checkpoint.title,
|
|
149
|
+
number: panel.index + 1, count: current.count, question: current.question, selection: [...this.selection].sort((a, b) => a - b),
|
|
150
|
+
answer: this.answers.get(current.key) ?? null, watchAgain: panel.kind === "checkpoint",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ICONS = { right: '<path d="M5 12.5l4.5 4.5L19 7.5"/>', wrong: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>', skipped: '<path d="M6 12h12"/>' };
|
|
156
|
+
function icon(kind: keyof typeof ICONS) {
|
|
157
|
+
const span = document.createElement("span");
|
|
158
|
+
span.className = "quiz-icon"; span.setAttribute("aria-hidden", "true");
|
|
159
|
+
span.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">${ICONS[kind]}</svg>`;
|
|
160
|
+
return span;
|
|
161
|
+
}
|
|
162
|
+
/** Black or white, whichever reads better on a color. */
|
|
163
|
+
function readableOn(hex: string) {
|
|
164
|
+
const channel = (offset: number) => { const value = parseInt(hex.slice(offset, offset + 2), 16) / 255; return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; };
|
|
165
|
+
const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
|
166
|
+
return luminance > 0.179 ? "#111418" : "#ffffff";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The question and score cards, centered over the dimmed picture. They are HTML buttons, so a viewer can tab to them,
|
|
171
|
+
* a screen reader reads them and a touch picks them; 1–6 on the keyboard pick too. A card is rebuilt only when its
|
|
172
|
+
* question or answer changes, so focus and hover survive every frame's update; ticks on a "select all" update in place.
|
|
173
|
+
*/
|
|
174
|
+
export class QuizOverlay {
|
|
175
|
+
readonly element = document.createElement("div");
|
|
176
|
+
private readonly live = document.createElement("p");
|
|
177
|
+
private shown = "";
|
|
178
|
+
private ticks = "";
|
|
179
|
+
private returnFocus: Element | null = null;
|
|
180
|
+
|
|
181
|
+
constructor(host: HTMLElement, quiz: Quiz, private readonly copy: QuizCopy, branding: Branding | undefined,
|
|
182
|
+
private readonly actions: { pick(index: number): void; check(): void; next(): void; watchAgain(): void; retake(): void; finish(): void }) {
|
|
183
|
+
this.element.className = "quiz-layer"; this.element.hidden = true;
|
|
184
|
+
const theme = quiz.theme ?? {};
|
|
185
|
+
const accent = theme.accent ?? branding?.colors.primary ?? "#2c6bd0";
|
|
186
|
+
const surface = theme.surface ?? "#fffdfa";
|
|
187
|
+
// The brand's text color suits the brand's background, not necessarily this card: the card picks its own.
|
|
188
|
+
this.element.style.setProperty("--quiz-accent", accent);
|
|
189
|
+
this.element.style.setProperty("--quiz-on-accent", readableOn(accent));
|
|
190
|
+
this.element.style.setProperty("--quiz-surface", surface);
|
|
191
|
+
this.element.style.setProperty("--quiz-text", theme.text ?? (readableOn(surface) === "#ffffff" ? "#f7f5ef" : "#16232a"));
|
|
192
|
+
if (branding) this.element.style.setProperty("--quiz-font", branding.fonts.heading.family);
|
|
193
|
+
this.live.className = "story-live"; this.live.setAttribute("role", "status");
|
|
194
|
+
host.append(this.element, this.live);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
render(view: QuizView) {
|
|
198
|
+
const key = view.kind === "none" ? "none" : view.kind === "results" ? `results:${view.score.marks.join()}` : `question:${view.key}:${view.answer ? "answered" : ""}`;
|
|
199
|
+
if (key !== this.shown) {
|
|
200
|
+
const opening = this.shown === "" || this.shown === "none";
|
|
201
|
+
if (opening && view.kind !== "none") this.returnFocus = document.activeElement;
|
|
202
|
+
const hadFocus = this.element.contains(document.activeElement);
|
|
203
|
+
this.shown = key; this.build(view, opening);
|
|
204
|
+
if (view.kind === "none") {
|
|
205
|
+
// Focus that was on the card goes back where it was before the card opened.
|
|
206
|
+
if (hadFocus && this.returnFocus instanceof HTMLElement && this.returnFocus.isConnected) this.returnFocus.focus({ preventScroll: true });
|
|
207
|
+
} else {
|
|
208
|
+
const target = this.element.querySelector<HTMLElement>(view.kind === "question" && !view.answer ? ".quiz-option" : ".quiz-actions .quiz-primary");
|
|
209
|
+
target?.focus({ preventScroll: true });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (view.kind === "question" && !view.answer && view.selection.join() !== this.ticks) this.tick(view.selection);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Shows the ticked options of a "select all" and enables Check answer once one is ticked. */
|
|
216
|
+
private tick(selection: number[]) {
|
|
217
|
+
this.ticks = selection.join();
|
|
218
|
+
for (const option of this.element.querySelectorAll<HTMLButtonElement>(".quiz-multi .quiz-option")) {
|
|
219
|
+
const on = selection.includes(Number(option.dataset.index));
|
|
220
|
+
option.classList.toggle("selected", on); option.setAttribute("aria-pressed", String(on));
|
|
221
|
+
}
|
|
222
|
+
const check = this.element.querySelector<HTMLButtonElement>(".quiz-check");
|
|
223
|
+
if (check) check.disabled = selection.length === 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** opening: the card appears over the film, rather than changing to the next question or the score. */
|
|
227
|
+
private build(view: QuizView, opening: boolean) {
|
|
228
|
+
this.element.replaceChildren(); this.ticks = "";
|
|
229
|
+
this.element.dataset.view = view.kind; this.element.hidden = view.kind === "none";
|
|
230
|
+
if (view.kind === "none") return;
|
|
231
|
+
const { copy } = this;
|
|
232
|
+
const node = <K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string) => {
|
|
233
|
+
const element = document.createElement(tag); element.className = className;
|
|
234
|
+
if (text !== undefined) element.textContent = text;
|
|
235
|
+
return element;
|
|
236
|
+
};
|
|
237
|
+
const button = (label: string, action: () => void, className: string) => {
|
|
238
|
+
const element = node("button", className, label); element.type = "button";
|
|
239
|
+
// A click on the card is not a click on the picture, which would pause the film.
|
|
240
|
+
element.addEventListener("click", (event) => { event.stopPropagation(); action(); });
|
|
241
|
+
return element;
|
|
242
|
+
};
|
|
243
|
+
const card = node("div", `quiz-card${view.kind === "results" ? " quiz-results" : ""}${view.kind === "question" && view.answer ? " answered" : ""}${opening ? " quiz-card-in" : ""}`);
|
|
244
|
+
card.setAttribute("role", "dialog");
|
|
245
|
+
const actions = node("div", "quiz-actions");
|
|
246
|
+
if (view.kind === "question") {
|
|
247
|
+
const { question, answer } = view;
|
|
248
|
+
const multiple = Array.isArray(question.answer);
|
|
249
|
+
const right = Array.isArray(question.answer) ? question.answer : [question.answer];
|
|
250
|
+
const title = view.title ?? (view.final ? copy.final : copy.check);
|
|
251
|
+
const eyebrow = view.count > 1 ? `${title} · ${fill(copy.progress, { n: view.number, total: view.count })}` : title;
|
|
252
|
+
const prompt = node("h2", "quiz-prompt", question.prompt);
|
|
253
|
+
card.setAttribute("aria-label", `${eyebrow}: ${question.prompt}`);
|
|
254
|
+
card.append(node("p", "quiz-eyebrow", eyebrow), prompt);
|
|
255
|
+
if (multiple && !answer) card.append(node("p", "quiz-hint", copy.selectAll));
|
|
256
|
+
// Long options, and an odd number of them on a wide card, read best as a list; others sit in two columns.
|
|
257
|
+
const long = question.options.some((option) => option.length > 32);
|
|
258
|
+
const options = node("div", `quiz-options${multiple ? " quiz-multi" : ""}${long ? " quiz-long" : question.options.length % 2 ? " quiz-odd" : ""}`);
|
|
259
|
+
options.setAttribute("role", "group"); options.setAttribute("aria-label", question.prompt);
|
|
260
|
+
question.options.forEach((label, index) => {
|
|
261
|
+
const option = button("", () => this.actions.pick(index), "quiz-option");
|
|
262
|
+
option.dataset.index = String(index);
|
|
263
|
+
const isRight = right.includes(index), chosen = Boolean(answer?.chosen.includes(index));
|
|
264
|
+
const state = !answer ? null : isRight ? "right" : chosen ? "wrong" : null;
|
|
265
|
+
const key = node("span", "quiz-key");
|
|
266
|
+
if (state) key.append(icon(state)); else key.textContent = String(index + 1);
|
|
267
|
+
option.append(key, node("span", "quiz-option-text", label));
|
|
268
|
+
if (answer) {
|
|
269
|
+
option.disabled = true;
|
|
270
|
+
option.classList.add(state === "right" ? "correct" : state === "wrong" ? "wrong" : "dim");
|
|
271
|
+
if (isRight && !chosen) option.classList.add("missed");
|
|
272
|
+
if (multiple || chosen) option.setAttribute("aria-pressed", String(chosen));
|
|
273
|
+
} else if (multiple) option.setAttribute("aria-pressed", "false");
|
|
274
|
+
options.append(option);
|
|
275
|
+
});
|
|
276
|
+
card.append(options);
|
|
277
|
+
if (!answer) {
|
|
278
|
+
if (multiple) { const check = button(copy.checkAnswer, () => this.actions.check(), "quiz-primary quiz-check"); check.disabled = true; actions.append(check); }
|
|
279
|
+
this.live.textContent = `${eyebrow}. ${question.prompt} ${multiple ? `${copy.selectAll} ` : ""}${question.options.map((option, index) => `${index + 1}: ${option}.`).join(" ")}`;
|
|
280
|
+
} else {
|
|
281
|
+
const feedback = node("p", `quiz-feedback ${answer.correct ? "correct" : "wrong"}`);
|
|
282
|
+
feedback.append(node("strong", "", answer.correct ? copy.correct : copy.incorrect));
|
|
283
|
+
if (!answer.correct) feedback.append(" ", node("span", "quiz-answer", fill(copy.answer, { answer: right.map((index) => question.options[index]).join(", ") })));
|
|
284
|
+
if (question.explanation) feedback.append(" ", node("span", "", question.explanation));
|
|
285
|
+
card.append(feedback);
|
|
286
|
+
const last = view.number === view.count;
|
|
287
|
+
actions.append(button(last ? (view.final ? copy.seeResults : copy.continue) : copy.next, () => this.actions.next(), "quiz-primary"));
|
|
288
|
+
if (view.watchAgain && !answer.correct) actions.append(button(copy.watchAgain, () => this.actions.watchAgain(), "quiz-secondary"));
|
|
289
|
+
this.live.textContent = feedback.textContent ?? "";
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
const { score } = view;
|
|
293
|
+
const perfect = score.total > 0 && score.correct === score.total;
|
|
294
|
+
const heading = fill(copy.score, { correct: score.correct, total: score.total });
|
|
295
|
+
card.setAttribute("aria-label", `${copy.complete}: ${heading}`);
|
|
296
|
+
const total = node("p", "quiz-score");
|
|
297
|
+
total.append(node("strong", "", String(score.correct)), node("span", "", `/${score.total}`));
|
|
298
|
+
card.append(node("p", "quiz-eyebrow", copy.complete), total, node("h2", "quiz-prompt", heading));
|
|
299
|
+
const verdict = perfect ? copy.perfect : score.passed === null ? null : score.passed ? copy.passed : fill(copy.notPassed, { pass: score.pass ?? 0 });
|
|
300
|
+
if (verdict) card.append(node("p", `quiz-verdict${perfect || score.passed ? " passed" : ""}`, verdict));
|
|
301
|
+
const marks = node("ol", "quiz-marks"); marks.setAttribute("aria-hidden", "true");
|
|
302
|
+
for (const mark of score.marks) { const item = node("li", mark); item.append(icon(mark)); marks.append(item); }
|
|
303
|
+
card.append(marks);
|
|
304
|
+
if (score.skipped) card.append(node("p", "quiz-hint", fill(copy.skipped, { count: score.skipped })));
|
|
305
|
+
// A viewer who has not passed is offered the retake first; a story's score gives way to its ending.
|
|
306
|
+
const retakeFirst = view.retake && !perfect && score.passed !== true;
|
|
307
|
+
const finish = button(view.story ? copy.continue : copy.restart, () => this.actions.finish(), retakeFirst ? "quiz-secondary" : "quiz-primary");
|
|
308
|
+
const retake = view.retake ? button(copy.retake, () => this.actions.retake(), retakeFirst ? "quiz-primary" : "quiz-secondary") : null;
|
|
309
|
+
actions.append(...(retakeFirst && retake ? [retake, finish] : retake ? [finish, retake] : [finish]));
|
|
310
|
+
this.live.textContent = `${heading}${verdict ? ` ${verdict}` : ""}`;
|
|
311
|
+
}
|
|
312
|
+
if (actions.childElementCount) card.append(actions);
|
|
313
|
+
this.element.append(card);
|
|
314
|
+
}
|
|
315
|
+
}
|