@openvidstudio/styles 0.0.0-stage → 1.4.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 +202 -0
- package/README.md +33 -3
- package/package.json +39 -3
- package/src/audio.ts +140 -0
- package/src/depth.ts +335 -0
- package/src/font.ts +116 -0
- package/src/index.ts +9 -0
- package/src/layout.ts +145 -0
- package/src/narrator.ts +243 -0
- package/src/props.ts +121 -0
- package/src/rng.ts +31 -0
- package/src/sprites.ts +90 -0
- package/src/tokens.ts +31 -0
package/src/narrator.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { drawSprite, frameAt, makeSprite, type DrawSpriteOptions, type Sprite } from "./sprites";
|
|
2
|
+
import { ACCENTS } from "./tokens";
|
|
3
|
+
|
|
4
|
+
export type Pose = "idle" | "talk" | "point" | "think" | "celebrate";
|
|
5
|
+
export const POSES: readonly Pose[] = ["idle", "talk", "point", "think", "celebrate"];
|
|
6
|
+
|
|
7
|
+
export const NARRATOR_WIDTH = 16;
|
|
8
|
+
export const NARRATOR_HEIGHT = 23;
|
|
9
|
+
|
|
10
|
+
/** The narrator's base palette. "b" (shirt) and "a" (sparkle) take the lesson's accent. */
|
|
11
|
+
export function narratorPalette(accent: string = ACCENTS.math): Record<string, string> {
|
|
12
|
+
return {
|
|
13
|
+
o: "#14172B",
|
|
14
|
+
h: "#5B4A8A",
|
|
15
|
+
s: "#FFD2A8",
|
|
16
|
+
e: "#14172B",
|
|
17
|
+
m: "#D6455D",
|
|
18
|
+
b: accent,
|
|
19
|
+
w: "#FFFFFF",
|
|
20
|
+
p: "#3F4B73",
|
|
21
|
+
a: accent,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class Grid {
|
|
26
|
+
private rows: string[][];
|
|
27
|
+
constructor(
|
|
28
|
+
readonly w: number,
|
|
29
|
+
readonly h: number,
|
|
30
|
+
) {
|
|
31
|
+
this.rows = Array.from({ length: h }, () => Array<string>(w).fill("."));
|
|
32
|
+
}
|
|
33
|
+
set(x: number, y: number, text: string): void {
|
|
34
|
+
for (let i = 0; i < text.length; i++) {
|
|
35
|
+
const px = x + i;
|
|
36
|
+
if (px >= 0 && px < this.w && y >= 0 && y < this.h) this.rows[y][px] = text[i];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
lines(): string[] {
|
|
40
|
+
return this.rows.map((r) => r.join(""));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Mouth = "closed" | "open" | "half" | "grin";
|
|
45
|
+
|
|
46
|
+
interface Spec {
|
|
47
|
+
pose: Pose;
|
|
48
|
+
/** Rows the figure sits below the top of the sprite (the headroom is for jumps and the thought dots). */
|
|
49
|
+
oy: number;
|
|
50
|
+
blink?: boolean;
|
|
51
|
+
mouth?: Mouth;
|
|
52
|
+
/** point: how far the arm reaches. think: dots shown. celebrate: sparkle phase. */
|
|
53
|
+
k?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function build(spec: Spec): string[] {
|
|
57
|
+
const g = new Grid(NARRATOR_WIDTH, NARRATOR_HEIGHT);
|
|
58
|
+
const y = spec.oy;
|
|
59
|
+
const eyes = spec.blink || spec.pose === "celebrate" ? "ssoossooss" : "sseessseess";
|
|
60
|
+
// head, 9 rows
|
|
61
|
+
g.set(0, y + 0, "....oooooooo....");
|
|
62
|
+
g.set(0, y + 1, "...ohhhhhhhho...");
|
|
63
|
+
g.set(0, y + 2, "..ohhhhhhhhhho..");
|
|
64
|
+
g.set(0, y + 3, "..o" + "hssssssssh" + "o..");
|
|
65
|
+
g.set(0, y + 4, "..o" + eyes + "o..");
|
|
66
|
+
g.set(0, y + 5, "..o" + "ssssssssss" + "o..");
|
|
67
|
+
const mouths: Record<Mouth, [string, string]> = {
|
|
68
|
+
closed: ["ssssmmssss", "ssssssssss"],
|
|
69
|
+
half: ["ssssmmssss", "sssssmssss"],
|
|
70
|
+
open: ["ssssmmssss", "ssssmmssss"],
|
|
71
|
+
grin: ["sssmmmmsss", "ssssssssss"],
|
|
72
|
+
};
|
|
73
|
+
const [mouth1, mouth2] = mouths[spec.mouth ?? "closed"];
|
|
74
|
+
g.set(0, y + 6, "..o" + mouth1 + "o..");
|
|
75
|
+
g.set(0, y + 7, "..o" + mouth2 + "o..");
|
|
76
|
+
g.set(0, y + 8, "...oooooooooo...");
|
|
77
|
+
|
|
78
|
+
// torso
|
|
79
|
+
g.set(0, y + 9, "....oooooooo....");
|
|
80
|
+
for (let r = 10; r <= 14; r++) g.set(0, y + r, r === 12 ? "....obbwwbbo...." : "....obbbbbbo....");
|
|
81
|
+
g.set(0, y + 15, "....oooooooo....");
|
|
82
|
+
|
|
83
|
+
// legs and shoes
|
|
84
|
+
g.set(0, y + 16, "....oppppppo....");
|
|
85
|
+
g.set(0, y + 17, "....opppoppo....");
|
|
86
|
+
g.set(0, y + 18, "....oppo.ppo....");
|
|
87
|
+
g.set(0, y + 19, "...owwo..owwo...");
|
|
88
|
+
g.set(0, y + 20, "...oooo..oooo...");
|
|
89
|
+
|
|
90
|
+
const armDown = (left: boolean): void => {
|
|
91
|
+
for (let r = 10; r <= 14; r++) {
|
|
92
|
+
if (left) {
|
|
93
|
+
g.set(2, y + r, "os");
|
|
94
|
+
} else {
|
|
95
|
+
g.set(12, y + r, "so");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (left) g.set(2, y + 15, "oo");
|
|
99
|
+
else g.set(12, y + 15, "oo");
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
switch (spec.pose) {
|
|
103
|
+
case "idle":
|
|
104
|
+
case "talk":
|
|
105
|
+
armDown(true);
|
|
106
|
+
armDown(false);
|
|
107
|
+
break;
|
|
108
|
+
case "point": {
|
|
109
|
+
armDown(true);
|
|
110
|
+
const reach = spec.k ?? 0;
|
|
111
|
+
g.set(12, y + 9, "oooo");
|
|
112
|
+
g.set(12, y + 10, "ssss");
|
|
113
|
+
g.set(12, y + 11, "oooo");
|
|
114
|
+
if (reach === 0) {
|
|
115
|
+
g.set(15, y + 9, ".");
|
|
116
|
+
g.set(15, y + 10, ".");
|
|
117
|
+
g.set(15, y + 11, ".");
|
|
118
|
+
g.set(14, y + 9, "o");
|
|
119
|
+
g.set(14, y + 10, "o");
|
|
120
|
+
g.set(14, y + 11, "o");
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case "think": {
|
|
125
|
+
armDown(true);
|
|
126
|
+
g.set(12, y + 8, "ss");
|
|
127
|
+
g.set(14, y + 8, "o");
|
|
128
|
+
g.set(12, y + 9, "ss");
|
|
129
|
+
g.set(14, y + 9, "o");
|
|
130
|
+
for (let r = 10; r <= 12; r++) g.set(12, y + r, "so");
|
|
131
|
+
const dots = spec.k ?? 0;
|
|
132
|
+
const at = [11, 13, 15];
|
|
133
|
+
for (let i = 0; i < dots && i < 3; i++) g.set(at[i], 0, "w");
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "celebrate": {
|
|
137
|
+
for (let r = 3; r <= 10; r++) {
|
|
138
|
+
g.set(0, y + r, "os");
|
|
139
|
+
g.set(14, y + r, "so");
|
|
140
|
+
}
|
|
141
|
+
g.set(2, y + 9, "s");
|
|
142
|
+
g.set(2, y + 10, "s");
|
|
143
|
+
g.set(13, y + 9, "s");
|
|
144
|
+
g.set(13, y + 10, "s");
|
|
145
|
+
g.set(0, y + 2, "ss");
|
|
146
|
+
g.set(14, y + 2, "ss");
|
|
147
|
+
const phase = spec.k ?? 0;
|
|
148
|
+
if (phase === 0) {
|
|
149
|
+
g.set(0, 0, "a");
|
|
150
|
+
g.set(15, 0, "a");
|
|
151
|
+
} else {
|
|
152
|
+
g.set(2, 0, "a");
|
|
153
|
+
g.set(13, 0, "a");
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return g.lines();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const OY = 2;
|
|
162
|
+
|
|
163
|
+
const FRAMES: Record<Pose, { frames: Spec[]; fps: number }> = {
|
|
164
|
+
idle: {
|
|
165
|
+
fps: 4,
|
|
166
|
+
frames: [
|
|
167
|
+
{ pose: "idle", oy: OY },
|
|
168
|
+
{ pose: "idle", oy: OY },
|
|
169
|
+
{ pose: "idle", oy: OY },
|
|
170
|
+
{ pose: "idle", oy: OY },
|
|
171
|
+
{ pose: "idle", oy: OY },
|
|
172
|
+
{ pose: "idle", oy: OY, blink: true },
|
|
173
|
+
{ pose: "idle", oy: OY },
|
|
174
|
+
{ pose: "idle", oy: OY },
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
talk: {
|
|
178
|
+
fps: 8,
|
|
179
|
+
frames: [
|
|
180
|
+
{ pose: "talk", oy: OY, mouth: "half" },
|
|
181
|
+
{ pose: "talk", oy: OY, mouth: "open" },
|
|
182
|
+
{ pose: "talk", oy: OY, mouth: "closed" },
|
|
183
|
+
{ pose: "talk", oy: OY, mouth: "open" },
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
point: {
|
|
187
|
+
fps: 3,
|
|
188
|
+
frames: [
|
|
189
|
+
{ pose: "point", oy: OY, mouth: "half", k: 0 },
|
|
190
|
+
{ pose: "point", oy: OY, mouth: "closed", k: 1 },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
think: {
|
|
194
|
+
fps: 2,
|
|
195
|
+
frames: [
|
|
196
|
+
{ pose: "think", oy: OY, k: 0 },
|
|
197
|
+
{ pose: "think", oy: OY, k: 1 },
|
|
198
|
+
{ pose: "think", oy: OY, k: 2 },
|
|
199
|
+
{ pose: "think", oy: OY, k: 3 },
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
celebrate: {
|
|
203
|
+
fps: 5,
|
|
204
|
+
frames: [
|
|
205
|
+
{ pose: "celebrate", oy: 1, mouth: "grin", k: 0 },
|
|
206
|
+
{ pose: "celebrate", oy: 0, mouth: "grin", k: 1 },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const cache = new Map<string, Sprite>();
|
|
212
|
+
|
|
213
|
+
/** The narrator sprite for a pose, in an accent colour. Sprites are cached per pose and accent. */
|
|
214
|
+
export function narratorSprite(pose: Pose, accent: string = ACCENTS.math): Sprite {
|
|
215
|
+
const key = `${pose}|${accent}`;
|
|
216
|
+
let s = cache.get(key);
|
|
217
|
+
if (!s) {
|
|
218
|
+
const def = FRAMES[pose];
|
|
219
|
+
s = makeSprite(narratorPalette(accent), def.frames.map(build), def.fps);
|
|
220
|
+
cache.set(key, s);
|
|
221
|
+
}
|
|
222
|
+
return s;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Which animation frame the pose shows at time t (seconds). Pure in t. */
|
|
226
|
+
export function narratorFrame(pose: Pose, t: number): number {
|
|
227
|
+
return frameAt(narratorSprite(pose), t);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Draws the narrator in a pose at time t. (x, y) is the sprite's top-left. */
|
|
231
|
+
export function drawNarrator(
|
|
232
|
+
ctx: CanvasRenderingContext2D,
|
|
233
|
+
pose: Pose,
|
|
234
|
+
x: number,
|
|
235
|
+
y: number,
|
|
236
|
+
scale: number,
|
|
237
|
+
t: number,
|
|
238
|
+
accent: string = ACCENTS.math,
|
|
239
|
+
opts: DrawSpriteOptions = {},
|
|
240
|
+
): void {
|
|
241
|
+
const sprite = narratorSprite(pose, accent);
|
|
242
|
+
drawSprite(ctx, sprite, x, y, scale, frameAt(sprite, t), opts);
|
|
243
|
+
}
|
package/src/props.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { drawSprite, frameAt, makeSprite, type DrawSpriteOptions, type Sprite } from "./sprites";
|
|
2
|
+
|
|
3
|
+
const BALL = makeSprite(
|
|
4
|
+
{ o: "#14172B", r: "#FF4D5E", w: "#FFFFFF" },
|
|
5
|
+
[
|
|
6
|
+
["..oooo..", ".orrrro.", "orwrrrro", "orrrrrro", "orrrrrro", "orrrrrro", ".orrrro.", "..oooo.."],
|
|
7
|
+
["..oooo..", ".orrrro.", "orrrrrro", "orrwrrro", "orrrrrro", "orrrrrro", ".orrrro.", "..oooo.."],
|
|
8
|
+
],
|
|
9
|
+
4,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const RULER = makeSprite({ o: "#14172B", y: "#FFD36B", t: "#14172B" }, [
|
|
13
|
+
[
|
|
14
|
+
"oooooooooooooooo",
|
|
15
|
+
"otyytyytyytyytyo",
|
|
16
|
+
"oyyyyyyyyyyyyyyo",
|
|
17
|
+
"oyyyyyyyyyyyyyyo",
|
|
18
|
+
"oooooooooooooooo",
|
|
19
|
+
],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const BATTERY = makeSprite(
|
|
23
|
+
{ o: "#14172B", w: "#E7ECF7", k: "#3A3F55", g: "#7CFFB2" },
|
|
24
|
+
[
|
|
25
|
+
["..oooo..", "..owwo..", "oooooooo", "okkkkkko", "okggggko", "okggggko", "okkkkkko", "okggggko", "okggggko", "okkkkkko", "okkkkkko", "oooooooo"],
|
|
26
|
+
["..oooo..", "..owwo..", "oooooooo", "okkkkkko", "okggggko", "okggggko", "okkkkkko", "okggggko", "okkkkkko", "okkkkkko", "okkkkkko", "oooooooo"],
|
|
27
|
+
],
|
|
28
|
+
2,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const CABLE = makeSprite({ o: "#14172B", w: "#E7ECF7", c: "#FFB347" }, [
|
|
32
|
+
[
|
|
33
|
+
"ooo.............",
|
|
34
|
+
"owwoo...........",
|
|
35
|
+
"ooooccc.........",
|
|
36
|
+
".....cccc.......",
|
|
37
|
+
".......cccc.....",
|
|
38
|
+
".........ccccooo",
|
|
39
|
+
"..........ooowwo",
|
|
40
|
+
"...........oooo.",
|
|
41
|
+
],
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const BRIDGE = makeSprite({ o: "#14172B", y: "#C98B4B" }, [
|
|
45
|
+
[
|
|
46
|
+
"........................",
|
|
47
|
+
"oooooooooooooooooooooooo",
|
|
48
|
+
"oyyyyyyyyyyyyyyyyyyyyyyo",
|
|
49
|
+
"oooooooooooooooooooooooo",
|
|
50
|
+
"o..o..o..o..o..o..o..o..",
|
|
51
|
+
"o.o.o.o.o.o.o.o.o.o.o.o.",
|
|
52
|
+
"oo..oo..oo..oo..oo..oo..",
|
|
53
|
+
"oooooooooooooooooooooooo",
|
|
54
|
+
],
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const BAT = makeSprite({ o: "#14172B", k: "#3A3F55", y: "#D9A066" }, [
|
|
58
|
+
[
|
|
59
|
+
"..oo..",
|
|
60
|
+
".okko.",
|
|
61
|
+
".okko.",
|
|
62
|
+
".okko.",
|
|
63
|
+
".okko.",
|
|
64
|
+
".okko.",
|
|
65
|
+
".okko.",
|
|
66
|
+
".okko.",
|
|
67
|
+
".okko.",
|
|
68
|
+
".oyyo.",
|
|
69
|
+
"oyyyyo",
|
|
70
|
+
"oyyyyo",
|
|
71
|
+
"oyyyyo",
|
|
72
|
+
"oyyyyo",
|
|
73
|
+
"oyyyyo",
|
|
74
|
+
".oooo.",
|
|
75
|
+
],
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const registry = new Map<string, Sprite>([
|
|
79
|
+
["ball", BALL],
|
|
80
|
+
["ruler", RULER],
|
|
81
|
+
["battery", BATTERY],
|
|
82
|
+
["cable", CABLE],
|
|
83
|
+
["bridge", BRIDGE],
|
|
84
|
+
["bat", BAT],
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** Names of the built-in props, in a stable order. */
|
|
88
|
+
export const BUILTIN_PROPS = ["ball", "ruler", "battery", "cable", "bridge", "bat"] as const;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Adds a prop a lesson needs (a beaker, a magnet, a cell). Build it with makeSprite. A name that
|
|
92
|
+
* already exists is refused unless `overwrite` is set, so a lesson cannot silently change a built-in.
|
|
93
|
+
*/
|
|
94
|
+
export function registerProp(name: string, sprite: Sprite, overwrite = false): void {
|
|
95
|
+
if (registry.has(name) && !overwrite) throw new Error(`A prop named "${name}" already exists; pass overwrite to replace it.`);
|
|
96
|
+
registry.set(name, sprite);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getProp(name: string): Sprite {
|
|
100
|
+
const s = registry.get(name);
|
|
101
|
+
if (!s) throw new Error(`No prop named "${name}". Known: ${propNames().join(", ")}.`);
|
|
102
|
+
return s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function propNames(): string[] {
|
|
106
|
+
return [...registry.keys()];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Draws a prop by name; animated props (ball, battery) pick their frame from t. */
|
|
110
|
+
export function drawProp(
|
|
111
|
+
ctx: CanvasRenderingContext2D,
|
|
112
|
+
name: string,
|
|
113
|
+
x: number,
|
|
114
|
+
y: number,
|
|
115
|
+
scale: number,
|
|
116
|
+
t = 0,
|
|
117
|
+
opts: DrawSpriteOptions = {},
|
|
118
|
+
): void {
|
|
119
|
+
const sprite = getProp(name);
|
|
120
|
+
drawSprite(ctx, sprite, x, y, scale, frameAt(sprite, t), opts);
|
|
121
|
+
}
|
package/src/rng.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small seeded PRNG (mulberry32). Every random-looking thing in this package draws from one of
|
|
3
|
+
* these, never from the platform's global generator, so a frame is a pure function of its inputs.
|
|
4
|
+
*/
|
|
5
|
+
export interface Rng {
|
|
6
|
+
/** Next float in [0, 1). */
|
|
7
|
+
next(): number;
|
|
8
|
+
/** Float in [min, max). */
|
|
9
|
+
range(min: number, max: number): number;
|
|
10
|
+
/** Integer in [min, max], both ends included. */
|
|
11
|
+
int(min: number, max: number): number;
|
|
12
|
+
/** One element of a non-empty array. */
|
|
13
|
+
pick<T>(items: readonly T[]): T;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createRng(seed: number): Rng {
|
|
17
|
+
let a = seed >>> 0;
|
|
18
|
+
const next = (): number => {
|
|
19
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
20
|
+
let t = a;
|
|
21
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
22
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
23
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
next,
|
|
27
|
+
range: (min, max) => min + next() * (max - min),
|
|
28
|
+
int: (min, max) => min + Math.floor(next() * (max - min + 1)),
|
|
29
|
+
pick: (items) => items[Math.floor(next() * items.length)],
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/sprites.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette-indexed string-grid sprites. Each frame is an array of equal-width strings; every
|
|
3
|
+
* character is a palette key, and "." is transparent. Drawing is integer-scale fillRect with
|
|
4
|
+
* smoothing off, so a pixel is exactly scale x scale device pixels with no blending.
|
|
5
|
+
*/
|
|
6
|
+
export interface Sprite {
|
|
7
|
+
readonly width: number;
|
|
8
|
+
readonly height: number;
|
|
9
|
+
/** Palette key (one character) to a CSS colour. */
|
|
10
|
+
readonly palette: Readonly<Record<string, string>>;
|
|
11
|
+
readonly frames: readonly (readonly string[])[];
|
|
12
|
+
/** Frames per second when played by time (see frameAt). */
|
|
13
|
+
readonly fps: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function makeSprite(
|
|
17
|
+
palette: Record<string, string>,
|
|
18
|
+
frames: readonly (readonly string[])[],
|
|
19
|
+
fps = 6,
|
|
20
|
+
): Sprite {
|
|
21
|
+
if (frames.length === 0) throw new Error("A sprite needs at least one frame.");
|
|
22
|
+
const height = frames[0].length;
|
|
23
|
+
const width = frames[0][0]?.length ?? 0;
|
|
24
|
+
frames.forEach((frame, f) => {
|
|
25
|
+
if (frame.length !== height) throw new Error(`Sprite frame ${f} has ${frame.length} rows; expected ${height}.`);
|
|
26
|
+
frame.forEach((row, r) => {
|
|
27
|
+
if (row.length !== width) throw new Error(`Sprite frame ${f} row ${r} is ${row.length} wide; expected ${width}.`);
|
|
28
|
+
for (const ch of row) {
|
|
29
|
+
if (ch !== "." && !(ch in palette)) throw new Error(`Sprite frame ${f} row ${r} uses "${ch}", which is not in the palette.`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
return { width, height, palette, frames, fps };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Which frame plays at time t (seconds): a pure function of t. */
|
|
37
|
+
export function frameAt(sprite: Sprite, t: number): number {
|
|
38
|
+
const n = sprite.frames.length;
|
|
39
|
+
const i = Math.floor(Math.max(0, t) * sprite.fps) % n;
|
|
40
|
+
return i;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DrawSpriteOptions {
|
|
44
|
+
/** Draw every opaque pixel in this one colour (a silhouette, for shadows). */
|
|
45
|
+
tint?: string;
|
|
46
|
+
/** Mirror left to right. */
|
|
47
|
+
flipX?: boolean;
|
|
48
|
+
/** Replace palette colours by key for this draw. */
|
|
49
|
+
palette?: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Draws one frame with its top-left at (x, y). The scale is rounded to a whole number of at
|
|
54
|
+
* least 1 and the position to whole pixels, so every sprite pixel stays a crisp block.
|
|
55
|
+
*/
|
|
56
|
+
export function drawSprite(
|
|
57
|
+
ctx: CanvasRenderingContext2D,
|
|
58
|
+
sprite: Sprite,
|
|
59
|
+
x: number,
|
|
60
|
+
y: number,
|
|
61
|
+
scale = 1,
|
|
62
|
+
frame = 0,
|
|
63
|
+
opts: DrawSpriteOptions = {},
|
|
64
|
+
): void {
|
|
65
|
+
const s = Math.max(1, Math.round(scale));
|
|
66
|
+
const ox = Math.round(x);
|
|
67
|
+
const oy = Math.round(y);
|
|
68
|
+
const rows = sprite.frames[((frame % sprite.frames.length) + sprite.frames.length) % sprite.frames.length];
|
|
69
|
+
ctx.save();
|
|
70
|
+
ctx.imageSmoothingEnabled = false;
|
|
71
|
+
ctx.globalCompositeOperation = "source-over";
|
|
72
|
+
for (let r = 0; r < rows.length; r++) {
|
|
73
|
+
const row = rows[r];
|
|
74
|
+
let c = 0;
|
|
75
|
+
while (c < row.length) {
|
|
76
|
+
const ch = row[c];
|
|
77
|
+
if (ch === ".") {
|
|
78
|
+
c++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
let end = c + 1;
|
|
82
|
+
while (end < row.length && row[end] === ch) end++;
|
|
83
|
+
ctx.fillStyle = opts.tint ?? opts.palette?.[ch] ?? sprite.palette[ch];
|
|
84
|
+
const px = opts.flipX ? sprite.width - end : c;
|
|
85
|
+
ctx.fillRect(ox + px * s, oy + r * s, (end - c) * s, s);
|
|
86
|
+
c = end;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
ctx.restore();
|
|
90
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type Subject = "math" | "physics" | "cs" | "chemistry" | "biology";
|
|
2
|
+
|
|
3
|
+
/** One accent per subject. */
|
|
4
|
+
export const ACCENTS: Record<Subject, string> = {
|
|
5
|
+
math: "#5CC8FF",
|
|
6
|
+
physics: "#FFB347",
|
|
7
|
+
cs: "#B58CFF",
|
|
8
|
+
chemistry: "#FF4D5E",
|
|
9
|
+
biology: "#7CFFB2",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const SUBJECTS = Object.keys(ACCENTS) as Subject[];
|
|
13
|
+
|
|
14
|
+
/** The flat dark ground the kit is designed against. */
|
|
15
|
+
export const VOID = "#0B0D14";
|
|
16
|
+
|
|
17
|
+
export function accentFor(subject: Subject): string {
|
|
18
|
+
return ACCENTS[subject];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** "#RRGGBB" to [r, g, b]. */
|
|
22
|
+
export function hexToRgb(hex: string): [number, number, number] {
|
|
23
|
+
const n = parseInt(hex.slice(1), 16);
|
|
24
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** An "rgba(...)" string for a hex colour at an alpha. */
|
|
28
|
+
export function withAlpha(hex: string, alpha: number): string {
|
|
29
|
+
const [r, g, b] = hexToRgb(hex);
|
|
30
|
+
return `rgba(${r},${g},${b},${Math.max(0, Math.min(1, alpha))})`;
|
|
31
|
+
}
|