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/paint.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { color, label, number, object, type DrawAction, type Point } from './drawing.ts';
|
|
2
|
+
import { PAINT_BRUSHES } from './brushes.ts';
|
|
3
|
+
export type Matrix = [number, number, number, number, number, number];
|
|
4
|
+
interface PaintStroke { brush: typeof PAINT_BRUSHES[number]; color: string; width: number; pressure: [number, number, number] }
|
|
5
|
+
export interface PaintSpec {
|
|
6
|
+
path: string;
|
|
7
|
+
fill?: { mode: 'solid' | 'watercolor'; color: string; opacity: number; bleed: number; texture: number };
|
|
8
|
+
stroke?: PaintStroke;
|
|
9
|
+
hatch?: Omit<PaintStroke, 'pressure'> & { spacing: number; angle: number };
|
|
10
|
+
clip_to?: string;
|
|
11
|
+
matrix: Matrix;
|
|
12
|
+
instances?: Matrix[];
|
|
13
|
+
}
|
|
14
|
+
export interface Contour { points: Point[]; closed: boolean }
|
|
15
|
+
export const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0];
|
|
16
|
+
|
|
17
|
+
/** Absolute SVG path commands retain corners and disconnected strokes without adding join lines. */
|
|
18
|
+
export function contours(path: string): Contour[] {
|
|
19
|
+
if (typeof path !== 'string' || !path.trim() || path.length > 16000) throw new Error('path 须为 1–16000 字符的 M/L/Q/C/Z 绝对坐标路径');
|
|
20
|
+
const tokens = path.match(/[MLQCZ]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/gi) ?? [];
|
|
21
|
+
if (path.replace(/[MLQCZ]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?|[\s,]/g, '')) throw new Error('path 只支持大写 M、L、Q、C、Z 与数值');
|
|
22
|
+
let i = 0, current: Point = { x: 0, y: 0 }, contour: Contour | undefined;
|
|
23
|
+
const result: Contour[] = [];
|
|
24
|
+
const point = (): Point => ({ x: number(Number(tokens[i++]), 'path.x', -8192, 8192), y: number(Number(tokens[i++]), 'path.y', -8192, 8192) });
|
|
25
|
+
while (i < tokens.length) {
|
|
26
|
+
const command = tokens[i++];
|
|
27
|
+
if (command === 'M') { current = point(); contour = { points: [current], closed: false }; result.push(contour); continue; }
|
|
28
|
+
if (!contour || contour.closed) throw new Error('每个子路径必须以 M 开始');
|
|
29
|
+
if (command === 'Z') {
|
|
30
|
+
contour.closed = true; current = contour.points[0];
|
|
31
|
+
if (contour.points.length > 1 && contour.points.at(-1)!.x === current.x && contour.points.at(-1)!.y === current.y) contour.points.pop();
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (command === 'L') { current = point(); contour.points.push(current); continue; }
|
|
35
|
+
if (command !== 'C' && command !== 'Q') throw new Error('每段坐标前须写 M、L、Q 或 C');
|
|
36
|
+
const start = current, control = point(), control2 = command === 'C' ? point() : control, end = point();
|
|
37
|
+
const length = Math.hypot(control.x - start.x, control.y - start.y) + Math.hypot(control2.x - control.x, control2.y - control.y) + Math.hypot(end.x - control2.x, end.y - control2.y);
|
|
38
|
+
const count = Math.max(2, Math.min(256, Math.ceil(length / 2)));
|
|
39
|
+
for (let n = 1; n <= count; n++) {
|
|
40
|
+
const t = n / count, u = 1 - t;
|
|
41
|
+
contour.points.push(command === 'C'
|
|
42
|
+
? { x: u ** 3 * start.x + 3 * u * u * t * control.x + 3 * u * t * t * control2.x + t ** 3 * end.x,
|
|
43
|
+
y: u ** 3 * start.y + 3 * u * u * t * control.y + 3 * u * t * t * control2.y + t ** 3 * end.y }
|
|
44
|
+
: { x: u * u * start.x + 2 * u * t * control.x + t * t * end.x, y: u * u * start.y + 2 * u * t * control.y + t * t * end.y });
|
|
45
|
+
}
|
|
46
|
+
current = end;
|
|
47
|
+
}
|
|
48
|
+
for (const part of result) part.points = part.points.filter((p, i, points) => !i || p.x !== points[i - 1].x || p.y !== points[i - 1].y);
|
|
49
|
+
if (!result.length || result.some((part) => part.points.length < (part.closed ? 3 : 2))) throw new Error('开放路径至少两个不同的点,闭合路径至少三个点');
|
|
50
|
+
if (result.reduce((sum, part) => sum + part.points.length, 0) > 8192) throw new Error('path 展开后最多 8192 个点,请拆分部件');
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stroke(raw: unknown): PaintStroke {
|
|
55
|
+
const value = object(raw), brush = value.brush ?? 'liner';
|
|
56
|
+
if (!PAINT_BRUSHES.includes(brush as PaintStroke['brush'])) throw new Error(`未知笔刷,可用:${PAINT_BRUSHES.join(', ')}`);
|
|
57
|
+
const pressure = value.pressure ?? [0.7, 1, 0.7];
|
|
58
|
+
if (!Array.isArray(pressure) || pressure.length !== 3) throw new Error('pressure 须为起笔、中段、收笔三个压力值');
|
|
59
|
+
return { brush: brush as PaintStroke['brush'], color: color(value.color), width: number(value.width ?? 3, 'stroke.width', 0.2, 128),
|
|
60
|
+
pressure: pressure.map((p) => number(p, 'pressure', 0.05, 3)) as PaintStroke['pressure'] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parsePaint(a: Record<string, unknown>): DrawAction {
|
|
64
|
+
const parts = contours(a.path as string);
|
|
65
|
+
const paint: PaintSpec = { path: a.path as string, matrix: [...IDENTITY] };
|
|
66
|
+
if (a.fill !== undefined) {
|
|
67
|
+
const fill = object(a.fill), mode = fill.mode ?? 'solid';
|
|
68
|
+
if (mode !== 'solid' && mode !== 'watercolor') throw new Error('fill.mode 须为 solid 或 watercolor');
|
|
69
|
+
if (parts.some((part) => !part.closed)) throw new Error('填色路径必须以 Z 闭合');
|
|
70
|
+
paint.fill = { mode, color: color(fill.color), opacity: number(fill.opacity ?? 1, 'fill.opacity', 0.01, 1),
|
|
71
|
+
bleed: number(fill.bleed ?? 0.025, 'fill.bleed', 0, 0.2), texture: number(fill.texture ?? 0.25, 'fill.texture', 0, 1) };
|
|
72
|
+
}
|
|
73
|
+
if (a.stroke !== undefined) paint.stroke = stroke(a.stroke);
|
|
74
|
+
if (a.hatch !== undefined) {
|
|
75
|
+
if (parts.some((part) => !part.closed)) throw new Error('排线路径必须以 Z 闭合');
|
|
76
|
+
const hatch = object(a.hatch);
|
|
77
|
+
const { pressure: _pressure, ...ink } = stroke(hatch);
|
|
78
|
+
paint.hatch = { ...ink, spacing: number(hatch.spacing ?? 6, 'hatch.spacing', 2, 128), angle: number(hatch.angle ?? 45, 'hatch.angle', -360, 360) };
|
|
79
|
+
}
|
|
80
|
+
if (!paint.stroke && !paint.fill && !paint.hatch) throw new Error('至少指定 stroke、fill 或 hatch 中的一项');
|
|
81
|
+
if (a.clip_to !== undefined) paint.clip_to = label(a.clip_to, 'clip_to');
|
|
82
|
+
if (a.matrix !== undefined) {
|
|
83
|
+
paint.matrix = parseMatrix(a.matrix);
|
|
84
|
+
}
|
|
85
|
+
if (a.instances !== undefined) {
|
|
86
|
+
if (!Array.isArray(a.instances) || a.instances.length < 1 || a.instances.length > 64) throw new Error('instances 须包含 1–64 个仿射矩阵');
|
|
87
|
+
paint.instances = a.instances.map(parseMatrix);
|
|
88
|
+
}
|
|
89
|
+
validateTransform(parts, paint.matrix, paint.instances);
|
|
90
|
+
return { kind: 'paint', paint, label: label(a.label, '部件名称'), points: [],
|
|
91
|
+
color: paint.stroke?.color ?? paint.fill?.color ?? paint.hatch!.color, size: paint.stroke?.width ?? 3,
|
|
92
|
+
opacity: 1, brush: 'pen', filled: !!paint.fill, tolerance: 16, density: 32 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseMatrix(value: unknown): Matrix {
|
|
96
|
+
if (!Array.isArray(value) || value.length !== 6) throw new Error('matrix 必须包含六个仿射变换参数');
|
|
97
|
+
return value.map((v) => number(v, 'matrix', -8192, 8192)) as Matrix;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validateTransform(parts: Contour[], matrix: Matrix, instances: Matrix[] = [IDENTITY]): void {
|
|
101
|
+
if (parts.reduce((sum, part) => sum + part.points.length, 0) * instances.length > 8192) throw new Error('部件展开实例后最多 8192 个点,请拆分部件');
|
|
102
|
+
for (const instance of instances) {
|
|
103
|
+
const combined = multiply(matrix, instance);
|
|
104
|
+
for (const part of parts) for (const point of part.points) {
|
|
105
|
+
const p = transformed(point, combined);
|
|
106
|
+
number(p.x, '变换后的 x', -8192, 8192); number(p.y, '变换后的 y', -8192, 8192);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Instances use local coordinates; the component transform moves their entire arrangement. */
|
|
112
|
+
export function paintContours(paint: PaintSpec): Contour[] {
|
|
113
|
+
const parts = contours(paint.path);
|
|
114
|
+
return (paint.instances ?? [IDENTITY]).flatMap((instance) => {
|
|
115
|
+
const matrix = multiply(paint.matrix, instance);
|
|
116
|
+
return parts.map((part) => ({ ...part, points: part.points.map((p) => transformed(p, matrix)) }));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function transformed(point: Point, matrix: Matrix): Point {
|
|
121
|
+
const [a, b, c, d, e, f] = matrix;
|
|
122
|
+
return { x: a * point.x + c * point.y + e, y: b * point.x + d * point.y + f };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function multiply(a: Matrix, b: Matrix): Matrix {
|
|
126
|
+
return [a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1], a[0] * b[2] + a[2] * b[3],
|
|
127
|
+
a[1] * b[2] + a[3] * b[3], a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function toolAction(action: DrawAction): unknown {
|
|
131
|
+
return action.kind === 'paint' ? { ...action.paint, label: action.label } : action;
|
|
132
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
const $ = (id) => document.getElementById(id);
|
|
2
|
+
const overlay = location.pathname === '/overlay';
|
|
3
|
+
document.body.classList.toggle('overlay', overlay);
|
|
4
|
+
const canvas = $('canvas'), ctx = canvas.getContext('2d');
|
|
5
|
+
const toolNames = { paint: '路径绘画', brush: '笔刷', line: '直线', rect: '矩形', ellipse: '椭圆', polygon: '多边形', bezier: '贝塞尔曲线', curve: '平滑曲线', fill: '填充', spray: '喷漆' };
|
|
6
|
+
let state = { board: null, references: [], queued: 0, cursor: { x: 0, y: 0, mode: 'idle' } };
|
|
7
|
+
let boardKey = '', referenceKey = '', stepKey = '', drawing = null, paintVersion = 0;
|
|
8
|
+
let pendingFiles = [];
|
|
9
|
+
let uploading = false;
|
|
10
|
+
|
|
11
|
+
function message(text, error = false) { $('message').textContent = text; $('message').classList.toggle('error', error); }
|
|
12
|
+
async function api(path, body, method = 'POST') {
|
|
13
|
+
const response = await fetch(path, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
14
|
+
const result = await response.json();
|
|
15
|
+
if (!response.ok) throw new Error(result.error || '操作失败');
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
async function command(name, args = {}) {
|
|
19
|
+
try {
|
|
20
|
+
const result = await api('/api/command', { name, args });
|
|
21
|
+
render(result.state);
|
|
22
|
+
return result.result;
|
|
23
|
+
} catch (error) { message(error.message, true); throw error; }
|
|
24
|
+
}
|
|
25
|
+
function paint(url) {
|
|
26
|
+
const version = ++paintVersion;
|
|
27
|
+
const image = new Image();
|
|
28
|
+
image.onload = () => { if (version === paintVersion) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(image, 0, 0); } };
|
|
29
|
+
image.src = url;
|
|
30
|
+
}
|
|
31
|
+
function resize() {
|
|
32
|
+
if (!state.board) return;
|
|
33
|
+
const stage = document.querySelector('.stage');
|
|
34
|
+
const scale = Math.min((stage.clientWidth - 52) / state.board.width, (stage.clientHeight - 52) / state.board.height, 1);
|
|
35
|
+
$('board-wrap').style.width = `${Math.max(1, state.board.width * scale)}px`;
|
|
36
|
+
$('board-wrap').style.height = `${Math.max(1, state.board.height * scale)}px`;
|
|
37
|
+
cursor(state.cursor);
|
|
38
|
+
}
|
|
39
|
+
function cursor(value) {
|
|
40
|
+
state.cursor = value;
|
|
41
|
+
const node = $('corti-cursor');
|
|
42
|
+
node.hidden = !state.board;
|
|
43
|
+
if (!state.board) return;
|
|
44
|
+
node.dataset.mode = value.mode;
|
|
45
|
+
node.classList.toggle('edge-right', value.x / state.board.width * $('board-wrap').clientWidth > $('board-wrap').clientWidth - 70);
|
|
46
|
+
node.classList.toggle('edge-bottom', value.y / state.board.height * $('board-wrap').clientHeight > $('board-wrap').clientHeight - 80);
|
|
47
|
+
node.style.transform = `translate(${value.x / state.board.width * $('board-wrap').clientWidth}px, ${value.y / state.board.height * $('board-wrap').clientHeight}px)`;
|
|
48
|
+
}
|
|
49
|
+
function render(next) {
|
|
50
|
+
const hadPreview = !!state.preview;
|
|
51
|
+
state = next;
|
|
52
|
+
const board = state.board;
|
|
53
|
+
$('board-title').textContent = board?.title || '画布';
|
|
54
|
+
$('board-meta').textContent = board ? `${board.width} × ${board.height} px · 已自动存档` : '尚未新建画布';
|
|
55
|
+
$('save').disabled = !board || state.queued > 0;
|
|
56
|
+
$('empty-board').hidden = !!board;
|
|
57
|
+
$('board-wrap').hidden = !board;
|
|
58
|
+
if (board) {
|
|
59
|
+
const key = `${board.id}:${board.revision}`;
|
|
60
|
+
if (key !== boardKey || (hadPreview && !state.preview)) {
|
|
61
|
+
boardKey = key; canvas.width = board.width; canvas.height = board.height;
|
|
62
|
+
paint(`/api/canvas.png?v=${encodeURIComponent(key)}`);
|
|
63
|
+
}
|
|
64
|
+
resize();
|
|
65
|
+
}
|
|
66
|
+
if (state.preview) paint(`data:image/png;base64,${state.preview}`);
|
|
67
|
+
cursor(state.cursor);
|
|
68
|
+
progress(state.painting, state.queued);
|
|
69
|
+
renderReferences(); renderSteps(); renderGame(state.game);
|
|
70
|
+
}
|
|
71
|
+
function progress(painting, queued) {
|
|
72
|
+
$('progress').classList.toggle('active', !!painting || queued > 0);
|
|
73
|
+
$('progress').textContent = painting ? `可缇正在绘制 ${painting.step} / ${painting.total} · ${painting.label}` : queued > 0 ? `队列处理中 · ${queued} 项` : state.board ? '已完成' : '就绪';
|
|
74
|
+
}
|
|
75
|
+
function renderReferences() {
|
|
76
|
+
const refs = state.references;
|
|
77
|
+
$('reference-count').textContent = `${refs.length} / 64`;
|
|
78
|
+
const key = JSON.stringify(refs);
|
|
79
|
+
if (key === referenceKey) return;
|
|
80
|
+
referenceKey = key;
|
|
81
|
+
$('references').replaceChildren(...refs.map((ref, index) => {
|
|
82
|
+
const card = document.createElement('article'); card.className = 'reference-card';
|
|
83
|
+
const img = document.createElement('img'); img.src = ref.url; img.alt = ref.name; img.className = 'thumb'; img.tabIndex = 0;
|
|
84
|
+
const open = () => { $('reference-title').textContent = ref.name; $('reference-image').src = ref.url; $('reference-dialog').showModal(); };
|
|
85
|
+
img.onclick = open; img.onkeydown = (event) => { if (event.key === 'Enter') open(); };
|
|
86
|
+
const info = document.createElement('div'); info.className = 'reference-info';
|
|
87
|
+
const name = document.createElement('div'); name.className = 'ref-name'; name.textContent = `${String(index + 1).padStart(2, '0')} · ${ref.name}`;
|
|
88
|
+
const meta = document.createElement('small'); meta.textContent = `${ref.width} × ${ref.height} px`;
|
|
89
|
+
const input = document.createElement('input'); input.value = ref.name; input.maxLength = 120; input.setAttribute('aria-label', '参考图名称'); input.className = 'operator';
|
|
90
|
+
const actions = document.createElement('div'); actions.className = 'ref-actions operator';
|
|
91
|
+
const rename = document.createElement('button'); rename.textContent = '重命名'; rename.className = 'secondary';
|
|
92
|
+
rename.onclick = () => api(`/api/references/${ref.id}`, { name: input.value }, 'PATCH').catch((error) => message(error.message, true));
|
|
93
|
+
const remove = document.createElement('button'); remove.textContent = '移出队列'; remove.className = 'secondary';
|
|
94
|
+
remove.onclick = () => api(`/api/references/${ref.id}`, {}, 'DELETE').catch((error) => message(error.message, true));
|
|
95
|
+
actions.append(rename, remove); info.append(name, meta, input, actions); card.append(img, info); return card;
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
function renderSteps() {
|
|
99
|
+
const steps = state.board?.steps || [];
|
|
100
|
+
$('step-count').textContent = `${steps.length} 步`; $('step-empty').hidden = steps.length > 0;
|
|
101
|
+
const key = `${state.board?.id}:${state.board?.revision}`;
|
|
102
|
+
if (key === stepKey) return;
|
|
103
|
+
stepKey = key;
|
|
104
|
+
$('steps').replaceChildren(...steps.map((step, index) => {
|
|
105
|
+
const li = document.createElement('li'); li.className = 'step';
|
|
106
|
+
const top = document.createElement('div'); top.className = 'step-top';
|
|
107
|
+
const title = document.createElement('strong'); title.textContent = `${String(index + 1).padStart(2, '0')} ${step.action.label}`;
|
|
108
|
+
const undo = document.createElement('button'); undo.className = 'secondary operator'; undo.textContent = '撤销'; undo.title = `仅撤销 ${step.id}`;
|
|
109
|
+
undo.onclick = () => command('canvas_undo', { step_id: step.id }).catch(() => {});
|
|
110
|
+
top.append(title, undo);
|
|
111
|
+
const meta = document.createElement('small'); meta.textContent = `${toolNames[step.action.kind]} · ${step.action.color}`;
|
|
112
|
+
const id = document.createElement('code'); id.textContent = step.id;
|
|
113
|
+
li.append(top, meta, id); return li;
|
|
114
|
+
}));
|
|
115
|
+
$('steps').lastElementChild?.scrollIntoView({ block: 'nearest' });
|
|
116
|
+
}
|
|
117
|
+
function addFiles(files) {
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 10 * 1024 * 1024) { message(`${file.name}:请选择 10 MiB 以内的 PNG/JPG`, true); continue; }
|
|
120
|
+
if (state.references.length + pendingFiles.length >= 64) { message('参考图队列最多 64 张', true); break; }
|
|
121
|
+
pendingFiles.push({ file, name: file.name.replace(/\.[^.]+$/, '').slice(0, 120), preview: URL.createObjectURL(file) });
|
|
122
|
+
}
|
|
123
|
+
renderPending();
|
|
124
|
+
}
|
|
125
|
+
function renderPending() {
|
|
126
|
+
$('pending').replaceChildren();
|
|
127
|
+
pendingFiles.forEach((item) => {
|
|
128
|
+
const row = document.createElement('div'); row.className = 'pending-row';
|
|
129
|
+
const img = document.createElement('img'); img.src = item.preview; img.alt = '待上传参考图';
|
|
130
|
+
const input = document.createElement('input'); input.value = item.name; input.maxLength = 120; input.setAttribute('aria-label', '上传前命名'); input.oninput = () => { item.name = input.value; };
|
|
131
|
+
input.disabled = uploading;
|
|
132
|
+
row.append(img, input); $('pending').append(row);
|
|
133
|
+
});
|
|
134
|
+
if (!pendingFiles.length) return;
|
|
135
|
+
const upload = document.createElement('button'); upload.textContent = `上传 ${pendingFiles.length} 张参考图`;
|
|
136
|
+
upload.disabled = uploading;
|
|
137
|
+
upload.onclick = async () => {
|
|
138
|
+
const batch = [...pendingFiles];
|
|
139
|
+
uploading = true; renderPending();
|
|
140
|
+
try {
|
|
141
|
+
for (const item of batch) {
|
|
142
|
+
const base64 = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result).split(',')[1]); reader.onerror = () => reject(new Error('读取图片失败')); reader.readAsDataURL(item.file); });
|
|
143
|
+
await api('/api/references', { name: item.name, base64 });
|
|
144
|
+
URL.revokeObjectURL(item.preview); pendingFiles.shift();
|
|
145
|
+
}
|
|
146
|
+
message('参考图已上传');
|
|
147
|
+
} catch (error) { message(error.message, true); }
|
|
148
|
+
uploading = false; renderPending();
|
|
149
|
+
};
|
|
150
|
+
const clear = document.createElement('button'); clear.textContent = '取消待上传'; clear.className = 'secondary'; clear.onclick = () => { for (const item of pendingFiles) URL.revokeObjectURL(item.preview); pendingFiles = []; renderPending(); };
|
|
151
|
+
clear.disabled = uploading;
|
|
152
|
+
$('pending').append(upload, clear);
|
|
153
|
+
}
|
|
154
|
+
$('files').onchange = (event) => { addFiles(event.target.files); event.target.value = ''; };
|
|
155
|
+
$('dropzone').ondragover = (event) => { event.preventDefault(); $('dropzone').classList.add('drag'); };
|
|
156
|
+
$('dropzone').ondragleave = () => $('dropzone').classList.remove('drag');
|
|
157
|
+
$('dropzone').ondrop = (event) => { event.preventDefault(); $('dropzone').classList.remove('drag'); addFiles(event.dataTransfer.files); };
|
|
158
|
+
document.addEventListener('paste', (event) => {
|
|
159
|
+
if (overlay) return;
|
|
160
|
+
const images = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith('image/'));
|
|
161
|
+
if (!images.length) return;
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
addFiles(images);
|
|
164
|
+
});
|
|
165
|
+
$('new').onclick = () => $('new-dialog').showModal();
|
|
166
|
+
$('cancel-new').onclick = () => $('new-dialog').close();
|
|
167
|
+
$('new-form').onsubmit = async (event) => {
|
|
168
|
+
event.preventDefault();
|
|
169
|
+
const submit = event.submitter; submit.disabled = true;
|
|
170
|
+
try { await command('canvas_new', { title: $('title').value, width: +$('width').value, height: +$('height').value, background: $('background').value }); $('new-dialog').close(); }
|
|
171
|
+
catch {} finally { submit.disabled = false; }
|
|
172
|
+
};
|
|
173
|
+
$('save').onclick = async () => {
|
|
174
|
+
try { const saved = await command('canvas_save'); const a = document.createElement('a'); a.href = saved.url; a.download = saved.filename; a.click(); message(`已保存:${saved.path}`); } catch {}
|
|
175
|
+
};
|
|
176
|
+
$('close-reference').onclick = () => $('reference-dialog').close();
|
|
177
|
+
$('size').oninput = () => { $('size-value').value = $('size').value; };
|
|
178
|
+
function point(event) { const rect = canvas.getBoundingClientRect(); return { x: Math.max(0, Math.min(canvas.width - 1, (event.clientX - rect.left) / rect.width * canvas.width)), y: Math.max(0, Math.min(canvas.height - 1, (event.clientY - rect.top) / rect.height * canvas.height)) }; }
|
|
179
|
+
canvas.onpointerdown = (event) => {
|
|
180
|
+
if (overlay || !state.board || state.queued || event.button !== 0) return;
|
|
181
|
+
canvas.setPointerCapture(event.pointerId); drawing = [point(event)];
|
|
182
|
+
};
|
|
183
|
+
canvas.onpointermove = (event) => {
|
|
184
|
+
const p = point(event); $('coordinates').textContent = `(${Math.round(p.x)}, ${Math.round(p.y)})`;
|
|
185
|
+
if (drawing && drawing.length < 1024 && Math.hypot(p.x - drawing.at(-1).x, p.y - drawing.at(-1).y) > 2) drawing.push(p);
|
|
186
|
+
};
|
|
187
|
+
canvas.onpointerup = (event) => {
|
|
188
|
+
if (!drawing) return;
|
|
189
|
+
const points = drawing; drawing = null;
|
|
190
|
+
const tool = $('brush').value, isBrush = ['pen', 'marker', 'soft', 'eraser'].includes(tool);
|
|
191
|
+
const selected = tool === 'fill' ? [points[0]] : ['rect', 'ellipse', 'line'].includes(tool) ? [points[0], point(event)] : [...points.slice(0, 1023), point(event)];
|
|
192
|
+
void command('canvas_draw', { actions: [{ kind: isBrush ? 'brush' : tool, brush: isBrush ? tool : 'pen', points: selected, color: $('color').value, size: +$('size').value, opacity: +$('opacity').value / 100, filled: $('filled').checked, label: `手绘 · ${$('brush').selectedOptions[0].text}` }] }).catch(() => {});
|
|
193
|
+
};
|
|
194
|
+
canvas.onpointercancel = () => { drawing = null; };
|
|
195
|
+
new ResizeObserver(resize).observe(document.querySelector('.stage'));
|
|
196
|
+
const events = new EventSource('/events');
|
|
197
|
+
events.onopen = () => { $('connection').textContent = '画室在线'; $('connection').classList.add('online'); };
|
|
198
|
+
events.onerror = () => { $('connection').textContent = '连接中断 · 正在重连'; $('connection').classList.remove('online'); };
|
|
199
|
+
events.onmessage = (event) => {
|
|
200
|
+
const packet = JSON.parse(event.data);
|
|
201
|
+
if (packet.type === 'state') render(packet.data);
|
|
202
|
+
if (packet.type === 'frame') { state.preview = packet.data.png; state.painting = packet.data; cursor(packet.data.cursor); progress(packet.data, state.queued); paint(`data:image/png;base64,${packet.data.png}`); }
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// ---- 你画我猜:计分板、倒计时与揭示都是服务端状态的投影,页面只负责动画 ----
|
|
206
|
+
const ARC = 2 * Math.PI * 52;
|
|
207
|
+
let clockOffset = 0, rankKey = '', timerKey = '', revealKey = '', revealHide = null, timerVisible = false, timerRound = null;
|
|
208
|
+
const formatClock = (ms) => { const s = Math.max(0, Math.ceil(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
|
|
209
|
+
function renderGame(game) {
|
|
210
|
+
if (!game) return;
|
|
211
|
+
clockOffset = game.now - Date.now();
|
|
212
|
+
const playing = game.mode === 'guess';
|
|
213
|
+
document.body.classList.toggle('game', playing);
|
|
214
|
+
$('brand-title').textContent = playing ? '可缇的你画我猜' : 'Corti 的头像画室';
|
|
215
|
+
$('game-badge').hidden = !playing;
|
|
216
|
+
$('game-round').textContent = game.round ? game.round.id : game.rounds;
|
|
217
|
+
renderRanks(playing ? game.scoreboard : []);
|
|
218
|
+
renderTimer(playing ? game.round : null);
|
|
219
|
+
renderReveal(playing ? game.reveal : null);
|
|
220
|
+
}
|
|
221
|
+
function renderRanks(list) {
|
|
222
|
+
const top = list.slice(0, 10);
|
|
223
|
+
const key = JSON.stringify(top);
|
|
224
|
+
if (key === rankKey) return;
|
|
225
|
+
rankKey = key;
|
|
226
|
+
const ol = $('ranks');
|
|
227
|
+
const before = new Map(Array.from(ol.children, (li) => [li.dataset.name, { top: li.getBoundingClientRect().top, score: +li.dataset.score }]));
|
|
228
|
+
$('score-count').textContent = list.length ? `${list.length} 人参与` : '';
|
|
229
|
+
$('rank-empty').hidden = top.length > 0;
|
|
230
|
+
ol.replaceChildren(...top.map((entry) => {
|
|
231
|
+
const li = document.createElement('li'); li.className = `rank-row rank-${Math.min(entry.rank, 4)}`; li.dataset.name = entry.name; li.dataset.score = entry.score;
|
|
232
|
+
const badge = document.createElement('span'); badge.className = 'rank-badge'; badge.textContent = entry.rank;
|
|
233
|
+
const name = document.createElement('span'); name.className = 'rank-name'; name.textContent = entry.name; name.title = entry.name;
|
|
234
|
+
const score = document.createElement('span'); score.className = 'rank-score'; score.textContent = entry.score;
|
|
235
|
+
li.append(badge, name, score); return li;
|
|
236
|
+
}));
|
|
237
|
+
for (const li of ol.children) {
|
|
238
|
+
const prev = before.get(li.dataset.name);
|
|
239
|
+
if (!prev) { li.classList.add('enter'); continue; }
|
|
240
|
+
const dy = prev.top - li.getBoundingClientRect().top;
|
|
241
|
+
if (dy) li.animate([{ transform: `translateY(${dy}px)` }, { transform: 'translateY(0)' }], { duration: 550, easing: 'cubic-bezier(.2,.9,.25,1.15)' });
|
|
242
|
+
const delta = +li.dataset.score - prev.score;
|
|
243
|
+
if (!delta) continue;
|
|
244
|
+
li.querySelector('.rank-score').animate([{ transform: 'scale(1)' }, { transform: 'scale(1.5)' }, { transform: 'scale(1)' }], { duration: 600, easing: 'ease-out' });
|
|
245
|
+
const chip = document.createElement('i'); chip.className = 'delta'; chip.textContent = `${delta > 0 ? '+' : ''}${delta}`;
|
|
246
|
+
li.append(chip); setTimeout(() => chip.remove(), 1200);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function renderTimer(round) {
|
|
250
|
+
const node = $('timer');
|
|
251
|
+
timerRound = round;
|
|
252
|
+
if (!round) {
|
|
253
|
+
if (!timerVisible) return;
|
|
254
|
+
timerVisible = false;
|
|
255
|
+
node.animate([{ transform: 'scale(1)', opacity: 1 }, { transform: 'scale(.6)', opacity: 0 }], { duration: 250, easing: 'ease-in' }).onfinish = () => { if (!timerVisible) node.hidden = true; };
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!timerVisible) { timerVisible = true; node.hidden = false; node.classList.remove('pop'); void node.offsetWidth; node.classList.add('pop'); }
|
|
259
|
+
const key = `${round.id}:${round.length}:${round.hint}`;
|
|
260
|
+
if (key !== timerKey) {
|
|
261
|
+
timerKey = key;
|
|
262
|
+
$('timer-blanks').replaceChildren(...Array.from({ length: Math.min(round.length, 20) }, () => { const blank = document.createElement('span'); blank.className = 'blank'; blank.textContent = '?'; return blank; }));
|
|
263
|
+
$('timer-hint').hidden = !round.hint; $('timer-hint').textContent = round.hint ? `提示 · ${round.hint}` : '';
|
|
264
|
+
}
|
|
265
|
+
tickTimer();
|
|
266
|
+
}
|
|
267
|
+
function tickTimer() {
|
|
268
|
+
const round = timerRound;
|
|
269
|
+
if (!round || !timerVisible) return;
|
|
270
|
+
const remaining = round.endsAt - (Date.now() + clockOffset);
|
|
271
|
+
const over = round.expired || remaining <= 0;
|
|
272
|
+
const fraction = over ? 0 : Math.min(1, remaining / (round.endsAt - round.startedAt));
|
|
273
|
+
$('timer-arc').style.strokeDashoffset = ARC * (1 - fraction);
|
|
274
|
+
$('timer-clock').textContent = over ? '0:00' : formatClock(remaining);
|
|
275
|
+
$('timer').classList.toggle('urgent', !over && remaining <= 10000);
|
|
276
|
+
$('timer').classList.toggle('over', over);
|
|
277
|
+
$('timer-label').textContent = over ? '时间到!等可缇揭晓答案~' : '猜猜我在画什么?';
|
|
278
|
+
}
|
|
279
|
+
setInterval(tickTimer, 200);
|
|
280
|
+
function renderReveal(reveal) {
|
|
281
|
+
const node = $('reveal');
|
|
282
|
+
const key = reveal ? `${reveal.roundId}:${reveal.at}` : '';
|
|
283
|
+
if (key === revealKey) return;
|
|
284
|
+
revealKey = key;
|
|
285
|
+
clearTimeout(revealHide);
|
|
286
|
+
const age = reveal ? Date.now() + clockOffset - reveal.at : 0;
|
|
287
|
+
if (!reveal || age > 25000) { node.hidden = true; return; }
|
|
288
|
+
const letters = Array.from(reveal.answer);
|
|
289
|
+
const columns = (limit) => Math.ceil(letters.length / Math.ceil(letters.length / limit));
|
|
290
|
+
$('reveal-answer').style.setProperty('--wide-columns', columns(10));
|
|
291
|
+
$('reveal-answer').style.setProperty('--narrow-columns', columns(5));
|
|
292
|
+
$('reveal-answer').replaceChildren(...letters.map((char, index) => { const span = document.createElement('span'); span.textContent = char; span.style.setProperty('--i', index); return span; }));
|
|
293
|
+
$('reveal-winners').replaceChildren(...reveal.winners.map((name, index) => { const li = document.createElement('li'); li.textContent = `${index + 1}. ${name}`; li.style.setProperty('--i', index); return li; }));
|
|
294
|
+
const colors = ['#dfa0b6', '#e7c478', '#91b6d1', '#a4bea5', '#b7a4cf'];
|
|
295
|
+
$('confetti').replaceChildren(...Array.from({ length: 48 }, () => {
|
|
296
|
+
const piece = document.createElement('i');
|
|
297
|
+
piece.style.setProperty('--x', Math.random() * 100); piece.style.setProperty('--d', `${2.5 + Math.random() * 2.5}s`); piece.style.setProperty('--w', `${Math.random() * 1.2}s`);
|
|
298
|
+
piece.style.setProperty('--r', `${Math.random() * 360}deg`); piece.style.setProperty('--c', colors[Math.floor(Math.random() * colors.length)]);
|
|
299
|
+
return piece;
|
|
300
|
+
}));
|
|
301
|
+
node.hidden = false;
|
|
302
|
+
node.classList.remove('pop'); void node.offsetWidth; node.classList.add('pop');
|
|
303
|
+
revealHide = setTimeout(() => { node.hidden = true; }, 25000 - age);
|
|
304
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// p5.brush 2.2.2 composites pigments against opaque white paper. Canvas layers need premultiplied alpha.
|
|
2
|
+
(() => {
|
|
3
|
+
const pigmentShaders = new WeakSet(), compositorPrograms = new WeakSet();
|
|
4
|
+
const background = /vec3 bgColor\s*=[^;]+;/;
|
|
5
|
+
const output = /outColor\s*=\s*vec4\(spectral_mix\(bgColor,\s*pigment.rgb,\s*mixIntensity\),\s*1\.\);/;
|
|
6
|
+
globalThis.canvasPigmentAlphaReady = false;
|
|
7
|
+
const gl = WebGL2RenderingContext.prototype;
|
|
8
|
+
const shaderSource = gl.shaderSource;
|
|
9
|
+
gl.shaderSource = function(shader, source) {
|
|
10
|
+
if (source.includes('vec3 bgColor')) {
|
|
11
|
+
if (!background.test(source) || !output.test(source)) throw new Error('Unsupported p5.brush pigment shader');
|
|
12
|
+
source = source.replace(background, `
|
|
13
|
+
float coverage = clamp(mixIntensity, 0.0, 1.0);
|
|
14
|
+
float alpha = coverage + source.a * (1.0 - coverage);
|
|
15
|
+
vec3 bgColor = source.a > 0.0001 ? source.rgb / source.a : pigment.rgb;
|
|
16
|
+
`).replace(output, `
|
|
17
|
+
vec3 mixed = spectral_mix(bgColor, pigment.rgb, coverage);
|
|
18
|
+
vec3 rgb = mix(pigment.rgb, mixed, source.a);
|
|
19
|
+
outColor = vec4(rgb * alpha, alpha);
|
|
20
|
+
`);
|
|
21
|
+
pigmentShaders.add(shader);
|
|
22
|
+
globalThis.canvasPigmentAlphaReady = true;
|
|
23
|
+
}
|
|
24
|
+
return shaderSource.call(this, shader, source);
|
|
25
|
+
};
|
|
26
|
+
const attachShader = gl.attachShader;
|
|
27
|
+
gl.attachShader = function(program, shader) {
|
|
28
|
+
if (pigmentShaders.has(shader)) compositorPrograms.add(program);
|
|
29
|
+
return attachShader.call(this, program, shader);
|
|
30
|
+
};
|
|
31
|
+
const drawArrays = gl.drawArrays;
|
|
32
|
+
gl.drawArrays = function(...args) {
|
|
33
|
+
// The shader already includes the destination; blending it again would apply its alpha twice.
|
|
34
|
+
const blending = compositorPrograms.has(this.getParameter(this.CURRENT_PROGRAM)) && this.isEnabled(this.BLEND);
|
|
35
|
+
if (blending) this.disable(this.BLEND);
|
|
36
|
+
drawArrays.apply(this, args);
|
|
37
|
+
if (blending) this.enable(this.BLEND);
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const inkWidths = { liner: 1, pen: 0.3, rotring: 0.15, '2B': 0.3, HB: 0.3, '2H': 0.2, cpencil: 0.35, charcoal: 0.35, marker: 2, spray: 0.2 };
|
|
2
|
+
let target;
|
|
3
|
+
globalThis.renderBrushLayer = ({ width, height, parts, paint, seed }) => {
|
|
4
|
+
if (!target) {
|
|
5
|
+
target = brush.createCanvas(width, height, { pixelDensity: 1 });
|
|
6
|
+
brush.scaleBrushes(1);
|
|
7
|
+
brush.add('liner', { type: 'marker', weight: 1, scatter: 0, opacity: 255, spacing: 0.15, pressure: [1, 1], noise: 0, markerTip: false, rotate: 'natural' });
|
|
8
|
+
}
|
|
9
|
+
brush.clear(); brush.seed(seed); brush.noiseSeed(seed); brush.noField(); brush.noClip();
|
|
10
|
+
brush.noFill(); brush.noWash(); brush.noStroke(); brush.noHatch(); brush.noMass();
|
|
11
|
+
brush.push(); brush.translate(-width / 2, -height / 2);
|
|
12
|
+
if (paint.fill) {
|
|
13
|
+
const fill = paint.fill;
|
|
14
|
+
if (fill.mode === 'solid') brush.wash(fill.color, fill.opacity * 255);
|
|
15
|
+
else { brush.fill(fill.color, fill.opacity * 255); brush.fillBleed(fill.bleed); brush.fillTexture(fill.texture, 0.12, false); }
|
|
16
|
+
}
|
|
17
|
+
if (paint.stroke) brush.set(paint.stroke.brush, paint.stroke.color, paint.stroke.width / inkWidths[paint.stroke.brush]);
|
|
18
|
+
if (paint.hatch) {
|
|
19
|
+
const hatch = paint.hatch;
|
|
20
|
+
brush.hatchStyle(hatch.brush, hatch.color, hatch.width / inkWidths[hatch.brush]);
|
|
21
|
+
brush.hatch(hatch.spacing, hatch.angle * Math.PI / 180, { rand: 0.05 });
|
|
22
|
+
}
|
|
23
|
+
for (const part of parts) {
|
|
24
|
+
const pressure = paint.stroke?.pressure ?? [1, 1, 1];
|
|
25
|
+
let distance = 0;
|
|
26
|
+
const lengths = part.points.map((p, i) => i ? Math.hypot(p.x - part.points[i - 1].x, p.y - part.points[i - 1].y) : 0);
|
|
27
|
+
const length = lengths.reduce((sum, value) => sum + value, 0);
|
|
28
|
+
brush.beginShape(0);
|
|
29
|
+
part.points.forEach((p, i) => {
|
|
30
|
+
distance += lengths[i];
|
|
31
|
+
const t = length ? distance / length : 0;
|
|
32
|
+
const value = t < 0.5 ? pressure[0] + (pressure[1] - pressure[0]) * t * 2 : pressure[1] + (pressure[2] - pressure[1]) * (t - 0.5) * 2;
|
|
33
|
+
brush.vertex(p.x, p.y, value);
|
|
34
|
+
});
|
|
35
|
+
brush.endShape(part.closed);
|
|
36
|
+
}
|
|
37
|
+
brush.pop(); brush.render();
|
|
38
|
+
if (!globalThis.canvasPigmentAlphaReady) throw new Error('p5.brush alpha adapter was not applied');
|
|
39
|
+
return target.toDataURL('image/png').split(',')[1];
|
|
40
|
+
};
|