@blkc-code/render-canvas 0.0.0-stage → 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/dist/draw.d.ts +16 -0
- package/dist/draw.d.ts.map +1 -0
- package/dist/draw.js +427 -0
- package/dist/draw.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/office.d.ts +18 -0
- package/dist/office.d.ts.map +1 -0
- package/dist/office.js +119 -0
- package/dist/office.js.map +1 -0
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +11 -0
- package/dist/runtime.js.map +1 -0
- package/dist/runtime.txt +11 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/world.d.ts +117 -0
- package/dist/world.d.ts.map +1 -0
- package/dist/world.js +322 -0
- package/dist/world.js.map +1 -0
- package/package.json +43 -4
- package/src/draw.ts +429 -0
- package/src/index.ts +73 -0
- package/src/office.ts +137 -0
- package/src/runtime.ts +8 -0
- package/src/types.ts +33 -0
- package/src/world.ts +363 -0
- package/README.md +0 -3
package/src/draw.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draws the office. Every piece of art here is original to this project, made of
|
|
3
|
+
* rectangles and the character maps below at 16 pixels a tile.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { FURNITURE, isSeated, type Agent, type Item, type Room, type World } from "./world.js";
|
|
7
|
+
|
|
8
|
+
export const TILE = 16;
|
|
9
|
+
|
|
10
|
+
export interface Palette { primary: string; background: string; text: string; accent: string }
|
|
11
|
+
|
|
12
|
+
type Ctx = CanvasRenderingContext2D;
|
|
13
|
+
|
|
14
|
+
const rect = (c: Ctx, color: string, x: number, y: number, w: number, h: number): void => {
|
|
15
|
+
c.fillStyle = color;
|
|
16
|
+
c.fillRect(Math.round(x), Math.round(y), w, h);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// --- Characters: 12 wide. h hair, s skin, e eye, t shirt, T shirt shadow, l legs, b shoes -----------------
|
|
20
|
+
|
|
21
|
+
const HEAD_FRONT = ["...hhhhhh...", "..hhhhhhhh..", "..hhhhhhhh..", "..hssssssh..", "..ssessess..", "..ssssssss..", "...ssssss..."];
|
|
22
|
+
const HEAD_BACK = ["...hhhhhh...", "..hhhhhhhh..", "..hhhhhhhh..", "..hhhhhhhh..", "..hhhhhhhh..", "..hhhhhhhh..", "...ssssss..."];
|
|
23
|
+
const HEAD_SIDE = ["...hhhhhh...", "..hhhhhhhh..", "..hhhhhhhh..", "..hhssssss..", "..hhsssess..", "..hsssssss..", "...ssssss..."];
|
|
24
|
+
const BODY = ["..tttttttt..", ".tttttttttt.", ".stttttttts.", ".stTTTTTTts."];
|
|
25
|
+
const BODY_SIDE = ["...tttttt...", "...tttttt...", "...ttsttt...", "...TTsTTT..."];
|
|
26
|
+
const LEGS_STAND = ["..llllllll..", "..lll..lll..", "..lll..lll..", "..bbb..bbb.."];
|
|
27
|
+
const LEGS_A = ["..llllllll..", "..lll..lll..", "..bbb..lll..", ".......bbb.."];
|
|
28
|
+
const LEGS_B = ["..llllllll..", "..lll..lll..", "..lll..bbb..", "..bbb......."];
|
|
29
|
+
const LEGS_SIDE_STAND = ["...llllll...", "...lll.lll..", "...lll.lll..", "...bbb.bbb.."];
|
|
30
|
+
const LEGS_SIDE_A = ["...llllll...", "..lll..lll..", ".lll....lll.", ".bb......bb."];
|
|
31
|
+
const LEGS_SIDE_B = ["...llllll...", "...llllll...", "....llll....", "....bbbb...."];
|
|
32
|
+
|
|
33
|
+
function drawMap(c: Ctx, map: string[], x: number, y: number, colors: Record<string, string>, flip = false): void {
|
|
34
|
+
map.forEach((row, ry) => {
|
|
35
|
+
for (let rx = 0; rx < row.length; rx++) {
|
|
36
|
+
const key = row[flip ? row.length - 1 - rx : rx] as string;
|
|
37
|
+
const color = colors[key];
|
|
38
|
+
if (color) rect(c, color, x + rx, y + ry, 1, 1);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function shade(hex: string, amount: number): string {
|
|
44
|
+
const n = parseInt(hex.slice(1).padEnd(6, hex.slice(-1)), 16);
|
|
45
|
+
const channel = (shift: number): number => Math.max(0, Math.min(255, ((n >> shift) & 255) + amount));
|
|
46
|
+
return `rgb(${channel(16)},${channel(8)},${channel(0)})`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function drawAgent(c: Ctx, agent: Agent, time: number, selected: boolean): void {
|
|
50
|
+
const px = agent.x * TILE + 2;
|
|
51
|
+
const seated = isSeated(agent);
|
|
52
|
+
const colors = { h: agent.hair, s: agent.skin, e: "#1a1a24", t: agent.shirt, T: shade(agent.shirt, -36), l: "#39415a", b: "#1f2433" };
|
|
53
|
+
const stride = Math.floor(time * 8 + agent.seed) % 2 === 0;
|
|
54
|
+
const breathe = !agent.walking && Math.floor(time * 1.5 + agent.seed % 7) % 2 === 0 ? 1 : 0;
|
|
55
|
+
const height = seated ? 11 : 15;
|
|
56
|
+
// Seated people sit just in front of the desk, on a chair, with their heads clear of the keyboard.
|
|
57
|
+
const py = agent.y * TILE + (seated ? 6 : -1) - (agent.walking && stride ? 1 : 0);
|
|
58
|
+
|
|
59
|
+
if (seated) {
|
|
60
|
+
rect(c, "#1f2937", px + 1, agent.y * TILE + 3, 10, 9);
|
|
61
|
+
rect(c, "#374151", px + 2, agent.y * TILE + 11, 8, 3);
|
|
62
|
+
rect(c, "#111827", px + 5, agent.y * TILE + 14, 2, 2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Shadow
|
|
66
|
+
c.fillStyle = "rgba(0,0,0,0.25)";
|
|
67
|
+
c.fillRect(Math.round(px + 2), Math.round(agent.y * TILE + 14), 8, 2);
|
|
68
|
+
|
|
69
|
+
if (selected) {
|
|
70
|
+
c.fillStyle = "rgba(255,255,255,0.28)";
|
|
71
|
+
c.fillRect(Math.round(px - 1), Math.round(agent.y * TILE + 13), 14, 4);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const side = agent.facing === "left" || agent.facing === "right";
|
|
75
|
+
const head = side ? HEAD_SIDE : agent.facing === "up" ? HEAD_BACK : HEAD_FRONT;
|
|
76
|
+
const body = side ? BODY_SIDE : BODY;
|
|
77
|
+
const legs = !agent.walking
|
|
78
|
+
? side ? LEGS_SIDE_STAND : LEGS_STAND
|
|
79
|
+
: side ? (stride ? LEGS_SIDE_A : LEGS_SIDE_B) : stride ? LEGS_A : LEGS_B;
|
|
80
|
+
const flip = agent.facing === "left";
|
|
81
|
+
|
|
82
|
+
drawMap(c, head, px, py - height + breathe * 0, colors, flip);
|
|
83
|
+
drawMap(c, body, px, py - height + head.length, colors, flip);
|
|
84
|
+
if (!seated) drawMap(c, legs, px, py - height + head.length + body.length, colors, flip);
|
|
85
|
+
else if (agent.state === "working") {
|
|
86
|
+
// Hands on the keys.
|
|
87
|
+
const typing = Math.floor(time * 9 + agent.seed) % 2;
|
|
88
|
+
rect(c, agent.skin, px + 1 + typing, py - height + head.length + 3, 1, 1);
|
|
89
|
+
rect(c, agent.skin, px + 10 - (1 - typing), py - height + head.length + 3, 1, 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function bubble(c: Ctx, x: number, y: number, draw: (x: number, y: number) => void): void {
|
|
94
|
+
rect(c, "#111827", x - 1, y - 1, 13, 11);
|
|
95
|
+
rect(c, "#ffffff", x, y, 11, 9);
|
|
96
|
+
rect(c, "#ffffff", x + 4, y + 9, 3, 1);
|
|
97
|
+
rect(c, "#111827", x + 4, y + 10, 3, 1);
|
|
98
|
+
draw(x, y);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function drawStatus(c: Ctx, agent: Agent, time: number): void {
|
|
102
|
+
const x = Math.round(agent.x * TILE + 2);
|
|
103
|
+
const y = Math.round(agent.y * TILE - 26 + (isSeated(agent) ? 8 : 0));
|
|
104
|
+
const pulse = Math.floor(time * 2 + agent.seed % 3) % 2 === 0;
|
|
105
|
+
if (agent.state === "blocked") {
|
|
106
|
+
bubble(c, x, y, (bx, by) => {
|
|
107
|
+
rect(c, pulse ? "#dc2626" : "#991b1b", bx + 5, by + 1, 2, 5);
|
|
108
|
+
rect(c, pulse ? "#dc2626" : "#991b1b", bx + 5, by + 7, 2, 1);
|
|
109
|
+
});
|
|
110
|
+
} else if (agent.state === "thinking") {
|
|
111
|
+
const dots = Math.floor(time * 3) % 4;
|
|
112
|
+
bubble(c, x, y, (bx, by) => {
|
|
113
|
+
for (let i = 0; i < 3; i++) rect(c, i < dots ? "#111827" : "#d1d5db", bx + 2 + i * 3, by + 4, 2, 2);
|
|
114
|
+
});
|
|
115
|
+
} else if (agent.state === "reviewing" && !agent.walking) {
|
|
116
|
+
bubble(c, x, y, (bx, by) => {
|
|
117
|
+
rect(c, "#2563eb", bx + 4, by + 1, 4, 1); rect(c, "#2563eb", bx + 7, by + 2, 1, 2);
|
|
118
|
+
rect(c, "#2563eb", bx + 5, by + 4, 2, 1); rect(c, "#2563eb", bx + 5, by + 5, 1, 1); rect(c, "#2563eb", bx + 5, by + 7, 1, 1);
|
|
119
|
+
});
|
|
120
|
+
} else if (agent.state === "done") {
|
|
121
|
+
const color = pulse ? "#f59e0b" : "#fbbf24";
|
|
122
|
+
bubble(c, x, y, (bx, by) => {
|
|
123
|
+
rect(c, color, bx + 5, by + 1, 1, 7); rect(c, color, bx + 2, by + 3, 7, 2); rect(c, color, bx + 4, by + 2, 3, 5);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- The room -------------------------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function drawFloor(c: Ctx, room: Room, palette: Palette): void {
|
|
131
|
+
for (let ty = room.y; ty < room.y + room.h; ty++) {
|
|
132
|
+
for (let tx = room.x; tx < room.x + room.w; tx++) {
|
|
133
|
+
const x = tx * TILE;
|
|
134
|
+
const y = ty * TILE;
|
|
135
|
+
if (room.floor === "carpet") {
|
|
136
|
+
rect(c, "#4c4f7a", x, y, TILE, TILE);
|
|
137
|
+
for (let i = 0; i < 6; i++) rect(c, "#55588a", x + ((tx * 7 + i * 5) % 15), y + ((ty * 3 + i * 7) % 15), 1, 1);
|
|
138
|
+
} else if (room.floor === "tile") {
|
|
139
|
+
rect(c, (tx + ty) % 2 === 0 ? "#cfd8e3" : "#bcc7d4", x, y, TILE, TILE);
|
|
140
|
+
rect(c, "#aab5c2", x, y + 15, TILE, 1);
|
|
141
|
+
rect(c, "#aab5c2", x + 15, y, 1, TILE);
|
|
142
|
+
} else {
|
|
143
|
+
rect(c, "#a06f45", x, y, TILE, TILE);
|
|
144
|
+
const offset = ty % 2 === 0 ? 0 : 8;
|
|
145
|
+
rect(c, "#8f6039", x, y + 7, TILE, 1);
|
|
146
|
+
rect(c, "#8f6039", x, y + 15, TILE, 1);
|
|
147
|
+
rect(c, "#8f6039", x + ((offset + tx * 8) % 16), y, 1, 7);
|
|
148
|
+
rect(c, "#ad7c52", x + 3, y + 3, 4, 1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
void palette;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const WALL_TOP = "#2f3350";
|
|
156
|
+
const WALL_FACE = "#565c86";
|
|
157
|
+
const WALL_LINE = "#454a70";
|
|
158
|
+
|
|
159
|
+
function drawWalls(c: Ctx, world: World, palette: Palette): void {
|
|
160
|
+
const open = new Set<string>();
|
|
161
|
+
for (const room of world.rooms) {
|
|
162
|
+
for (let y = room.y; y < room.y + room.h; y++) for (let x = room.x; x < room.x + room.w; x++) open.add(`${x},${y}`);
|
|
163
|
+
}
|
|
164
|
+
for (const door of world.doors) open.add(`${door.x},${door.y}`);
|
|
165
|
+
|
|
166
|
+
for (let ty = 0; ty < world.rows; ty++) {
|
|
167
|
+
for (let tx = 0; tx < world.cols; tx++) {
|
|
168
|
+
if (open.has(`${tx},${ty}`)) continue;
|
|
169
|
+
const x = tx * TILE;
|
|
170
|
+
const y = ty * TILE;
|
|
171
|
+
const faceBelow = open.has(`${tx},${ty + 1}`);
|
|
172
|
+
if (faceBelow) {
|
|
173
|
+
// The wall's front, with a band in the Kit's colour and a window every few tiles.
|
|
174
|
+
rect(c, WALL_FACE, x, y, TILE, TILE);
|
|
175
|
+
rect(c, WALL_LINE, x, y + 13, TILE, 3);
|
|
176
|
+
rect(c, palette.primary, x, y + 12, TILE, 1);
|
|
177
|
+
if (tx % 4 === 3 && ty <= 3) {
|
|
178
|
+
rect(c, "#1f2937", x + 1, y + 1, 14, 9);
|
|
179
|
+
rect(c, "#8ed0ff", x + 2, y + 2, 12, 7);
|
|
180
|
+
rect(c, "#bfe6ff", x + 3, y + 3, 4, 2);
|
|
181
|
+
rect(c, "#1f2937", x + 8, y + 2, 1, 7);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
rect(c, WALL_TOP, x, y, TILE, TILE);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const door of world.doors) {
|
|
189
|
+
rect(c, "#6b4a2f", door.x * TILE, door.y * TILE, TILE, TILE);
|
|
190
|
+
rect(c, "#7c5637", door.x * TILE + 2, door.y * TILE, 12, TILE);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function drawDesk(c: Ctx, x: number, y: number, on: boolean, time: number, seed: number, palette: Palette): void {
|
|
195
|
+
rect(c, "#6b4427", x + 1, y + 13, 46, 17);
|
|
196
|
+
rect(c, "#8a5a33", x + 1, y + 11, 46, 11);
|
|
197
|
+
rect(c, "#a06c3f", x + 1, y + 11, 46, 2);
|
|
198
|
+
rect(c, "#4a2e1a", x + 3, y + 22, 3, 9);
|
|
199
|
+
rect(c, "#4a2e1a", x + 42, y + 22, 3, 9);
|
|
200
|
+
// Monitor
|
|
201
|
+
rect(c, "#111827", x + 15, y - 1, 18, 13);
|
|
202
|
+
rect(c, "#374151", x + 22, y + 12, 4, 2);
|
|
203
|
+
if (on) {
|
|
204
|
+
rect(c, "#0f2a2e", x + 16, y, 16, 11);
|
|
205
|
+
const scroll = Math.floor(time * 3 + seed % 13);
|
|
206
|
+
for (let line = 0; line < 4; line++) {
|
|
207
|
+
const width = 3 + ((seed >> line) + (scroll + line) * 7) % 11;
|
|
208
|
+
rect(c, line % 2 === 0 ? palette.primary : "#7dd3fc", x + 17, y + 1 + line * 2.5, width, 1);
|
|
209
|
+
}
|
|
210
|
+
c.fillStyle = "rgba(125,211,252,0.10)";
|
|
211
|
+
c.fillRect(x + 11, y + 11, 26, 8);
|
|
212
|
+
} else {
|
|
213
|
+
rect(c, "#1f2937", x + 16, y, 16, 11);
|
|
214
|
+
rect(c, "#374151", x + 18, y + 2, 3, 1);
|
|
215
|
+
}
|
|
216
|
+
// Keyboard and mug
|
|
217
|
+
rect(c, "#d1d5db", x + 17, y + 15, 14, 4);
|
|
218
|
+
rect(c, "#9ca3af", x + 18, y + 16, 12, 1);
|
|
219
|
+
rect(c, "#f3f4f6", x + 38, y + 14, 4, 4);
|
|
220
|
+
rect(c, palette.primary, x + 38, y + 16, 4, 1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function drawBoard(c: Ctx, x: number, y: number, world: World): void {
|
|
224
|
+
rect(c, "#2b2118", x, y - 11, 64, 26);
|
|
225
|
+
rect(c, "#d9c29c", x + 2, y - 9, 60, 22);
|
|
226
|
+
const colors = { todo: "#fde68a", doing: "#93c5fd", done: "#86efac" };
|
|
227
|
+
const per = 8;
|
|
228
|
+
world.tasks.slice(0, per * 2).forEach((task, index) => {
|
|
229
|
+
const col = index % per;
|
|
230
|
+
const row = Math.floor(index / per);
|
|
231
|
+
rect(c, "#00000033", x + 5 + col * 7, y - 6 + row * 9, 6, 7);
|
|
232
|
+
rect(c, colors[task.state], x + 4 + col * 7, y - 7 + row * 9, 6, 7);
|
|
233
|
+
rect(c, "#00000040", x + 5 + col * 7, y - 5 + row * 9, 4, 1);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function drawFurniture(c: Ctx, item: Item, world: World, time: number, palette: Palette): void {
|
|
238
|
+
const x = item.x * TILE;
|
|
239
|
+
const y = item.y * TILE;
|
|
240
|
+
switch (item.type) {
|
|
241
|
+
case "desk": {
|
|
242
|
+
const seatX = item.x + 1;
|
|
243
|
+
const seatY = item.y + 2;
|
|
244
|
+
const user = world.agents.find((a) => a.seat?.x === seatX && a.seat?.y === seatY && isSeated(a) && a.state !== "blocked");
|
|
245
|
+
drawDesk(c, x, y, Boolean(user), time, user?.seed ?? 0, palette);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "coffee":
|
|
249
|
+
rect(c, "#374151", x, y + 2, 32, 14);
|
|
250
|
+
rect(c, "#6b7280", x, y + 2, 32, 2);
|
|
251
|
+
rect(c, "#111827", x + 4, y - 8, 14, 14);
|
|
252
|
+
rect(c, "#ef4444", x + 15, y - 5, 2, 2);
|
|
253
|
+
rect(c, "#f3f4f6", x + 8, y + 3, 5, 4);
|
|
254
|
+
rect(c, "#f3f4f6", x + 22, y - 1, 5, 5);
|
|
255
|
+
for (let i = 0; i < 3; i++) {
|
|
256
|
+
c.fillStyle = "rgba(255,255,255,0.45)";
|
|
257
|
+
c.fillRect(x + 9 + Math.round(Math.sin(time * 2 + i) * 1.5), y - 1 - ((time * 8 + i * 4) % 9), 1, 2);
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
case "water":
|
|
261
|
+
rect(c, "#e5e7eb", x + 3, y + 3, 10, 13);
|
|
262
|
+
rect(c, "#7dd3fc", x + 4, y - 7, 8, 10);
|
|
263
|
+
rect(c, "#bae6fd", x + 5, y - 6, 2, 8);
|
|
264
|
+
rect(c, "#3b82f6", x + 5, y + 6, 2, 2);
|
|
265
|
+
break;
|
|
266
|
+
case "sofa":
|
|
267
|
+
rect(c, palette.primary, x + 1, y - 6, 46, 9);
|
|
268
|
+
rect(c, "#00000030", x + 1, y + 1, 46, 2);
|
|
269
|
+
rect(c, "#4b5563", x, y + 3, 48, 11);
|
|
270
|
+
rect(c, "#6b7280", x + 3, y + 3, 20, 7);
|
|
271
|
+
rect(c, "#6b7280", x + 25, y + 3, 20, 7);
|
|
272
|
+
rect(c, "#374151", x, y + 12, 48, 3);
|
|
273
|
+
break;
|
|
274
|
+
case "plant":
|
|
275
|
+
rect(c, "#92400e", x + 4, y + 9, 8, 7);
|
|
276
|
+
rect(c, "#b45309", x + 3, y + 8, 10, 2);
|
|
277
|
+
for (const [lx, ly, lw, lh] of [[6, -3, 4, 11], [2, 0, 5, 6], [9, 1, 5, 6], [4, -6, 3, 6], [9, -5, 3, 5]] as const) {
|
|
278
|
+
rect(c, "#15803d", x + lx, y + ly, lw, lh);
|
|
279
|
+
}
|
|
280
|
+
rect(c, "#22c55e", x + 7, y - 3, 2, 8);
|
|
281
|
+
rect(c, "#22c55e", x + 3, y + 1, 2, 3);
|
|
282
|
+
break;
|
|
283
|
+
case "board":
|
|
284
|
+
drawBoard(c, x, y, world);
|
|
285
|
+
break;
|
|
286
|
+
case "shelf":
|
|
287
|
+
rect(c, "#5b3a21", x, y - 12, 32, 28);
|
|
288
|
+
for (let shelf = 0; shelf < 2; shelf++) {
|
|
289
|
+
rect(c, "#3b2413", x + 2, y - 10 + shelf * 13, 28, 11);
|
|
290
|
+
for (let book = 0; book < 8; book++) {
|
|
291
|
+
const color = ["#ef4444", "#3b82f6", "#10b981", "#f59e0b", "#8b5cf6"][(book * 3 + shelf) % 5] as string;
|
|
292
|
+
rect(c, color, x + 3 + book * 3, y - 8 + shelf * 13 + (book % 3), 2, 9 - (book % 3));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
case "server":
|
|
297
|
+
rect(c, "#111827", x + 1, y - 12, 14, 28);
|
|
298
|
+
for (let i = 0; i < 5; i++) {
|
|
299
|
+
rect(c, "#1f2937", x + 2, y - 11 + i * 5, 12, 4);
|
|
300
|
+
const on = Math.floor(time * 3 + i * 2 + item.x) % 3 !== 0;
|
|
301
|
+
rect(c, on ? "#22c55e" : "#14532d", x + 3, y - 10 + i * 5, 1, 1);
|
|
302
|
+
rect(c, i % 2 === 0 ? "#f59e0b" : "#3b82f6", x + 5, y - 10 + i * 5, 1, 1);
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
case "table":
|
|
306
|
+
rect(c, "#57534e", x + 2, y + 14, 44, 14);
|
|
307
|
+
rect(c, "#78716c", x + 2, y + 3, 44, 14);
|
|
308
|
+
rect(c, "#a8a29e", x + 2, y + 3, 44, 2);
|
|
309
|
+
rect(c, "#f9fafb", x + 10, y + 6, 8, 6);
|
|
310
|
+
rect(c, "#f9fafb", x + 28, y + 8, 8, 6);
|
|
311
|
+
break;
|
|
312
|
+
case "rug":
|
|
313
|
+
rect(c, palette.primary, x, y, 64, 48);
|
|
314
|
+
c.fillStyle = "rgba(0,0,0,0.35)";
|
|
315
|
+
c.fillRect(x + 3, y + 3, 58, 42);
|
|
316
|
+
rect(c, palette.primary, x + 6, y + 6, 52, 1);
|
|
317
|
+
rect(c, palette.primary, x + 6, y + 41, 52, 1);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
void FURNITURE;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function drawLabels(c: Ctx, world: World): void {
|
|
324
|
+
c.font = "bold 7px ui-monospace, Menlo, Consolas, monospace";
|
|
325
|
+
c.textBaseline = "top";
|
|
326
|
+
for (const room of world.rooms) {
|
|
327
|
+
if (!room.name) continue;
|
|
328
|
+
const label = room.name.toUpperCase();
|
|
329
|
+
// On the wall above the room, clear of the furniture.
|
|
330
|
+
const x = room.x * TILE + 3;
|
|
331
|
+
const y = room.y * TILE - 12;
|
|
332
|
+
c.fillStyle = "rgba(0,0,0,0.45)";
|
|
333
|
+
c.fillText(label, x + 1, y + 1);
|
|
334
|
+
c.fillStyle = "rgba(255,255,255,0.85)";
|
|
335
|
+
c.fillText(label, x, y);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function nameTag(c: Ctx, agent: Agent, selected: boolean, width: number): void {
|
|
340
|
+
const name = agent.name;
|
|
341
|
+
c.font = "bold 7px ui-monospace, Menlo, Consolas, monospace";
|
|
342
|
+
c.textBaseline = "top";
|
|
343
|
+
const w = Math.ceil(c.measureText(name).width) + 5;
|
|
344
|
+
const cx = agent.x * TILE + 8;
|
|
345
|
+
const x = Math.round(Math.max(1, Math.min(width - w - 1, cx - w / 2)));
|
|
346
|
+
const y = Math.round(agent.y * TILE + 15);
|
|
347
|
+
c.fillStyle = selected ? "#ffffff" : "rgba(17,24,39,0.82)";
|
|
348
|
+
c.fillRect(x, y, w, 10);
|
|
349
|
+
c.fillStyle = selected ? "#111827" : "#ffffff";
|
|
350
|
+
c.fillText(name, x + 2.5, y + 1.5);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function wrap(c: Ctx, text: string, max: number): string[] {
|
|
354
|
+
const lines: string[] = [];
|
|
355
|
+
let line = "";
|
|
356
|
+
for (const word of text.split(/\s+/)) {
|
|
357
|
+
const next = line ? `${line} ${word}` : word;
|
|
358
|
+
if (c.measureText(next).width > max && line) {
|
|
359
|
+
lines.push(line);
|
|
360
|
+
line = word;
|
|
361
|
+
} else {
|
|
362
|
+
line = next;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (line) lines.push(line);
|
|
366
|
+
return lines.slice(0, 3);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const STATE_LABEL: Record<string, string> = { working: "Working", thinking: "Thinking", blocked: "Blocked", reviewing: "Reviewing", idle: "On a break", done: "Finished", away: "Away" };
|
|
370
|
+
|
|
371
|
+
function drawCard(c: Ctx, agent: Agent, width: number): void {
|
|
372
|
+
c.font = "7px ui-monospace, Menlo, Consolas, monospace";
|
|
373
|
+
c.textBaseline = "top";
|
|
374
|
+
const body = wrap(c, agent.task || STATE_LABEL[agent.state] || "", 104);
|
|
375
|
+
const w = 114;
|
|
376
|
+
const h = 18 + body.length * 9 + 3;
|
|
377
|
+
const x = Math.round(Math.max(2, Math.min(width - w - 2, agent.x * TILE + 8 - w / 2)));
|
|
378
|
+
const y = Math.round(Math.max(2, agent.y * TILE - h - 22));
|
|
379
|
+
rect(c, "#111827", x - 1, y - 1, w + 2, h + 2);
|
|
380
|
+
rect(c, "#ffffff", x, y, w, h);
|
|
381
|
+
c.fillStyle = "#111827";
|
|
382
|
+
c.font = "bold 7px ui-monospace, Menlo, Consolas, monospace";
|
|
383
|
+
c.fillText(`${agent.name}: ${STATE_LABEL[agent.state] ?? ""}`.slice(0, 26), x + 4, y + 4);
|
|
384
|
+
c.fillStyle = "#374151";
|
|
385
|
+
c.font = "7px ui-monospace, Menlo, Consolas, monospace";
|
|
386
|
+
body.forEach((line, index) => c.fillText(line, x + 4, y + 15 + index * 9));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function drawProgress(c: Ctx, world: World, palette: Palette): void {
|
|
390
|
+
if (world.tasks.length === 0) return;
|
|
391
|
+
const done = world.tasks.filter((task) => task.state === "done").length;
|
|
392
|
+
const label = `QUESTS ${done}/${world.tasks.length}`;
|
|
393
|
+
c.font = "bold 7px ui-monospace, Menlo, Consolas, monospace";
|
|
394
|
+
c.textBaseline = "top";
|
|
395
|
+
const w = 96;
|
|
396
|
+
rect(c, "rgba(17,24,39,0.85)", 4, 4, w, 20);
|
|
397
|
+
c.fillStyle = "#ffffff";
|
|
398
|
+
c.fillText(label, 8, 6.5);
|
|
399
|
+
rect(c, "#374151", 8, 16, w - 8, 4);
|
|
400
|
+
rect(c, palette.primary, 8, 16, Math.round(((w - 8) * done) / world.tasks.length), 4);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function drawWorld(c: Ctx, world: World, time: number, palette: Palette, selected?: string): void {
|
|
404
|
+
const width = world.cols * TILE;
|
|
405
|
+
rect(c, WALL_TOP, 0, 0, width, world.rows * TILE);
|
|
406
|
+
for (const room of world.rooms) drawFloor(c, room, palette);
|
|
407
|
+
drawWalls(c, world, palette);
|
|
408
|
+
|
|
409
|
+
// Flat things first, then everything else in depth order so people pass behind desks.
|
|
410
|
+
for (const item of world.furniture) if (item.type === "rug") drawFurniture(c, item, world, time, palette);
|
|
411
|
+
const scene: { depth: number; draw: () => void }[] = [];
|
|
412
|
+
for (const item of world.furniture) {
|
|
413
|
+
if (item.type === "rug") continue;
|
|
414
|
+
scene.push({ depth: (item.y + FURNITURE[item.type].h) * TILE - 2, draw: () => drawFurniture(c, item, world, time, palette) });
|
|
415
|
+
}
|
|
416
|
+
const shown = world.agents.filter((agent) => agent.state !== "away");
|
|
417
|
+
for (const agent of shown) {
|
|
418
|
+
scene.push({ depth: agent.y * TILE + 14, draw: () => drawAgent(c, agent, time, agent.id === selected) });
|
|
419
|
+
}
|
|
420
|
+
scene.sort((a, b) => a.depth - b.depth);
|
|
421
|
+
for (const item of scene) item.draw();
|
|
422
|
+
|
|
423
|
+
drawLabels(c, world);
|
|
424
|
+
for (const agent of shown) nameTag(c, agent, agent.id === selected, width);
|
|
425
|
+
for (const agent of shown) if (agent.id !== selected) drawStatus(c, agent, time);
|
|
426
|
+
drawProgress(c, world, palette);
|
|
427
|
+
const picked = shown.find((agent) => agent.id === selected);
|
|
428
|
+
if (picked) drawCard(c, picked, width);
|
|
429
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLKC Render-Canvas.
|
|
3
|
+
*
|
|
4
|
+
* A canvas Block is ordinary markup from @blkc-code/render-web plus its data, and this
|
|
5
|
+
* package brings it to life. Call mountCanvasBlocks() once the page is in the document:
|
|
6
|
+
* a <canvas data-blkc-canvas="office"> with a JSON <script> beside it starts running.
|
|
7
|
+
*
|
|
8
|
+
* @package @blkc-code/render-canvas
|
|
9
|
+
* @license Apache-2.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createOffice, type OfficeHandle } from "./office.js";
|
|
13
|
+
import type { OfficeScene } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export { createOffice } from "./office.js";
|
|
16
|
+
export type { OfficeHandle, OfficeOptions } from "./office.js";
|
|
17
|
+
export * from "./types.js";
|
|
18
|
+
export { agentAt, applyUpdate, createWorld, findPath, step } from "./world.js";
|
|
19
|
+
export type { Agent, World } from "./world.js";
|
|
20
|
+
|
|
21
|
+
export interface MountedBlock {
|
|
22
|
+
element: HTMLCanvasElement;
|
|
23
|
+
destroy(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type CanvasBlockFactory = (canvas: HTMLCanvasElement, data: unknown, root: Element) => { destroy(): void };
|
|
27
|
+
|
|
28
|
+
const factories = new Map<string, CanvasBlockFactory>();
|
|
29
|
+
|
|
30
|
+
/** Teach mountCanvasBlocks about another kind of canvas Block. */
|
|
31
|
+
export function registerCanvasBlock(id: string, factory: CanvasBlockFactory): void {
|
|
32
|
+
factories.set(id, factory);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mountOffice(canvas: HTMLCanvasElement, data: unknown, root: Element): OfficeHandle {
|
|
36
|
+
const buttons = [...root.querySelectorAll<HTMLButtonElement>("button[data-blkc-agent]")];
|
|
37
|
+
const office = createOffice(canvas, data as OfficeScene, {
|
|
38
|
+
onSelect(id) {
|
|
39
|
+
for (const button of buttons) button.setAttribute("aria-pressed", String(button.dataset["blkcAgent"] === id));
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
for (const button of buttons) {
|
|
43
|
+
button.disabled = false;
|
|
44
|
+
button.addEventListener("click", () => office.select(button.dataset["blkcAgent"]));
|
|
45
|
+
}
|
|
46
|
+
return office;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
registerCanvasBlock("office", mountOffice);
|
|
50
|
+
|
|
51
|
+
const mounted = new WeakSet<Element>();
|
|
52
|
+
|
|
53
|
+
/** Start every canvas Block under `root` that isn't running yet. */
|
|
54
|
+
export function mountCanvasBlocks(root: ParentNode = document): MountedBlock[] {
|
|
55
|
+
const out: MountedBlock[] = [];
|
|
56
|
+
for (const canvas of root.querySelectorAll<HTMLCanvasElement>("canvas[data-blkc-canvas]")) {
|
|
57
|
+
if (mounted.has(canvas)) continue;
|
|
58
|
+
const factory = factories.get(canvas.dataset["blkcCanvas"] ?? "");
|
|
59
|
+
if (!factory) continue;
|
|
60
|
+
const host = canvas.closest("[data-blkc-canvas-root]") ?? canvas.parentElement ?? canvas;
|
|
61
|
+
const source = host.querySelector("script[data-blkc-canvas-data]");
|
|
62
|
+
let data: unknown = {};
|
|
63
|
+
try {
|
|
64
|
+
data = JSON.parse(source?.textContent || "{}");
|
|
65
|
+
} catch {
|
|
66
|
+
data = {};
|
|
67
|
+
}
|
|
68
|
+
mounted.add(canvas);
|
|
69
|
+
const block = factory(canvas, data, host);
|
|
70
|
+
out.push({ element: canvas, destroy: () => block.destroy() });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
package/src/office.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** Puts a world on a canvas: sizing, the frame loop, and pointer and keyboard input. */
|
|
2
|
+
|
|
3
|
+
import { drawWorld, TILE, type Palette } from "./draw.js";
|
|
4
|
+
import type { OfficeScene } from "./types.js";
|
|
5
|
+
import { agentAt, applyUpdate, createWorld, step, type World } from "./world.js";
|
|
6
|
+
|
|
7
|
+
export interface OfficeHandle {
|
|
8
|
+
world: World;
|
|
9
|
+
/** Change agents or tasks without restarting anyone. */
|
|
10
|
+
update(scene: Pick<OfficeScene, "agents" | "tasks">): void;
|
|
11
|
+
/** Pick an agent by id, or pass nothing to clear. */
|
|
12
|
+
select(id?: string): void;
|
|
13
|
+
destroy(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OfficeOptions {
|
|
17
|
+
/** Called when the selection changes, by click or by select(). */
|
|
18
|
+
onSelect?: (id: string | undefined) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SAFE_COLOUR = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/a-z]+\))$/i;
|
|
22
|
+
|
|
23
|
+
function readPalette(canvas: HTMLCanvasElement): Palette {
|
|
24
|
+
const styles = getComputedStyle(canvas);
|
|
25
|
+
const token = (name: string, fallback: string): string => {
|
|
26
|
+
const value = styles.getPropertyValue(name).trim();
|
|
27
|
+
return SAFE_COLOUR.test(value) ? value : fallback;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
primary: token("--blkc-color-primary", "#3b82f6"),
|
|
31
|
+
accent: token("--blkc-color-accent", "#f59e0b"),
|
|
32
|
+
background: token("--blkc-color-background", "#1a1a2e"),
|
|
33
|
+
text: token("--blkc-color-text", "#ffffff"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Draw an office and keep it running until destroy(). */
|
|
38
|
+
export function createOffice(canvas: HTMLCanvasElement, scene: OfficeScene = {}, options: OfficeOptions = {}): OfficeHandle {
|
|
39
|
+
const world = createWorld(scene);
|
|
40
|
+
const context = canvas.getContext("2d");
|
|
41
|
+
if (!context) throw new Error("This browser can't draw to a canvas.");
|
|
42
|
+
const c = context;
|
|
43
|
+
const width = world.cols * TILE;
|
|
44
|
+
const height = world.rows * TILE;
|
|
45
|
+
const palette = readPalette(canvas);
|
|
46
|
+
const still = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
47
|
+
|
|
48
|
+
let selected: string | undefined;
|
|
49
|
+
let scale = 0;
|
|
50
|
+
let time = 0;
|
|
51
|
+
let last = 0;
|
|
52
|
+
let frame = 0;
|
|
53
|
+
let visible = true;
|
|
54
|
+
let destroyed = false;
|
|
55
|
+
|
|
56
|
+
const paint = (): void => {
|
|
57
|
+
c.setTransform(scale, 0, 0, scale, 0, 0);
|
|
58
|
+
c.imageSmoothingEnabled = false;
|
|
59
|
+
drawWorld(c, world, time, palette, selected);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// The backing store is a whole multiple of the art, so every pixel stays square and text stays sharp.
|
|
63
|
+
const resize = (): void => {
|
|
64
|
+
const shown = canvas.getBoundingClientRect().width || width;
|
|
65
|
+
const ratio = typeof devicePixelRatio === "number" ? devicePixelRatio : 1;
|
|
66
|
+
const next = Math.max(1, Math.ceil((shown * ratio) / width));
|
|
67
|
+
if (next === scale) return;
|
|
68
|
+
scale = next;
|
|
69
|
+
canvas.width = width * scale;
|
|
70
|
+
canvas.height = height * scale;
|
|
71
|
+
paint();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const loop = (now: number): void => {
|
|
75
|
+
if (destroyed) return;
|
|
76
|
+
frame = requestAnimationFrame(loop);
|
|
77
|
+
if (!visible) {
|
|
78
|
+
last = now;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const dt = Math.min((now - last) / 1000 || 0, 0.1); // a stalled tab mustn't teleport people
|
|
82
|
+
last = now;
|
|
83
|
+
time += dt;
|
|
84
|
+
step(world, dt);
|
|
85
|
+
paint();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const select = (id?: string): void => {
|
|
89
|
+
const next = id === selected ? undefined : id;
|
|
90
|
+
selected = next;
|
|
91
|
+
options.onSelect?.(selected);
|
|
92
|
+
if (still) paint();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const pointerTile = (event: PointerEvent): { x: number; y: number } => {
|
|
96
|
+
const box = canvas.getBoundingClientRect();
|
|
97
|
+
return { x: ((event.clientX - box.left) / box.width) * world.cols, y: ((event.clientY - box.top) / box.height) * world.rows };
|
|
98
|
+
};
|
|
99
|
+
const onDown = (event: PointerEvent): void => {
|
|
100
|
+
const { x, y } = pointerTile(event);
|
|
101
|
+
select(agentAt(world, x, y)?.id);
|
|
102
|
+
};
|
|
103
|
+
const onMove = (event: PointerEvent): void => {
|
|
104
|
+
const { x, y } = pointerTile(event);
|
|
105
|
+
canvas.style.cursor = agentAt(world, x, y) ? "pointer" : "default";
|
|
106
|
+
};
|
|
107
|
+
canvas.addEventListener("pointerdown", onDown);
|
|
108
|
+
canvas.addEventListener("pointermove", onMove);
|
|
109
|
+
|
|
110
|
+
const resizer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : undefined;
|
|
111
|
+
resizer?.observe(canvas);
|
|
112
|
+
const viewer = typeof IntersectionObserver === "function"
|
|
113
|
+
? new IntersectionObserver((entries) => { visible = entries.some((entry) => entry.isIntersecting); })
|
|
114
|
+
: undefined;
|
|
115
|
+
viewer?.observe(canvas);
|
|
116
|
+
|
|
117
|
+
resize();
|
|
118
|
+
if (scale === 0) { scale = 1; paint(); }
|
|
119
|
+
if (!still) frame = requestAnimationFrame(loop);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
world,
|
|
123
|
+
update(next) {
|
|
124
|
+
applyUpdate(world, next);
|
|
125
|
+
if (still) paint();
|
|
126
|
+
},
|
|
127
|
+
select,
|
|
128
|
+
destroy() {
|
|
129
|
+
destroyed = true;
|
|
130
|
+
cancelAnimationFrame(frame);
|
|
131
|
+
resizer?.disconnect();
|
|
132
|
+
viewer?.disconnect();
|
|
133
|
+
canvas.removeEventListener("pointerdown", onDown);
|
|
134
|
+
canvas.removeEventListener("pointermove", onMove);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The script tag version: starts every canvas Block on the page by itself. */
|
|
2
|
+
import { mountCanvasBlocks } from "./index.js";
|
|
3
|
+
|
|
4
|
+
if (document.readyState === "loading") {
|
|
5
|
+
document.addEventListener("DOMContentLoaded", () => mountCanvasBlocks());
|
|
6
|
+
} else {
|
|
7
|
+
mountCanvasBlocks();
|
|
8
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** What a Kit writes to describe an office. All of it is plain JSON. */
|
|
2
|
+
|
|
3
|
+
export type AgentState = "working" | "thinking" | "blocked" | "reviewing" | "idle" | "done" | "away";
|
|
4
|
+
export type TaskState = "todo" | "doing" | "done";
|
|
5
|
+
export type FloorStyle = "wood" | "carpet" | "tile";
|
|
6
|
+
export type FurnitureType =
|
|
7
|
+
| "desk" | "coffee" | "sofa" | "plant" | "board" | "shelf" | "server" | "table" | "rug" | "water";
|
|
8
|
+
|
|
9
|
+
export interface OfficeRoom { id?: string; name?: string; x: number; y: number; w: number; h: number; floor?: FloorStyle }
|
|
10
|
+
export interface OfficeDoor { x: number; y: number }
|
|
11
|
+
export interface OfficeFurniture { type: FurnitureType; x: number; y: number }
|
|
12
|
+
export interface OfficeAgent {
|
|
13
|
+
id: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
role?: string;
|
|
16
|
+
state?: AgentState;
|
|
17
|
+
task?: string;
|
|
18
|
+
/** Character colours. Anything left out is picked from the id. */
|
|
19
|
+
hair?: string;
|
|
20
|
+
skin?: string;
|
|
21
|
+
shirt?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface OfficeTask { title: string; state?: TaskState; agent?: string }
|
|
24
|
+
|
|
25
|
+
export interface OfficeScene {
|
|
26
|
+
cols?: number;
|
|
27
|
+
rows?: number;
|
|
28
|
+
rooms?: OfficeRoom[];
|
|
29
|
+
doors?: OfficeDoor[];
|
|
30
|
+
furniture?: OfficeFurniture[];
|
|
31
|
+
agents?: OfficeAgent[];
|
|
32
|
+
tasks?: OfficeTask[];
|
|
33
|
+
}
|