cortico-world-canvas 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 +21 -0
- package/README.md +162 -0
- package/package.json +52 -0
- package/src/ENV_PROMPT.md +63 -0
- package/src/GAME_GUIDE.md +20 -0
- package/src/RESEARCH.md +58 -0
- package/src/brush-renderer.ts +56 -0
- package/src/brushes.ts +1 -0
- package/src/config.ts +22 -0
- package/src/definition.ts +11 -0
- package/src/drawing.ts +238 -0
- package/src/engine-child.ts +23 -0
- package/src/game.ts +207 -0
- package/src/index.ts +13 -0
- package/src/inspect.ts +47 -0
- package/src/motion.ts +132 -0
- package/src/paint.ts +132 -0
- package/src/public/app.js +304 -0
- package/src/public/brush-alpha.js +39 -0
- package/src/public/brush-engine.js +40 -0
- package/src/public/corti-cursor.svg +213 -0
- package/src/public/index.html +47 -0
- package/src/public/styles.css +1326 -0
- package/src/server.ts +306 -0
- package/src/store.ts +286 -0
- package/src/tools.ts +111 -0
- package/src/world.ts +120 -0
package/src/drawing.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createCanvas, type Canvas, type CanvasRenderingContext2D, type Image } from 'canvas';
|
|
2
|
+
import { paintContours, parsePaint, type PaintSpec } from './paint.ts';
|
|
3
|
+
|
|
4
|
+
export type Point = { x: number; y: number };
|
|
5
|
+
type DrawKind = 'brush' | 'line' | 'rect' | 'ellipse' | 'polygon' | 'bezier' | 'curve' | 'fill' | 'spray' | 'paint';
|
|
6
|
+
export interface DrawAction {
|
|
7
|
+
kind: DrawKind;
|
|
8
|
+
points: Point[];
|
|
9
|
+
color: string;
|
|
10
|
+
size: number;
|
|
11
|
+
opacity: number;
|
|
12
|
+
brush: 'pen' | 'marker' | 'soft' | 'eraser';
|
|
13
|
+
filled: boolean;
|
|
14
|
+
closed?: boolean;
|
|
15
|
+
stroke?: string;
|
|
16
|
+
tolerance: number;
|
|
17
|
+
density: number;
|
|
18
|
+
label: string;
|
|
19
|
+
paint?: PaintSpec;
|
|
20
|
+
}
|
|
21
|
+
export interface DrawStep { id: string; action: DrawAction; seed: number; raster?: string }
|
|
22
|
+
export interface Board {
|
|
23
|
+
id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
background: string;
|
|
28
|
+
revision: number;
|
|
29
|
+
steps: DrawStep[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function object(value: unknown): Record<string, unknown> {
|
|
33
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('参数必须是对象');
|
|
34
|
+
return value as Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function number(value: unknown, name: string, min: number, max: number, integer = false): number {
|
|
38
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) {
|
|
39
|
+
throw new Error(`${name} 必须是 ${min}–${max} 的${integer ? '整数' : '数值'}`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function label(value: unknown, name = '名称'): string {
|
|
45
|
+
if (typeof value !== 'string' || !value.trim() || value.trim().length > 120) throw new Error(`${name} 必须是 1–120 字的文本`);
|
|
46
|
+
return value.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function color(value: unknown): string {
|
|
50
|
+
if (typeof value !== 'string' || !/^#[\da-f]{6}$/i.test(value)) throw new Error('颜色须为 #RRGGBB');
|
|
51
|
+
return value.toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseActions(input: unknown, board: Board): DrawAction[] {
|
|
55
|
+
if (!Array.isArray(input) || input.length < 1 || input.length > 64) throw new Error('每批需要 1–64 个绘图动作');
|
|
56
|
+
let totalPoints = 0;
|
|
57
|
+
let pathCharacters = 0;
|
|
58
|
+
let instances = 0;
|
|
59
|
+
return input.map((raw, index) => {
|
|
60
|
+
const a = object(raw);
|
|
61
|
+
if (a.path !== undefined) {
|
|
62
|
+
const parsed = parsePaint(a);
|
|
63
|
+
pathCharacters += parsed.paint!.path.length;
|
|
64
|
+
if (pathCharacters > 64000) throw new Error('每批 path 总长度最多 64000 字符');
|
|
65
|
+
instances += parsed.paint!.instances?.length ?? 1;
|
|
66
|
+
if (instances > 256) throw new Error('每批 p5.brush 实例总数最多 256');
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
69
|
+
const kind = a.kind as DrawKind;
|
|
70
|
+
const ranges: Record<Exclude<DrawKind, 'paint'>, [number, number]> = {
|
|
71
|
+
brush: [1, 1024], line: [2, 2], rect: [2, 2], ellipse: [2, 2], polygon: [3, 1024],
|
|
72
|
+
bezier: [4, 1024], curve: [2, 1024], fill: [1, 1], spray: [1, 1024],
|
|
73
|
+
};
|
|
74
|
+
if (!Object.hasOwn(ranges, kind)) throw new Error(`动作 ${index + 1}: 未知 kind`);
|
|
75
|
+
const [min, max] = ranges[kind as Exclude<DrawKind, 'paint'>];
|
|
76
|
+
if (!Array.isArray(a.points) || a.points.length < min || a.points.length > max) throw new Error(`${kind} 需要 ${min}–${max} 个 points`);
|
|
77
|
+
if (kind === 'bezier' && (a.points.length - 1) % 3 !== 0) throw new Error('bezier 需要一个起点,再接每段的控制点1、控制点2、终点(共 4、7、10… 个点)');
|
|
78
|
+
if (kind === 'curve' && (a.closed || a.filled) && a.points.length < 3) throw new Error('闭合 curve 至少需要三个点');
|
|
79
|
+
totalPoints += a.points.length;
|
|
80
|
+
if (totalPoints > 8192) throw new Error('每批最多 8192 个点');
|
|
81
|
+
const points = a.points.map((rawPoint) => {
|
|
82
|
+
const p = object(rawPoint);
|
|
83
|
+
return { x: number(p.x, 'x', 0, board.width - 1), y: number(p.y, 'y', 0, board.height - 1) };
|
|
84
|
+
});
|
|
85
|
+
const brush = a.brush ?? 'pen';
|
|
86
|
+
if (!['pen', 'marker', 'soft', 'eraser'].includes(brush as string)) throw new Error('未知笔刷');
|
|
87
|
+
if (a.filled !== undefined && typeof a.filled !== 'boolean') throw new Error('filled 必须是布尔值');
|
|
88
|
+
if (a.closed !== undefined && typeof a.closed !== 'boolean') throw new Error('closed 必须是布尔值');
|
|
89
|
+
return {
|
|
90
|
+
kind, points, color: color(a.color ?? '#242333'), size: number(a.size ?? 8, 'size', 1, 512),
|
|
91
|
+
opacity: number(a.opacity ?? 1, 'opacity', 0.01, 1), brush: brush as DrawAction['brush'],
|
|
92
|
+
filled: a.filled === true, tolerance: number(a.tolerance ?? 16, 'tolerance', 0, 255, true),
|
|
93
|
+
...(a.closed === undefined ? {} : { closed: a.closed }),
|
|
94
|
+
...(a.stroke === undefined ? {} : { stroke: color(a.stroke) }),
|
|
95
|
+
density: number(a.density ?? 32, 'density', 1, 128, true),
|
|
96
|
+
label: a.label === undefined ? kind : label(a.label, '步骤说明'),
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Rendering and cursor sampling share the same cubic control points. */
|
|
102
|
+
export function cubicSegments(a: DrawAction): [Point, Point, Point, Point][] {
|
|
103
|
+
const points = a.points;
|
|
104
|
+
const segments: [Point, Point, Point, Point][] = [];
|
|
105
|
+
if (a.kind === 'bezier') {
|
|
106
|
+
for (let i = 1; i < points.length; i += 3) segments.push([points[i - 1], points[i], points[i + 1], points[i + 2]]);
|
|
107
|
+
} else if (a.kind === 'curve') {
|
|
108
|
+
const closed = a.closed || a.filled;
|
|
109
|
+
const at = (i: number) => closed ? points[(i + points.length) % points.length] : points[Math.max(0, Math.min(i, points.length - 1))];
|
|
110
|
+
for (let i = 0; i < points.length - (closed ? 0 : 1); i++) {
|
|
111
|
+
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
|
|
112
|
+
segments.push([p1,
|
|
113
|
+
{ x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 },
|
|
114
|
+
{ x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 }, p2]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return segments;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function blank(board: Board): Canvas {
|
|
121
|
+
const canvas = createCanvas(board.width, board.height);
|
|
122
|
+
const ctx = canvas.getContext('2d');
|
|
123
|
+
ctx.fillStyle = board.background;
|
|
124
|
+
ctx.fillRect(0, 0, board.width, board.height);
|
|
125
|
+
return canvas;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function replay(board: Board, raster?: (step: DrawStep) => Image): Canvas {
|
|
129
|
+
const canvas = blank(board);
|
|
130
|
+
for (const step of board.steps) draw(canvas, board, step, raster);
|
|
131
|
+
return canvas;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Each step starts with a fresh context; spray randomness belongs to the persisted step. */
|
|
135
|
+
export function draw(canvas: Canvas, board: Board, step: DrawStep, raster?: (step: DrawStep) => Image): void {
|
|
136
|
+
const a = step.action;
|
|
137
|
+
const ctx = canvas.getContext('2d');
|
|
138
|
+
ctx.save();
|
|
139
|
+
try {
|
|
140
|
+
if (a.kind === 'paint') {
|
|
141
|
+
if (!raster) throw new Error('p5.brush 步骤需要已渲染的图层');
|
|
142
|
+
if (a.paint!.clip_to) {
|
|
143
|
+
const target = board.steps.find((candidate) => candidate.id === a.paint!.clip_to)!;
|
|
144
|
+
ctx.beginPath();
|
|
145
|
+
for (const part of paintContours(target.action.paint!)) {
|
|
146
|
+
const points = part.points;
|
|
147
|
+
// Each p5 subpath fills independently; use matching winding for the union clip.
|
|
148
|
+
const area = points.reduce((sum, p, i) => { const q = points[(i + 1) % points.length]; return sum + p.x * q.y - q.x * p.y; }, 0);
|
|
149
|
+
if (area < 0) points.reverse();
|
|
150
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
151
|
+
for (const p of points.slice(1)) ctx.lineTo(p.x, p.y);
|
|
152
|
+
ctx.closePath();
|
|
153
|
+
}
|
|
154
|
+
ctx.clip();
|
|
155
|
+
}
|
|
156
|
+
ctx.drawImage(raster(step), 0, 0);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
ctx.globalAlpha = a.opacity;
|
|
160
|
+
ctx.strokeStyle = ctx.fillStyle = a.color;
|
|
161
|
+
ctx.lineWidth = a.size;
|
|
162
|
+
ctx.lineCap = ctx.lineJoin = 'round';
|
|
163
|
+
if (a.kind === 'fill') { flood(ctx, board, a); return; }
|
|
164
|
+
if (a.kind === 'spray') {
|
|
165
|
+
let seed = step.seed;
|
|
166
|
+
const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 0x100000000; };
|
|
167
|
+
for (const p of a.points) for (let n = 0; n < a.density; n++) {
|
|
168
|
+
const angle = random() * Math.PI * 2;
|
|
169
|
+
const radius = Math.sqrt(random()) * a.size / 2;
|
|
170
|
+
ctx.fillRect(Math.round(p.x + Math.cos(angle) * radius), Math.round(p.y + Math.sin(angle) * radius), 1, 1);
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (a.kind === 'brush' && a.brush === 'soft') {
|
|
175
|
+
const stamp = (x: number, y: number) => {
|
|
176
|
+
const gradient = ctx.createRadialGradient(x, y, 0, x, y, a.size / 2);
|
|
177
|
+
gradient.addColorStop(0, a.color + '50');
|
|
178
|
+
gradient.addColorStop(1, a.color + '00');
|
|
179
|
+
ctx.fillStyle = gradient;
|
|
180
|
+
ctx.fillRect(x - a.size / 2, y - a.size / 2, a.size, a.size);
|
|
181
|
+
};
|
|
182
|
+
stamp(a.points[0].x, a.points[0].y);
|
|
183
|
+
for (let i = 1; i < a.points.length; i++) {
|
|
184
|
+
const p = a.points[i - 1], q = a.points[i];
|
|
185
|
+
const count = Math.ceil(Math.hypot(q.x - p.x, q.y - p.y) / Math.max(1, a.size / 6));
|
|
186
|
+
for (let n = 1; n <= count; n++) stamp(p.x + (q.x - p.x) * n / count, p.y + (q.y - p.y) * n / count);
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (a.kind === 'brush' && a.brush === 'marker') { ctx.globalAlpha *= 0.35; ctx.lineCap = 'square'; }
|
|
191
|
+
if (a.kind === 'brush' && a.brush === 'eraser') ctx.strokeStyle = ctx.fillStyle = board.background;
|
|
192
|
+
ctx.beginPath();
|
|
193
|
+
const [p, q] = a.points;
|
|
194
|
+
if (a.kind === 'rect') ctx.rect(Math.min(p.x, q.x), Math.min(p.y, q.y), Math.abs(q.x - p.x), Math.abs(q.y - p.y));
|
|
195
|
+
else if (a.kind === 'ellipse') ctx.ellipse((p.x + q.x) / 2, (p.y + q.y) / 2, Math.abs(q.x - p.x) / 2, Math.abs(q.y - p.y) / 2, 0, 0, Math.PI * 2);
|
|
196
|
+
else if (a.kind === 'bezier' || a.kind === 'curve') {
|
|
197
|
+
ctx.moveTo(p.x, p.y);
|
|
198
|
+
for (const [, c1, c2, end] of cubicSegments(a)) ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, end.x, end.y);
|
|
199
|
+
if (a.closed || a.filled) ctx.closePath();
|
|
200
|
+
} else if (a.points.length === 1) {
|
|
201
|
+
ctx.arc(p.x, p.y, a.size / 2, 0, Math.PI * 2);
|
|
202
|
+
ctx.fill();
|
|
203
|
+
return;
|
|
204
|
+
} else {
|
|
205
|
+
ctx.moveTo(p.x, p.y);
|
|
206
|
+
for (const point of a.points.slice(1)) ctx.lineTo(point.x, point.y);
|
|
207
|
+
if (a.kind === 'polygon') ctx.closePath();
|
|
208
|
+
}
|
|
209
|
+
if (a.filled && ['rect', 'ellipse', 'polygon', 'bezier', 'curve'].includes(a.kind)) {
|
|
210
|
+
ctx.fill();
|
|
211
|
+
if (a.stroke) { ctx.strokeStyle = a.stroke; ctx.stroke(); }
|
|
212
|
+
} else ctx.stroke();
|
|
213
|
+
} finally { ctx.restore(); }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function flood(ctx: CanvasRenderingContext2D, board: Board, a: DrawAction): void {
|
|
217
|
+
const { width, height } = board;
|
|
218
|
+
const image = ctx.getImageData(0, 0, width, height);
|
|
219
|
+
const pixels = image.data;
|
|
220
|
+
const start = Math.floor(a.points[0].y) * width + Math.floor(a.points[0].x);
|
|
221
|
+
const original = Array.from(pixels.slice(start * 4, start * 4 + 4));
|
|
222
|
+
const replacement = [1, 3, 5].map((offset) => parseInt(a.color.slice(offset, offset + 2), 16));
|
|
223
|
+
const seen = new Uint8Array(width * height);
|
|
224
|
+
const stack = new Int32Array(width * height);
|
|
225
|
+
let count = 0;
|
|
226
|
+
const enqueue = (pixel: number) => { if (!seen[pixel]) { seen[pixel] = 1; stack[count++] = pixel; } };
|
|
227
|
+
enqueue(start);
|
|
228
|
+
while (count) {
|
|
229
|
+
const pixel = stack[--count], offset = pixel * 4;
|
|
230
|
+
if (original.some((value, channel) => Math.abs(pixels[offset + channel] - value) > a.tolerance)) continue;
|
|
231
|
+
for (let channel = 0; channel < 3; channel++) pixels[offset + channel] = Math.round(pixels[offset + channel] * (1 - a.opacity) + replacement[channel] * a.opacity);
|
|
232
|
+
if (pixel % width > 0) enqueue(pixel - 1);
|
|
233
|
+
if (pixel % width < width - 1) enqueue(pixel + 1);
|
|
234
|
+
if (pixel >= width) enqueue(pixel - width);
|
|
235
|
+
if (pixel < width * (height - 1)) enqueue(pixel + width);
|
|
236
|
+
}
|
|
237
|
+
ctx.putImageData(image, 0, 0);
|
|
238
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CanvasServer } from './server.ts';
|
|
2
|
+
|
|
3
|
+
let server: CanvasServer | null = null;
|
|
4
|
+
process.on('message', (message: { id: number; name: string; args: Record<string, unknown> }) => {
|
|
5
|
+
void (async () => {
|
|
6
|
+
if (message.name === 'init') {
|
|
7
|
+
server = new CanvasServer({
|
|
8
|
+
directory: message.args.directory as string, port: message.args.port as number,
|
|
9
|
+
browserPath: message.args.browserPath as string,
|
|
10
|
+
onReferences: (references) => process.send?.({ event: 'references', references }),
|
|
11
|
+
onGameEvent: (text) => process.send?.({ event: 'game', text }),
|
|
12
|
+
});
|
|
13
|
+
await server.start();
|
|
14
|
+
return { url: server.url };
|
|
15
|
+
}
|
|
16
|
+
if (!server) throw new Error('画布尚未初始化');
|
|
17
|
+
if (message.name === 'stop') { await server.stop(); return null; }
|
|
18
|
+
return server.command(message.name, message.args);
|
|
19
|
+
})().then((value) => {
|
|
20
|
+
if (process.connected) process.send?.({ id: message.id, value });
|
|
21
|
+
}).catch((error) => { if (process.connected) process.send?.({ id: message.id, error: String(error.message ?? error) }); });
|
|
22
|
+
});
|
|
23
|
+
process.on('disconnect', () => { void server?.abort().finally(() => process.exit(0)); });
|
package/src/game.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { number, object } from './drawing.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 你画我猜的运行态:界面模式、当前一局、上一局揭示与计分板。
|
|
8
|
+
* 游戏规则不在这里执行——谁答对、记几分由主持人按说明书判断后写入;
|
|
9
|
+
* 存储只保证题目在揭示前不出现在网页状态里,以及倒计时到点有一次回调。
|
|
10
|
+
*/
|
|
11
|
+
type GameMode = 'free' | 'guess';
|
|
12
|
+
interface ScoreEntry { name: string; score: number }
|
|
13
|
+
interface RankedEntry extends ScoreEntry { rank: number }
|
|
14
|
+
interface GameRound { id: number; answer: string; hint: string | null; startedAt: number; endsAt: number; expired: boolean }
|
|
15
|
+
interface GameReveal { roundId: number; answer: string; hint: string | null; winners: string[]; at: number }
|
|
16
|
+
/** 网页看到的投影:进行中一局只给字数与提示,题目留在文件里。 */
|
|
17
|
+
interface GameView {
|
|
18
|
+
mode: GameMode;
|
|
19
|
+
now: number;
|
|
20
|
+
rounds: number;
|
|
21
|
+
round: { id: number; hint: string | null; length: number; startedAt: number; endsAt: number; expired: boolean } | null;
|
|
22
|
+
reveal: GameReveal | null;
|
|
23
|
+
scoreboard: RankedEntry[];
|
|
24
|
+
}
|
|
25
|
+
/** 主持人看到的读数:与 GameView 的区别是带题目和剩余秒数。 */
|
|
26
|
+
export interface GameStatus {
|
|
27
|
+
mode: GameMode;
|
|
28
|
+
round: { id: number; answer: string; hint: string | null; remainingSeconds: number; expired: boolean } | null;
|
|
29
|
+
lastReveal: { roundId: number; answer: string; winners: string[] } | null;
|
|
30
|
+
scoreboard: RankedEntry[];
|
|
31
|
+
}
|
|
32
|
+
interface GameFile { mode: GameMode; rounds: number; round: GameRound | null; reveal: GameReveal | null; scoreboard: ScoreEntry[] }
|
|
33
|
+
|
|
34
|
+
export const GAME_GUIDE_PATH = fileURLToPath(new URL('./GAME_GUIDE.md', import.meta.url));
|
|
35
|
+
const SCOREBOARD_LIMIT = 500;
|
|
36
|
+
const BATCH_LIMIT = 64;
|
|
37
|
+
|
|
38
|
+
function text(value: unknown, name: string, max: number): string {
|
|
39
|
+
if (typeof value !== 'string' || !value.trim() || Array.from(value.trim()).length > max) throw new Error(`${name} 必须是 1–${max} 字的文本`);
|
|
40
|
+
return value.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function names(value: unknown, name: string): string[] {
|
|
44
|
+
if (!Array.isArray(value) || value.length > BATCH_LIMIT) throw new Error(`${name} 须为最多 ${BATCH_LIMIT} 个名字`);
|
|
45
|
+
return value.map((item) => text(item, name, 40));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class GameStore {
|
|
49
|
+
private data: GameFile;
|
|
50
|
+
private countdown: ReturnType<typeof setTimeout> | null = null;
|
|
51
|
+
|
|
52
|
+
constructor(private readonly options: { directory: string; onChange?: () => void; onExpire?: (round: GameRound) => void }) {
|
|
53
|
+
const path = this.path();
|
|
54
|
+
this.data = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) as GameFile : { mode: 'free', rounds: 0, round: null, reveal: null, scoreboard: [] };
|
|
55
|
+
if (this.data.round && !this.data.round.expired) this.arm();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private path(): string { return join(this.options.directory, 'game.json'); }
|
|
59
|
+
|
|
60
|
+
private persist(): void {
|
|
61
|
+
const path = this.path();
|
|
62
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(this.data));
|
|
63
|
+
renameSync(`${path}.tmp`, path);
|
|
64
|
+
this.options.onChange?.();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
guide(): string { return readFileSync(GAME_GUIDE_PATH, 'utf8').trim(); }
|
|
68
|
+
|
|
69
|
+
ranked(): RankedEntry[] {
|
|
70
|
+
const sorted = [...this.data.scoreboard].sort((a, b) => b.score - a.score);
|
|
71
|
+
const ranked: RankedEntry[] = [];
|
|
72
|
+
sorted.forEach((entry, index) => {
|
|
73
|
+
const previous = ranked[index - 1];
|
|
74
|
+
ranked.push({ ...entry, rank: previous && previous.score === entry.score ? previous.rank : index + 1 });
|
|
75
|
+
});
|
|
76
|
+
return ranked;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
view(): GameView {
|
|
80
|
+
const { round } = this.data;
|
|
81
|
+
return {
|
|
82
|
+
mode: this.data.mode, now: Date.now(), rounds: this.data.rounds,
|
|
83
|
+
round: round ? { id: round.id, hint: round.hint, length: Array.from(round.answer).length, startedAt: round.startedAt, endsAt: round.endsAt, expired: round.expired } : null,
|
|
84
|
+
reveal: this.data.reveal, scoreboard: this.ranked(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
status(): GameStatus {
|
|
89
|
+
const { round, reveal } = this.data;
|
|
90
|
+
return {
|
|
91
|
+
mode: this.data.mode,
|
|
92
|
+
round: round ? { id: round.id, answer: round.answer, hint: round.hint, remainingSeconds: Math.max(0, Math.ceil((round.endsAt - Date.now()) / 1000)), expired: round.expired } : null,
|
|
93
|
+
lastReveal: reveal ? { roundId: reveal.roundId, answer: reveal.answer, winners: reveal.winners } : null,
|
|
94
|
+
scoreboard: this.ranked(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
enter(): GameStatus {
|
|
99
|
+
if (this.data.mode !== 'guess') { this.data.mode = 'guess'; this.persist(); }
|
|
100
|
+
return this.status();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 回到自由画板:未揭示的一局作废,计分板保留。 */
|
|
104
|
+
exit(): GameStatus {
|
|
105
|
+
this.clearTimer();
|
|
106
|
+
this.data = { ...this.data, mode: 'free', round: null, reveal: null };
|
|
107
|
+
this.persist();
|
|
108
|
+
return this.status();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
startRound(args: Record<string, unknown>): GameStatus {
|
|
112
|
+
const current = this.data.round;
|
|
113
|
+
if (current) throw new Error(`第 ${current.id} 局尚未结束:先用 canvas_game_reveal 揭示答案,或 canvas_game_timer 的 stop 取消这一局`);
|
|
114
|
+
const answer = text(args.answer, 'answer', 40);
|
|
115
|
+
const seconds = number(args.seconds ?? 300, 'seconds', 1, 3600);
|
|
116
|
+
const hint = args.hint === undefined || args.hint === null ? null : text(args.hint, 'hint', 40);
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
this.data = { ...this.data, mode: 'guess', rounds: this.data.rounds + 1, reveal: null,
|
|
119
|
+
round: { id: this.data.rounds + 1, answer, hint, startedAt: now, endsAt: now + seconds * 1000, expired: false } };
|
|
120
|
+
this.persist();
|
|
121
|
+
this.arm();
|
|
122
|
+
return this.status();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
timer(args: Record<string, unknown>): GameStatus {
|
|
126
|
+
const round = this.data.round;
|
|
127
|
+
if (!round) throw new Error('没有进行中的一局,请先 canvas_game_round 开局');
|
|
128
|
+
if (args.stop === true) {
|
|
129
|
+
this.clearTimer();
|
|
130
|
+
this.data = { ...this.data, round: null };
|
|
131
|
+
this.persist();
|
|
132
|
+
return this.status();
|
|
133
|
+
}
|
|
134
|
+
const seconds = number(args.seconds, 'seconds', 1, 3600);
|
|
135
|
+
this.data = { ...this.data, round: { ...round, startedAt: Date.now(), endsAt: Date.now() + seconds * 1000, expired: false } };
|
|
136
|
+
this.persist();
|
|
137
|
+
this.arm();
|
|
138
|
+
return this.status();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
reveal(args: Record<string, unknown>): GameStatus {
|
|
142
|
+
const round = this.data.round;
|
|
143
|
+
if (!round) throw new Error('没有可揭示的一局,请先 canvas_game_round 开局');
|
|
144
|
+
const winners = args.winners === undefined ? [] : names(args.winners, 'winners').slice(0, 20);
|
|
145
|
+
this.clearTimer();
|
|
146
|
+
this.data = { ...this.data, round: null, reveal: { roundId: round.id, answer: round.answer, hint: round.hint, winners, at: Date.now() } };
|
|
147
|
+
this.persist();
|
|
148
|
+
return this.status();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
score(args: Record<string, unknown>): { scoreboard: RankedEntry[]; changes: { name: string; before: number | null; after: number | null }[] } {
|
|
152
|
+
const entries = args.entries === undefined ? [] : args.entries;
|
|
153
|
+
if (!Array.isArray(entries) || entries.length > BATCH_LIMIT) throw new Error(`entries 须为最多 ${BATCH_LIMIT} 项`);
|
|
154
|
+
const remove = args.remove === undefined ? [] : names(args.remove, 'remove');
|
|
155
|
+
if (!entries.length && !remove.length && args.reset !== true) throw new Error('entries、remove、reset 至少提供一项');
|
|
156
|
+
const board: ScoreEntry[] = args.reset === true ? [] : [...this.data.scoreboard];
|
|
157
|
+
const changes: { name: string; before: number | null; after: number | null }[] = [];
|
|
158
|
+
for (const raw of entries) {
|
|
159
|
+
const entry = object(raw);
|
|
160
|
+
const name = text(entry.name, 'name', 40);
|
|
161
|
+
const hasAdd = entry.add !== undefined, hasSet = entry.set !== undefined;
|
|
162
|
+
if (hasAdd === hasSet) throw new Error(`${name}:add 与 set 二选一`);
|
|
163
|
+
const value = number(hasAdd ? entry.add : entry.set, hasAdd ? 'add' : 'set', -100000, 100000, true);
|
|
164
|
+
const index = board.findIndex((item) => item.name === name);
|
|
165
|
+
const before = index >= 0 ? board[index].score : null;
|
|
166
|
+
const after = hasAdd ? (before ?? 0) + value : value;
|
|
167
|
+
if (index >= 0) board[index] = { name, score: after };
|
|
168
|
+
else {
|
|
169
|
+
if (board.length >= SCOREBOARD_LIMIT) throw new Error(`计分板最多 ${SCOREBOARD_LIMIT} 人,请用 remove 或 reset 清理`);
|
|
170
|
+
board.push({ name, score: after });
|
|
171
|
+
}
|
|
172
|
+
changes.push({ name, before, after });
|
|
173
|
+
}
|
|
174
|
+
for (const name of remove) {
|
|
175
|
+
const index = board.findIndex((item) => item.name === name);
|
|
176
|
+
if (index < 0) continue;
|
|
177
|
+
changes.push({ name, before: board[index].score, after: null });
|
|
178
|
+
board.splice(index, 1);
|
|
179
|
+
}
|
|
180
|
+
this.data = { ...this.data, scoreboard: board };
|
|
181
|
+
this.persist();
|
|
182
|
+
return { scoreboard: this.ranked(), changes };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
close(): void { this.clearTimer(); }
|
|
186
|
+
|
|
187
|
+
private clearTimer(): void {
|
|
188
|
+
if (this.countdown) clearTimeout(this.countdown);
|
|
189
|
+
this.countdown = null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private arm(): void {
|
|
193
|
+
this.clearTimer();
|
|
194
|
+
const round = this.data.round!;
|
|
195
|
+
this.countdown = setTimeout(() => this.expire(round.id), Math.max(0, round.endsAt - Date.now()));
|
|
196
|
+
this.countdown.unref();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private expire(id: number): void {
|
|
200
|
+
const round = this.data.round;
|
|
201
|
+
if (!round || round.id !== id || round.expired) return;
|
|
202
|
+
this.countdown = null;
|
|
203
|
+
this.data = { ...this.data, round: { ...round, expired: true } };
|
|
204
|
+
this.persist();
|
|
205
|
+
this.options.onExpire?.(this.data.round!);
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包入口:默认导出 `WorldDefinition`,加载器按 `cortico.kind === 'world'` 认它。
|
|
3
|
+
*
|
|
4
|
+
* 配置段类型一并导出,给 bot 侧在 `declares` 覆盖里写 `worlds.canvas` 的字面量时用。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CANVAS } from './definition.ts';
|
|
8
|
+
|
|
9
|
+
export default CANVAS;
|
|
10
|
+
|
|
11
|
+
export { CANVAS };
|
|
12
|
+
export { CANVAS_DEFAULTS, CANVAS_CONFIG_GROUP } from './config.ts';
|
|
13
|
+
export type { CanvasConfigSection } from './config.ts';
|
package/src/inspect.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createCanvas, type Canvas, type Image } from 'canvas';
|
|
2
|
+
import { number, object } from './drawing.ts';
|
|
3
|
+
|
|
4
|
+
/** Inspection coordinates refer to source pixels; the grid never enters the artwork. */
|
|
5
|
+
export function inspectImage(source: Canvas | Image, args: Record<string, unknown>) {
|
|
6
|
+
if (args.grid !== undefined && typeof args.grid !== 'boolean') throw new Error('grid 必须是布尔值');
|
|
7
|
+
const raw = args.region === undefined ? { x: 0, y: 0, width: source.width, height: source.height } : object(args.region);
|
|
8
|
+
const x = number(raw.x, 'region.x', 0, source.width - 1, true);
|
|
9
|
+
const y = number(raw.y, 'region.y', 0, source.height - 1, true);
|
|
10
|
+
const width = number(raw.width, 'region.width', 1, source.width - x, true);
|
|
11
|
+
const height = number(raw.height, 'region.height', 1, source.height - y, true);
|
|
12
|
+
const scale = Math.min(args.region === undefined ? 1 : 4, 1024 / Math.max(width, height));
|
|
13
|
+
const offset = args.grid ? 32 : 0;
|
|
14
|
+
const canvas = createCanvas(Math.ceil(width * scale) + offset, Math.ceil(height * scale) + offset);
|
|
15
|
+
const ctx = canvas.getContext('2d');
|
|
16
|
+
ctx.fillStyle = '#ffffff';
|
|
17
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
18
|
+
ctx.drawImage(source, x, y, width, height, offset, offset, width * scale, height * scale);
|
|
19
|
+
let gridStep: number | undefined;
|
|
20
|
+
if (args.grid) {
|
|
21
|
+
const desired = Math.max(width, height) / 8;
|
|
22
|
+
const unit = 10 ** Math.floor(Math.log10(desired));
|
|
23
|
+
gridStep = [1, 2, 5, 10].map((n) => n * unit).find((n) => n >= desired)!;
|
|
24
|
+
gridStep = Math.max(1, gridStep);
|
|
25
|
+
ctx.font = '12px sans-serif';
|
|
26
|
+
ctx.fillStyle = '#263047';
|
|
27
|
+
ctx.strokeStyle = '#26304740';
|
|
28
|
+
ctx.lineWidth = 1;
|
|
29
|
+
ctx.setLineDash([3, 4]);
|
|
30
|
+
ctx.textAlign = 'center';
|
|
31
|
+
for (let column = Math.ceil(x / gridStep) * gridStep; column < x + width; column += gridStep) {
|
|
32
|
+
const px = offset + (column - x) * scale;
|
|
33
|
+
ctx.beginPath(); ctx.moveTo(px, offset); ctx.lineTo(px, canvas.height); ctx.stroke();
|
|
34
|
+
ctx.fillText(String(column), Math.max(offset + 12, Math.min(canvas.width - 15, px)), 20);
|
|
35
|
+
}
|
|
36
|
+
ctx.textAlign = 'right';
|
|
37
|
+
for (let row = Math.ceil(y / gridStep) * gridStep; row < y + height; row += gridStep) {
|
|
38
|
+
const py = offset + (row - y) * scale;
|
|
39
|
+
ctx.beginPath(); ctx.moveTo(offset, py); ctx.lineTo(canvas.width, py); ctx.stroke();
|
|
40
|
+
ctx.fillText(String(row), offset - 4, Math.max(offset + 12, Math.min(canvas.height - 3, py + 4)));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { png: canvas.toBuffer('image/png'), view: {
|
|
44
|
+
region: { x, y, width, height }, scale, offset: { x: offset, y: offset },
|
|
45
|
+
width: canvas.width, height: canvas.height, ...(gridStep === undefined ? {} : { gridStep }),
|
|
46
|
+
} };
|
|
47
|
+
}
|
package/src/motion.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createCanvas, type Canvas } from 'canvas';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { cubicSegments, draw, type Board, type DrawAction, type DrawStep, type Point } from './drawing.ts';
|
|
4
|
+
import { paintContours } from './paint.ts';
|
|
5
|
+
|
|
6
|
+
export interface Cursor extends Point { mode: 'idle' | 'moving' | 'pressed' | 'released' }
|
|
7
|
+
|
|
8
|
+
export function strokePath(a: DrawAction): Point[] {
|
|
9
|
+
if (a.kind === 'paint') return paintContours(a.paint!).flatMap((part) => {
|
|
10
|
+
const points = part.points;
|
|
11
|
+
return part.closed ? [...points, points[0]] : points;
|
|
12
|
+
});
|
|
13
|
+
const [p, q] = a.points;
|
|
14
|
+
if (a.kind === 'rect') return [p, { x: q.x, y: p.y }, q, { x: p.x, y: q.y }, p];
|
|
15
|
+
if (a.kind === 'polygon') return [...a.points, p];
|
|
16
|
+
if (a.kind === 'ellipse') return Array.from({ length: 65 }, (_, i) => {
|
|
17
|
+
const angle = i / 64 * Math.PI * 2;
|
|
18
|
+
return { x: (p.x + q.x) / 2 + Math.abs(q.x - p.x) / 2 * Math.cos(angle), y: (p.y + q.y) / 2 + Math.abs(q.y - p.y) / 2 * Math.sin(angle) };
|
|
19
|
+
});
|
|
20
|
+
if (a.kind === 'bezier' || a.kind === 'curve') {
|
|
21
|
+
const path = [p];
|
|
22
|
+
for (const [start, c1, c2, end] of cubicSegments(a)) {
|
|
23
|
+
const length = Math.hypot(c1.x - start.x, c1.y - start.y) + Math.hypot(c2.x - c1.x, c2.y - c1.y) + Math.hypot(end.x - c2.x, end.y - c2.y);
|
|
24
|
+
const count = Math.max(4, Math.min(64, Math.ceil(length / 4)));
|
|
25
|
+
for (let i = 1; i <= count; i++) {
|
|
26
|
+
const t = i / count, u = 1 - t;
|
|
27
|
+
path.push({ x: u ** 3 * start.x + 3 * u ** 2 * t * c1.x + 3 * u * t ** 2 * c2.x + t ** 3 * end.x,
|
|
28
|
+
y: u ** 3 * start.y + 3 * u ** 2 * t * c1.y + 3 * u * t ** 2 * c2.y + t ** 3 * end.y });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (a.closed || a.filled) path.push(p);
|
|
32
|
+
return path;
|
|
33
|
+
}
|
|
34
|
+
return a.points;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function pathPrefix(points: Point[], fraction: number): Point[] {
|
|
38
|
+
const distances = points.slice(1).map((p, i) => Math.hypot(p.x - points[i].x, p.y - points[i].y));
|
|
39
|
+
let remaining = distances.reduce((sum, value) => sum + value, 0) * fraction;
|
|
40
|
+
const result = [points[0]];
|
|
41
|
+
for (let i = 0; i < distances.length; i++) {
|
|
42
|
+
if (remaining >= distances[i]) { result.push(points[i + 1]); remaining -= distances[i]; }
|
|
43
|
+
else {
|
|
44
|
+
const t = remaining / distances[i];
|
|
45
|
+
result.push({ x: points[i].x + (points[i + 1].x - points[i].x) * t, y: points[i].y + (points[i + 1].y - points[i].y) * t });
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Preview pixels are temporary. The final frame uses the exact persisted action. */
|
|
53
|
+
export async function animateStep(
|
|
54
|
+
canvas: Canvas, board: Board, step: DrawStep, cursor: Cursor, duration: number,
|
|
55
|
+
emit: (cursor: Cursor) => void,
|
|
56
|
+
finish: (target: Canvas) => void = (target) => draw(target, board, step),
|
|
57
|
+
): Promise<Cursor> {
|
|
58
|
+
if (step.action.kind === 'paint') return animatePaint(canvas, board, step, cursor, duration, emit, finish);
|
|
59
|
+
const ctx = canvas.getContext('2d');
|
|
60
|
+
const base = ctx.getImageData(0, 0, board.width, board.height);
|
|
61
|
+
const path = strokePath(step.action), start = path[0];
|
|
62
|
+
const travelFrames = duration ? Math.max(2, Math.ceil(duration * 0.22 / 50)) : 0;
|
|
63
|
+
for (let frame = 1; frame <= travelFrames; frame++) {
|
|
64
|
+
const t = frame / travelFrames, eased = t * t * (3 - 2 * t);
|
|
65
|
+
const arc = Math.sin(t * Math.PI) * Math.min(16, Math.hypot(start.x - cursor.x, start.y - cursor.y) * 0.08);
|
|
66
|
+
emit({ x: cursor.x + (start.x - cursor.x) * eased, y: Math.max(0, Math.min(board.height - 1, cursor.y + (start.y - cursor.y) * eased - arc)), mode: 'moving' });
|
|
67
|
+
await delay(duration * 0.22 / travelFrames);
|
|
68
|
+
}
|
|
69
|
+
const frames = duration ? Math.max(2, Math.ceil(duration * 0.78 / 50)) : 0;
|
|
70
|
+
for (let frame = 1; frame <= frames; frame++) {
|
|
71
|
+
const t = frame / frames, prefix = pathPrefix(path, t * t * (3 - 2 * t));
|
|
72
|
+
ctx.putImageData(base, 0, 0);
|
|
73
|
+
const a = step.action;
|
|
74
|
+
if (a.kind !== 'fill') draw(canvas, board, { ...step, action: {
|
|
75
|
+
...a, kind: a.kind === 'spray' ? 'spray' : 'brush', brush: a.kind === 'brush' ? a.brush : 'pen', points: prefix, filled: false,
|
|
76
|
+
color: a.filled ? a.stroke ?? a.color : a.color,
|
|
77
|
+
} });
|
|
78
|
+
emit({ ...prefix[prefix.length - 1], mode: 'pressed' });
|
|
79
|
+
await delay(duration * 0.78 / frames);
|
|
80
|
+
}
|
|
81
|
+
ctx.putImageData(base, 0, 0);
|
|
82
|
+
finish(canvas);
|
|
83
|
+
const result: Cursor = { ...path[path.length - 1], mode: 'released' };
|
|
84
|
+
emit(result);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Reveal the rendered pixels along each stroke, including its clip, and lift between subpaths. */
|
|
89
|
+
async function animatePaint(canvas: Canvas, board: Board, step: DrawStep, cursor: Cursor, duration: number,
|
|
90
|
+
emit: (cursor: Cursor) => void, finish: (target: Canvas) => void): Promise<Cursor> {
|
|
91
|
+
const paint = step.action.paint!;
|
|
92
|
+
const paths = paintContours(paint).map((part) => {
|
|
93
|
+
const points = part.points;
|
|
94
|
+
return part.closed ? [...points, points[0]] : points;
|
|
95
|
+
});
|
|
96
|
+
const ctx = canvas.getContext('2d'), base = ctx.getImageData(0, 0, board.width, board.height);
|
|
97
|
+
const layer = createCanvas(board.width, board.height); finish(layer);
|
|
98
|
+
const mask = createCanvas(board.width, board.height), maskCtx = mask.getContext('2d');
|
|
99
|
+
const revealed = createCanvas(board.width, board.height), revealCtx = revealed.getContext('2d');
|
|
100
|
+
const lengths = paths.map((path) => path.slice(1).reduce((sum, p, i) => sum + Math.hypot(p.x - path[i].x, p.y - path[i].y), 0));
|
|
101
|
+
const total = lengths.reduce((sum, value) => sum + value, 0);
|
|
102
|
+
maskCtx.lineCap = 'round'; maskCtx.lineJoin = 'round';
|
|
103
|
+
maskCtx.lineWidth = paint.stroke ? paint.stroke.width * Math.max(...paint.stroke.pressure) * (paint.stroke.brush === 'spray' ? 4 : 2) + 2 : 4;
|
|
104
|
+
for (let index = 0; index < paths.length; index++) {
|
|
105
|
+
const path = paths[index], start = path[0], time = duration * lengths[index] / total;
|
|
106
|
+
const travelFrames = duration ? Math.max(1, Math.ceil(time * 0.22 / 50)) : 0;
|
|
107
|
+
for (let frame = 1; frame <= travelFrames; frame++) {
|
|
108
|
+
const t = frame / travelFrames, eased = t * t * (3 - 2 * t);
|
|
109
|
+
const arc = Math.sin(t * Math.PI) * Math.min(16, Math.hypot(start.x - cursor.x, start.y - cursor.y) * 0.08);
|
|
110
|
+
emit({ x: cursor.x + (start.x - cursor.x) * eased, y: cursor.y + (start.y - cursor.y) * eased - arc, mode: 'moving' });
|
|
111
|
+
await delay(time * 0.22 / travelFrames);
|
|
112
|
+
}
|
|
113
|
+
const frames = duration ? Math.max(1, Math.ceil(time * 0.78 / 50)) : 0;
|
|
114
|
+
for (let frame = 1; frame <= frames; frame++) {
|
|
115
|
+
const t = frame / frames, prefix = pathPrefix(path, t * t * (3 - 2 * t));
|
|
116
|
+
maskCtx.beginPath(); maskCtx.moveTo(prefix[0].x, prefix[0].y);
|
|
117
|
+
for (const point of prefix.slice(1)) maskCtx.lineTo(point.x, point.y);
|
|
118
|
+
maskCtx.stroke();
|
|
119
|
+
revealCtx.clearRect(0, 0, board.width, board.height);
|
|
120
|
+
revealCtx.drawImage(layer, 0, 0);
|
|
121
|
+
revealCtx.globalCompositeOperation = 'destination-in'; revealCtx.drawImage(mask, 0, 0);
|
|
122
|
+
revealCtx.globalCompositeOperation = 'source-over';
|
|
123
|
+
ctx.putImageData(base, 0, 0); ctx.drawImage(revealed, 0, 0);
|
|
124
|
+
emit({ ...prefix.at(-1)!, mode: 'pressed' });
|
|
125
|
+
await delay(time * 0.78 / frames);
|
|
126
|
+
}
|
|
127
|
+
cursor = { ...path.at(-1)!, mode: 'released' }; emit(cursor);
|
|
128
|
+
}
|
|
129
|
+
ctx.putImageData(base, 0, 0); ctx.drawImage(layer, 0, 0);
|
|
130
|
+
emit(cursor);
|
|
131
|
+
return cursor;
|
|
132
|
+
}
|