@aethervtt/shard-sprite 0.0.1
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 +3 -0
- package/dist/atlas.d.ts +112 -0
- package/dist/atlas.js +511 -0
- package/dist/clip.d.ts +73 -0
- package/dist/clip.js +278 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +186 -0
- package/dist/lighting-shaders.d.ts +5 -0
- package/dist/lighting-shaders.js +267 -0
- package/dist/lighting.d.ts +44 -0
- package/dist/lighting.js +149 -0
- package/dist/lights2d.d.ts +203 -0
- package/dist/lights2d.js +1093 -0
- package/dist/occluders.d.ts +64 -0
- package/dist/occluders.js +241 -0
- package/dist/outline.d.ts +24 -0
- package/dist/outline.js +183 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/plugin.js +67 -0
- package/dist/preview.d.ts +9 -0
- package/dist/preview.js +71 -0
- package/dist/render.d.ts +202 -0
- package/dist/render.js +1216 -0
- package/dist/shaders.d.ts +2 -0
- package/dist/shaders.js +263 -0
- package/dist/sprite.d.ts +26 -0
- package/dist/sprite.js +67 -0
- package/dist/tilemap.d.ts +94 -0
- package/dist/tilemap.js +267 -0
- package/package.json +39 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ShardError } from '@aethervtt/shard-core';
|
|
2
|
+
import type { TileLayer } from './tilemap.js';
|
|
3
|
+
/**
|
|
4
|
+
* Occluder geometry. Every shape reduces to segments (ax, ay, bx, by), first in the occluder's
|
|
5
|
+
* local space, then in world space for the shadow pass. Pure functions: tested headless.
|
|
6
|
+
*/
|
|
7
|
+
/** Segments of a closed polygon (x, y pairs), appended to `out`. */
|
|
8
|
+
export declare function polygonSegments(points: ArrayLike<number>, out: number[]): void;
|
|
9
|
+
/** A w × h box centered on the origin, as a polygon. */
|
|
10
|
+
export declare function boxPolygon(w: number, h: number, cx?: number, cy?: number): number[];
|
|
11
|
+
/** Sides of a circle's polygon: its chord error is under 1% of the radius. */
|
|
12
|
+
export declare const CIRCLE_SIDES = 24;
|
|
13
|
+
export declare function circlePolygon(r: number, sides?: number): number[];
|
|
14
|
+
/** A capsule along Y: two half circles `halfHeight` from the center, joined by straight sides. */
|
|
15
|
+
export declare function capsulePolygon(r: number, halfHeight: number, sides?: number): number[];
|
|
16
|
+
/** The convex hull of points (x, y pairs), counter-clockwise (monotone chain). */
|
|
17
|
+
export declare function convexHull(points: ArrayLike<number>): number[];
|
|
18
|
+
/**
|
|
19
|
+
* Why a polygon can't occlude, or undefined when it can: fewer than 3 points, no area, or two
|
|
20
|
+
* edges that cross.
|
|
21
|
+
*/
|
|
22
|
+
export declare function polygonProblem(points: ArrayLike<number>): string | undefined;
|
|
23
|
+
export declare function invalidOccluder(message: string, path?: string): ShardError;
|
|
24
|
+
/** The fields of LightOccluder2d a shape needs. */
|
|
25
|
+
export interface OccluderShapeValue {
|
|
26
|
+
shape: 'box' | 'circle' | 'polygon' | 'sprite' | 'collider';
|
|
27
|
+
size: ArrayLike<number>;
|
|
28
|
+
points: ArrayLike<number>[];
|
|
29
|
+
}
|
|
30
|
+
/** The fields of physics/Collider an occluder reads (2D reads x and y). */
|
|
31
|
+
export interface ColliderValue {
|
|
32
|
+
shape: string;
|
|
33
|
+
radius: number;
|
|
34
|
+
halfExtents: ArrayLike<number>;
|
|
35
|
+
halfHeight: number;
|
|
36
|
+
points: ArrayLike<number>[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Local-space segments of an occluder. `sprite` takes the outline polygon the caller resolved
|
|
40
|
+
* from the sprite; `collider` the entity's Collider. Throws `sprite/invalid-occluder`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function occluderSegments(value: OccluderShapeValue, extra?: {
|
|
43
|
+
outline?: ArrayLike<number>;
|
|
44
|
+
collider?: ColliderValue;
|
|
45
|
+
}): number[];
|
|
46
|
+
/**
|
|
47
|
+
* A sprite's outline in its local space: an atlas outline (normalized region coordinates, y down)
|
|
48
|
+
* placed by the sprite's size, anchor, and flips; without one, the sprite's rectangle.
|
|
49
|
+
*/
|
|
50
|
+
export declare function spriteOutline(outline: ArrayLike<number> | undefined, w: number, h: number, ax: number, ay: number, flipX: boolean, flipY: boolean): number[];
|
|
51
|
+
/**
|
|
52
|
+
* Boundary edges of one chunk of an occluding tile layer, in tile units (tile (x, y) covers
|
|
53
|
+
* [x, x + 1] × [−y − 1, −y]), appended to `out`. A side is an edge when exactly one of its two
|
|
54
|
+
* cells is filled, counting cells outside the chunk as empty: edits only rebuild their chunk, and
|
|
55
|
+
* the extra edges on chunk borders sit inside walls where no light reaches them first. Collinear
|
|
56
|
+
* runs merge into one segment.
|
|
57
|
+
*/
|
|
58
|
+
export declare function tileChunkEdges(layer: Pick<TileLayer, 'tiles' | 'width' | 'height'>, chunkSize: number, chunk: number, out: number[]): void;
|
|
59
|
+
/**
|
|
60
|
+
* Transforms local segments by an affine (3 × 4 rows, the entity's GlobalTransform) into world
|
|
61
|
+
* XY, writing `count` segments from `src` to `dst` at `offset` (in segments). Returns the world
|
|
62
|
+
* bounds in `bounds` (minX, minY, maxX, maxY).
|
|
63
|
+
*/
|
|
64
|
+
export declare function transformSegments(src: ArrayLike<number>, g: ArrayLike<number>, go: number, sx: number, sy: number, dst: Float32Array, offset: number, bounds: Float32Array): void;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
function polygonSegments(points, out) {
|
|
3
|
+
const n = points.length / 2;
|
|
4
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
5
|
+
out.push(points[j * 2], points[j * 2 + 1], points[i * 2], points[i * 2 + 1]);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function boxPolygon(w, h, cx = 0, cy = 0) {
|
|
9
|
+
const x = w / 2;
|
|
10
|
+
const y = h / 2;
|
|
11
|
+
return [cx - x, cy - y, cx + x, cy - y, cx + x, cy + y, cx - x, cy + y];
|
|
12
|
+
}
|
|
13
|
+
const CIRCLE_SIDES = 24;
|
|
14
|
+
function circlePolygon(r, sides = CIRCLE_SIDES) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (let k = 0; k < sides; k++) {
|
|
17
|
+
const a = k / sides * Math.PI * 2;
|
|
18
|
+
out.push(Math.cos(a) * r, Math.sin(a) * r);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
function capsulePolygon(r, halfHeight, sides = CIRCLE_SIDES) {
|
|
23
|
+
const out = [];
|
|
24
|
+
const half = sides / 2;
|
|
25
|
+
for (let k = 0; k <= half; k++) {
|
|
26
|
+
const a = k / half * Math.PI;
|
|
27
|
+
out.push(Math.cos(a) * r, halfHeight + Math.sin(a) * r);
|
|
28
|
+
}
|
|
29
|
+
for (let k = 0; k <= half; k++) {
|
|
30
|
+
const a = Math.PI + k / half * Math.PI;
|
|
31
|
+
out.push(Math.cos(a) * r, -halfHeight + Math.sin(a) * r);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function convexHull(points) {
|
|
36
|
+
const idx = Array.from({ length: points.length / 2 }, (_, i) => i).sort(
|
|
37
|
+
(a, b) => points[a * 2] - points[b * 2] || points[a * 2 + 1] - points[b * 2 + 1]
|
|
38
|
+
);
|
|
39
|
+
const cross = (o, a, b) => (points[a * 2] - points[o * 2]) * (points[b * 2 + 1] - points[o * 2 + 1]) - (points[a * 2 + 1] - points[o * 2 + 1]) * (points[b * 2] - points[o * 2]);
|
|
40
|
+
const lower = [];
|
|
41
|
+
for (const i of idx) {
|
|
42
|
+
while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), i) <= 0) lower.pop();
|
|
43
|
+
lower.push(i);
|
|
44
|
+
}
|
|
45
|
+
const upper = [];
|
|
46
|
+
for (let k = idx.length - 1; k >= 0; k--) {
|
|
47
|
+
const i = idx[k];
|
|
48
|
+
while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), i) <= 0) upper.pop();
|
|
49
|
+
upper.push(i);
|
|
50
|
+
}
|
|
51
|
+
const hull = lower.slice(0, -1).concat(upper.slice(0, -1));
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const i of hull) out.push(points[i * 2], points[i * 2 + 1]);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
function segmentsCross(ax, ay, bx, by, cx, cy, dx, dy) {
|
|
57
|
+
const d1 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
|
|
58
|
+
const d2 = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax);
|
|
59
|
+
const d3 = (dx - cx) * (ay - cy) - (dy - cy) * (ax - cx);
|
|
60
|
+
const d4 = (dx - cx) * (by - cy) - (dy - cy) * (bx - cx);
|
|
61
|
+
return d1 * d2 < 0 && d3 * d4 < 0;
|
|
62
|
+
}
|
|
63
|
+
function polygonProblem(points) {
|
|
64
|
+
const n = points.length / 2;
|
|
65
|
+
if (n < 3) return `a polygon needs at least 3 points, not ${n}`;
|
|
66
|
+
for (let i = 0; i < n; i++) {
|
|
67
|
+
const i2 = (i + 1) % n;
|
|
68
|
+
for (let j = i + 2; j < n; j++) {
|
|
69
|
+
const j2 = (j + 1) % n;
|
|
70
|
+
if (j2 === i) continue;
|
|
71
|
+
if (segmentsCross(
|
|
72
|
+
points[i * 2],
|
|
73
|
+
points[i * 2 + 1],
|
|
74
|
+
points[i2 * 2],
|
|
75
|
+
points[i2 * 2 + 1],
|
|
76
|
+
points[j * 2],
|
|
77
|
+
points[j * 2 + 1],
|
|
78
|
+
points[j2 * 2],
|
|
79
|
+
points[j2 * 2 + 1]
|
|
80
|
+
)) {
|
|
81
|
+
return `edges ${i} and ${j} cross (self-intersecting)`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let area = 0;
|
|
86
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
87
|
+
area += points[j * 2] * points[i * 2 + 1] - points[i * 2] * points[j * 2 + 1];
|
|
88
|
+
}
|
|
89
|
+
if (Math.abs(area) < 1e-9) return "the polygon has no area";
|
|
90
|
+
return void 0;
|
|
91
|
+
}
|
|
92
|
+
function invalidOccluder(message, path) {
|
|
93
|
+
return new ShardError("sprite/invalid-occluder", `Invalid LightOccluder2d: ${message}`, {
|
|
94
|
+
hint: "Give polygons 3+ points without crossing edges, boxes a nonzero size, and collider occluders a cuboid, ball, capsule, or convex Collider.",
|
|
95
|
+
path
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function occluderSegments(value, extra = {}) {
|
|
99
|
+
const out = [];
|
|
100
|
+
switch (value.shape) {
|
|
101
|
+
case "box": {
|
|
102
|
+
const w = value.size[0];
|
|
103
|
+
const h = value.size[1];
|
|
104
|
+
if (!(w > 0 && h > 0)) throw invalidOccluder(`a box of size ${w} \xD7 ${h}`, "/size");
|
|
105
|
+
polygonSegments(boxPolygon(w, h), out);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "circle": {
|
|
109
|
+
const r = value.size[0];
|
|
110
|
+
if (!(r > 0)) throw invalidOccluder(`a circle of radius ${r}`, "/size");
|
|
111
|
+
polygonSegments(circlePolygon(r), out);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "polygon": {
|
|
115
|
+
const flat = [];
|
|
116
|
+
for (const p of value.points) flat.push(p[0], p[1]);
|
|
117
|
+
const problem = polygonProblem(flat);
|
|
118
|
+
if (problem) throw invalidOccluder(problem, "/points");
|
|
119
|
+
polygonSegments(flat, out);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case "sprite": {
|
|
123
|
+
const o = extra.outline;
|
|
124
|
+
if (!o || o.length < 6) throw invalidOccluder("the sprite has no outline or size");
|
|
125
|
+
polygonSegments(o, out);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case "collider": {
|
|
129
|
+
const c = extra.collider;
|
|
130
|
+
if (!c) throw invalidOccluder("shape 'collider' needs a physics/Collider on the entity");
|
|
131
|
+
if (c.shape === "cuboid") {
|
|
132
|
+
const w = c.halfExtents[0] * 2;
|
|
133
|
+
const h = c.halfExtents[1] * 2;
|
|
134
|
+
if (!(w > 0 && h > 0)) throw invalidOccluder(`a cuboid collider of size ${w} \xD7 ${h}`);
|
|
135
|
+
polygonSegments(boxPolygon(w, h), out);
|
|
136
|
+
} else if (c.shape === "ball") {
|
|
137
|
+
if (!(c.radius > 0)) throw invalidOccluder(`a ball collider of radius ${c.radius}`);
|
|
138
|
+
polygonSegments(circlePolygon(c.radius), out);
|
|
139
|
+
} else if (c.shape === "capsule") {
|
|
140
|
+
polygonSegments(capsulePolygon(c.radius, c.halfHeight), out);
|
|
141
|
+
} else if (c.shape === "convex") {
|
|
142
|
+
const flat = [];
|
|
143
|
+
for (const p of c.points) flat.push(p[0], p[1]);
|
|
144
|
+
const hull = convexHull(flat);
|
|
145
|
+
if (hull.length < 6) throw invalidOccluder("a convex collider with fewer than 3 points");
|
|
146
|
+
polygonSegments(hull, out);
|
|
147
|
+
} else {
|
|
148
|
+
throw invalidOccluder(
|
|
149
|
+
`a ${c.shape} collider (occluders support cuboid, ball, capsule, and convex)`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
function spriteOutline(outline, w, h, ax, ay, flipX, flipY) {
|
|
158
|
+
const src = outline && outline.length >= 6 ? outline : [0, 0, 1, 0, 1, 1, 0, 1];
|
|
159
|
+
const out = [];
|
|
160
|
+
for (let k = 0; k < src.length; k += 2) {
|
|
161
|
+
const u = flipX ? 1 - src[k] : src[k];
|
|
162
|
+
const v = flipY ? 1 - src[k + 1] : src[k + 1];
|
|
163
|
+
out.push((u - ax) * w, (ay - v) * h);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
function tileChunkEdges(layer, chunkSize, chunk, out) {
|
|
168
|
+
const chunksX = Math.ceil(layer.width / chunkSize);
|
|
169
|
+
const x0 = chunk % chunksX * chunkSize;
|
|
170
|
+
const y0 = Math.floor(chunk / chunksX) * chunkSize;
|
|
171
|
+
const x1 = Math.min(layer.width, x0 + chunkSize);
|
|
172
|
+
const y1 = Math.min(layer.height, y0 + chunkSize);
|
|
173
|
+
const filled = (x, y) => x >= x0 && x < x1 && y >= y0 && y < y1 && layer.tiles[y * layer.width + x] !== 0;
|
|
174
|
+
for (let y = y0; y <= y1; y++) {
|
|
175
|
+
let start = -1;
|
|
176
|
+
for (let x = x0; x <= x1; x++) {
|
|
177
|
+
const edge = x < x1 && filled(x, y) !== filled(x, y - 1);
|
|
178
|
+
if (edge && start < 0) start = x;
|
|
179
|
+
else if (!edge && start >= 0) {
|
|
180
|
+
out.push(start, -y, x, -y);
|
|
181
|
+
start = -1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (let x = x0; x <= x1; x++) {
|
|
186
|
+
let start = -1;
|
|
187
|
+
for (let y = y0; y <= y1; y++) {
|
|
188
|
+
const edge = y < y1 && filled(x, y) !== filled(x - 1, y);
|
|
189
|
+
if (edge && start < 0) start = y;
|
|
190
|
+
else if (!edge && start >= 0) {
|
|
191
|
+
out.push(x, -start, x, -y);
|
|
192
|
+
start = -1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function transformSegments(src, g, go, sx, sy, dst, offset, bounds) {
|
|
198
|
+
const n = src.length / 4;
|
|
199
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
200
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
201
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
202
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
203
|
+
const m0 = g[go];
|
|
204
|
+
const m1 = g[go + 1];
|
|
205
|
+
const m3 = g[go + 3];
|
|
206
|
+
const m4 = g[go + 4];
|
|
207
|
+
const m5 = g[go + 5];
|
|
208
|
+
const m7 = g[go + 7];
|
|
209
|
+
for (let s = 0; s < n; s++) {
|
|
210
|
+
for (let e = 0; e < 2; e++) {
|
|
211
|
+
const lx = src[s * 4 + e * 2] * sx;
|
|
212
|
+
const ly = src[s * 4 + e * 2 + 1] * sy;
|
|
213
|
+
const x = m0 * lx + m1 * ly + m3;
|
|
214
|
+
const y = m4 * lx + m5 * ly + m7;
|
|
215
|
+
dst[(offset + s) * 4 + e * 2] = x;
|
|
216
|
+
dst[(offset + s) * 4 + e * 2 + 1] = y;
|
|
217
|
+
if (x < minX) minX = x;
|
|
218
|
+
if (x > maxX) maxX = x;
|
|
219
|
+
if (y < minY) minY = y;
|
|
220
|
+
if (y > maxY) maxY = y;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
bounds[0] = minX;
|
|
224
|
+
bounds[1] = minY;
|
|
225
|
+
bounds[2] = maxX;
|
|
226
|
+
bounds[3] = maxY;
|
|
227
|
+
}
|
|
228
|
+
export {
|
|
229
|
+
CIRCLE_SIDES,
|
|
230
|
+
boxPolygon,
|
|
231
|
+
capsulePolygon,
|
|
232
|
+
circlePolygon,
|
|
233
|
+
convexHull,
|
|
234
|
+
invalidOccluder,
|
|
235
|
+
occluderSegments,
|
|
236
|
+
polygonProblem,
|
|
237
|
+
polygonSegments,
|
|
238
|
+
spriteOutline,
|
|
239
|
+
tileChunkEdges,
|
|
240
|
+
transformSegments
|
|
241
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alpha outlines for `shape: 'sprite'` occluders: marching squares over a region's alpha at 0.5,
|
|
3
|
+
* the largest loop kept, simplified with Douglas–Peucker until it has at most `maxPoints` points.
|
|
4
|
+
* Runs in the atlas importer, so outlines cost nothing at runtime.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Closed loops (x, y pairs in pixels, y down, pixel (x, y) spanning [x, x + 1]) where alpha crosses
|
|
8
|
+
* `threshold`, interpolated between pixel centers. `alpha(x, y)` is 0..1 and 0 outside the region.
|
|
9
|
+
*/
|
|
10
|
+
export declare function traceAlpha(width: number, height: number, alpha: (x: number, y: number) => number, threshold?: number): number[][];
|
|
11
|
+
/** Signed area of a closed loop (x, y pairs). */
|
|
12
|
+
export declare function loopArea(p: ArrayLike<number>): number;
|
|
13
|
+
/** Distance from a point to a closed polygon's boundary. */
|
|
14
|
+
export declare function distanceToLoop(x: number, y: number, p: ArrayLike<number>): number;
|
|
15
|
+
/** Douglas–Peucker on a closed loop: every dropped point stays within `eps` of the result. */
|
|
16
|
+
export declare function simplifyLoop(p: number[], eps: number): number[];
|
|
17
|
+
/**
|
|
18
|
+
* A region's outline: the largest alpha loop, simplified to within `eps` texels, loosened until
|
|
19
|
+
* it has at most `maxPoints` points. Pixel coordinates (y down); empty when nothing is opaque.
|
|
20
|
+
*/
|
|
21
|
+
export declare function alphaOutline(width: number, height: number, alpha: (x: number, y: number) => number, options?: {
|
|
22
|
+
eps?: number;
|
|
23
|
+
maxPoints?: number;
|
|
24
|
+
}): number[];
|
package/dist/outline.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const CASES = [
|
|
2
|
+
[],
|
|
3
|
+
[3, 2],
|
|
4
|
+
[2, 1],
|
|
5
|
+
[3, 1],
|
|
6
|
+
[0, 1],
|
|
7
|
+
[],
|
|
8
|
+
// saddle, resolved by the center
|
|
9
|
+
[0, 2],
|
|
10
|
+
[0, 3],
|
|
11
|
+
[0, 3],
|
|
12
|
+
[0, 2],
|
|
13
|
+
[],
|
|
14
|
+
// saddle
|
|
15
|
+
[0, 1],
|
|
16
|
+
[3, 1],
|
|
17
|
+
[2, 1],
|
|
18
|
+
[3, 2],
|
|
19
|
+
[]
|
|
20
|
+
];
|
|
21
|
+
function traceAlpha(width, height, alpha, threshold = 0.5) {
|
|
22
|
+
const cw = width + 2;
|
|
23
|
+
const ch = height + 2;
|
|
24
|
+
const a = new Float32Array(cw * ch);
|
|
25
|
+
for (let y = 0; y < height; y++)
|
|
26
|
+
for (let x = 0; x < width; x++) a[(y + 1) * cw + x + 1] = alpha(x, y);
|
|
27
|
+
const inside = (i, j) => a[j * cw + i] >= threshold;
|
|
28
|
+
const px = /* @__PURE__ */ new Map();
|
|
29
|
+
const py = /* @__PURE__ */ new Map();
|
|
30
|
+
const nbr = /* @__PURE__ */ new Map();
|
|
31
|
+
const point = (id) => {
|
|
32
|
+
if (px.has(id)) return;
|
|
33
|
+
const k = id >> 1;
|
|
34
|
+
const i = k % cw;
|
|
35
|
+
const j = (k - i) / cw;
|
|
36
|
+
const i2 = id & 1 ? i : i + 1;
|
|
37
|
+
const j2 = id & 1 ? j + 1 : j;
|
|
38
|
+
const v0 = a[j * cw + i];
|
|
39
|
+
const v1 = a[j2 * cw + i2];
|
|
40
|
+
const t = v1 === v0 ? 0.5 : Math.min(1, Math.max(0, (threshold - v0) / (v1 - v0)));
|
|
41
|
+
px.set(id, i - 0.5 + (i2 - i) * t);
|
|
42
|
+
py.set(id, j - 0.5 + (j2 - j) * t);
|
|
43
|
+
};
|
|
44
|
+
const link = (p, q) => {
|
|
45
|
+
point(p);
|
|
46
|
+
point(q);
|
|
47
|
+
const l = nbr.get(p);
|
|
48
|
+
if (l) l.push(q);
|
|
49
|
+
else nbr.set(p, [q]);
|
|
50
|
+
const m = nbr.get(q);
|
|
51
|
+
if (m) m.push(p);
|
|
52
|
+
else nbr.set(q, [p]);
|
|
53
|
+
};
|
|
54
|
+
for (let j = 0; j < ch - 1; j++) {
|
|
55
|
+
for (let i = 0; i < cw - 1; i++) {
|
|
56
|
+
const c = (inside(i, j) ? 8 : 0) | (inside(i + 1, j) ? 4 : 0) | (inside(i + 1, j + 1) ? 2 : 0) | (inside(i, j + 1) ? 1 : 0);
|
|
57
|
+
if (c === 0 || c === 15) continue;
|
|
58
|
+
const e = [
|
|
59
|
+
(j * cw + i) * 2,
|
|
60
|
+
(j * cw + i + 1) * 2 + 1,
|
|
61
|
+
((j + 1) * cw + i) * 2,
|
|
62
|
+
(j * cw + i) * 2 + 1
|
|
63
|
+
];
|
|
64
|
+
if (c === 5 || c === 10) {
|
|
65
|
+
const center = (a[j * cw + i] + a[j * cw + i + 1] + a[(j + 1) * cw + i + 1] + a[(j + 1) * cw + i]) / 4 >= threshold;
|
|
66
|
+
const cutTlBr = c === 5 === center;
|
|
67
|
+
if (cutTlBr) {
|
|
68
|
+
link(e[0], e[3]);
|
|
69
|
+
link(e[2], e[1]);
|
|
70
|
+
} else {
|
|
71
|
+
link(e[0], e[1]);
|
|
72
|
+
link(e[3], e[2]);
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const s = CASES[c];
|
|
77
|
+
link(e[s[0]], e[s[1]]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const loops = [];
|
|
81
|
+
const done = /* @__PURE__ */ new Set();
|
|
82
|
+
for (const start of nbr.keys()) {
|
|
83
|
+
if (done.has(start)) continue;
|
|
84
|
+
const loop = [];
|
|
85
|
+
let cur = start;
|
|
86
|
+
while (cur !== void 0 && !done.has(cur)) {
|
|
87
|
+
done.add(cur);
|
|
88
|
+
loop.push(px.get(cur), py.get(cur));
|
|
89
|
+
const n = nbr.get(cur);
|
|
90
|
+
cur = !done.has(n[0]) ? n[0] : n[1];
|
|
91
|
+
}
|
|
92
|
+
if (loop.length >= 6) loops.push(loop);
|
|
93
|
+
}
|
|
94
|
+
return loops;
|
|
95
|
+
}
|
|
96
|
+
function loopArea(p) {
|
|
97
|
+
let s = 0;
|
|
98
|
+
const n = p.length / 2;
|
|
99
|
+
for (let i = 0, j = n - 1; i < n; j = i++)
|
|
100
|
+
s += p[j * 2] * p[i * 2 + 1] - p[i * 2] * p[j * 2 + 1];
|
|
101
|
+
return s / 2;
|
|
102
|
+
}
|
|
103
|
+
function segDist(px, py, ax, ay, bx, by) {
|
|
104
|
+
const dx = bx - ax;
|
|
105
|
+
const dy = by - ay;
|
|
106
|
+
const l2 = dx * dx + dy * dy;
|
|
107
|
+
const t = l2 > 0 ? Math.min(1, Math.max(0, ((px - ax) * dx + (py - ay) * dy) / l2)) : 0;
|
|
108
|
+
const x = ax + t * dx - px;
|
|
109
|
+
const y = ay + t * dy - py;
|
|
110
|
+
return Math.sqrt(x * x + y * y);
|
|
111
|
+
}
|
|
112
|
+
function distanceToLoop(x, y, p) {
|
|
113
|
+
let best = Number.POSITIVE_INFINITY;
|
|
114
|
+
const n = p.length / 2;
|
|
115
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
116
|
+
best = Math.min(best, segDist(x, y, p[j * 2], p[j * 2 + 1], p[i * 2], p[i * 2 + 1]));
|
|
117
|
+
}
|
|
118
|
+
return best;
|
|
119
|
+
}
|
|
120
|
+
function dp(p, first, last, eps, keep) {
|
|
121
|
+
let worst = -1;
|
|
122
|
+
let at = -1;
|
|
123
|
+
const n = p.length / 2;
|
|
124
|
+
const ax = p[first * 2];
|
|
125
|
+
const ay = p[first * 2 + 1];
|
|
126
|
+
const bx = p[last % n * 2];
|
|
127
|
+
const by = p[last % n * 2 + 1];
|
|
128
|
+
for (let k = first + 1; k < last; k++) {
|
|
129
|
+
const d = segDist(p[k * 2], p[k * 2 + 1], ax, ay, bx, by);
|
|
130
|
+
if (d > worst) {
|
|
131
|
+
worst = d;
|
|
132
|
+
at = k;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (worst > eps) {
|
|
136
|
+
keep[at] = 1;
|
|
137
|
+
dp(p, first, at, eps, keep);
|
|
138
|
+
dp(p, at, last, eps, keep);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function simplifyLoop(p, eps) {
|
|
142
|
+
const n = p.length / 2;
|
|
143
|
+
if (n <= 3) return p.slice();
|
|
144
|
+
let far = 0;
|
|
145
|
+
let fd = -1;
|
|
146
|
+
for (let k = 1; k < n; k++) {
|
|
147
|
+
const dx = p[k * 2] - p[0];
|
|
148
|
+
const dy = p[k * 2 + 1] - p[1];
|
|
149
|
+
if (dx * dx + dy * dy > fd) {
|
|
150
|
+
fd = dx * dx + dy * dy;
|
|
151
|
+
far = k;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const keep = new Uint8Array(n);
|
|
155
|
+
keep[0] = 1;
|
|
156
|
+
keep[far] = 1;
|
|
157
|
+
dp(p, 0, far, eps, keep);
|
|
158
|
+
dp(p, far, n, eps, keep);
|
|
159
|
+
const out = [];
|
|
160
|
+
for (let k = 0; k < n; k++) if (keep[k]) out.push(p[k * 2], p[k * 2 + 1]);
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function alphaOutline(width, height, alpha, options = {}) {
|
|
164
|
+
const loops = traceAlpha(width, height, alpha);
|
|
165
|
+
if (loops.length === 0) return [];
|
|
166
|
+
let best = loops[0];
|
|
167
|
+
for (const l of loops) if (Math.abs(loopArea(l)) > Math.abs(loopArea(best))) best = l;
|
|
168
|
+
const maxPoints = options.maxPoints ?? 32;
|
|
169
|
+
let eps = options.eps ?? 0.5;
|
|
170
|
+
let out = simplifyLoop(best, eps);
|
|
171
|
+
while (out.length / 2 > maxPoints) {
|
|
172
|
+
eps *= 1.25;
|
|
173
|
+
out = simplifyLoop(best, eps);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
export {
|
|
178
|
+
alphaOutline,
|
|
179
|
+
distanceToLoop,
|
|
180
|
+
loopArea,
|
|
181
|
+
simplifyLoop,
|
|
182
|
+
traceAlpha
|
|
183
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprites, atlases, frame animation, and tilemaps, drawn in the render graph: world sprites after
|
|
3
|
+
* opaque 3D (so 2D gets HDR and post-processing), screen sprites over the finished image. Cameras
|
|
4
|
+
* with Lighting2d light them with 2D lights and shadows.
|
|
5
|
+
*/
|
|
6
|
+
export declare const spritePlugin: import("@aethervtt/shard-runtime").Plugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Last, Update } from "@aethervtt/shard-core";
|
|
2
|
+
import { Gpu, Graph, Picking, RenderDescribers, RenderSet, Shaders } from "@aethervtt/shard-render";
|
|
3
|
+
import { definePlugin } from "@aethervtt/shard-runtime";
|
|
4
|
+
import * as atlasModule from "./atlas.js";
|
|
5
|
+
import { TextureAtlases } from "./atlas.js";
|
|
6
|
+
import * as clipModule from "./clip.js";
|
|
7
|
+
import { animateSprites, SpriteClips } from "./clip.js";
|
|
8
|
+
import * as lightingModule from "./lighting.js";
|
|
9
|
+
import * as lights2dModule from "./lights2d.js";
|
|
10
|
+
import { lights2dNode, prepareLights2d } from "./lights2d.js";
|
|
11
|
+
import * as renderModule from "./render.js";
|
|
12
|
+
import {
|
|
13
|
+
describeSprites,
|
|
14
|
+
drawSpritePicks,
|
|
15
|
+
observeSpriteRemovals,
|
|
16
|
+
overlayNode,
|
|
17
|
+
prepareSprites,
|
|
18
|
+
SpriteStore,
|
|
19
|
+
Sprites,
|
|
20
|
+
spriteNode,
|
|
21
|
+
Tilemaps
|
|
22
|
+
} from "./render.js";
|
|
23
|
+
import { SPRITE_SHADERS } from "./shaders.js";
|
|
24
|
+
import * as spriteModule from "./sprite.js";
|
|
25
|
+
import { Sprite2dSettings } from "./sprite.js";
|
|
26
|
+
import * as tilemapModule from "./tilemap.js";
|
|
27
|
+
import { TilemapDatas } from "./tilemap.js";
|
|
28
|
+
const spritePlugin = definePlugin({
|
|
29
|
+
name: "sprite",
|
|
30
|
+
provides: [
|
|
31
|
+
atlasModule,
|
|
32
|
+
clipModule,
|
|
33
|
+
lightingModule,
|
|
34
|
+
lights2dModule,
|
|
35
|
+
renderModule,
|
|
36
|
+
spriteModule,
|
|
37
|
+
tilemapModule
|
|
38
|
+
],
|
|
39
|
+
dependencies: ["render/forward"],
|
|
40
|
+
build(app) {
|
|
41
|
+
const w = app.world;
|
|
42
|
+
w.initResource(TextureAtlases);
|
|
43
|
+
w.initResource(SpriteClips);
|
|
44
|
+
w.initResource(TilemapDatas);
|
|
45
|
+
w.initResource(Sprite2dSettings);
|
|
46
|
+
w.initResource(Tilemaps);
|
|
47
|
+
observeSpriteRemovals(w);
|
|
48
|
+
app.addSystems(Update, animateSprites).addSystems(Last, prepareSprites.inSet(RenderSet.Prepare)).addSystems(Last, prepareLights2d.inSet(RenderSet.Prepare));
|
|
49
|
+
},
|
|
50
|
+
ready(app) {
|
|
51
|
+
const world = app.world;
|
|
52
|
+
app.insertResource(Sprites, new SpriteStore(world.resource(Gpu)));
|
|
53
|
+
const shaders = world.resource(Shaders);
|
|
54
|
+
for (const [path, source] of Object.entries(SPRITE_SHADERS)) {
|
|
55
|
+
shaders.register(path, source, `engine:${path}`);
|
|
56
|
+
}
|
|
57
|
+
const graph = world.resource(Graph);
|
|
58
|
+
graph.addNode("sprites/lights2d", lights2dNode(world));
|
|
59
|
+
graph.addNode("sprites", spriteNode(world));
|
|
60
|
+
graph.addNode("sprites/overlay", overlayNode(world));
|
|
61
|
+
world.initResource(RenderDescribers).set("sprites", (w) => describeSprites(w));
|
|
62
|
+
world.initResource(Picking).drawers.set("sprites", drawSpritePicks);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
export {
|
|
66
|
+
spritePlugin
|
|
67
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PreviewImage } from '@aethervtt/shard-assets';
|
|
2
|
+
/** Nearest-neighbour fit of RGBA8 pixels into width × height, keeping the aspect ratio. */
|
|
3
|
+
export declare function fitImage(src: Uint8Array, sw: number, sh: number, width: number, height: number): PreviewImage;
|
|
4
|
+
/** A one-pixel rectangle outline. */
|
|
5
|
+
export declare function outline(image: PreviewImage, x: number, y: number, w: number, h: number, color: readonly number[]): void;
|
|
6
|
+
/** A one-pixel line (DDA). */
|
|
7
|
+
export declare function drawLine(image: PreviewImage, x0: number, y0: number, x1: number, y1: number, color: readonly number[]): void;
|
|
8
|
+
/** Draws a number in a 3×5 pixel font, white on a black box so it reads on any image. */
|
|
9
|
+
export declare function drawLabel(image: PreviewImage, text: string, x: number, y: number): void;
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function fitImage(src, sw, sh, width, height) {
|
|
2
|
+
const scale = Math.min(width / sw, height / sh);
|
|
3
|
+
const w = Math.max(1, Math.round(sw * scale));
|
|
4
|
+
const h = Math.max(1, Math.round(sh * scale));
|
|
5
|
+
const out = new Uint8Array(w * h * 4);
|
|
6
|
+
for (let y = 0; y < h; y++) {
|
|
7
|
+
const sy = Math.min(sh - 1, Math.floor(y / scale));
|
|
8
|
+
for (let x = 0; x < w; x++) {
|
|
9
|
+
const sx = Math.min(sw - 1, Math.floor(x / scale));
|
|
10
|
+
out.set(src.subarray((sy * sw + sx) * 4, (sy * sw + sx) * 4 + 4), (y * w + x) * 4);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return { width: w, height: h, data: out };
|
|
14
|
+
}
|
|
15
|
+
function put(image, x, y, c) {
|
|
16
|
+
if (x < 0 || y < 0 || x >= image.width || y >= image.height) return;
|
|
17
|
+
image.data.set(c, (y * image.width + x) * 4);
|
|
18
|
+
}
|
|
19
|
+
function outline(image, x, y, w, h, color) {
|
|
20
|
+
const x0 = Math.round(x);
|
|
21
|
+
const y0 = Math.round(y);
|
|
22
|
+
const x1 = Math.round(x + w) - 1;
|
|
23
|
+
const y1 = Math.round(y + h) - 1;
|
|
24
|
+
for (let i = x0; i <= x1; i++) {
|
|
25
|
+
put(image, i, y0, color);
|
|
26
|
+
put(image, i, y1, color);
|
|
27
|
+
}
|
|
28
|
+
for (let j = y0; j <= y1; j++) {
|
|
29
|
+
put(image, x0, j, color);
|
|
30
|
+
put(image, x1, j, color);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function drawLine(image, x0, y0, x1, y1, color) {
|
|
34
|
+
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
|
|
35
|
+
for (let k = 0; k <= steps; k++) {
|
|
36
|
+
const t = k / steps;
|
|
37
|
+
put(image, Math.floor(x0 + (x1 - x0) * t), Math.floor(y0 + (y1 - y0) * t), color);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const DIGITS = [
|
|
41
|
+
[7, 5, 5, 5, 7],
|
|
42
|
+
[2, 6, 2, 2, 7],
|
|
43
|
+
[7, 1, 7, 4, 7],
|
|
44
|
+
[7, 1, 3, 1, 7],
|
|
45
|
+
[5, 5, 7, 1, 1],
|
|
46
|
+
[7, 4, 7, 1, 7],
|
|
47
|
+
[7, 4, 7, 5, 7],
|
|
48
|
+
[7, 1, 1, 2, 2],
|
|
49
|
+
[7, 5, 7, 5, 7],
|
|
50
|
+
[7, 5, 7, 1, 7]
|
|
51
|
+
];
|
|
52
|
+
function drawLabel(image, text, x, y) {
|
|
53
|
+
const w = text.length * 4 + 1;
|
|
54
|
+
for (let j = -1; j < 6; j++) for (let i = -1; i < w; i++) put(image, x + i, y + j, [0, 0, 0, 255]);
|
|
55
|
+
for (let k = 0; k < text.length; k++) {
|
|
56
|
+
const glyph = DIGITS[text.charCodeAt(k) - 48];
|
|
57
|
+
if (!glyph) continue;
|
|
58
|
+
for (let row = 0; row < 5; row++) {
|
|
59
|
+
for (let col = 0; col < 3; col++) {
|
|
60
|
+
if (glyph[row] >> 2 - col & 1)
|
|
61
|
+
put(image, x + k * 4 + col, y + row, [255, 255, 255, 255]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
drawLabel,
|
|
68
|
+
drawLine,
|
|
69
|
+
fitImage,
|
|
70
|
+
outline
|
|
71
|
+
};
|