@waica/engine 0.19.0 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/animation/sheet.js +9 -3
- package/dist/assets/asset-loader.js +3 -2
- package/dist/audio/web-audio-backend.js +15 -6
- package/dist/authoring-defaults.js +4 -4
- package/dist/camera-effects.js +7 -3
- package/dist/collision-shape.js +45 -20
- package/dist/component-registry.d.ts +11 -1
- package/dist/component-registry.js +26 -3
- package/dist/component-update-schedule.d.ts +2 -0
- package/dist/component-update-schedule.js +124 -68
- package/dist/components/animated-sprite.js +3 -2
- package/dist/components/sprite.js +3 -2
- package/dist/components/tilemap.d.ts +6 -0
- package/dist/components/tilemap.js +49 -38
- package/dist/entity.js +6 -4
- package/dist/game.js +23 -9
- package/dist/pointer.js +27 -10
- package/dist/render-sort.js +11 -5
- package/dist/report-rejection.d.ts +7 -0
- package/dist/report-rejection.js +12 -0
- package/dist/runtime-bridge.d.ts +4 -0
- package/dist/runtime-bridge.js +35 -27
- package/dist/runtime-inspection.js +106 -86
- package/dist/scene.d.ts +0 -1
- package/dist/scene.js +34 -1
- package/dist/spatial-broadphase.js +60 -34
- package/dist/spatial-query-geometry.js +48 -35
- package/package.json +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { anchoredPiecesOf } from './ui.js';
|
|
2
|
+
import { componentClassOf } from './component-registry.js';
|
|
2
3
|
export const RUNTIME_PROJECTION_LIMITS = {
|
|
3
4
|
depth: 5,
|
|
4
5
|
entries: 100,
|
|
@@ -11,7 +12,7 @@ function marker(context, path, kind, detail = {}) {
|
|
|
11
12
|
return { $waica: kind, ...detail };
|
|
12
13
|
}
|
|
13
14
|
function isPlainRecord(value) {
|
|
14
|
-
const prototype =
|
|
15
|
+
const prototype = Reflect.getPrototypeOf(value);
|
|
15
16
|
return prototype === Object.prototype || prototype === null;
|
|
16
17
|
}
|
|
17
18
|
const textEncoder = new TextEncoder();
|
|
@@ -41,17 +42,8 @@ function projectValue(value, path, context, depth = 0) {
|
|
|
41
42
|
}
|
|
42
43
|
if (value === null || typeof value === 'boolean')
|
|
43
44
|
return value;
|
|
44
|
-
if (typeof value === 'string')
|
|
45
|
-
|
|
46
|
-
if (bytes <= RUNTIME_PROJECTION_LIMITS.stringBytes)
|
|
47
|
-
return value;
|
|
48
|
-
return marker(context, path, 'truncated', {
|
|
49
|
-
reason: 'string',
|
|
50
|
-
preview: stringPreview(value, RUNTIME_PROJECTION_LIMITS.stringBytes),
|
|
51
|
-
originalLength: value.length,
|
|
52
|
-
originalBytes: bytes,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
45
|
+
if (typeof value === 'string')
|
|
46
|
+
return projectString(value, path, context);
|
|
55
47
|
if (typeof value === 'bigint')
|
|
56
48
|
return { $waica: 'bigint', value: value.toString() };
|
|
57
49
|
if (typeof value === 'number') {
|
|
@@ -66,69 +58,93 @@ function projectValue(value, path, context, depth = 0) {
|
|
|
66
58
|
if (previousPath)
|
|
67
59
|
return marker(context, path, 'cycle', { path: previousPath });
|
|
68
60
|
context.seen.set(value, path);
|
|
61
|
+
return projectObject(value, { path, depth }, context);
|
|
62
|
+
}
|
|
63
|
+
function projectString(value, path, context) {
|
|
64
|
+
const bytes = utf8Bytes(value);
|
|
65
|
+
if (bytes <= RUNTIME_PROJECTION_LIMITS.stringBytes)
|
|
66
|
+
return value;
|
|
67
|
+
return marker(context, path, 'truncated', {
|
|
68
|
+
reason: 'string',
|
|
69
|
+
preview: stringPreview(value, RUNTIME_PROJECTION_LIMITS.stringBytes),
|
|
70
|
+
originalLength: value.length,
|
|
71
|
+
originalBytes: bytes,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Projects a not-yet-seen object: dates, collections and plain records; anything else is unsupported. */
|
|
75
|
+
function projectObject(value, site, context) {
|
|
76
|
+
const { path } = site;
|
|
69
77
|
if (value instanceof Date) {
|
|
70
78
|
return Number.isFinite(value.getTime())
|
|
71
79
|
? { $waica: 'date', value: value.toISOString() }
|
|
72
80
|
: marker(context, path, 'error', { message: 'Invalid Date' });
|
|
73
81
|
}
|
|
74
|
-
if (Array.isArray(value))
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
if (value instanceof Map) {
|
|
84
|
-
const sourceEntries = [...value.entries()];
|
|
85
|
-
const entries = sourceEntries.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
|
|
86
|
-
.map(([key, entry], index) => [
|
|
87
|
-
projectValue(key, `${path}.entries[${index}].key`, context, depth + 1),
|
|
88
|
-
projectValue(entry, `${path}.entries[${index}].value`, context, depth + 1),
|
|
89
|
-
]);
|
|
90
|
-
const omitted = sourceEntries.length - entries.length;
|
|
91
|
-
return {
|
|
92
|
-
$waica: 'map',
|
|
93
|
-
entries,
|
|
94
|
-
...(omitted > 0
|
|
95
|
-
? { truncated: marker(context, path, 'truncated', { reason: 'entries', omitted }) }
|
|
96
|
-
: {}),
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
if (value instanceof Set) {
|
|
100
|
-
const sourceValues = [...value];
|
|
101
|
-
const values = sourceValues.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
|
|
102
|
-
.map((entry, index) => projectValue(entry, `${path}.values[${index}]`, context, depth + 1));
|
|
103
|
-
const omitted = sourceValues.length - values.length;
|
|
104
|
-
return {
|
|
105
|
-
$waica: 'set',
|
|
106
|
-
values,
|
|
107
|
-
...(omitted > 0
|
|
108
|
-
? { truncated: marker(context, path, 'truncated', { reason: 'entries', omitted }) }
|
|
109
|
-
: {}),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
if (isPlainRecord(value)) {
|
|
113
|
-
const keys = Object.keys(value).sort();
|
|
114
|
-
const projected = Object.fromEntries(keys.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
|
|
115
|
-
.map((key) => [
|
|
116
|
-
key,
|
|
117
|
-
projectValue(value[key], `${path}.${key}`, context, depth + 1),
|
|
118
|
-
]));
|
|
119
|
-
const omitted = keys.length - Object.keys(projected).length;
|
|
120
|
-
if (omitted === 0)
|
|
121
|
-
return projected;
|
|
122
|
-
return marker(context, path, 'truncated', {
|
|
123
|
-
reason: 'entries',
|
|
124
|
-
omitted,
|
|
125
|
-
value: projected,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
82
|
+
if (Array.isArray(value))
|
|
83
|
+
return projectArray(value, site, context);
|
|
84
|
+
if (value instanceof Map)
|
|
85
|
+
return projectMap(value, site, context);
|
|
86
|
+
if (value instanceof Set)
|
|
87
|
+
return projectSet(value, site, context);
|
|
88
|
+
if (isPlainRecord(value))
|
|
89
|
+
return projectRecord(value, site, context);
|
|
128
90
|
return marker(context, path, 'unsupported', {
|
|
129
91
|
type: value.constructor?.name ?? 'object',
|
|
130
92
|
});
|
|
131
93
|
}
|
|
94
|
+
function projectArray(value, { path, depth }, context) {
|
|
95
|
+
const entries = value.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
|
|
96
|
+
.map((entry, index) => projectValue(entry, `${path}[${index}]`, context, depth + 1));
|
|
97
|
+
const omitted = value.length - entries.length;
|
|
98
|
+
if (omitted > 0) {
|
|
99
|
+
entries.push(marker(context, path, 'truncated', { reason: 'entries', omitted }));
|
|
100
|
+
}
|
|
101
|
+
return entries;
|
|
102
|
+
}
|
|
103
|
+
function projectMap(value, { path, depth }, context) {
|
|
104
|
+
const sourceEntries = [...value.entries()];
|
|
105
|
+
const entries = sourceEntries.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
|
|
106
|
+
.map(([key, entry], index) => [
|
|
107
|
+
projectValue(key, `${path}.entries[${index}].key`, context, depth + 1),
|
|
108
|
+
projectValue(entry, `${path}.entries[${index}].value`, context, depth + 1),
|
|
109
|
+
]);
|
|
110
|
+
const omitted = sourceEntries.length - entries.length;
|
|
111
|
+
return {
|
|
112
|
+
$waica: 'map',
|
|
113
|
+
entries,
|
|
114
|
+
...(omitted > 0
|
|
115
|
+
? { truncated: marker(context, path, 'truncated', { reason: 'entries', omitted }) }
|
|
116
|
+
: {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function projectSet(value, { path, depth }, context) {
|
|
120
|
+
const sourceValues = [...value];
|
|
121
|
+
const values = sourceValues.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
|
|
122
|
+
.map((entry, index) => projectValue(entry, `${path}.values[${index}]`, context, depth + 1));
|
|
123
|
+
const omitted = sourceValues.length - values.length;
|
|
124
|
+
return {
|
|
125
|
+
$waica: 'set',
|
|
126
|
+
values,
|
|
127
|
+
...(omitted > 0
|
|
128
|
+
? { truncated: marker(context, path, 'truncated', { reason: 'entries', omitted }) }
|
|
129
|
+
: {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function projectRecord(value, { path, depth }, context) {
|
|
133
|
+
const keys = Object.keys(value).sort();
|
|
134
|
+
const projected = Object.fromEntries(keys.slice(0, RUNTIME_PROJECTION_LIMITS.entries)
|
|
135
|
+
.map((key) => [
|
|
136
|
+
key,
|
|
137
|
+
projectValue(value[key], `${path}.${key}`, context, depth + 1),
|
|
138
|
+
]));
|
|
139
|
+
const omitted = keys.length - Object.keys(projected).length;
|
|
140
|
+
if (omitted === 0)
|
|
141
|
+
return projected;
|
|
142
|
+
return marker(context, path, 'truncated', {
|
|
143
|
+
reason: 'entries',
|
|
144
|
+
omitted,
|
|
145
|
+
value: projected,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
132
148
|
function errorMessage(error) {
|
|
133
149
|
return error instanceof Error ? error.message : String(error);
|
|
134
150
|
}
|
|
@@ -154,7 +170,7 @@ function componentState(component, path, context) {
|
|
|
154
170
|
continue;
|
|
155
171
|
const valuePath = `${path}.${key}`;
|
|
156
172
|
try {
|
|
157
|
-
const value = component
|
|
173
|
+
const value = Reflect.get(component, key);
|
|
158
174
|
if (typeof value === 'function')
|
|
159
175
|
continue;
|
|
160
176
|
state[key] = projectValue(value, valuePath, context, 1);
|
|
@@ -208,6 +224,27 @@ function capAnchored(snapshot) {
|
|
|
208
224
|
}
|
|
209
225
|
return capped;
|
|
210
226
|
}
|
|
227
|
+
/** An entity's position, rotation (with its Euler order) and scale, as plain numbers. */
|
|
228
|
+
function transformSnapshot(entity) {
|
|
229
|
+
return {
|
|
230
|
+
position: {
|
|
231
|
+
x: entity.position.x,
|
|
232
|
+
y: entity.position.y,
|
|
233
|
+
z: entity.position.z,
|
|
234
|
+
},
|
|
235
|
+
rotation: {
|
|
236
|
+
x: entity.node.rotation.x,
|
|
237
|
+
y: entity.node.rotation.y,
|
|
238
|
+
z: entity.node.rotation.z,
|
|
239
|
+
order: entity.node.rotation.order,
|
|
240
|
+
},
|
|
241
|
+
scale: {
|
|
242
|
+
x: entity.scale.x,
|
|
243
|
+
y: entity.scale.y,
|
|
244
|
+
z: entity.scale.z,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
211
248
|
export class RuntimeInspector {
|
|
212
249
|
game;
|
|
213
250
|
ids = new WeakMap();
|
|
@@ -227,7 +264,7 @@ export class RuntimeInspector {
|
|
|
227
264
|
if (nameFilter && !nameFilter.has(entity.name))
|
|
228
265
|
return [];
|
|
229
266
|
const components = entity.components.flatMap((component, index) => {
|
|
230
|
-
const Class = component
|
|
267
|
+
const Class = componentClassOf(component);
|
|
231
268
|
if (componentFilter && !componentFilter.has(Class.componentName))
|
|
232
269
|
return [];
|
|
233
270
|
const context = { issues: projectionIssues, seen: new Map() };
|
|
@@ -242,24 +279,7 @@ export class RuntimeInspector {
|
|
|
242
279
|
return [{
|
|
243
280
|
id,
|
|
244
281
|
name: entity.name,
|
|
245
|
-
transform:
|
|
246
|
-
position: {
|
|
247
|
-
x: entity.position.x,
|
|
248
|
-
y: entity.position.y,
|
|
249
|
-
z: entity.position.z,
|
|
250
|
-
},
|
|
251
|
-
rotation: {
|
|
252
|
-
x: entity.node.rotation.x,
|
|
253
|
-
y: entity.node.rotation.y,
|
|
254
|
-
z: entity.node.rotation.z,
|
|
255
|
-
order: entity.node.rotation.order,
|
|
256
|
-
},
|
|
257
|
-
scale: {
|
|
258
|
-
x: entity.scale.x,
|
|
259
|
-
y: entity.scale.y,
|
|
260
|
-
z: entity.scale.z,
|
|
261
|
-
},
|
|
262
|
-
},
|
|
282
|
+
transform: transformSnapshot(entity),
|
|
263
283
|
components,
|
|
264
284
|
}];
|
|
265
285
|
});
|
package/dist/scene.d.ts
CHANGED
|
@@ -86,7 +86,6 @@ export declare function resolveProps(props: Record<string, unknown> | undefined,
|
|
|
86
86
|
* are never mutated; merged components are fresh objects.
|
|
87
87
|
*/
|
|
88
88
|
export declare function resolveEntityComponents(entity: SceneEntityJson, prefabs?: Record<string, PrefabJson>): SceneComponentJson[];
|
|
89
|
-
/** Instantiates a scene entity into the game. */
|
|
90
89
|
export declare function spawnFromJson(game: Game, json: SceneEntityJson, registry: SceneRegistry): Entity;
|
|
91
90
|
/**
|
|
92
91
|
* Loads a full scene into the game, replacing whatever scene is already
|
package/dist/scene.js
CHANGED
|
@@ -56,6 +56,39 @@ export function resolveEntityComponents(entity, prefabs) {
|
|
|
56
56
|
return [...fromPrefab, ...inline];
|
|
57
57
|
}
|
|
58
58
|
/** Instantiates a scene entity into the game. */
|
|
59
|
+
/**
|
|
60
|
+
* Every member Component declares, checked against the class by `satisfies`:
|
|
61
|
+
* adding a member without listing it here fails to compile.
|
|
62
|
+
*/
|
|
63
|
+
const COMPONENT_MEMBERS = {
|
|
64
|
+
entity: true,
|
|
65
|
+
game: true,
|
|
66
|
+
inspectState: true,
|
|
67
|
+
onReady: true,
|
|
68
|
+
onUpdate: true,
|
|
69
|
+
onProjectionChange: true,
|
|
70
|
+
onCollide: true,
|
|
71
|
+
onContact: true,
|
|
72
|
+
onInteract: true,
|
|
73
|
+
onDestroy: true,
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Scene props set none of Component's own members, so as far as the type
|
|
77
|
+
* system is concerned they are a Partial<Component> (their other keys are the
|
|
78
|
+
* subclass's public props, which Entity.add assigns).
|
|
79
|
+
*/
|
|
80
|
+
function isComponentProps(props) {
|
|
81
|
+
return Object.keys(props).every((key) => !Object.hasOwn(COMPONENT_MEMBERS, key));
|
|
82
|
+
}
|
|
83
|
+
/** Scene JSON props with any Component member dropped and reported. */
|
|
84
|
+
function sceneProps(props, type, entityName) {
|
|
85
|
+
const dropped = Object.keys(props).filter((key) => Object.hasOwn(COMPONENT_MEMBERS, key));
|
|
86
|
+
const kept = Object.fromEntries(Object.entries(props).filter(([key]) => !dropped.includes(key)));
|
|
87
|
+
if (dropped.length > 0) {
|
|
88
|
+
console.warn(`[waica] component "${type}" on "${entityName}" ignores scene props that name Component members: ${dropped.join(', ')}`);
|
|
89
|
+
}
|
|
90
|
+
return isComponentProps(kept) ? kept : {};
|
|
91
|
+
}
|
|
59
92
|
export function spawnFromJson(game, json, registry) {
|
|
60
93
|
const entity = game.spawn(json.name);
|
|
61
94
|
if (json.position)
|
|
@@ -70,7 +103,7 @@ export function spawnFromJson(game, json, registry) {
|
|
|
70
103
|
// code. A throwing constructor or onReady costs that one component —
|
|
71
104
|
// never the rest of the scene, and never the editor hosting it.
|
|
72
105
|
try {
|
|
73
|
-
entity.add(Class, resolveProps(comp.props, registry));
|
|
106
|
+
entity.add(Class, sceneProps(resolveProps(comp.props, registry), comp.type, json.name));
|
|
74
107
|
}
|
|
75
108
|
catch (error) {
|
|
76
109
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -47,12 +47,23 @@ function eachCell(range, visit) {
|
|
|
47
47
|
visit(cellKey(x, y));
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
/** The value of the indexed entry at `index`; buckets and overflow only hold indices into `entries`. */
|
|
51
|
+
function entryValue(entries, index) {
|
|
52
|
+
const entry = entries[index];
|
|
53
|
+
if (!entry)
|
|
54
|
+
throw new Error(`Spatial broadphase has no entry at index ${index}`);
|
|
55
|
+
return entry.value;
|
|
56
|
+
}
|
|
57
|
+
/** The entry index stored at `position` of a bucket that is iterated within its bounds. */
|
|
58
|
+
function indexAt(bucket, position) {
|
|
59
|
+
const index = bucket[position];
|
|
60
|
+
if (index === undefined)
|
|
61
|
+
throw new Error(`Spatial broadphase bucket has no index at position ${position}`);
|
|
62
|
+
return index;
|
|
63
|
+
}
|
|
64
|
+
/** The indexable sources in query order, each with the cell range it covers. */
|
|
65
|
+
function spatialEntries(sources, cellSize, isIndexable) {
|
|
66
|
+
return sources
|
|
56
67
|
.map((source, serial) => {
|
|
57
68
|
if (!isIndexable(source.body))
|
|
58
69
|
return null;
|
|
@@ -64,6 +75,8 @@ export function createSpatialBroadphase(sources, cellSize, isIndexable = usableC
|
|
|
64
75
|
})
|
|
65
76
|
.filter((entry) => entry !== null)
|
|
66
77
|
.sort((a, b) => a.order - b.order || a.serial - b.serial);
|
|
78
|
+
}
|
|
79
|
+
function spatialGrid(entries) {
|
|
67
80
|
const buckets = new Map();
|
|
68
81
|
const overflow = [];
|
|
69
82
|
for (const [index, entry] of entries.entries()) {
|
|
@@ -79,6 +92,43 @@ export function createSpatialBroadphase(sources, cellSize, isIndexable = usableC
|
|
|
79
92
|
buckets.set(key, [index]);
|
|
80
93
|
});
|
|
81
94
|
}
|
|
95
|
+
return { buckets, overflow };
|
|
96
|
+
}
|
|
97
|
+
/** Distinct index pairs sharing a cell, or involving an overflow entry, in ascending order. */
|
|
98
|
+
function candidatePairIndices(grid, entryCount) {
|
|
99
|
+
const pairKeys = new Set();
|
|
100
|
+
const width = entryCount;
|
|
101
|
+
const addPair = (left, right) => {
|
|
102
|
+
if (left === right)
|
|
103
|
+
return;
|
|
104
|
+
const first = Math.min(left, right);
|
|
105
|
+
const second = Math.max(left, right);
|
|
106
|
+
pairKeys.add(first * width + second);
|
|
107
|
+
};
|
|
108
|
+
for (const bucket of grid.buckets.values()) {
|
|
109
|
+
for (let left = 0; left < bucket.length; left += 1) {
|
|
110
|
+
for (let right = left + 1; right < bucket.length; right += 1) {
|
|
111
|
+
addPair(indexAt(bucket, left), indexAt(bucket, right));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const overflowIndex of grid.overflow) {
|
|
116
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
117
|
+
addPair(overflowIndex, index);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return [...pairKeys]
|
|
121
|
+
.map((key) => [Math.floor(key / width), key % width])
|
|
122
|
+
.sort(([a1, a2], [b1, b2]) => a1 - b1 || a2 - b2);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Package-internal fresh uniform grid. Bodies above the fixed occupancy cap
|
|
126
|
+
* remain in an overflow bucket and are conservatively visible everywhere.
|
|
127
|
+
*/
|
|
128
|
+
export function createSpatialBroadphase(sources, cellSize, isIndexable = usableCollisionBody) {
|
|
129
|
+
const entries = spatialEntries(sources, cellSize, isIndexable);
|
|
130
|
+
const grid = spatialGrid(entries);
|
|
131
|
+
const { buckets, overflow } = grid;
|
|
82
132
|
return {
|
|
83
133
|
stats: { indexed: entries.length - overflow.length, overflow: overflow.length },
|
|
84
134
|
candidates(bounds) {
|
|
@@ -92,36 +142,12 @@ export function createSpatialBroadphase(sources, cellSize, isIndexable = usableC
|
|
|
92
142
|
});
|
|
93
143
|
return [...selected]
|
|
94
144
|
.sort((a, b) => a - b)
|
|
95
|
-
.map((index) => entries
|
|
145
|
+
.map((index) => entryValue(entries, index));
|
|
96
146
|
},
|
|
97
147
|
pairs() {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (left === right)
|
|
102
|
-
return;
|
|
103
|
-
const first = Math.min(left, right);
|
|
104
|
-
const second = Math.max(left, right);
|
|
105
|
-
pairKeys.add(first * width + second);
|
|
106
|
-
};
|
|
107
|
-
for (const bucket of buckets.values()) {
|
|
108
|
-
for (let left = 0; left < bucket.length; left += 1) {
|
|
109
|
-
for (let right = left + 1; right < bucket.length; right += 1) {
|
|
110
|
-
addPair(bucket[left], bucket[right]);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
for (const overflowIndex of overflow) {
|
|
115
|
-
for (let index = 0; index < entries.length; index += 1) {
|
|
116
|
-
addPair(overflowIndex, index);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return [...pairKeys]
|
|
120
|
-
.map((key) => [Math.floor(key / width), key % width])
|
|
121
|
-
.sort(([a1, a2], [b1, b2]) => a1 - b1 || a2 - b2)
|
|
122
|
-
.map(([first, second]) => [
|
|
123
|
-
entries[first].value,
|
|
124
|
-
entries[second].value,
|
|
148
|
+
return candidatePairIndices(grid, entries.length).map(([first, second]) => [
|
|
149
|
+
entryValue(entries, first),
|
|
150
|
+
entryValue(entries, second),
|
|
125
151
|
]);
|
|
126
152
|
},
|
|
127
153
|
};
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { collisionVertices, } from './collision-shape.js';
|
|
2
2
|
/** Internal absolute tolerance shared by point and ray boundary rules. */
|
|
3
3
|
export const SPATIAL_QUERY_EPSILON = 1e-9;
|
|
4
|
+
/** The item at an `index` the caller already bounded to `items.length`. */
|
|
5
|
+
function itemAt(items, index) {
|
|
6
|
+
const item = items[index];
|
|
7
|
+
if (item === undefined)
|
|
8
|
+
throw new Error(`Spatial query geometry has no item at index ${index} of ${items.length}`);
|
|
9
|
+
return item;
|
|
10
|
+
}
|
|
4
11
|
function signedArea(points) {
|
|
5
12
|
let twiceArea = 0;
|
|
6
13
|
for (let index = 0; index < points.length; index += 1) {
|
|
7
|
-
const point = points
|
|
8
|
-
const next = points
|
|
14
|
+
const point = itemAt(points, index);
|
|
15
|
+
const next = itemAt(points, (index + 1) % points.length);
|
|
9
16
|
twiceArea += point[0] * next[1] - next[0] * point[1];
|
|
10
17
|
}
|
|
11
18
|
return twiceArea / 2;
|
|
@@ -46,8 +53,8 @@ function pointOnSegment(point, a, b) {
|
|
|
46
53
|
function polygonPointLocation(polygon, point) {
|
|
47
54
|
let inside = false;
|
|
48
55
|
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index, index += 1) {
|
|
49
|
-
const a = polygon
|
|
50
|
-
const b = polygon
|
|
56
|
+
const a = itemAt(polygon, index);
|
|
57
|
+
const b = itemAt(polygon, previous);
|
|
51
58
|
if (pointOnSegment(point, a, b))
|
|
52
59
|
return 'boundary';
|
|
53
60
|
const crosses = (a[1] > point[1]) !== (b[1] > point[1]) &&
|
|
@@ -130,12 +137,13 @@ function groupEvents(events) {
|
|
|
130
137
|
const sorted = [...events].sort((a, b) => a.distance === b.distance ? a.edgeOrder - b.edgeOrder : a.distance - b.distance);
|
|
131
138
|
const groups = [];
|
|
132
139
|
for (const event of sorted) {
|
|
133
|
-
|
|
140
|
+
let current = groups.at(-1);
|
|
134
141
|
if (!current || Math.abs(event.distance - current.distance) > SPATIAL_QUERY_EPSILON) {
|
|
135
|
-
|
|
142
|
+
current = { distance: event.distance, normals: [] };
|
|
143
|
+
groups.push(current);
|
|
136
144
|
}
|
|
137
145
|
if (event.normal) {
|
|
138
|
-
|
|
146
|
+
current.normals.push({ edgeOrder: event.edgeOrder, normal: event.normal });
|
|
139
147
|
}
|
|
140
148
|
}
|
|
141
149
|
return groups.map((group) => ({
|
|
@@ -167,6 +175,30 @@ function selectVertexNormal(normals, direction, crossing) {
|
|
|
167
175
|
}
|
|
168
176
|
return selected;
|
|
169
177
|
}
|
|
178
|
+
/** Every edge's ray events, for each edge with a usable outward normal. */
|
|
179
|
+
function polygonEdgeEvents(polygon, winding, ray) {
|
|
180
|
+
const events = [];
|
|
181
|
+
for (let index = 0; index < polygon.length; index += 1) {
|
|
182
|
+
const a = itemAt(polygon, index);
|
|
183
|
+
const b = itemAt(polygon, (index + 1) % polygon.length);
|
|
184
|
+
const normal = edgeNormal(a, b, winding);
|
|
185
|
+
if (!normal)
|
|
186
|
+
continue;
|
|
187
|
+
events.push(...edgeEvents(ray.origin, ray.direction, ray.maxDistance, a, b, normal, index));
|
|
188
|
+
}
|
|
189
|
+
return events;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Whether the ray enters or leaves the polygon at an event group, from where
|
|
193
|
+
* it was just before and just after it. A ray starting on the boundary and
|
|
194
|
+
* heading inside counts as entering.
|
|
195
|
+
*/
|
|
196
|
+
function boundaryCrossing(distance, before, after) {
|
|
197
|
+
if ((before === 'outside' || (distance === 0 && before === 'boundary')) && after === 'inside') {
|
|
198
|
+
return 'entry';
|
|
199
|
+
}
|
|
200
|
+
return before === 'inside' && after === 'outside' ? 'exit' : null;
|
|
201
|
+
}
|
|
170
202
|
function polygonRay(body, origin, direction, maxDistance) {
|
|
171
203
|
const polygon = collisionVertices(body);
|
|
172
204
|
if (!polygon.every(([x, y]) => Number.isFinite(x) && Number.isFinite(y)))
|
|
@@ -175,21 +207,12 @@ function polygonRay(body, origin, direction, maxDistance) {
|
|
|
175
207
|
if (Math.abs(area) <= SPATIAL_QUERY_EPSILON)
|
|
176
208
|
return null;
|
|
177
209
|
const winding = area > 0 ? 1 : -1;
|
|
178
|
-
const
|
|
179
|
-
for (let index = 0; index < polygon.length; index += 1) {
|
|
180
|
-
const a = polygon[index];
|
|
181
|
-
const b = polygon[(index + 1) % polygon.length];
|
|
182
|
-
const normal = edgeNormal(a, b, winding);
|
|
183
|
-
if (!normal)
|
|
184
|
-
continue;
|
|
185
|
-
events.push(...edgeEvents(origin, direction, maxDistance, a, b, normal, index));
|
|
186
|
-
}
|
|
187
|
-
const groups = groupEvents(events);
|
|
210
|
+
const groups = groupEvents(polygonEdgeEvents(polygon, winding, { origin, direction, maxDistance }));
|
|
188
211
|
const originLocation = polygonPointLocation(polygon, origin);
|
|
189
212
|
const scale = Math.max(Math.abs(body.width), Math.abs(body.height), 1);
|
|
190
213
|
const terminalProbe = Math.max(SPATIAL_QUERY_EPSILON * 4, Math.min(scale * 1e-6, 1e-3));
|
|
191
214
|
for (let index = 0; index < groups.length; index += 1) {
|
|
192
|
-
const group = groups
|
|
215
|
+
const group = itemAt(groups, index);
|
|
193
216
|
if (group.distance > maxDistance + SPATIAL_QUERY_EPSILON)
|
|
194
217
|
break;
|
|
195
218
|
const previous = groups[index - 1];
|
|
@@ -198,15 +221,7 @@ function polygonRay(body, origin, direction, maxDistance) {
|
|
|
198
221
|
? originLocation
|
|
199
222
|
: polygonPointLocation(polygon, pointAlongRay(origin, direction, previous ? (previous.distance + group.distance) / 2 : group.distance / 2));
|
|
200
223
|
const afterLocation = polygonPointLocation(polygon, pointAlongRay(origin, direction, next ? (group.distance + next.distance) / 2 : group.distance + terminalProbe));
|
|
201
|
-
|
|
202
|
-
if ((beforeLocation === 'outside' ||
|
|
203
|
-
(group.distance === 0 && beforeLocation === 'boundary')) &&
|
|
204
|
-
afterLocation === 'inside') {
|
|
205
|
-
crossing = 'entry';
|
|
206
|
-
}
|
|
207
|
-
else if (beforeLocation === 'inside' && afterLocation === 'outside') {
|
|
208
|
-
crossing = 'exit';
|
|
209
|
-
}
|
|
224
|
+
const crossing = boundaryCrossing(group.distance, beforeLocation, afterLocation);
|
|
210
225
|
if (!crossing)
|
|
211
226
|
continue;
|
|
212
227
|
const normal = selectVertexNormal(group.normals, direction, crossing);
|
|
@@ -257,14 +272,12 @@ function ellipseRay(body, origin, direction, maxDistance) {
|
|
|
257
272
|
halfChordDistance <= SPATIAL_QUERY_EPSILON) {
|
|
258
273
|
return null;
|
|
259
274
|
}
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
: roots[1] > SPATIAL_QUERY_EPSILON
|
|
267
|
-
? roots[1]
|
|
275
|
+
const nearRoot = (closestParameter - halfChordParameter) / directionScale;
|
|
276
|
+
const farRoot = (closestParameter + halfChordParameter) / directionScale;
|
|
277
|
+
const rawDistance = nearRoot >= -SPATIAL_QUERY_EPSILON
|
|
278
|
+
? nearRoot
|
|
279
|
+
: farRoot > SPATIAL_QUERY_EPSILON
|
|
280
|
+
? farRoot
|
|
268
281
|
: undefined;
|
|
269
282
|
if (rawDistance === undefined || !inRayRange(rawDistance, maxDistance))
|
|
270
283
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waica/engine",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.2",
|
|
4
4
|
"description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@types/three": "^0.
|
|
24
|
-
"three": "^0.
|
|
23
|
+
"@types/three": "^0.186.0",
|
|
24
|
+
"three": "^0.186.1"
|
|
25
25
|
}
|
|
26
26
|
}
|