Skip to content

Commit b0825d9

Browse files
author
DavidQ
committed
Move vector collision accuracy to shared handling and fix bezel visibility outside fullscreen - PR_26133_108-shared-vector-collision-and-bezel-fix
1 parent f010507 commit b0825d9

7 files changed

Lines changed: 246 additions & 161 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# PR_26133_108-shared-vector-collision-and-bezel-fix
2+
3+
## Summary
4+
- Promoted concave vector polygon collision accuracy into the shared `arePolygonsColliding` engine path.
5+
- Removed the duplicated Asteroids-only vector collision helpers so Asteroids bullet, UFO, ship, and asteroid polygon collisions all use the shared collision path.
6+
- Hardened fullscreen bezel image attachment so the overlay is tracked before loading and syncs visible immediately after image readiness in normal and fullscreen modes.
7+
- Added targeted shared collision, Asteroids collision, and Asteroids bezel assertions.
8+
9+
## Scope Notes
10+
- Read `docs/dev/PROJECT_INSTRUCTIONS.md` before implementation.
11+
- Used the PR_26133_107 report/current baseline as prior reference; `tmp/PR_26133_107-asteroids-bezel-collision-fixes_delta.zip` was not present in this workspace.
12+
- Scope stayed limited to shared vector collision accuracy and bezel visibility.
13+
- No sample JSON, `start_of_day`, workspace manifest/schema contracts, or `imageDataUrl` contracts were changed.
14+
15+
## Playwright Impact
16+
- Playwright impacted: Yes.
17+
- Validated Asteroids normal-page bezel display, fullscreen bezel display, fullscreen transparent-window canvas fit, and Object Vector Asteroids runtime rendering.
18+
- Expected pass behavior: the actual bezel image is mounted, `display:block`, and sized in both normal page window mode and fullscreen mode; shared vector collision detects concave edge overlaps and avoids concave notch false positives.
19+
- Expected fail behavior: tests fail if the bezel is fullscreen-only, the overlay image is not visibly mounted, fullscreen canvas fit regresses, or vector polygon collision falls back to convex-only SAT behavior.
20+
21+
## Validation Results
22+
- PASS `node --check src/engine/collision/polygon.js`
23+
- PASS `node --check games/Asteroids/game/AsteroidsWorld.js`
24+
- PASS `node --check src/engine/runtime/fullscreenBezel.js`
25+
- PASS `node --check tests/final/PrecisionCollisionSystems.test.mjs`
26+
- PASS `node --check tests/games/AsteroidsCollisionTimingStress.test.mjs`
27+
- PASS `node --input-type=module -e "import { run } from './tests/final/PrecisionCollisionSystems.test.mjs'; run();"`
28+
- PASS `node --input-type=module -e "import { run } from './tests/games/AsteroidsCollisionTimingStress.test.mjs'; run();"`
29+
- PASS `npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs --grep "fits the game canvas inside the fullscreen play area|loads Object Vector Studio V2 runtime assets into Asteroids gameplay rendering" --project=playwright --workers=1 --reporter=list` (2 passed)
30+
- PASS `npm run test:workspace-v2` (56 passed)
31+
- PASS `git diff --check`
32+
- PASS Playwright V8 coverage report generated at `docs/dev/reports/playwright_v8_coverage_report.txt`.
33+
- `(57%) games/Asteroids/game/AsteroidsWorld.js - changed JS file with browser V8 coverage`
34+
- `(88%) src/engine/runtime/fullscreenBezel.js - changed JS file with browser V8 coverage`
35+
- `(91%) src/engine/collision/polygon.js - changed JS file with browser V8 coverage`
36+
37+
## Manual Validation
38+
1. Open `/games/Asteroids/index.html`.
39+
2. Confirm the bezel is visible around the canvas before entering fullscreen.
40+
3. Click the canvas to enter fullscreen and confirm the bezel remains visible and the canvas fits inside the transparent bezel window.
41+
4. Start a game and verify slow or stationary ship overlap with the visible large-asteroid vector edge destroys the ship.
42+
5. Verify bullets and UFOs do not trigger hits while sitting in the concave empty notch of the visible large asteroid.
43+
44+
## Full Samples Smoke
45+
- Skipped as requested.
46+
- Reason: this PR is limited to shared vector collision behavior, Asteroids collision/bezel validation, and does not modify shared sample loading, sample JSON, or broad sample runtime behavior.

