@forgeax/engine-picking 0.1.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/LICENSE +202 -0
- package/README.md +168 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/glyph-text-pick.test.d.ts +2 -0
- package/dist/__tests__/glyph-text-pick.test.d.ts.map +1 -0
- package/dist/__tests__/pick-errors.test-d.d.ts +2 -0
- package/dist/__tests__/pick-errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/pick-errors.test.d.ts +2 -0
- package/dist/__tests__/pick-errors.test.d.ts.map +1 -0
- package/dist/__tests__/pick-tile.test.d.ts +2 -0
- package/dist/__tests__/pick-tile.test.d.ts.map +1 -0
- package/dist/__tests__/pick-vertex.unit.test.d.ts +2 -0
- package/dist/__tests__/pick-vertex.unit.test.d.ts.map +1 -0
- package/dist/__tests__/pick.test.d.ts +2 -0
- package/dist/__tests__/pick.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-regression.integration.test.d.ts +2 -0
- package/dist/__tests__/visibility-regression.integration.test.d.ts.map +1 -0
- package/dist/asset-port.d.ts +6 -0
- package/dist/asset-port.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +444 -0
- package/dist/index.mjs.map +1 -0
- package/dist/pick-core.d.ts +39 -0
- package/dist/pick-core.d.ts.map +1 -0
- package/dist/pick-errors.d.ts +39 -0
- package/dist/pick-errors.d.ts.map +1 -0
- package/dist/pick-tile.d.ts +44 -0
- package/dist/pick-tile.d.ts.map +1 -0
- package/dist/pick-vertex.d.ts +87 -0
- package/dist/pick-vertex.d.ts.map +1 -0
- package/dist/pick.d.ts +34 -0
- package/dist/pick.d.ts.map +1 -0
- package/dist/viewport-to-world.d.ts +12 -0
- package/dist/viewport-to-world.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/glyph-text-pick.test.ts +131 -0
- package/src/__tests__/pick-errors.test-d.ts +66 -0
- package/src/__tests__/pick-errors.test.ts +71 -0
- package/src/__tests__/pick-tile.test.ts +239 -0
- package/src/__tests__/pick-vertex.unit.test.ts +1588 -0
- package/src/__tests__/pick.test.ts +525 -0
- package/src/__tests__/visibility-regression.integration.test.ts +74 -0
- package/src/asset-port.ts +13 -0
- package/src/index.ts +21 -0
- package/src/pick-core.ts +114 -0
- package/src/pick-errors.ts +69 -0
- package/src/pick-tile.ts +157 -0
- package/src/pick-vertex.ts +593 -0
- package/src/pick.ts +148 -0
- package/src/viewport-to-world.ts +23 -0
package/src/pick-core.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// pick-core.ts — shared picking skeleton for pick() / pickVertex*() (feat-20260705 M2 M0).
|
|
2
|
+
//
|
|
3
|
+
// `pick.ts` (screen-to-entity ray-AABB) and `pick-vertex.ts` (vertex-level) share a
|
|
4
|
+
// verbatim skeleton (F11): the `Transform.world` row type, the row reader, and the
|
|
5
|
+
// camera-validation → view=invert(worldMatrix) → projection-branch → screenToRay
|
|
6
|
+
// sequence. This module is the single source of truth for that skeleton
|
|
7
|
+
// (architecture-principles §2 Derive, Don't Duplicate; AC-201). pick.ts and
|
|
8
|
+
// pick-vertex.ts import from here rather than each maintaining their own copy.
|
|
9
|
+
//
|
|
10
|
+
// Error channel (charter P3): a `cameraEntity` that carries no `Camera` is the one
|
|
11
|
+
// unrecoverable precondition — `computeScreenRay` throws a structured `PickError`
|
|
12
|
+
// (`code: 'camera-component-missing'`). A camera entity that carries no resolvable
|
|
13
|
+
// `Transform.world` is a degenerate miss (no view matrix can be built) — signalled by
|
|
14
|
+
// a `undefined` return, which callers translate to their own miss shape
|
|
15
|
+
// (`undefined` for pick, `[]`/`undefined` for the vertex queries).
|
|
16
|
+
|
|
17
|
+
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
18
|
+
import { mat4, ray } from '@forgeax/engine-math';
|
|
19
|
+
import { Camera, type CameraProjection, cameraProjectionFromF32 } from '@forgeax/engine-render';
|
|
20
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
21
|
+
import { PickError } from './pick-errors';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read an entity's resolved world mat4 (16 column-major floats) from the
|
|
25
|
+
* `Transform.world` column array view (feat-20260601 D-3). Returns a fresh
|
|
26
|
+
* copy (the view aliases live slot bytes); `undefined` when the entity has no
|
|
27
|
+
* Transform / world column.
|
|
28
|
+
*/
|
|
29
|
+
export function readWorldMatrix(world: World, entity: EntityHandle): Float32Array | undefined {
|
|
30
|
+
const result = world.get(entity, Transform);
|
|
31
|
+
return result.ok ? new Float32Array(result.value.world) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The screen-to-world ray plus the camera matrices used to build it.
|
|
36
|
+
*
|
|
37
|
+
* - `ray` — the unprojected world-space pick ray (origin + direction).
|
|
38
|
+
* - `view` — `invert(camera Transform.world)`.
|
|
39
|
+
* - `proj` — the camera projection matrix (perspective / orthographic).
|
|
40
|
+
* - `projectionKind` — the resolved camera projection discriminant.
|
|
41
|
+
*
|
|
42
|
+
* `view` and `proj` are returned separately so vertex picking can build its own
|
|
43
|
+
* `viewProj = proj * view` for `worldToScreen` without recomputing the branch.
|
|
44
|
+
*/
|
|
45
|
+
export interface ScreenRay {
|
|
46
|
+
readonly ray: ray.Ray;
|
|
47
|
+
readonly view: mat4.Mat4;
|
|
48
|
+
readonly proj: mat4.Mat4;
|
|
49
|
+
readonly projectionKind: CameraProjection;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the screen-to-world ray for `cameraEntity` at the viewport-relative
|
|
54
|
+
* `(screenX, screenY)` coordinate: validate the camera component, read its world
|
|
55
|
+
* transform, invert it to a view matrix, branch the projection on the camera
|
|
56
|
+
* discriminant, and unproject the coordinate into a world-space ray.
|
|
57
|
+
*
|
|
58
|
+
* @returns The `ScreenRay`, or `undefined` when the camera entity carries no
|
|
59
|
+
* resolvable `Transform.world` (a degenerate miss — no view matrix can be built).
|
|
60
|
+
* @throws {PickError} `code: 'camera-component-missing'` when `cameraEntity` has no `Camera`.
|
|
61
|
+
*/
|
|
62
|
+
export function computeScreenRay(
|
|
63
|
+
world: World,
|
|
64
|
+
cameraEntity: EntityHandle,
|
|
65
|
+
screenX: number,
|
|
66
|
+
screenY: number,
|
|
67
|
+
viewportWidth: number,
|
|
68
|
+
viewportHeight: number,
|
|
69
|
+
): ScreenRay | undefined {
|
|
70
|
+
// An invalid viewport cannot define a screen ray; refuse it before the lower
|
|
71
|
+
// math layer's safe fallback can become a fabricated origin hit.
|
|
72
|
+
if (
|
|
73
|
+
!Number.isFinite(viewportWidth) ||
|
|
74
|
+
!Number.isFinite(viewportHeight) ||
|
|
75
|
+
viewportWidth <= 0 ||
|
|
76
|
+
viewportHeight <= 0
|
|
77
|
+
) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- precondition: camera component present (structured error, charter P3) ---
|
|
82
|
+
const camRes = world.get(cameraEntity, Camera);
|
|
83
|
+
if (!camRes.ok) {
|
|
84
|
+
throw new PickError(cameraEntity as unknown as number);
|
|
85
|
+
}
|
|
86
|
+
const cam = camRes.value;
|
|
87
|
+
|
|
88
|
+
// --- camera world transform (feat-20260601 D-3: read Transform.world mat4) ---
|
|
89
|
+
const camWorld = readWorldMatrix(world, cameraEntity);
|
|
90
|
+
if (camWorld === undefined) {
|
|
91
|
+
// A camera entity without a Transform cannot define a view matrix; treat the
|
|
92
|
+
// degenerate case as a miss rather than fabricating an identity view (no spurious hit).
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- view = invert(camera world mat4) ---
|
|
97
|
+
const view = mat4.create();
|
|
98
|
+
mat4.invert(view, camWorld as unknown as mat4.Mat4Like);
|
|
99
|
+
|
|
100
|
+
// --- projection: branch on the camera discriminant (research Finding 5a) ---
|
|
101
|
+
const projectionKind = cameraProjectionFromF32(cam.projection);
|
|
102
|
+
const proj = mat4.create();
|
|
103
|
+
if (projectionKind === 'orthographic') {
|
|
104
|
+
mat4.orthographic(proj, cam.left, cam.right, cam.bottom, cam.top, cam.near, cam.far);
|
|
105
|
+
} else {
|
|
106
|
+
mat4.perspective(proj, cam.fov, cam.aspect, cam.near, cam.far);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- screen -> world ray (two-point unproject; clamp + NaN/Inf sanitized inside) ---
|
|
110
|
+
const r = ray.create();
|
|
111
|
+
ray.screenToRay(r, screenX, screenY, viewportWidth, viewportHeight, view, proj, projectionKind);
|
|
112
|
+
|
|
113
|
+
return { ray: r, view, proj, projectionKind };
|
|
114
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @forgeax/engine-picking — pick error model (feat-20260529-picking-raycasting-screen-to-entity M3 / w9).
|
|
2
|
+
//
|
|
3
|
+
// PickError code owner + derived PickErrorCode projection. The screen-to-entity
|
|
4
|
+
// `pick(...)` free function returns `undefined` for the recoverable miss path (no ray
|
|
5
|
+
// hit) and surfaces a structured PickError ONLY for the one unrecoverable precondition
|
|
6
|
+
// failure: the supplied cameraEntity does not hold a `Camera` component, so no view /
|
|
7
|
+
// projection matrix can be built. Separating the error channel (PickError) from the
|
|
8
|
+
// normal miss channel (undefined) lets AI users branch with `if (hit)` for the common
|
|
9
|
+
// case and inspect `.code` for the precondition failure (charter P3).
|
|
10
|
+
//
|
|
11
|
+
// D-1: a NEW closed union rather than reusing RuntimeErrorCode. RuntimeErrorCode's 8
|
|
12
|
+
// members are all render / skin / shadow domain; folding a picking precondition into it
|
|
13
|
+
// would dilute its semantic focus. A dedicated single-member union keeps the picking
|
|
14
|
+
// error surface cohesive and additively evolvable (AGENTS.md Error model minor add-only).
|
|
15
|
+
//
|
|
16
|
+
// Related: requirements AC-11 (structured error signal) + AC-13 (closed-union SSOT);
|
|
17
|
+
// plan-strategy D-1; plan-tasks.json w9 acceptanceCheck; charter P3.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detail for the `PickError` camera-component-missing code.
|
|
21
|
+
*
|
|
22
|
+
* Carries the offending camera entity (packed `Entity` u32) so AI consumers can read
|
|
23
|
+
* `.detail.cameraEntity` by property access (charter P4) — no string parsing of the
|
|
24
|
+
* human-facing message.
|
|
25
|
+
*/
|
|
26
|
+
export interface PickCameraMissingDetail {
|
|
27
|
+
readonly cameraEntity: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Structured error for the picking precondition failure: the supplied `cameraEntity`
|
|
32
|
+
* does not hold a `Camera` component, so `pick()` cannot build the view / projection
|
|
33
|
+
* matrices needed to unproject the screen coordinate.
|
|
34
|
+
*
|
|
35
|
+
* Three-field structured surface per the AGENTS.md error model + `.detail`:
|
|
36
|
+
* - `.code = 'camera-component-missing'` (closed `PickErrorCode`)
|
|
37
|
+
* - `.expected` — the expected precondition (camera entity carries a `Camera`)
|
|
38
|
+
* - `.hint` — an actionable `world.set` recovery directive
|
|
39
|
+
* - `.detail = { cameraEntity }` — the offending entity (charter P4)
|
|
40
|
+
*
|
|
41
|
+
* Surfaced (not the normal miss path): a no-hit ray returns `undefined` from `pick()`;
|
|
42
|
+
* this error is reserved for the unrecoverable precondition (charter P3 explicit failure,
|
|
43
|
+
* separate channel from the recoverable miss).
|
|
44
|
+
*/
|
|
45
|
+
export class PickError extends Error {
|
|
46
|
+
readonly code = 'camera-component-missing';
|
|
47
|
+
readonly expected: string;
|
|
48
|
+
readonly hint: string;
|
|
49
|
+
readonly detail: PickCameraMissingDetail;
|
|
50
|
+
|
|
51
|
+
constructor(cameraEntity: number) {
|
|
52
|
+
const expected = 'cameraEntity holds a Camera component';
|
|
53
|
+
const hint =
|
|
54
|
+
`cameraEntity ${cameraEntity} has no Camera component; spawn or attach one before ` +
|
|
55
|
+
'picking, e.g. world.set(cameraEntity, Camera, { fov: Math.PI / 4, aspect, near, far })';
|
|
56
|
+
super(`pick: cameraEntity ${cameraEntity} has no Camera component`);
|
|
57
|
+
this.name = 'PickError';
|
|
58
|
+
this.expected = expected;
|
|
59
|
+
this.hint = hint;
|
|
60
|
+
this.detail = { cameraEntity };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Closed union of picking precondition error codes, derived from the PickError owner.
|
|
66
|
+
*
|
|
67
|
+
* AI users perform exhaustive `switch (err.code)` without a default; TS guards completeness.
|
|
68
|
+
*/
|
|
69
|
+
export type PickErrorCode = PickError['code'];
|
package/src/pick-tile.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// pick-tile.ts - cell-level Tilemap query (feat-20260608 M0 baseline rebuild).
|
|
2
|
+
//
|
|
3
|
+
// `pickTile(world, tilemapEntity, worldX, worldY)` is a free function (not a
|
|
4
|
+
// method on `World` -- charter F1 single-import barrel from
|
|
5
|
+
// `@forgeax/engine-runtime`). It converts world-space coordinates into the
|
|
6
|
+
// Tilemap's local cell grid, walks every TileLayer that ChildOf-s the
|
|
7
|
+
// supplied tilemap, and returns the FIRST non-zero tile id seen when scanning
|
|
8
|
+
// layers in DESCENDING `layerOrder` (highest layer drawn on top wins).
|
|
9
|
+
//
|
|
10
|
+
// charter mapping:
|
|
11
|
+
// - P3 explicit failure as value: out-of-bounds and "every layer empty"
|
|
12
|
+
// resolve to `Result.ok(null)` -- never throws or fires onError. Dead
|
|
13
|
+
// handles and live entities without Tilemap are separate structural
|
|
14
|
+
// failures; the `PickTileError` union is closed to two variants and stays
|
|
15
|
+
// a runtime-only type (not exported through `@forgeax/engine-types`, since
|
|
16
|
+
// pickTile is a runtime-only system).
|
|
17
|
+
// - P4 consistent abstraction: matches the `pick(world, ...)` raycast
|
|
18
|
+
// surface in pick.ts -- free function, world+entity input, structured
|
|
19
|
+
// error union, hit-or-null return.
|
|
20
|
+
// - F1 single-import: exported from `@forgeax/engine-picking`'s barrel.
|
|
21
|
+
//
|
|
22
|
+
// Anchors: requirements §integration-points (engine-runtime pickTile);
|
|
23
|
+
// plan-tasks m0-t8; plan-strategy §D-5 file-by-file ECS API adaptation
|
|
24
|
+
// (World-owned Query pattern from render-system-extract.ts).
|
|
25
|
+
|
|
26
|
+
import { Entity, type EntityHandle, type World } from '@forgeax/engine-ecs';
|
|
27
|
+
import { mat4, vec3 } from '@forgeax/engine-math';
|
|
28
|
+
import { TileLayer, Tilemap } from '@forgeax/engine-render/authoring';
|
|
29
|
+
import { ChildOf, Transform } from '@forgeax/engine-scene';
|
|
30
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Closed error union for `pickTile` (charter P3 + P4). Two variants cover the
|
|
34
|
+
* structural-break paths; "ray hit nothing" is a value (`Result.ok(null)`),
|
|
35
|
+
* not an error.
|
|
36
|
+
*/
|
|
37
|
+
export type PickTileError =
|
|
38
|
+
| { readonly code: 'tilemap-not-found'; readonly tilemapEntity: EntityHandle }
|
|
39
|
+
| { readonly code: 'tilemap-component-missing'; readonly tilemapEntity: EntityHandle };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Successful picking outcome. Returned through `Result.ok`; a `null` value
|
|
43
|
+
* means the query landed in-bounds but every layer at that cell was empty
|
|
44
|
+
* (or the point was outside the tilemap world bounds).
|
|
45
|
+
*/
|
|
46
|
+
export interface PickTileHit {
|
|
47
|
+
readonly layerEntity: EntityHandle;
|
|
48
|
+
readonly cellX: number;
|
|
49
|
+
readonly cellY: number;
|
|
50
|
+
readonly tileId: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Find the topmost non-zero tile under `(worldX, worldY)` for a given
|
|
55
|
+
* Tilemap entity, walking child TileLayer entities in DESCENDING
|
|
56
|
+
* `layerOrder`.
|
|
57
|
+
*
|
|
58
|
+
* @returns
|
|
59
|
+
* - `Result.ok(PickTileHit)` for a non-zero cell on some layer.
|
|
60
|
+
* - `Result.ok(null)` for an empty cell or out-of-bounds query.
|
|
61
|
+
* - `Result.err({ code: 'tilemap-not-found' })` for a dead handle.
|
|
62
|
+
* - `Result.err({ code: 'tilemap-component-missing' })` for a live entity
|
|
63
|
+
* without a Tilemap component.
|
|
64
|
+
*
|
|
65
|
+
* The caller must propagate the World so `Transform.world` is current. A
|
|
66
|
+
* singular world transform follows `mat4.invert`'s deterministic identity
|
|
67
|
+
* fallback, which keeps this error union structural rather than adding a
|
|
68
|
+
* third diagnostic arm.
|
|
69
|
+
*/
|
|
70
|
+
export function pickTile(
|
|
71
|
+
world: World,
|
|
72
|
+
tilemapEntity: EntityHandle,
|
|
73
|
+
worldX: number,
|
|
74
|
+
worldY: number,
|
|
75
|
+
): Result<PickTileHit | null, PickTileError> {
|
|
76
|
+
const entityResult = world.get(tilemapEntity, Entity);
|
|
77
|
+
if (!entityResult.ok) {
|
|
78
|
+
return err({ code: 'tilemap-not-found', tilemapEntity });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const tilemapResult = world.get(tilemapEntity, Tilemap);
|
|
82
|
+
if (!tilemapResult.ok) {
|
|
83
|
+
return err({ code: 'tilemap-component-missing', tilemapEntity });
|
|
84
|
+
}
|
|
85
|
+
const tilemap = tilemapResult.value;
|
|
86
|
+
const cols = tilemap.cols;
|
|
87
|
+
const rows = tilemap.rows;
|
|
88
|
+
// feat-20260709 M3: tileSize is one inline array<f32,2> column; the
|
|
89
|
+
// world.get read path materialises it as a Float32Array ([width, height]).
|
|
90
|
+
const tileSizeX = tilemap.tileSize[0] ?? 1;
|
|
91
|
+
const tileSizeY = tilemap.tileSize[1] ?? 1;
|
|
92
|
+
|
|
93
|
+
// Tilemap entities without a Transform retain the origin-default path. For
|
|
94
|
+
// transformed maps, the full propagated affine inverse is the only correct
|
|
95
|
+
// way to recover local cell coordinates under rotation and non-uniform scale.
|
|
96
|
+
let localX = worldX;
|
|
97
|
+
let localY = worldY;
|
|
98
|
+
const transformResult = world.get(tilemapEntity, Transform);
|
|
99
|
+
if (transformResult.ok) {
|
|
100
|
+
const w = transformResult.value.world;
|
|
101
|
+
if (w !== undefined && w.length >= 16) {
|
|
102
|
+
const local = mat4.transformPoint(vec3.create(), mat4.invert(mat4.create(), w), [
|
|
103
|
+
worldX,
|
|
104
|
+
worldY,
|
|
105
|
+
0,
|
|
106
|
+
]);
|
|
107
|
+
localX = local[0] ?? 0;
|
|
108
|
+
localY = local[1] ?? 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (tileSizeX <= 0 || tileSizeY <= 0) return ok(null);
|
|
113
|
+
if (localX < 0 || localY < 0) return ok(null);
|
|
114
|
+
|
|
115
|
+
const cellX = Math.floor(localX / tileSizeX);
|
|
116
|
+
const cellY = Math.floor(localY / tileSizeY);
|
|
117
|
+
if (cellX < 0 || cellY < 0 || cellX >= cols || cellY >= rows) return ok(null);
|
|
118
|
+
|
|
119
|
+
// Collect every TileLayer ChildOf-ing the supplied Tilemap entity, with
|
|
120
|
+
// its layerOrder + tile array snapshot through one row iterator.
|
|
121
|
+
type LayerInfo = {
|
|
122
|
+
readonly entity: EntityHandle;
|
|
123
|
+
readonly tiles: ArrayLike<number>;
|
|
124
|
+
readonly layerOrder: number;
|
|
125
|
+
};
|
|
126
|
+
const layers: LayerInfo[] = [];
|
|
127
|
+
const layerQuery = world.query({ read: [TileLayer, ChildOf] }).unwrap();
|
|
128
|
+
for (const row of layerQuery) {
|
|
129
|
+
const layerEntity = row.entity;
|
|
130
|
+
const parent = row.get(ChildOf).parent;
|
|
131
|
+
if ((parent as unknown as number) !== (tilemapEntity as unknown as number)) continue;
|
|
132
|
+
const layerData = world.get(layerEntity, TileLayer);
|
|
133
|
+
if (!layerData.ok) continue;
|
|
134
|
+
layers.push({
|
|
135
|
+
entity: layerEntity,
|
|
136
|
+
tiles: layerData.value.tiles as ArrayLike<number>,
|
|
137
|
+
layerOrder: row.get(TileLayer).layerOrder,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
layers.sort((a, b) => b.layerOrder - a.layerOrder);
|
|
142
|
+
|
|
143
|
+
const cellIndex = cellY * cols + cellX;
|
|
144
|
+
for (const layer of layers) {
|
|
145
|
+
if (cellIndex >= layer.tiles.length) continue;
|
|
146
|
+
const tileId = layer.tiles[cellIndex] ?? 0;
|
|
147
|
+
if (tileId !== 0) {
|
|
148
|
+
return ok({
|
|
149
|
+
layerEntity: layer.entity,
|
|
150
|
+
cellX,
|
|
151
|
+
cellY,
|
|
152
|
+
tileId,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return ok(null);
|
|
157
|
+
}
|