termi-kids 0.1.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/LICENSE +34 -0
- package/README.md +148 -0
- package/SAFETY.md +187 -0
- package/bin/termi.js +22 -0
- package/dist/agent/context.js +126 -0
- package/dist/agent/loop.js +172 -0
- package/dist/agent/prompts/system.js +45 -0
- package/dist/agent/tools.js +335 -0
- package/dist/auth/keychain.js +146 -0
- package/dist/auth/oauth.js +375 -0
- package/dist/auth/tokens.js +219 -0
- package/dist/cli.js +258 -0
- package/dist/config/paths.js +92 -0
- package/dist/config/pin.js +150 -0
- package/dist/config/settings.js +131 -0
- package/dist/grownups/panel.js +483 -0
- package/dist/learn/lessons.js +490 -0
- package/dist/learn/runner.js +193 -0
- package/dist/preview/server.js +407 -0
- package/dist/projects/create.js +103 -0
- package/dist/projects/ideas.js +182 -0
- package/dist/projects/quests.js +277 -0
- package/dist/projects/scaffolds/art.js +484 -0
- package/dist/projects/scaffolds/biggames.js +554 -0
- package/dist/projects/scaffolds/characters.js +580 -0
- package/dist/projects/scaffolds/games.js +516 -0
- package/dist/projects/scaffolds/index.js +24 -0
- package/dist/projects/scaffolds/music.js +528 -0
- package/dist/projects/scaffolds/pets.js +567 -0
- package/dist/projects/scaffolds/quizzes.js +757 -0
- package/dist/projects/scaffolds/stories.js +620 -0
- package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
- package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
- package/dist/projects/scaffolds/websites.js +474 -0
- package/dist/projects/snapshots.js +203 -0
- package/dist/projects/store.js +325 -0
- package/dist/providers/errors.js +207 -0
- package/dist/providers/index.js +316 -0
- package/dist/providers/models.js +38 -0
- package/dist/safety/audit.js +195 -0
- package/dist/safety/blocks.js +29 -0
- package/dist/safety/classifier.js +337 -0
- package/dist/safety/codescan.js +168 -0
- package/dist/safety/guarddownload.js +79 -0
- package/dist/safety/guardrunner.js +125 -0
- package/dist/safety/localguard.js +227 -0
- package/dist/safety/modelstore.js +127 -0
- package/dist/safety/prefilter.js +214 -0
- package/dist/safety/session.js +118 -0
- package/dist/safety/taxonomy.js +246 -0
- package/dist/safety/textextract.js +193 -0
- package/dist/setup/launcher.js +65 -0
- package/dist/setup/wizard.js +469 -0
- package/dist/surfaces/chat.js +439 -0
- package/dist/surfaces/commands.js +206 -0
- package/dist/surfaces/home.js +438 -0
- package/dist/types.js +5 -0
- package/dist/ui/banner.js +35 -0
- package/dist/ui/celebrate.js +141 -0
- package/dist/ui/errors.js +97 -0
- package/dist/ui/mascot.js +223 -0
- package/dist/ui/text.js +156 -0
- package/dist/ui/theme.js +92 -0
- package/package.json +67 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dance Party scaffold: a WebAudio step sequencer with a dancing character.
|
|
3
|
+
* All sounds are synthesized with oscillators and noise. No audio files, no network.
|
|
4
|
+
*/
|
|
5
|
+
const robotDance = {
|
|
6
|
+
id: 'robot-dance',
|
|
7
|
+
label: 'Robot Dance',
|
|
8
|
+
emoji: '🤖',
|
|
9
|
+
palette: { bg: '#0d1b2a', fg: '#e0fbfc', accent: '#00f5d4' },
|
|
10
|
+
glyphs: { dancer: '🤖', kick: '🥁', clap: '👏', beep: '✨', boop: '🫧' },
|
|
11
|
+
strings: {
|
|
12
|
+
partyName: 'Robot Dance Lab',
|
|
13
|
+
hint: 'Tap the squares to build your beat.',
|
|
14
|
+
dancerName: 'Bolt',
|
|
15
|
+
kickLabel: 'Kick',
|
|
16
|
+
clapLabel: 'Clap',
|
|
17
|
+
beepLabel: 'Beep',
|
|
18
|
+
boopLabel: 'Boop',
|
|
19
|
+
playMsg: 'Bolt is dancing! Keep the beat going.',
|
|
20
|
+
stopMsg: 'Nice beat! Press play to dance again.',
|
|
21
|
+
},
|
|
22
|
+
narrativeIntro: 'Bolt the robot loves to dance. Build a beat and watch those robot moves.',
|
|
23
|
+
nonViolent: true,
|
|
24
|
+
nonCompetitive: true,
|
|
25
|
+
};
|
|
26
|
+
const glowDisco = {
|
|
27
|
+
id: 'glow-disco',
|
|
28
|
+
label: 'Glow Disco',
|
|
29
|
+
emoji: '🪩',
|
|
30
|
+
palette: { bg: '#10002b', fg: '#fdf0ff', accent: '#ff6ec7' },
|
|
31
|
+
glyphs: { dancer: '🕺', kick: '🥁', clap: '👏', beep: '💫', boop: '🫧' },
|
|
32
|
+
strings: {
|
|
33
|
+
partyName: 'Glow Disco',
|
|
34
|
+
hint: 'Tap the squares to light up your beat.',
|
|
35
|
+
dancerName: 'Neon',
|
|
36
|
+
kickLabel: 'Kick',
|
|
37
|
+
clapLabel: 'Clap',
|
|
38
|
+
beepLabel: 'Beep',
|
|
39
|
+
boopLabel: 'Boop',
|
|
40
|
+
playMsg: 'Neon is dancing! The floor is glowing.',
|
|
41
|
+
stopMsg: 'Great song! Press play for more disco.',
|
|
42
|
+
},
|
|
43
|
+
narrativeIntro: 'The disco floor just lit up. Neon is ready to dance to your beat.',
|
|
44
|
+
nonViolent: true,
|
|
45
|
+
nonCompetitive: true,
|
|
46
|
+
};
|
|
47
|
+
const EXTRAS = {
|
|
48
|
+
'robot-dance': {
|
|
49
|
+
beepFreq: 880,
|
|
50
|
+
boopFreq: 330,
|
|
51
|
+
startBeat: { kick: [0, 4], clap: [2, 6], beep: [1, 5], boop: [3, 7] },
|
|
52
|
+
prompts: [
|
|
53
|
+
'Make Bolt spin all the way around on the last beat.',
|
|
54
|
+
'Add a new sound row that goes zap.',
|
|
55
|
+
'Let me make the beat go super fast, like 200.',
|
|
56
|
+
'Add a button that makes a surprise beat for me.',
|
|
57
|
+
'Give Bolt a robot dog that dances along.',
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
'glow-disco': {
|
|
61
|
+
beepFreq: 988,
|
|
62
|
+
boopFreq: 262,
|
|
63
|
+
startBeat: { kick: [0, 4], clap: [2, 6], beep: [3, 7], boop: [1, 5] },
|
|
64
|
+
prompts: [
|
|
65
|
+
'Make the floor lights flash with every kick.',
|
|
66
|
+
'Add a sparkly cymbal sound to the grid.',
|
|
67
|
+
'Make Neon do a big jump every eight beats.',
|
|
68
|
+
'Add a slow motion button for silly dancing.',
|
|
69
|
+
'Make the disco ball spin faster when the music plays.',
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
function extrasFor(id) {
|
|
74
|
+
return EXTRAS[id] ?? EXTRAS['robot-dance'];
|
|
75
|
+
}
|
|
76
|
+
function s(theme, key) {
|
|
77
|
+
return theme.strings[key] ?? '';
|
|
78
|
+
}
|
|
79
|
+
function g(theme, key) {
|
|
80
|
+
return theme.glyphs[key] ?? '';
|
|
81
|
+
}
|
|
82
|
+
function escapeHtml(text) {
|
|
83
|
+
return text
|
|
84
|
+
.replace(/&/g, '&')
|
|
85
|
+
.replace(/</g, '<')
|
|
86
|
+
.replace(/>/g, '>')
|
|
87
|
+
.replace(/"/g, '"');
|
|
88
|
+
}
|
|
89
|
+
function buildHtml(theme, prettyName) {
|
|
90
|
+
const name = escapeHtml(prettyName);
|
|
91
|
+
return `<!DOCTYPE html>
|
|
92
|
+
<html lang="en">
|
|
93
|
+
<head>
|
|
94
|
+
<meta charset="utf-8">
|
|
95
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
96
|
+
<title>${name} ${theme.emoji}</title>
|
|
97
|
+
<link rel="stylesheet" href="style.css">
|
|
98
|
+
</head>
|
|
99
|
+
<body>
|
|
100
|
+
<main class="party">
|
|
101
|
+
<header>
|
|
102
|
+
<h1>${theme.emoji} ${name}</h1>
|
|
103
|
+
<p class="subtitle">${escapeHtml(s(theme, 'partyName'))}</p>
|
|
104
|
+
<p class="intro">${escapeHtml(theme.narrativeIntro)}</p>
|
|
105
|
+
</header>
|
|
106
|
+
|
|
107
|
+
<section id="stage" class="stage">
|
|
108
|
+
<div class="lights"><span></span><span></span><span></span><span></span><span></span></div>
|
|
109
|
+
<div id="dancer" class="dancer idle">${g(theme, 'dancer')}</div>
|
|
110
|
+
<p class="dancer-name">${escapeHtml(s(theme, 'dancerName'))}</p>
|
|
111
|
+
</section>
|
|
112
|
+
|
|
113
|
+
<section class="controls">
|
|
114
|
+
<button id="play" class="play-button">▶️ Play</button>
|
|
115
|
+
<label class="tempo-row">
|
|
116
|
+
🐢
|
|
117
|
+
<input id="tempo" type="range" min="70" max="180" value="110">
|
|
118
|
+
🐇
|
|
119
|
+
<span id="tempo-label">110 BPM</span>
|
|
120
|
+
</label>
|
|
121
|
+
</section>
|
|
122
|
+
|
|
123
|
+
<section id="grid" class="grid" aria-label="Beat grid"></section>
|
|
124
|
+
|
|
125
|
+
<p id="message" class="message">${escapeHtml(s(theme, 'hint'))}</p>
|
|
126
|
+
</main>
|
|
127
|
+
<script src="game.js"></script>
|
|
128
|
+
</body>
|
|
129
|
+
</html>
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
function buildCss(theme) {
|
|
133
|
+
return `:root {
|
|
134
|
+
--bg: ${theme.palette.bg};
|
|
135
|
+
--fg: ${theme.palette.fg};
|
|
136
|
+
--accent: ${theme.palette.accent};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
* { box-sizing: border-box; }
|
|
140
|
+
|
|
141
|
+
body {
|
|
142
|
+
margin: 0;
|
|
143
|
+
min-height: 100vh;
|
|
144
|
+
background: var(--bg);
|
|
145
|
+
color: var(--fg);
|
|
146
|
+
font-family: "Avenir Next", "Trebuchet MS", Verdana, sans-serif;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.party {
|
|
150
|
+
width: min(680px, 94vw);
|
|
151
|
+
margin: 0 auto;
|
|
152
|
+
padding: 18px 0 40px;
|
|
153
|
+
text-align: center;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
header h1 { margin: 8px 0 2px; font-size: 2rem; }
|
|
157
|
+
.subtitle { margin: 0; color: var(--accent); font-weight: bold; }
|
|
158
|
+
.intro { margin: 6px 0 14px; opacity: 0.85; }
|
|
159
|
+
|
|
160
|
+
.stage {
|
|
161
|
+
background: rgba(255, 255, 255, 0.05);
|
|
162
|
+
border: 3px solid var(--accent);
|
|
163
|
+
border-radius: 18px;
|
|
164
|
+
padding: 14px 10px 6px;
|
|
165
|
+
margin-bottom: 16px;
|
|
166
|
+
transition: box-shadow 0.1s ease;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.stage.boom { box-shadow: 0 0 40px var(--accent); }
|
|
170
|
+
|
|
171
|
+
.lights {
|
|
172
|
+
display: flex;
|
|
173
|
+
gap: 14px;
|
|
174
|
+
justify-content: center;
|
|
175
|
+
margin-bottom: 6px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.lights span {
|
|
179
|
+
width: 18px;
|
|
180
|
+
height: 18px;
|
|
181
|
+
border-radius: 50%;
|
|
182
|
+
background: var(--accent);
|
|
183
|
+
opacity: 0.25;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
body.partying .lights span { animation: blink 0.5s infinite alternate; }
|
|
187
|
+
body.partying .lights span:nth-child(2n) { animation-delay: 0.25s; }
|
|
188
|
+
|
|
189
|
+
@keyframes blink {
|
|
190
|
+
from { opacity: 0.2; transform: scale(0.8); }
|
|
191
|
+
to { opacity: 1; transform: scale(1.25); }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.dancer {
|
|
195
|
+
display: inline-block;
|
|
196
|
+
font-size: 96px;
|
|
197
|
+
line-height: 1.1;
|
|
198
|
+
transition: transform 0.1s ease;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.dancer.idle { transform: none; }
|
|
202
|
+
.dancer.pose0 { transform: rotate(-12deg) translateY(-10px); }
|
|
203
|
+
.dancer.pose1 { transform: rotate(10deg) scale(1.08); }
|
|
204
|
+
.dancer.pose2 { transform: rotate(-8deg) scale(0.95) translateY(4px); }
|
|
205
|
+
.dancer.pose3 { transform: rotate(14deg) translateY(-14px); }
|
|
206
|
+
|
|
207
|
+
.dancer-name { margin: 4px 0 8px; font-weight: bold; color: var(--accent); }
|
|
208
|
+
|
|
209
|
+
.controls {
|
|
210
|
+
display: flex;
|
|
211
|
+
flex-wrap: wrap;
|
|
212
|
+
gap: 14px;
|
|
213
|
+
justify-content: center;
|
|
214
|
+
align-items: center;
|
|
215
|
+
margin-bottom: 16px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.play-button {
|
|
219
|
+
font: inherit;
|
|
220
|
+
font-size: 1.2rem;
|
|
221
|
+
font-weight: bold;
|
|
222
|
+
color: var(--bg);
|
|
223
|
+
background: var(--accent);
|
|
224
|
+
border: none;
|
|
225
|
+
border-radius: 999px;
|
|
226
|
+
padding: 12px 28px;
|
|
227
|
+
cursor: pointer;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.play-button:hover { transform: scale(1.05); }
|
|
231
|
+
|
|
232
|
+
.tempo-row {
|
|
233
|
+
display: flex;
|
|
234
|
+
align-items: center;
|
|
235
|
+
gap: 8px;
|
|
236
|
+
font-size: 1.1rem;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#tempo { accent-color: var(--accent); width: 160px; }
|
|
240
|
+
|
|
241
|
+
.grid {
|
|
242
|
+
display: grid;
|
|
243
|
+
grid-template-columns: minmax(76px, auto) repeat(8, 1fr);
|
|
244
|
+
gap: 6px;
|
|
245
|
+
align-items: center;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.row-label {
|
|
249
|
+
text-align: right;
|
|
250
|
+
padding-right: 8px;
|
|
251
|
+
font-weight: bold;
|
|
252
|
+
white-space: nowrap;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.step {
|
|
256
|
+
aspect-ratio: 1 / 1;
|
|
257
|
+
border: 2px solid rgba(255, 255, 255, 0.25);
|
|
258
|
+
border-radius: 10px;
|
|
259
|
+
background: rgba(255, 255, 255, 0.06);
|
|
260
|
+
cursor: pointer;
|
|
261
|
+
padding: 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.step.downbeat { border-color: rgba(255, 255, 255, 0.45); }
|
|
265
|
+
.step:hover { background: rgba(255, 255, 255, 0.18); }
|
|
266
|
+
.step.on { background: var(--accent); border-color: var(--accent); }
|
|
267
|
+
.step.now { outline: 3px solid var(--fg); transform: scale(1.08); }
|
|
268
|
+
|
|
269
|
+
.message {
|
|
270
|
+
min-height: 1.4em;
|
|
271
|
+
margin-top: 14px;
|
|
272
|
+
color: var(--accent);
|
|
273
|
+
font-weight: bold;
|
|
274
|
+
}
|
|
275
|
+
`;
|
|
276
|
+
}
|
|
277
|
+
function buildJs(theme) {
|
|
278
|
+
const extra = extrasFor(theme.id);
|
|
279
|
+
const themeData = {
|
|
280
|
+
id: theme.id,
|
|
281
|
+
partyName: s(theme, 'partyName'),
|
|
282
|
+
hint: s(theme, 'hint'),
|
|
283
|
+
dancer: g(theme, 'dancer'),
|
|
284
|
+
dancerName: s(theme, 'dancerName'),
|
|
285
|
+
rows: [
|
|
286
|
+
{ id: 'kick', label: s(theme, 'kickLabel'), emoji: g(theme, 'kick') },
|
|
287
|
+
{ id: 'clap', label: s(theme, 'clapLabel'), emoji: g(theme, 'clap') },
|
|
288
|
+
{ id: 'beep', label: s(theme, 'beepLabel'), emoji: g(theme, 'beep') },
|
|
289
|
+
{ id: 'boop', label: s(theme, 'boopLabel'), emoji: g(theme, 'boop') },
|
|
290
|
+
],
|
|
291
|
+
sound: { beep: extra.beepFreq, boop: extra.boopFreq },
|
|
292
|
+
startBeat: extra.startBeat,
|
|
293
|
+
messages: { play: s(theme, 'playMsg'), stop: s(theme, 'stopMsg') },
|
|
294
|
+
};
|
|
295
|
+
return `// ====================================================
|
|
296
|
+
// THEME SETTINGS
|
|
297
|
+
// These values set the dancer, sounds, and words.
|
|
298
|
+
// Change one, save the file, and reload the page!
|
|
299
|
+
// ====================================================
|
|
300
|
+
const THEME = ${JSON.stringify(themeData, null, 2)};
|
|
301
|
+
|
|
302
|
+
const STEPS = 8;
|
|
303
|
+
|
|
304
|
+
// ----- find the page pieces -----
|
|
305
|
+
const grid = document.getElementById("grid");
|
|
306
|
+
const playButton = document.getElementById("play");
|
|
307
|
+
const tempoSlider = document.getElementById("tempo");
|
|
308
|
+
const tempoLabel = document.getElementById("tempo-label");
|
|
309
|
+
const dancer = document.getElementById("dancer");
|
|
310
|
+
const stageBox = document.getElementById("stage");
|
|
311
|
+
const messageBox = document.getElementById("message");
|
|
312
|
+
|
|
313
|
+
// ----- music state -----
|
|
314
|
+
let audio = null;
|
|
315
|
+
let playing = false;
|
|
316
|
+
let currentStep = 0;
|
|
317
|
+
let nextTime = 0;
|
|
318
|
+
let timer = null;
|
|
319
|
+
let noiseBuffer = null;
|
|
320
|
+
const pattern = {};
|
|
321
|
+
|
|
322
|
+
// Browsers only allow sound after a click.
|
|
323
|
+
// So we create the audio engine when it is first needed.
|
|
324
|
+
function getAudio() {
|
|
325
|
+
if (!audio) audio = new (window.AudioContext || window.webkitAudioContext)();
|
|
326
|
+
return audio;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ----- build the beat grid -----
|
|
330
|
+
THEME.rows.forEach((row) => {
|
|
331
|
+
pattern[row.id] = new Array(STEPS).fill(false);
|
|
332
|
+
const label = document.createElement("div");
|
|
333
|
+
label.className = "row-label";
|
|
334
|
+
label.textContent = row.emoji + " " + row.label;
|
|
335
|
+
grid.appendChild(label);
|
|
336
|
+
for (let step = 0; step < STEPS; step++) {
|
|
337
|
+
const b = document.createElement("button");
|
|
338
|
+
b.className = "step" + (step % 4 === 0 ? " downbeat" : "");
|
|
339
|
+
b.dataset.row = row.id;
|
|
340
|
+
b.dataset.step = step;
|
|
341
|
+
b.title = row.label + " on beat " + (step + 1);
|
|
342
|
+
b.addEventListener("click", () => {
|
|
343
|
+
pattern[row.id][step] = !pattern[row.id][step];
|
|
344
|
+
b.classList.toggle("on", pattern[row.id][step]);
|
|
345
|
+
});
|
|
346
|
+
grid.appendChild(b);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Load a starter beat so the first play sounds great.
|
|
351
|
+
Object.keys(THEME.startBeat).forEach((rowId) => {
|
|
352
|
+
if (!pattern[rowId]) return;
|
|
353
|
+
THEME.startBeat[rowId].forEach((step) => {
|
|
354
|
+
pattern[rowId][step] = true;
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
document.querySelectorAll(".step").forEach((b) => {
|
|
359
|
+
const on = pattern[b.dataset.row][Number(b.dataset.step)];
|
|
360
|
+
b.classList.toggle("on", on);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ----- the four sounds, made from scratch -----
|
|
364
|
+
function makeNoise(ctx) {
|
|
365
|
+
if (noiseBuffer) return noiseBuffer;
|
|
366
|
+
const length = Math.floor(ctx.sampleRate * 0.2);
|
|
367
|
+
noiseBuffer = ctx.createBuffer(1, length, ctx.sampleRate);
|
|
368
|
+
const data = noiseBuffer.getChannelData(0);
|
|
369
|
+
for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1;
|
|
370
|
+
return noiseBuffer;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function playKick(ctx, time) {
|
|
374
|
+
const osc = ctx.createOscillator();
|
|
375
|
+
const gain = ctx.createGain();
|
|
376
|
+
osc.type = "sine";
|
|
377
|
+
osc.frequency.setValueAtTime(150, time);
|
|
378
|
+
osc.frequency.exponentialRampToValueAtTime(45, time + 0.22);
|
|
379
|
+
gain.gain.setValueAtTime(0.9, time);
|
|
380
|
+
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.25);
|
|
381
|
+
osc.connect(gain);
|
|
382
|
+
gain.connect(ctx.destination);
|
|
383
|
+
osc.start(time);
|
|
384
|
+
osc.stop(time + 0.3);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function playClap(ctx, time) {
|
|
388
|
+
const noise = ctx.createBufferSource();
|
|
389
|
+
noise.buffer = makeNoise(ctx);
|
|
390
|
+
const filter = ctx.createBiquadFilter();
|
|
391
|
+
filter.type = "bandpass";
|
|
392
|
+
filter.frequency.value = 1800;
|
|
393
|
+
const gain = ctx.createGain();
|
|
394
|
+
gain.gain.setValueAtTime(0.7, time);
|
|
395
|
+
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.18);
|
|
396
|
+
noise.connect(filter);
|
|
397
|
+
filter.connect(gain);
|
|
398
|
+
gain.connect(ctx.destination);
|
|
399
|
+
noise.start(time);
|
|
400
|
+
noise.stop(time + 0.2);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function playBeep(ctx, time) {
|
|
404
|
+
const osc = ctx.createOscillator();
|
|
405
|
+
const gain = ctx.createGain();
|
|
406
|
+
osc.type = "square";
|
|
407
|
+
osc.frequency.value = THEME.sound.beep;
|
|
408
|
+
gain.gain.setValueAtTime(0.18, time);
|
|
409
|
+
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.12);
|
|
410
|
+
osc.connect(gain);
|
|
411
|
+
gain.connect(ctx.destination);
|
|
412
|
+
osc.start(time);
|
|
413
|
+
osc.stop(time + 0.15);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function playBoop(ctx, time) {
|
|
417
|
+
const osc = ctx.createOscillator();
|
|
418
|
+
const gain = ctx.createGain();
|
|
419
|
+
osc.type = "sine";
|
|
420
|
+
osc.frequency.setValueAtTime(THEME.sound.boop, time);
|
|
421
|
+
osc.frequency.exponentialRampToValueAtTime(THEME.sound.boop * 0.7, time + 0.18);
|
|
422
|
+
gain.gain.setValueAtTime(0.3, time);
|
|
423
|
+
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.2);
|
|
424
|
+
osc.connect(gain);
|
|
425
|
+
gain.connect(ctx.destination);
|
|
426
|
+
osc.start(time);
|
|
427
|
+
osc.stop(time + 0.22);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function playSound(rowId, time) {
|
|
431
|
+
const ctx = getAudio();
|
|
432
|
+
if (rowId === "kick") playKick(ctx, time);
|
|
433
|
+
if (rowId === "clap") playClap(ctx, time);
|
|
434
|
+
if (rowId === "beep") playBeep(ctx, time);
|
|
435
|
+
if (rowId === "boop") playBoop(ctx, time);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ----- the beat clock -----
|
|
439
|
+
// We schedule sounds a tiny bit early so they land right on time.
|
|
440
|
+
function stepLength() {
|
|
441
|
+
const bpm = Number(tempoSlider.value);
|
|
442
|
+
return 60 / bpm / 2;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function tick() {
|
|
446
|
+
const ctx = getAudio();
|
|
447
|
+
while (nextTime < ctx.currentTime + 0.12) {
|
|
448
|
+
scheduleStep(currentStep, nextTime);
|
|
449
|
+
nextTime += stepLength();
|
|
450
|
+
currentStep = (currentStep + 1) % STEPS;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function scheduleStep(step, time) {
|
|
455
|
+
THEME.rows.forEach((row) => {
|
|
456
|
+
if (pattern[row.id][step]) playSound(row.id, time);
|
|
457
|
+
});
|
|
458
|
+
const wait = Math.max(0, (time - getAudio().currentTime) * 1000);
|
|
459
|
+
setTimeout(() => {
|
|
460
|
+
if (playing) showStep(step);
|
|
461
|
+
}, wait);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ----- dancing in time -----
|
|
465
|
+
function showStep(step) {
|
|
466
|
+
document.querySelectorAll(".step").forEach((b) => {
|
|
467
|
+
b.classList.toggle("now", Number(b.dataset.step) === step);
|
|
468
|
+
});
|
|
469
|
+
dancer.className = "dancer pose" + (step % 4);
|
|
470
|
+
const boomRow = THEME.rows[0];
|
|
471
|
+
const boom = boomRow && pattern[boomRow.id][step];
|
|
472
|
+
stageBox.classList.toggle("boom", Boolean(boom));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ----- play and stop -----
|
|
476
|
+
function startParty(ctx) {
|
|
477
|
+
playing = true;
|
|
478
|
+
currentStep = 0;
|
|
479
|
+
nextTime = ctx.currentTime + 0.1;
|
|
480
|
+
timer = setInterval(tick, 25);
|
|
481
|
+
playButton.textContent = "⏹️ Stop";
|
|
482
|
+
document.body.classList.add("partying");
|
|
483
|
+
messageBox.textContent = THEME.messages.play;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function stopParty() {
|
|
487
|
+
playing = false;
|
|
488
|
+
clearInterval(timer);
|
|
489
|
+
document.querySelectorAll(".step").forEach((b) => b.classList.remove("now"));
|
|
490
|
+
dancer.className = "dancer idle";
|
|
491
|
+
stageBox.classList.remove("boom");
|
|
492
|
+
document.body.classList.remove("partying");
|
|
493
|
+
playButton.textContent = "▶️ Play";
|
|
494
|
+
messageBox.textContent = THEME.messages.stop;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
playButton.addEventListener("click", () => {
|
|
498
|
+
const ctx = getAudio();
|
|
499
|
+
// resume() wakes the sound engine after the first click.
|
|
500
|
+
ctx.resume().then(() => {
|
|
501
|
+
if (playing) stopParty();
|
|
502
|
+
else startParty(ctx);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
tempoSlider.addEventListener("input", () => {
|
|
507
|
+
tempoLabel.textContent = tempoSlider.value + " BPM";
|
|
508
|
+
});
|
|
509
|
+
`;
|
|
510
|
+
}
|
|
511
|
+
export const musicScaffold = {
|
|
512
|
+
id: 'music',
|
|
513
|
+
label: 'Dance Party',
|
|
514
|
+
emoji: '🎵',
|
|
515
|
+
ageNote: 'Loud and happy. Build a beat in seconds.',
|
|
516
|
+
themes: [robotDance, glowDisco],
|
|
517
|
+
files(theme, prettyName) {
|
|
518
|
+
return {
|
|
519
|
+
'index.html': buildHtml(theme, prettyName),
|
|
520
|
+
'style.css': buildCss(theme),
|
|
521
|
+
'game.js': buildJs(theme),
|
|
522
|
+
};
|
|
523
|
+
},
|
|
524
|
+
starterPrompts(theme) {
|
|
525
|
+
return [...extrasFor(theme.id).prompts];
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
export default musicScaffold;
|