games/Asteroids/game/AsteroidsWorld.js

Lines changed: 1 addition & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ const WAVE_SPAWN_MARGIN_Y = 120;
3131
const WAVE_SPAWN_ATTEMPTS = 60;
3232
const ASTEROID_SPAWN_SAFE_PADDING = 24;
3333
const MAX_UPDATE_STEP_SECONDS = 1 / 60;
34-
const VECTOR_COLLISION_EPSILON = 0.000001;
3534
let hasLoggedWorldConstruction = false;
3635
let hasLoggedWorldStartGame = false;
3736

@@ -136,116 +135,6 @@ function getRectOverlapDepth(x, y, radius, rect) {
136135
return Math.max(0, Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom));
137136
}
138137

139-
function isFiniteVectorPoint(point) {
140-
return Number.isFinite(point?.x) && Number.isFinite(point?.y);
141-
}
142-
143-
function areVectorPointsEqual(left, right) {
144-
return Math.abs(left.x - right.x) <= VECTOR_COLLISION_EPSILON
145-
&& Math.abs(left.y - right.y) <= VECTOR_COLLISION_EPSILON;
146-
}
147-
148-
function normalizeVectorPolygon(points) {
149-
if (!Array.isArray(points)) {
150-
return [];
151-
}
152-
153-
const polygon = points
154-
.filter(isFiniteVectorPoint)
155-
.map((point) => ({ x: point.x, y: point.y }));
156-
157-
if (polygon.length > 1 && areVectorPointsEqual(polygon[0], polygon[polygon.length - 1])) {
158-
polygon.pop();
159-
}
160-
161-
return polygon;
162-
}
163-
164-
function vectorCross(start, end, point) {
165-
return ((end.x - start.x) * (point.y - start.y)) - ((end.y - start.y) * (point.x - start.x));
166-
}
167-
168-
function isPointOnVectorSegment(point, start, end) {
169-
if (Math.abs(vectorCross(start, end, point)) > VECTOR_COLLISION_EPSILON) {
170-
return false;
171-
}
172-
173-
return point.x >= Math.min(start.x, end.x) - VECTOR_COLLISION_EPSILON
174-
&& point.x <= Math.max(start.x, end.x) + VECTOR_COLLISION_EPSILON
175-
&& point.y >= Math.min(start.y, end.y) - VECTOR_COLLISION_EPSILON
176-
&& point.y <= Math.max(start.y, end.y) + VECTOR_COLLISION_EPSILON;
177-
}
178-
179-
function areVectorSegmentsIntersecting(leftStart, leftEnd, rightStart, rightEnd) {
180-
const leftStartToRightStart = vectorCross(leftStart, leftEnd, rightStart);
181-
const leftStartToRightEnd = vectorCross(leftStart, leftEnd, rightEnd);
182-
const rightStartToLeftStart = vectorCross(rightStart, rightEnd, leftStart);
183-
const rightStartToLeftEnd = vectorCross(rightStart, rightEnd, leftEnd);
184-
185-
if (isPointOnVectorSegment(rightStart, leftStart, leftEnd)
186-
|| isPointOnVectorSegment(rightEnd, leftStart, leftEnd)
187-
|| isPointOnVectorSegment(leftStart, rightStart, rightEnd)
188-
|| isPointOnVectorSegment(leftEnd, rightStart, rightEnd)) {
189-
return true;
190-
}
191-
192-
return ((leftStartToRightStart > VECTOR_COLLISION_EPSILON && leftStartToRightEnd < -VECTOR_COLLISION_EPSILON)
193-
|| (leftStartToRightStart < -VECTOR_COLLISION_EPSILON && leftStartToRightEnd > VECTOR_COLLISION_EPSILON))
194-
&& ((rightStartToLeftStart > VECTOR_COLLISION_EPSILON && rightStartToLeftEnd < -VECTOR_COLLISION_EPSILON)
195-
|| (rightStartToLeftStart < -VECTOR_COLLISION_EPSILON && rightStartToLeftEnd > VECTOR_COLLISION_EPSILON));
196-
}
197-
198-
function isPointInsideVectorPolygon(point, polygon) {
199-
let inside = false;
200-
201-
for (let index = 0, previousIndex = polygon.length - 1; index < polygon.length; previousIndex = index, index += 1) {
202-
const current = polygon[index];
203-
const previous = polygon[previousIndex];
204-
205-
if (isPointOnVectorSegment(point, previous, current)) {
206-
return true;
207-
}
208-
209-
const crossesRay = (current.y > point.y) !== (previous.y > point.y);
210-
if (crossesRay) {
211-
const rayX = ((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x;
212-
if (point.x < rayX) {
213-
inside = !inside;
214-
}
215-
}
216-
}
217-
218-
return inside;
219-
}
220-
221-
function areVectorPolygonsOverlapping(leftPoints, rightPoints) {
222-
const leftPolygon = normalizeVectorPolygon(leftPoints);
223-
const rightPolygon = normalizeVectorPolygon(rightPoints);
224-
225-
if (leftPolygon.length < 3 || rightPolygon.length < 3) {
226-
return false;
227-
}
228-
229-
for (let leftIndex = 0; leftIndex < leftPolygon.length; leftIndex += 1) {
230-
const leftStart = leftPolygon[leftIndex];
231-
const leftEnd = leftPolygon[(leftIndex + 1) % leftPolygon.length];
232-
233-
for (let rightIndex = 0; rightIndex < rightPolygon.length; rightIndex += 1) {
234-
if (areVectorSegmentsIntersecting(
235-
leftStart,
236-
leftEnd,
237-
rightPolygon[rightIndex],
238-
rightPolygon[(rightIndex + 1) % rightPolygon.length]
239-
)) {
240-
return true;
241-
}
242-
}
243-
}
244-
245-
return leftPolygon.some((point) => isPointInsideVectorPolygon(point, rightPolygon))
246-
|| rightPolygon.some((point) => isPointInsideVectorPolygon(point, leftPolygon));
247-
}
248-
249138
export default class AsteroidsWorld {
250139
constructor(bounds, { rng = Math.random, asteroidGeometryProfiles = null } = {}) {
251140
if (!hasLoggedWorldConstruction) {
@@ -920,7 +809,7 @@ export default class AsteroidsWorld {
920809
const shipPolygon = this.ship.getPoints();
921810
for (let asteroidIndex = this.asteroids.length - 1; asteroidIndex >= 0; asteroidIndex -= 1) {
922811
const asteroid = this.asteroids[asteroidIndex];
923-
if (areVectorPolygonsOverlapping(shipPolygon, asteroid.getPoints())) {
812+
if (arePolygonsColliding(shipPolygon, asteroid.getPoints())) {
924813
const result = this.splitAsteroid(asteroidIndex);
925814
this.destroyShip();
926815
events.scoreEvents.push(result.points);

src/engine/collision/polygon.js

Lines changed: 109 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,65 @@ David Quesenberry
44
03/22/2026
55
polygon.js
66
*/
7-
function projectPolygon(points, axis) {
8-
let min = Infinity;
9-
let max = -Infinity;
10-
11-
for (const point of points) {
12-
const projection = (point.x * axis.x) + (point.y * axis.y);
13-
min = Math.min(min, projection);
14-
max = Math.max(max, projection);
7+
const POLYGON_COLLISION_EPSILON = 0.000001;
8+
9+
function isFinitePolygonPoint(point) {
10+
return Number.isFinite(point?.x) && Number.isFinite(point?.y);
11+
}
12+
13+
function arePolygonPointsEqual(left, right) {
14+
return Math.abs(left.x - right.x) <= POLYGON_COLLISION_EPSILON
15+
&& Math.abs(left.y - right.y) <= POLYGON_COLLISION_EPSILON;
16+
}
17+
18+
function normalizePolygon(points) {
19+
if (!Array.isArray(points)) {
20+
return [];
21+
}
22+
23+
const polygon = points
24+
.filter(isFinitePolygonPoint)
25+
.map((point) => ({ x: point.x, y: point.y }));
26+
27+
if (polygon.length > 1 && arePolygonPointsEqual(polygon[0], polygon[polygon.length - 1])) {
28+
polygon.pop();
1529
}
1630

17-
return { min, max };
31+
return polygon;
1832
}
1933

20-
function getAxes(points) {
21-
const axes = [];
22-
23-
for (let index = 0; index < points.length; index += 1) {
24-
const current = points[index];
25-
const next = points[(index + 1) % points.length];
26-
const edge = {
27-
x: next.x - current.x,
28-
y: next.y - current.y,
29-
};
30-
const normal = {
31-
x: -edge.y,
32-
y: edge.x,
33-
};
34-
const length = Math.hypot(normal.x, normal.y) || 1;
35-
axes.push({
36-
x: normal.x / length,
37-
y: normal.y / length,
38-
});
34+
function polygonCross(start, end, point) {
35+
return ((end.x - start.x) * (point.y - start.y)) - ((end.y - start.y) * (point.x - start.x));
36+
}
37+
38+
function isPointOnPolygonSegment(point, start, end) {
39+
if (Math.abs(polygonCross(start, end, point)) > POLYGON_COLLISION_EPSILON) {
40+
return false;
41+
}
42+
43+
return point.x >= Math.min(start.x, end.x) - POLYGON_COLLISION_EPSILON
44+
&& point.x <= Math.max(start.x, end.x) + POLYGON_COLLISION_EPSILON
45+
&& point.y >= Math.min(start.y, end.y) - POLYGON_COLLISION_EPSILON
46+
&& point.y <= Math.max(start.y, end.y) + POLYGON_COLLISION_EPSILON;
47+
}
48+
49+
function arePolygonSegmentsIntersecting(leftStart, leftEnd, rightStart, rightEnd) {
50+
const leftStartToRightStart = polygonCross(leftStart, leftEnd, rightStart);
51+
const leftStartToRightEnd = polygonCross(leftStart, leftEnd, rightEnd);
52+
const rightStartToLeftStart = polygonCross(rightStart, rightEnd, leftStart);
53+
const rightStartToLeftEnd = polygonCross(rightStart, rightEnd, leftEnd);
54+
55+
if (isPointOnPolygonSegment(rightStart, leftStart, leftEnd)
56+
|| isPointOnPolygonSegment(rightEnd, leftStart, leftEnd)
57+
|| isPointOnPolygonSegment(leftStart, rightStart, rightEnd)
58+
|| isPointOnPolygonSegment(leftEnd, rightStart, rightEnd)) {
59+
return true;
3960
}
4061

41-
return axes;
62+
return ((leftStartToRightStart > POLYGON_COLLISION_EPSILON && leftStartToRightEnd < -POLYGON_COLLISION_EPSILON)
63+
|| (leftStartToRightStart < -POLYGON_COLLISION_EPSILON && leftStartToRightEnd > POLYGON_COLLISION_EPSILON))
64+
&& ((rightStartToLeftStart > POLYGON_COLLISION_EPSILON && rightStartToLeftEnd < -POLYGON_COLLISION_EPSILON)
65+
|| (rightStartToLeftStart < -POLYGON_COLLISION_EPSILON && rightStartToLeftEnd > POLYGON_COLLISION_EPSILON));
4266
}
4367

4468
export function getPolygonBounds(points) {
@@ -53,33 +77,72 @@ export function getPolygonBounds(points) {
5377
}
5478

5579
export function isPointInPolygon(point, polygon) {
80+
if (!isFinitePolygonPoint(point)) {
81+
return false;
82+
}
83+
84+
const normalizedPolygon = normalizePolygon(polygon);
85+
if (normalizedPolygon.length < 3) {
86+
return false;
87+
}
88+
5689
let inside = false;
5790

58-
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
59-
const xi = polygon[i].x;
60-
const yi = polygon[i].y;
61-
const xj = polygon[j].x;
62-
const yj = polygon[j].y;
63-
const intersects = ((yi > point.y) !== (yj > point.y))
64-
&& (point.x < ((xj - xi) * (point.y - yi)) / ((yj - yi) || 0.00001) + xi);
65-
if (intersects) {
66-
inside = !inside;
91+
for (let index = 0, previousIndex = normalizedPolygon.length - 1; index < normalizedPolygon.length; previousIndex = index, index += 1) {
92+
const current = normalizedPolygon[index];
93+
const previous = normalizedPolygon[previousIndex];
94+
95+
if (isPointOnPolygonSegment(point, previous, current)) {
96+
return true;
97+
}
98+
99+
const crossesRay = (current.y > point.y) !== (previous.y > point.y);
100+
if (crossesRay) {
101+
const rayX = ((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x;
102+
if (point.x < rayX) {
103+
inside = !inside;
104+
}
67105
}
68106
}
69107

70108
return inside;
71109
}
72110

73-
export function arePolygonsColliding(a, b) {
74-
const axes = [...getAxes(a), ...getAxes(b)];
111+
export function arePolygonsColliding(leftPoints, rightPoints) {
112+
const leftPolygon = normalizePolygon(leftPoints);
113+
const rightPolygon = normalizePolygon(rightPoints);
114+
115+
if (leftPolygon.length < 3 || rightPolygon.length < 3) {
116+
return false;
117+
}
118+
119+
for (let leftIndex = 0; leftIndex < leftPolygon.length; leftIndex += 1) {
120+
const leftStart = leftPolygon[leftIndex];
121+
const leftEnd = leftPolygon[(leftIndex + 1) % leftPolygon.length];
122+
123+
for (let rightIndex = 0; rightIndex < rightPolygon.length; rightIndex += 1) {
124+
if (arePolygonSegmentsIntersecting(
125+
leftStart,
126+
leftEnd,
127+
rightPolygon[rightIndex],
128+
rightPolygon[(rightIndex + 1) % rightPolygon.length]
129+
)) {
130+
return true;
131+
}
132+
}
133+
}
134+
135+
for (const point of leftPolygon) {
136+
if (isPointInPolygon(point, rightPolygon)) {
137+
return true;
138+
}
139+
}
75140

76-
for (const axis of axes) {
77-
const projectionA = projectPolygon(a, axis);
78-
const projectionB = projectPolygon(b, axis);
79-
if (projectionA.max < projectionB.min || projectionB.max < projectionA.min) {
80-
return false;
141+
for (const point of rightPolygon) {
142+
if (isPointInPolygon(point, leftPolygon)) {
143+
return true;
81144
}
82145
}
83146

84-
return true;
147+
return false;
85148
}

src/engine/runtime/fullscreenBezel.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,11 @@ export default class fullscreenBezel {
928928
this.refreshStretchConfig();
929929
this.ready = true;
930930
this.missing = false;
931+
const fullscreenElement = this.documentRef?.fullscreenElement || null;
932+
this.sync({
933+
fullscreenActive: !!fullscreenElement,
934+
fullscreenElement
935+
});
931936
};
932937
element.onerror = () => {
933938
this.ready = false;
@@ -937,10 +942,9 @@ export default class fullscreenBezel {
937942
this.imageSize = null;
938943
this.applyCanvasNormalLayout();
939944
};
940-
element.src = resolveRuntimeAssetUrl(this.path, this.documentRef);
941-
942-
this.host.appendChild?.(element);
943945
this.element = element;
946+
this.host.appendChild?.(element);
947+
element.src = resolveRuntimeAssetUrl(this.path, this.documentRef);
944948
}
945949

946950
detach() {

0 commit comments

Comments
 (0)