@boltr/geometry 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/__tests__/movement.test.js +159 -0
- package/__tests__/primitives.test.js +118 -0
- package/__tests__/terrain.test.js +99 -0
- package/package.json +24 -0
- package/src/index.js +44 -0
- package/src/los.js +61 -0
- package/src/movement.js +235 -0
- package/src/primitives.js +142 -0
- package/src/terrain.js +108 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const { describe, it } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const {
|
|
4
|
+
isPositionOccupied,
|
|
5
|
+
isInFriendlyExclusionZone,
|
|
6
|
+
isInEnemyExclusionZone,
|
|
7
|
+
isPathBlockedByEnemies,
|
|
8
|
+
validateWaypointPath,
|
|
9
|
+
} = require('../src/movement');
|
|
10
|
+
|
|
11
|
+
const makeUnit = (overrides) => ({
|
|
12
|
+
unit_index: 0, player_id: 1, position: { x: 10, y: 10 },
|
|
13
|
+
base_radius: 0.5, eliminated: false, ...overrides,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('isPositionOccupied', () => {
|
|
17
|
+
it('detects overlap with sum of radii', () => {
|
|
18
|
+
const units = [makeUnit({ base_radius: 0.5 })];
|
|
19
|
+
// Distance 0.8 < 0.5 + 0.5 = 1.0 → occupied
|
|
20
|
+
assert.ok(isPositionOccupied({ x: 10.8, y: 10 }, 0.5, units));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns false when no overlap', () => {
|
|
24
|
+
const units = [makeUnit({ base_radius: 0.5 })];
|
|
25
|
+
// Distance 2 > 0.5 + 0.5 = 1.0
|
|
26
|
+
assert.ok(!isPositionOccupied({ x: 12, y: 10 }, 0.5, units));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('excludes self by unit_index', () => {
|
|
30
|
+
const units = [makeUnit({ unit_index: 0, base_radius: 0.5 })];
|
|
31
|
+
assert.ok(!isPositionOccupied({ x: 10, y: 10 }, 0.5, units, 0));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('uses larger base_radius', () => {
|
|
35
|
+
const units = [makeUnit({ base_radius: 1.0 })];
|
|
36
|
+
// Distance 1.2 < 1.0 + 0.5 = 1.5 → occupied
|
|
37
|
+
assert.ok(isPositionOccupied({ x: 11.2, y: 10 }, 0.5, units));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('isInFriendlyExclusionZone', () => {
|
|
42
|
+
it('returns true when friendly bases would overlap', () => {
|
|
43
|
+
const op = makeUnit({ unit_index: 0, player_id: 1, base_radius: 0.5 });
|
|
44
|
+
const units = [
|
|
45
|
+
op,
|
|
46
|
+
makeUnit({ unit_index: 1, player_id: 1, position: { x: 15, y: 10 }, base_radius: 0.5 }),
|
|
47
|
+
];
|
|
48
|
+
// Try to end at 14.5 → distance 0.5 < 0.5 + 0.5 = 1.0
|
|
49
|
+
assert.ok(isInFriendlyExclusionZone({ x: 14.5, y: 10 }, op, units));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns false for enemy units', () => {
|
|
53
|
+
const op = makeUnit({ unit_index: 0, player_id: 1, base_radius: 0.5 });
|
|
54
|
+
const units = [
|
|
55
|
+
op,
|
|
56
|
+
makeUnit({ unit_index: 1, player_id: 2, position: { x: 10.5, y: 10 }, base_radius: 0.5 }),
|
|
57
|
+
];
|
|
58
|
+
assert.ok(!isInFriendlyExclusionZone({ x: 10.5, y: 10 }, op, units));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('isPathBlockedByEnemies', () => {
|
|
63
|
+
it('detects enemy blocking the path', () => {
|
|
64
|
+
const op = makeUnit({ unit_index: 0, player_id: 1 });
|
|
65
|
+
const enemy = makeUnit({ unit_index: 2, player_id: 2, position: { x: 5, y: 10 }, base_radius: 0.5 });
|
|
66
|
+
const units = [op, enemy];
|
|
67
|
+
// Path from (0, 10) to (10, 10) passes through enemy at (5, 10)
|
|
68
|
+
// Blocking radius = 0.5 + 1.0 = 1.5
|
|
69
|
+
const result = isPathBlockedByEnemies({ x: 0, y: 10 }, { x: 10, y: 10 }, op, units);
|
|
70
|
+
assert.ok(result.blocked);
|
|
71
|
+
assert.equal(result.blocker.unit_index, 2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('exempts charge target', () => {
|
|
75
|
+
const op = makeUnit({ unit_index: 0, player_id: 1 });
|
|
76
|
+
const enemy = makeUnit({ unit_index: 2, player_id: 2, position: { x: 5, y: 10 }, base_radius: 0.5 });
|
|
77
|
+
const units = [op, enemy];
|
|
78
|
+
const result = isPathBlockedByEnemies({ x: 0, y: 10 }, { x: 10, y: 10 }, op, units, 2);
|
|
79
|
+
assert.ok(!result.blocked);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('ignores friendly units', () => {
|
|
83
|
+
const op = makeUnit({ unit_index: 0, player_id: 1 });
|
|
84
|
+
const friendly = makeUnit({ unit_index: 1, player_id: 1, position: { x: 5, y: 10 } });
|
|
85
|
+
const units = [op, friendly];
|
|
86
|
+
const result = isPathBlockedByEnemies({ x: 0, y: 10 }, { x: 10, y: 10 }, op, units);
|
|
87
|
+
assert.ok(!result.blocked);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('validateWaypointPath', () => {
|
|
92
|
+
const op = makeUnit({ unit_index: 0, player_id: 1, base_radius: 0.5 });
|
|
93
|
+
const emptyUnits = [op];
|
|
94
|
+
|
|
95
|
+
it('validates a simple single-waypoint path', () => {
|
|
96
|
+
const result = validateWaypointPath(
|
|
97
|
+
{ x: 0, y: 10 }, [{ x: 3, y: 10 }], 0.5, 6, op, emptyUnits, [], null
|
|
98
|
+
);
|
|
99
|
+
assert.ok(result.valid);
|
|
100
|
+
assert.ok(Math.abs(result.totalDistance - 3) < 0.001);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('validates multi-waypoint path', () => {
|
|
104
|
+
const result = validateWaypointPath(
|
|
105
|
+
{ x: 0, y: 0 }, [{ x: 3, y: 0 }, { x: 3, y: 4 }], 0.5, 10, op, emptyUnits, [], null
|
|
106
|
+
);
|
|
107
|
+
assert.ok(result.valid);
|
|
108
|
+
assert.ok(Math.abs(result.totalDistance - 7) < 0.001);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects path exceeding max distance', () => {
|
|
112
|
+
const result = validateWaypointPath(
|
|
113
|
+
{ x: 0, y: 0 }, [{ x: 10, y: 0 }], 0.5, 6, op, emptyUnits, [], null
|
|
114
|
+
);
|
|
115
|
+
assert.ok(!result.valid);
|
|
116
|
+
assert.ok(result.error.includes('exceeds maximum'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects empty waypoints', () => {
|
|
120
|
+
const result = validateWaypointPath(
|
|
121
|
+
{ x: 0, y: 0 }, [], 0.5, 6, op, emptyUnits, [], null
|
|
122
|
+
);
|
|
123
|
+
assert.ok(!result.valid);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('adds per-piece terrain penalty', () => {
|
|
127
|
+
const terrain = [
|
|
128
|
+
{ type: 'lightCover', vertices: [{ x: 1, y: -2 }, { x: 2, y: -2 }, { x: 2, y: 2 }, { x: 1, y: 2 }] },
|
|
129
|
+
{ type: 'difficult', vertices: [{ x: 3, y: -2 }, { x: 4, y: -2 }, { x: 4, y: 2 }, { x: 3, y: 2 }] },
|
|
130
|
+
];
|
|
131
|
+
// Path crosses 2 pieces → +4" penalty, total = 5 + 4 = 9
|
|
132
|
+
const result = validateWaypointPath(
|
|
133
|
+
{ x: 0, y: 0 }, [{ x: 5, y: 0 }], 0.5, 8, op, emptyUnits, terrain, null
|
|
134
|
+
);
|
|
135
|
+
assert.ok(!result.valid);
|
|
136
|
+
assert.ok(result.error.includes('4 inches penalty'));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('rejects path blocked by enemy', () => {
|
|
140
|
+
const enemy = makeUnit({ unit_index: 2, player_id: 2, position: { x: 5, y: 0 }, base_radius: 0.5 });
|
|
141
|
+
const units = [op, enemy];
|
|
142
|
+
const result = validateWaypointPath(
|
|
143
|
+
{ x: 0, y: 0 }, [{ x: 10, y: 0 }], 0.5, 15, op, units, [], null
|
|
144
|
+
);
|
|
145
|
+
assert.ok(!result.valid);
|
|
146
|
+
assert.ok(result.error.includes('blocked by enemy'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('rejects path ending on blocked terrain', () => {
|
|
150
|
+
const terrain = [
|
|
151
|
+
{ type: 'heavyCover', vertices: [{ x: 4, y: -2 }, { x: 6, y: -2 }, { x: 6, y: 2 }, { x: 4, y: 2 }] },
|
|
152
|
+
];
|
|
153
|
+
const result = validateWaypointPath(
|
|
154
|
+
{ x: 0, y: 0 }, [{ x: 5, y: 0 }], 0.5, 10, op, emptyUnits, terrain, null
|
|
155
|
+
);
|
|
156
|
+
assert.ok(!result.valid);
|
|
157
|
+
assert.ok(result.error.includes('heavyCover'));
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const { describe, it } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const {
|
|
4
|
+
calculateDistance,
|
|
5
|
+
isPointInPolygon,
|
|
6
|
+
lineSegmentsIntersect,
|
|
7
|
+
lineIntersectsPolygon,
|
|
8
|
+
pointToLineSegmentDistance,
|
|
9
|
+
pointToPolygonDistance,
|
|
10
|
+
lineSegmentIntersectsCircle,
|
|
11
|
+
isBaseOverlappingTerrain,
|
|
12
|
+
} = require('../src/primitives');
|
|
13
|
+
|
|
14
|
+
const square = [
|
|
15
|
+
{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }, { x: 0, y: 4 },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
describe('calculateDistance', () => {
|
|
19
|
+
it('returns 0 for same point', () => {
|
|
20
|
+
assert.equal(calculateDistance({ x: 1, y: 1 }, { x: 1, y: 1 }), 0);
|
|
21
|
+
});
|
|
22
|
+
it('calculates horizontal distance', () => {
|
|
23
|
+
assert.equal(calculateDistance({ x: 0, y: 0 }, { x: 3, y: 0 }), 3);
|
|
24
|
+
});
|
|
25
|
+
it('calculates diagonal distance', () => {
|
|
26
|
+
const d = calculateDistance({ x: 0, y: 0 }, { x: 3, y: 4 });
|
|
27
|
+
assert.ok(Math.abs(d - 5) < 0.0001);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('isPointInPolygon', () => {
|
|
32
|
+
it('returns true for point inside', () => {
|
|
33
|
+
assert.ok(isPointInPolygon({ x: 2, y: 2 }, square));
|
|
34
|
+
});
|
|
35
|
+
it('returns false for point outside', () => {
|
|
36
|
+
assert.ok(!isPointInPolygon({ x: 5, y: 5 }, square));
|
|
37
|
+
});
|
|
38
|
+
it('returns false for invalid vertices', () => {
|
|
39
|
+
assert.ok(!isPointInPolygon({ x: 0, y: 0 }, null));
|
|
40
|
+
assert.ok(!isPointInPolygon({ x: 0, y: 0 }, []));
|
|
41
|
+
assert.ok(!isPointInPolygon({ x: 0, y: 0 }, [{ x: 0, y: 0 }]));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('lineSegmentsIntersect', () => {
|
|
46
|
+
it('detects crossing segments', () => {
|
|
47
|
+
assert.ok(lineSegmentsIntersect(0, 0, 4, 4, 0, 4, 4, 0));
|
|
48
|
+
});
|
|
49
|
+
it('returns false for parallel segments', () => {
|
|
50
|
+
assert.ok(!lineSegmentsIntersect(0, 0, 4, 0, 0, 1, 4, 1));
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('lineIntersectsPolygon', () => {
|
|
55
|
+
it('detects line crossing polygon', () => {
|
|
56
|
+
assert.ok(lineIntersectsPolygon({ x: -1, y: 2 }, { x: 5, y: 2 }, square));
|
|
57
|
+
});
|
|
58
|
+
it('returns false for line outside polygon', () => {
|
|
59
|
+
assert.ok(!lineIntersectsPolygon({ x: 5, y: 5 }, { x: 6, y: 6 }, square));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('pointToLineSegmentDistance', () => {
|
|
64
|
+
it('returns perpendicular distance', () => {
|
|
65
|
+
const d = pointToLineSegmentDistance({ x: 2, y: 3 }, { x: 0, y: 0 }, { x: 4, y: 0 });
|
|
66
|
+
assert.ok(Math.abs(d - 3) < 0.0001);
|
|
67
|
+
});
|
|
68
|
+
it('returns distance to endpoint when perpendicular falls outside', () => {
|
|
69
|
+
const d = pointToLineSegmentDistance({ x: 5, y: 0 }, { x: 0, y: 0 }, { x: 4, y: 0 });
|
|
70
|
+
assert.ok(Math.abs(d - 1) < 0.0001);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('pointToPolygonDistance', () => {
|
|
75
|
+
it('returns 0 for point on edge', () => {
|
|
76
|
+
const d = pointToPolygonDistance({ x: 2, y: 0 }, square);
|
|
77
|
+
assert.ok(d < 0.0001);
|
|
78
|
+
});
|
|
79
|
+
it('returns correct distance to nearest edge', () => {
|
|
80
|
+
const d = pointToPolygonDistance({ x: 2, y: -1 }, square);
|
|
81
|
+
assert.ok(Math.abs(d - 1) < 0.0001);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('lineSegmentIntersectsCircle', () => {
|
|
86
|
+
it('detects segment passing through circle', () => {
|
|
87
|
+
assert.ok(lineSegmentIntersectsCircle(
|
|
88
|
+
{ x: 0, y: 0 }, { x: 10, y: 0 },
|
|
89
|
+
{ x: 5, y: 0.5 }, 1
|
|
90
|
+
));
|
|
91
|
+
});
|
|
92
|
+
it('returns false for segment far from circle', () => {
|
|
93
|
+
assert.ok(!lineSegmentIntersectsCircle(
|
|
94
|
+
{ x: 0, y: 0 }, { x: 10, y: 0 },
|
|
95
|
+
{ x: 5, y: 5 }, 1
|
|
96
|
+
));
|
|
97
|
+
});
|
|
98
|
+
it('detects segment tangent within radius', () => {
|
|
99
|
+
assert.ok(lineSegmentIntersectsCircle(
|
|
100
|
+
{ x: 0, y: 0 }, { x: 10, y: 0 },
|
|
101
|
+
{ x: 5, y: 0.9 }, 1
|
|
102
|
+
));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('isBaseOverlappingTerrain', () => {
|
|
107
|
+
it('returns true when center is inside polygon', () => {
|
|
108
|
+
assert.ok(isBaseOverlappingTerrain({ x: 2, y: 2 }, 0.5, square));
|
|
109
|
+
});
|
|
110
|
+
it('returns true when base circle overlaps polygon boundary', () => {
|
|
111
|
+
// Point is 0.3 inches from polygon edge, base radius is 0.5
|
|
112
|
+
assert.ok(isBaseOverlappingTerrain({ x: -0.3, y: 2 }, 0.5, square));
|
|
113
|
+
});
|
|
114
|
+
it('returns false when base circle does not overlap', () => {
|
|
115
|
+
// Point is 1 inch from polygon, base radius is 0.5
|
|
116
|
+
assert.ok(!isBaseOverlappingTerrain({ x: -1, y: 2 }, 0.5, square));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const { describe, it } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const {
|
|
4
|
+
checkTargetOnBlockedTerrain,
|
|
5
|
+
checkMovementBlocked,
|
|
6
|
+
countPenaltyTerrainPieces,
|
|
7
|
+
checkTerrainMovementPenalty,
|
|
8
|
+
} = require('../src/terrain');
|
|
9
|
+
|
|
10
|
+
const impassableBlock = {
|
|
11
|
+
type: 'impassable',
|
|
12
|
+
vertices: [{ x: 8, y: 8 }, { x: 12, y: 8 }, { x: 12, y: 12 }, { x: 8, y: 12 }],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const lightCoverBlock = {
|
|
16
|
+
type: 'lightCover',
|
|
17
|
+
vertices: [{ x: 2, y: 8 }, { x: 4, y: 8 }, { x: 4, y: 12 }, { x: 2, y: 12 }],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const heavyCoverBlock = {
|
|
21
|
+
type: 'heavyCover',
|
|
22
|
+
vertices: [{ x: 14, y: 8 }, { x: 16, y: 8 }, { x: 16, y: 12 }, { x: 14, y: 12 }],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const difficultBlock = {
|
|
26
|
+
type: 'difficult',
|
|
27
|
+
vertices: [{ x: 18, y: 8 }, { x: 20, y: 8 }, { x: 20, y: 12 }, { x: 18, y: 12 }],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('checkTargetOnBlockedTerrain', () => {
|
|
31
|
+
it('returns terrain type when base overlaps blocked terrain', () => {
|
|
32
|
+
// Center at (10, 10) is inside the impassable block
|
|
33
|
+
assert.equal(checkTargetOnBlockedTerrain({ x: 10, y: 10 }, 0.5, [impassableBlock]), 'impassable');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns terrain type when base edge overlaps blocked terrain', () => {
|
|
37
|
+
// Center 0.3" from edge, base_radius 0.5 → overlaps
|
|
38
|
+
assert.equal(checkTargetOnBlockedTerrain({ x: 7.8, y: 10 }, 0.5, [impassableBlock]), 'impassable');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns null when base does not overlap blocked terrain', () => {
|
|
42
|
+
assert.equal(checkTargetOnBlockedTerrain({ x: 5, y: 10 }, 0.5, [impassableBlock]), null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns null for empty terrain', () => {
|
|
46
|
+
assert.equal(checkTargetOnBlockedTerrain({ x: 10, y: 10 }, 0.5, []), null);
|
|
47
|
+
assert.equal(checkTargetOnBlockedTerrain({ x: 10, y: 10 }, 0.5, null), null);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('checkMovementBlocked', () => {
|
|
52
|
+
it('returns path_blocked when crossing impassable', () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
checkMovementBlocked({ x: 5, y: 10 }, { x: 15, y: 10 }, 0.5, [impassableBlock]),
|
|
55
|
+
'path_blocked'
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns target_blocked when destination base overlaps impassable', () => {
|
|
60
|
+
// End position just outside but base overlaps
|
|
61
|
+
assert.equal(
|
|
62
|
+
checkMovementBlocked({ x: 5, y: 10 }, { x: 7.8, y: 10 }, 0.5, [impassableBlock]),
|
|
63
|
+
'target_blocked'
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns null when path is clear', () => {
|
|
68
|
+
assert.equal(
|
|
69
|
+
checkMovementBlocked({ x: 0, y: 10 }, { x: 5, y: 10 }, 0.5, [impassableBlock]),
|
|
70
|
+
null
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('countPenaltyTerrainPieces', () => {
|
|
76
|
+
it('counts distinct terrain pieces crossed', () => {
|
|
77
|
+
const terrain = [lightCoverBlock, difficultBlock];
|
|
78
|
+
// Path from (0, 10) to (22, 10) crosses both
|
|
79
|
+
assert.equal(countPenaltyTerrainPieces({ x: 0, y: 10 }, { x: 22, y: 10 }, terrain), 2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns 0 when no penalty terrain crossed', () => {
|
|
83
|
+
assert.equal(countPenaltyTerrainPieces({ x: 0, y: 0 }, { x: 1, y: 0 }, [lightCoverBlock]), 0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('counts heavyCover as penalty terrain', () => {
|
|
87
|
+
assert.equal(countPenaltyTerrainPieces({ x: 13, y: 10 }, { x: 17, y: 10 }, [heavyCoverBlock]), 1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('checkTerrainMovementPenalty', () => {
|
|
92
|
+
it('returns true when penalty terrain crossed', () => {
|
|
93
|
+
assert.ok(checkTerrainMovementPenalty({ x: 0, y: 10 }, { x: 5, y: 10 }, [lightCoverBlock]));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns false when no penalty terrain crossed', () => {
|
|
97
|
+
assert.ok(!checkTerrainMovementPenalty({ x: 0, y: 0 }, { x: 1, y: 0 }, [lightCoverBlock]));
|
|
98
|
+
});
|
|
99
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@boltr/geometry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared geometry primitives for BOLTR tactical combat game",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./primitives": "./src/primitives.js",
|
|
9
|
+
"./terrain": "./src/terrain.js",
|
|
10
|
+
"./los": "./src/los.js",
|
|
11
|
+
"./movement": "./src/movement.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test __tests__/*.test.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["boltr", "geometry", "game"],
|
|
17
|
+
"license": "UNLICENSED",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18.0.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @boltr/geometry
|
|
3
|
+
*
|
|
4
|
+
* Shared geometry primitives and game-specific spatial helpers
|
|
5
|
+
* for the BOLTR tactical combat game.
|
|
6
|
+
*
|
|
7
|
+
* Used by both the Xano backend (lambdas) and the frontend client.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const primitives = require('./primitives');
|
|
11
|
+
const terrain = require('./terrain');
|
|
12
|
+
const los = require('./los');
|
|
13
|
+
const movement = require('./movement');
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
// Primitives
|
|
17
|
+
calculateDistance: primitives.calculateDistance,
|
|
18
|
+
isPointInPolygon: primitives.isPointInPolygon,
|
|
19
|
+
lineSegmentsIntersect: primitives.lineSegmentsIntersect,
|
|
20
|
+
lineIntersectsPolygon: primitives.lineIntersectsPolygon,
|
|
21
|
+
pointToLineSegmentDistance: primitives.pointToLineSegmentDistance,
|
|
22
|
+
pointToPolygonDistance: primitives.pointToPolygonDistance,
|
|
23
|
+
lineSegmentIntersectsCircle: primitives.lineSegmentIntersectsCircle,
|
|
24
|
+
isBaseOverlappingTerrain: primitives.isBaseOverlappingTerrain,
|
|
25
|
+
|
|
26
|
+
// Terrain
|
|
27
|
+
BLOCKED_TERRAIN_TYPES: terrain.BLOCKED_TERRAIN_TYPES,
|
|
28
|
+
PENALTY_TERRAIN_TYPES: terrain.PENALTY_TERRAIN_TYPES,
|
|
29
|
+
checkTargetOnBlockedTerrain: terrain.checkTargetOnBlockedTerrain,
|
|
30
|
+
checkMovementBlocked: terrain.checkMovementBlocked,
|
|
31
|
+
countPenaltyTerrainPieces: terrain.countPenaltyTerrainPieces,
|
|
32
|
+
checkTerrainMovementPenalty: terrain.checkTerrainMovementPenalty,
|
|
33
|
+
|
|
34
|
+
// Line of Sight
|
|
35
|
+
checkLineOfSight: los.checkLineOfSight,
|
|
36
|
+
|
|
37
|
+
// Movement
|
|
38
|
+
DEFAULT_BASE_RADIUS: movement.DEFAULT_BASE_RADIUS,
|
|
39
|
+
isPositionOccupied: movement.isPositionOccupied,
|
|
40
|
+
isInFriendlyExclusionZone: movement.isInFriendlyExclusionZone,
|
|
41
|
+
isInEnemyExclusionZone: movement.isInEnemyExclusionZone,
|
|
42
|
+
isPathBlockedByEnemies: movement.isPathBlockedByEnemies,
|
|
43
|
+
validateWaypointPath: movement.validateWaypointPath,
|
|
44
|
+
};
|
package/src/los.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @boltr/geometry - Line of Sight checks
|
|
3
|
+
*
|
|
4
|
+
* Determines cover status between a shooter and target based on
|
|
5
|
+
* intervening terrain.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
lineIntersectsPolygon,
|
|
10
|
+
pointToPolygonDistance,
|
|
11
|
+
} = require('./primitives');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check line of sight between shooter and target through terrain.
|
|
15
|
+
*
|
|
16
|
+
* Cover shooting mechanics:
|
|
17
|
+
* - Shooter bracing on cover (within 1"): shoots across without penalty
|
|
18
|
+
* - Target in cover (within 1"): gets defense bonus
|
|
19
|
+
* - Heavy cover blocks LOS when neither operative is within 1"
|
|
20
|
+
* - Light cover at distance has no effect (OBSCURED removed)
|
|
21
|
+
*
|
|
22
|
+
* @param {{position: {x: number, y: number}}} shooter
|
|
23
|
+
* @param {{position: {x: number, y: number}}} target
|
|
24
|
+
* @param {Array} terrain
|
|
25
|
+
* @returns {'CLEAR'|'LIGHT_COVER'|'HEAVY_COVER'|'BLOCKED'}
|
|
26
|
+
*/
|
|
27
|
+
function checkLineOfSight(shooter, target, terrain) {
|
|
28
|
+
if (!terrain || !Array.isArray(terrain) || terrain.length === 0) return 'CLEAR';
|
|
29
|
+
|
|
30
|
+
for (const piece of terrain) {
|
|
31
|
+
if (!piece.vertices || !Array.isArray(piece.vertices)) continue;
|
|
32
|
+
if (!lineIntersectsPolygon(shooter.position, target.position, piece.vertices)) continue;
|
|
33
|
+
|
|
34
|
+
const terrainType = piece.type || 'lightCover';
|
|
35
|
+
|
|
36
|
+
// Only lightCover and heavyCover affect shooting
|
|
37
|
+
if (terrainType !== 'lightCover' && terrainType !== 'heavyCover') continue;
|
|
38
|
+
|
|
39
|
+
const shooterDist = pointToPolygonDistance(shooter.position, piece.vertices);
|
|
40
|
+
const targetDist = pointToPolygonDistance(target.position, piece.vertices);
|
|
41
|
+
|
|
42
|
+
// Shooter bracing on cover - no penalty, skip this terrain piece
|
|
43
|
+
if (shooterDist <= 1.0) continue;
|
|
44
|
+
|
|
45
|
+
// Target in cover - defense bonus applies
|
|
46
|
+
if (targetDist <= 1.0) {
|
|
47
|
+
if (terrainType === 'heavyCover') return 'HEAVY_COVER';
|
|
48
|
+
if (terrainType === 'lightCover') return 'LIGHT_COVER';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Neither in contact with heavy cover - blocks LOS
|
|
52
|
+
if (terrainType === 'heavyCover') return 'BLOCKED';
|
|
53
|
+
|
|
54
|
+
// lightCover at distance - no effect (OBSCURED removed)
|
|
55
|
+
}
|
|
56
|
+
return 'CLEAR';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
checkLineOfSight,
|
|
61
|
+
};
|
package/src/movement.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @boltr/geometry - Movement validation helpers
|
|
3
|
+
*
|
|
4
|
+
* Waypoint-based movement validation, enemy path blocking,
|
|
5
|
+
* and friendly spacing checks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
calculateDistance,
|
|
10
|
+
lineSegmentIntersectsCircle,
|
|
11
|
+
lineIntersectsPolygon,
|
|
12
|
+
isBaseOverlappingTerrain,
|
|
13
|
+
} = require('./primitives');
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
checkTargetOnBlockedTerrain,
|
|
17
|
+
checkMovementBlocked,
|
|
18
|
+
countPenaltyTerrainPieces,
|
|
19
|
+
} = require('./terrain');
|
|
20
|
+
|
|
21
|
+
/** Default base radius when not specified on a unit */
|
|
22
|
+
const DEFAULT_BASE_RADIUS = 0.5;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a position's base overlaps another unit's base.
|
|
26
|
+
* Two bases overlap when the distance between centers is less than
|
|
27
|
+
* the sum of their radii.
|
|
28
|
+
*
|
|
29
|
+
* @param {{x: number, y: number}} position
|
|
30
|
+
* @param {number} baseRadius
|
|
31
|
+
* @param {Array} units Game state units array
|
|
32
|
+
* @param {number|null} excludeUnitIndex Unit to exclude (self)
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
function isPositionOccupied(position, baseRadius, units, excludeUnitIndex) {
|
|
36
|
+
return units.some(unit => {
|
|
37
|
+
if (unit.eliminated) return false;
|
|
38
|
+
if (excludeUnitIndex !== undefined && unit.unit_index === excludeUnitIndex) return false;
|
|
39
|
+
const unitRadius = unit.base_radius || DEFAULT_BASE_RADIUS;
|
|
40
|
+
return calculateDistance(unit.position, position) < (baseRadius + unitRadius);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if a position is within the exclusion zone of any friendly operative.
|
|
46
|
+
* Uses sum-of-radii (#54): two friendly bases cannot overlap.
|
|
47
|
+
*
|
|
48
|
+
* @param {{x: number, y: number}} position
|
|
49
|
+
* @param {object} operative The moving operative
|
|
50
|
+
* @param {Array} units
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function isInFriendlyExclusionZone(position, operative, units) {
|
|
54
|
+
const selfRadius = operative.base_radius || DEFAULT_BASE_RADIUS;
|
|
55
|
+
return units.some(friendly =>
|
|
56
|
+
friendly.player_id === operative.player_id &&
|
|
57
|
+
friendly.unit_index !== operative.unit_index &&
|
|
58
|
+
!friendly.eliminated &&
|
|
59
|
+
calculateDistance(position, friendly.position) < (selfRadius + (friendly.base_radius || DEFAULT_BASE_RADIUS))
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if position is within 1" exclusion zone of any enemy operative.
|
|
65
|
+
*
|
|
66
|
+
* @param {{x: number, y: number}} position
|
|
67
|
+
* @param {object} operative The moving operative
|
|
68
|
+
* @param {Array} units
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
function isInEnemyExclusionZone(position, operative, units) {
|
|
72
|
+
return units.some(enemy =>
|
|
73
|
+
enemy.player_id !== operative.player_id &&
|
|
74
|
+
!enemy.eliminated &&
|
|
75
|
+
calculateDistance(position, enemy.position) < 1
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if a straight-line path is blocked by any enemy operatives (#56).
|
|
81
|
+
* An enemy blocks a path when the line segment comes within
|
|
82
|
+
* (enemy.base_radius + 1.0) inches of the enemy center.
|
|
83
|
+
*
|
|
84
|
+
* @param {{x: number, y: number}} startPos
|
|
85
|
+
* @param {{x: number, y: number}} endPos
|
|
86
|
+
* @param {object} operative The moving operative
|
|
87
|
+
* @param {Array} units All game units
|
|
88
|
+
* @param {number|null} exemptUnitIndex Charge target to exempt
|
|
89
|
+
* @returns {{blocked: boolean, blocker?: object}}
|
|
90
|
+
*/
|
|
91
|
+
function isPathBlockedByEnemies(startPos, endPos, operative, units, exemptUnitIndex) {
|
|
92
|
+
for (const enemy of units) {
|
|
93
|
+
if (enemy.player_id === operative.player_id) continue;
|
|
94
|
+
if (enemy.eliminated) continue;
|
|
95
|
+
if (exemptUnitIndex !== undefined && enemy.unit_index === exemptUnitIndex) continue;
|
|
96
|
+
|
|
97
|
+
const blockingRadius = (enemy.base_radius || DEFAULT_BASE_RADIUS) + 1.0;
|
|
98
|
+
if (lineSegmentIntersectsCircle(startPos, endPos, enemy.position, blockingRadius)) {
|
|
99
|
+
return { blocked: true, blocker: enemy };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { blocked: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate a waypoint path: checks each segment for terrain blocking,
|
|
107
|
+
* enemy path blocking, and computes total distance with per-piece penalty.
|
|
108
|
+
*
|
|
109
|
+
* @param {{x: number, y: number}} startPos Operative's current position
|
|
110
|
+
* @param {{x: number, y: number}[]} waypoints Array of waypoint positions
|
|
111
|
+
* @param {number} baseRadius Operative's base radius
|
|
112
|
+
* @param {number} maxDistance Maximum allowed movement distance
|
|
113
|
+
* @param {object} operative The moving operative
|
|
114
|
+
* @param {Array} units All game units
|
|
115
|
+
* @param {Array} terrain Terrain array from map_data
|
|
116
|
+
* @param {object} [mapData] Map data for bounds checking
|
|
117
|
+
* @param {object} [options] Options
|
|
118
|
+
* @param {number|null} [options.exemptEnemyUnitIndex] Enemy unit to exempt from path blocking (charge target)
|
|
119
|
+
* @param {boolean} [options.checkEnemyBlocking=true] Whether to check enemy path blocking
|
|
120
|
+
* @param {Function} [options.isWithinBounds] Bounds-checking function
|
|
121
|
+
* @returns {{valid: boolean, error?: string, totalDistance?: number}}
|
|
122
|
+
*/
|
|
123
|
+
function validateWaypointPath(startPos, waypoints, baseRadius, maxDistance, operative, units, terrain, mapData, options) {
|
|
124
|
+
const opts = options || {};
|
|
125
|
+
const checkEnemyBlocking = opts.checkEnemyBlocking !== false;
|
|
126
|
+
const exemptEnemyUnitIndex = opts.exemptEnemyUnitIndex;
|
|
127
|
+
const isWithinBounds = opts.isWithinBounds;
|
|
128
|
+
|
|
129
|
+
if (!waypoints || !Array.isArray(waypoints) || waypoints.length === 0) {
|
|
130
|
+
return { valid: false, error: 'Movement requires at least one waypoint' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let totalDistance = 0;
|
|
134
|
+
const terrainPiecesCrossed = new Set();
|
|
135
|
+
let currentPos = startPos;
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < waypoints.length; i++) {
|
|
138
|
+
const wp = waypoints[i];
|
|
139
|
+
const isFinal = (i === waypoints.length - 1);
|
|
140
|
+
|
|
141
|
+
// Validate waypoint format
|
|
142
|
+
if (!wp || typeof wp.x !== 'number' || typeof wp.y !== 'number') {
|
|
143
|
+
return { valid: false, error: `Invalid waypoint format at index ${i}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Bounds check
|
|
147
|
+
if (isWithinBounds && !isWithinBounds(wp, mapData)) {
|
|
148
|
+
return { valid: false, error: `Waypoint ${i} is outside battlefield bounds` };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check path segment for impassable terrain blocking
|
|
152
|
+
const blocked = checkMovementBlocked(currentPos, wp, baseRadius, terrain);
|
|
153
|
+
if (blocked) {
|
|
154
|
+
return { valid: false, error: `Movement blocked by impassable terrain at segment ${i}` };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check enemy path blocking (#56)
|
|
158
|
+
if (checkEnemyBlocking) {
|
|
159
|
+
const enemyBlock = isPathBlockedByEnemies(currentPos, wp, operative, units, exemptEnemyUnitIndex);
|
|
160
|
+
if (enemyBlock.blocked) {
|
|
161
|
+
return { valid: false, error: `Movement path blocked by enemy operative (unit ${enemyBlock.blocker.unit_index})` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Final waypoint: check destination terrain and spacing
|
|
166
|
+
if (isFinal) {
|
|
167
|
+
const blockedType = checkTargetOnBlockedTerrain(wp, baseRadius, terrain);
|
|
168
|
+
if (blockedType) {
|
|
169
|
+
return { valid: false, error: `Cannot end movement on ${blockedType} terrain` };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (isPositionOccupied(wp, baseRadius, units, operative.unit_index)) {
|
|
173
|
+
return { valid: false, error: 'Target position is occupied by another operative' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (isInFriendlyExclusionZone(wp, operative, units)) {
|
|
177
|
+
return { valid: false, error: 'Cannot end movement within exclusion zone of friendly operative' };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Intermediate waypoints: full validation
|
|
182
|
+
if (!isFinal) {
|
|
183
|
+
const blockedType = checkTargetOnBlockedTerrain(wp, baseRadius, terrain);
|
|
184
|
+
if (blockedType) {
|
|
185
|
+
return { valid: false, error: `Cannot pass through ${blockedType} terrain at waypoint ${i}` };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Accumulate terrain penalty pieces for this segment
|
|
190
|
+
if (terrain && Array.isArray(terrain)) {
|
|
191
|
+
const PENALTY_TERRAIN_TYPES = ['difficult', 'lightCover', 'heavyCover'];
|
|
192
|
+
for (const piece of terrain) {
|
|
193
|
+
if (!piece.vertices || !Array.isArray(piece.vertices)) continue;
|
|
194
|
+
if (!PENALTY_TERRAIN_TYPES.includes(piece.type)) continue;
|
|
195
|
+
if (lineIntersectsPolygon(currentPos, wp, piece.vertices)) {
|
|
196
|
+
// Use a stable identifier for the terrain piece
|
|
197
|
+
const pieceId = piece.id || `${piece.type}_${piece.vertices[0].x}_${piece.vertices[0].y}`;
|
|
198
|
+
terrainPiecesCrossed.add(pieceId);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Accumulate segment distance
|
|
204
|
+
totalDistance += calculateDistance(currentPos, wp);
|
|
205
|
+
currentPos = wp;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Apply per-terrain-piece penalty: +2" per unique piece crossed
|
|
209
|
+
const TERRAIN_PENALTY_PER_PIECE = 2;
|
|
210
|
+
const effectiveDistance = totalDistance + (terrainPiecesCrossed.size * TERRAIN_PENALTY_PER_PIECE);
|
|
211
|
+
|
|
212
|
+
if (effectiveDistance > maxDistance) {
|
|
213
|
+
if (terrainPiecesCrossed.size > 0) {
|
|
214
|
+
return {
|
|
215
|
+
valid: false,
|
|
216
|
+
error: `Movement through terrain adds ${terrainPiecesCrossed.size * TERRAIN_PENALTY_PER_PIECE} inches penalty (${totalDistance.toFixed(1)} + ${terrainPiecesCrossed.size * TERRAIN_PENALTY_PER_PIECE} > ${maxDistance})`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
valid: false,
|
|
221
|
+
error: `Movement distance ${totalDistance.toFixed(1)} inches exceeds maximum ${maxDistance} inches`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { valid: true, totalDistance, effectiveDistance, terrainPenalty: terrainPiecesCrossed.size * TERRAIN_PENALTY_PER_PIECE };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
DEFAULT_BASE_RADIUS,
|
|
230
|
+
isPositionOccupied,
|
|
231
|
+
isInFriendlyExclusionZone,
|
|
232
|
+
isInEnemyExclusionZone,
|
|
233
|
+
isPathBlockedByEnemies,
|
|
234
|
+
validateWaypointPath,
|
|
235
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @boltr/geometry - Pure geometry primitives
|
|
3
|
+
*
|
|
4
|
+
* These are stateless math functions with no game-logic dependencies.
|
|
5
|
+
* Every function takes plain {x, y} point objects and vertex arrays.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Euclidean distance between two points.
|
|
10
|
+
* @param {{x: number, y: number}} pos1
|
|
11
|
+
* @param {{x: number, y: number}} pos2
|
|
12
|
+
* @returns {number}
|
|
13
|
+
*/
|
|
14
|
+
function calculateDistance(pos1, pos2) {
|
|
15
|
+
return Math.sqrt(Math.pow(pos2.x - pos1.x, 2) + Math.pow(pos2.y - pos1.y, 2));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Point-in-polygon test (ray casting algorithm).
|
|
20
|
+
* @param {{x: number, y: number}} point
|
|
21
|
+
* @param {{x: number, y: number}[]} vertices
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
function isPointInPolygon(point, vertices) {
|
|
25
|
+
if (!vertices || !Array.isArray(vertices) || vertices.length < 3) return false;
|
|
26
|
+
let inside = false;
|
|
27
|
+
for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
|
|
28
|
+
const xi = vertices[i].x, yi = vertices[i].y;
|
|
29
|
+
const xj = vertices[j].x, yj = vertices[j].y;
|
|
30
|
+
if (((yi > point.y) !== (yj > point.y)) &&
|
|
31
|
+
(point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi)) {
|
|
32
|
+
inside = !inside;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return inside;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Test whether two line segments intersect (exclusive of endpoints).
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
function lineSegmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
|
|
43
|
+
const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
|
|
44
|
+
if (Math.abs(denom) < 0.0001) return false;
|
|
45
|
+
const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom;
|
|
46
|
+
const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom;
|
|
47
|
+
return ua > 0.001 && ua < 0.999 && ub > 0.001 && ub < 0.999;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Test whether a line segment intersects any edge of a polygon.
|
|
52
|
+
* @param {{x: number, y: number}} lineStart
|
|
53
|
+
* @param {{x: number, y: number}} lineEnd
|
|
54
|
+
* @param {{x: number, y: number}[]} vertices
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
function lineIntersectsPolygon(lineStart, lineEnd, vertices) {
|
|
58
|
+
if (!vertices || !Array.isArray(vertices) || vertices.length < 3) return false;
|
|
59
|
+
for (let i = 0; i < vertices.length; i++) {
|
|
60
|
+
const j = (i + 1) % vertices.length;
|
|
61
|
+
if (lineSegmentsIntersect(
|
|
62
|
+
lineStart.x, lineStart.y, lineEnd.x, lineEnd.y,
|
|
63
|
+
vertices[i].x, vertices[i].y, vertices[j].x, vertices[j].y
|
|
64
|
+
)) return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shortest distance from a point to a line segment.
|
|
71
|
+
* @param {{x: number, y: number}} point
|
|
72
|
+
* @param {{x: number, y: number}} lineStart
|
|
73
|
+
* @param {{x: number, y: number}} lineEnd
|
|
74
|
+
* @returns {number}
|
|
75
|
+
*/
|
|
76
|
+
function pointToLineSegmentDistance(point, lineStart, lineEnd) {
|
|
77
|
+
const dx = lineEnd.x - lineStart.x;
|
|
78
|
+
const dy = lineEnd.y - lineStart.y;
|
|
79
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
80
|
+
if (lengthSquared === 0) return calculateDistance(point, lineStart);
|
|
81
|
+
let t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / lengthSquared;
|
|
82
|
+
t = Math.max(0, Math.min(1, t));
|
|
83
|
+
const projection = { x: lineStart.x + t * dx, y: lineStart.y + t * dy };
|
|
84
|
+
return calculateDistance(point, projection);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Shortest distance from a point to the boundary of a polygon.
|
|
89
|
+
* @param {{x: number, y: number}} point
|
|
90
|
+
* @param {{x: number, y: number}[]} vertices
|
|
91
|
+
* @returns {number}
|
|
92
|
+
*/
|
|
93
|
+
function pointToPolygonDistance(point, vertices) {
|
|
94
|
+
if (!vertices || !Array.isArray(vertices) || vertices.length < 3) return Infinity;
|
|
95
|
+
let minDist = Infinity;
|
|
96
|
+
for (let i = 0; i < vertices.length; i++) {
|
|
97
|
+
const j = (i + 1) % vertices.length;
|
|
98
|
+
const dist = pointToLineSegmentDistance(point, vertices[i], vertices[j]);
|
|
99
|
+
if (dist < minDist) minDist = dist;
|
|
100
|
+
}
|
|
101
|
+
return minDist;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Test whether a line segment intersects a circle.
|
|
106
|
+
* Used for path-vs-enemy blocking checks (#56).
|
|
107
|
+
* @param {{x: number, y: number}} lineStart
|
|
108
|
+
* @param {{x: number, y: number}} lineEnd
|
|
109
|
+
* @param {{x: number, y: number}} center Circle center
|
|
110
|
+
* @param {number} radius Circle radius
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
function lineSegmentIntersectsCircle(lineStart, lineEnd, center, radius) {
|
|
114
|
+
const dist = pointToLineSegmentDistance(center, lineStart, lineEnd);
|
|
115
|
+
return dist < radius;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Test whether a base circle overlaps a terrain polygon.
|
|
120
|
+
* Minkowski-equivalent: the center is inside the polygon OR
|
|
121
|
+
* the center-to-boundary distance is less than the base radius.
|
|
122
|
+
* Used for terrain exclusion with operative base size (#55).
|
|
123
|
+
* @param {{x: number, y: number}} position Center of base
|
|
124
|
+
* @param {number} baseRadius Radius of base
|
|
125
|
+
* @param {{x: number, y: number}[]} vertices Polygon vertices
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
function isBaseOverlappingTerrain(position, baseRadius, vertices) {
|
|
129
|
+
if (isPointInPolygon(position, vertices)) return true;
|
|
130
|
+
return pointToPolygonDistance(position, vertices) < baseRadius;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
calculateDistance,
|
|
135
|
+
isPointInPolygon,
|
|
136
|
+
lineSegmentsIntersect,
|
|
137
|
+
lineIntersectsPolygon,
|
|
138
|
+
pointToLineSegmentDistance,
|
|
139
|
+
pointToPolygonDistance,
|
|
140
|
+
lineSegmentIntersectsCircle,
|
|
141
|
+
isBaseOverlappingTerrain,
|
|
142
|
+
};
|
package/src/terrain.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @boltr/geometry - Terrain interaction helpers
|
|
3
|
+
*
|
|
4
|
+
* Functions that check how movement paths and positions interact
|
|
5
|
+
* with terrain polygons on the battlefield.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
isPointInPolygon,
|
|
10
|
+
lineIntersectsPolygon,
|
|
11
|
+
isBaseOverlappingTerrain,
|
|
12
|
+
} = require('./primitives');
|
|
13
|
+
|
|
14
|
+
/** Terrain types that block placement / ending movement */
|
|
15
|
+
const BLOCKED_TERRAIN_TYPES = ['lightCover', 'heavyCover', 'impassable'];
|
|
16
|
+
|
|
17
|
+
/** Terrain types that add a movement penalty when crossed */
|
|
18
|
+
const PENALTY_TERRAIN_TYPES = ['difficult', 'lightCover', 'heavyCover'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check whether a position's base circle sits on blocked terrain.
|
|
22
|
+
* Returns the terrain type string if blocked, or null.
|
|
23
|
+
*
|
|
24
|
+
* @param {{x: number, y: number}} position
|
|
25
|
+
* @param {number} baseRadius
|
|
26
|
+
* @param {Array} terrain
|
|
27
|
+
* @returns {string|null}
|
|
28
|
+
*/
|
|
29
|
+
function checkTargetOnBlockedTerrain(position, baseRadius, terrain) {
|
|
30
|
+
if (!terrain || !Array.isArray(terrain) || terrain.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
for (const piece of terrain) {
|
|
33
|
+
if (!piece.vertices || !Array.isArray(piece.vertices)) continue;
|
|
34
|
+
if (!BLOCKED_TERRAIN_TYPES.includes(piece.type)) continue;
|
|
35
|
+
if (isBaseOverlappingTerrain(position, baseRadius, piece.vertices)) {
|
|
36
|
+
return piece.type;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check whether a straight-line movement path is blocked by impassable terrain.
|
|
44
|
+
* Also checks whether the destination base overlaps impassable terrain.
|
|
45
|
+
*
|
|
46
|
+
* @param {{x: number, y: number}} startPos
|
|
47
|
+
* @param {{x: number, y: number}} endPos
|
|
48
|
+
* @param {number} baseRadius
|
|
49
|
+
* @param {Array} terrain
|
|
50
|
+
* @returns {'path_blocked'|'target_blocked'|null}
|
|
51
|
+
*/
|
|
52
|
+
function checkMovementBlocked(startPos, endPos, baseRadius, terrain) {
|
|
53
|
+
if (!terrain || !Array.isArray(terrain) || terrain.length === 0) return null;
|
|
54
|
+
|
|
55
|
+
for (const piece of terrain) {
|
|
56
|
+
if (!piece.vertices || !Array.isArray(piece.vertices)) continue;
|
|
57
|
+
if (piece.type !== 'impassable') continue;
|
|
58
|
+
|
|
59
|
+
if (lineIntersectsPolygon(startPos, endPos, piece.vertices)) {
|
|
60
|
+
return 'path_blocked';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (isBaseOverlappingTerrain(endPos, baseRadius, piece.vertices)) {
|
|
64
|
+
return 'target_blocked';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Count distinct terrain pieces that impose a movement penalty along a path segment.
|
|
72
|
+
* Returns the number of unique penalty terrain pieces crossed.
|
|
73
|
+
*
|
|
74
|
+
* @param {{x: number, y: number}} startPos
|
|
75
|
+
* @param {{x: number, y: number}} endPos
|
|
76
|
+
* @param {Array} terrain
|
|
77
|
+
* @returns {number}
|
|
78
|
+
*/
|
|
79
|
+
function countPenaltyTerrainPieces(startPos, endPos, terrain) {
|
|
80
|
+
if (!terrain || !Array.isArray(terrain) || terrain.length === 0) return 0;
|
|
81
|
+
|
|
82
|
+
let count = 0;
|
|
83
|
+
for (const piece of terrain) {
|
|
84
|
+
if (!piece.vertices || !Array.isArray(piece.vertices)) continue;
|
|
85
|
+
if (!PENALTY_TERRAIN_TYPES.includes(piece.type)) continue;
|
|
86
|
+
if (lineIntersectsPolygon(startPos, endPos, piece.vertices)) {
|
|
87
|
+
count++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return count;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Legacy helper: returns boolean if ANY penalty terrain is crossed.
|
|
95
|
+
* Kept for backward compat during transition to per-piece penalty.
|
|
96
|
+
*/
|
|
97
|
+
function checkTerrainMovementPenalty(startPos, endPos, terrain) {
|
|
98
|
+
return countPenaltyTerrainPieces(startPos, endPos, terrain) > 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
BLOCKED_TERRAIN_TYPES,
|
|
103
|
+
PENALTY_TERRAIN_TYPES,
|
|
104
|
+
checkTargetOnBlockedTerrain,
|
|
105
|
+
checkMovementBlocked,
|
|
106
|
+
countPenaltyTerrainPieces,
|
|
107
|
+
checkTerrainMovementPenalty,
|
|
108
|
+
};
|