Skip to content

Commit f010507

Browse files
author
DavidQ
committed
Fix Asteroids page-window bezel visibility and low-speed vector collision accuracy - PR_26133_107-asteroids-bezel-collision-fixes
1 parent e21042f commit f010507

3 files changed

Lines changed: 169 additions & 1 deletion

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# PR_26133_107-asteroids-bezel-collision-fixes
2+
3+
## Summary
4+
- Preserved the PR_26133_106 Asteroids bezel behavior and validated that the bezel is visible in normal page window mode before fullscreen.
5+
- Replaced only the Asteroids player-ship vs asteroid collision branch with a local vector polygon overlap check that handles concave asteroid geometry by segment intersection and point containment.
6+
- Added a no-velocity regression case where the ship overlaps the visible concave edge of the large asteroid and must be destroyed immediately.
7+
8+
## Scope Notes
9+
- Read `docs/dev/PROJECT_INSTRUCTIONS.md` before implementation.
10+
- Used `tmp/PR_26133_106-asteroids-bezel-rounding-fixes_delta.zip` and `docs/dev/reports/PR_26133_106-asteroids-bezel-rounding-fixes_report.md` as the prior reference.
11+
- `docs/pr/BUILD_PR.md` is currently an unrelated Level 18 rebase note; this report follows the explicit PR_26133_107 BUILD command from the user.
12+
- Scope stayed limited to Asteroids bezel validation and Asteroids vector collision accuracy.
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 normal-page Asteroids bezel visibility, fullscreen transparent-window fit preservation, and Object Vector Asteroids runtime rendering through the existing targeted Workspace Manager V2 coverage.
18+
- Expected pass behavior: the bezel state is visible before fullscreen, fullscreen uses `transparent-window-fit`, Object Vector Asteroids rendering loads, and stationary/slow ship overlap with the visible asteroid polygon destroys the ship.
19+
- Expected fail behavior: tests fail if the bezel remains fullscreen-only, fullscreen canvas fit regresses, Object Vector Asteroids runtime rendering fails, or the no-velocity concave-edge ship collision is missed.
20+
21+
## Validation Results
22+
- PASS `node --check games/Asteroids/game/AsteroidsWorld.js`
23+
- PASS `node --check tests/games/AsteroidsCollisionTimingStress.test.mjs`
24+
- PASS `node --input-type=module -e "import { run } from './tests/games/AsteroidsCollisionTimingStress.test.mjs'; run();"`
25+
- 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)
26+
- PASS `npm run test:workspace-v2` (56 passed)
27+
- PASS `git diff --check`
28+
- PASS Playwright V8 coverage report generated at `docs/dev/reports/playwright_v8_coverage_report.txt`.
29+
- `(51%) games/Asteroids/game/AsteroidsWorld.js - changed JS file with browser V8 coverage`
30+
31+
## Manual Validation
32+
1. Open `/games/Asteroids/index.html`.
33+
2. Confirm the bezel is visible around the canvas before entering fullscreen.
34+
3. Click the canvas to enter fullscreen and confirm the canvas still fits inside the transparent bezel window.
35+
4. Start a game and let the ship drift slowly, or remain nearly stationary, into the visible edge of a large asteroid.
36+
5. Expected: the ship is destroyed as soon as its vector hull overlaps the visible vector asteroid polygon, including concave-edge overlap.
37+
38+
## Full Samples Smoke
39+
- Skipped as requested.
40+
- Reason: this PR is limited to Asteroids bezel/collision validation and does not modify the shared sample loader, sample JSON, or broad sample runtime behavior.

games/Asteroids/game/AsteroidsWorld.js

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ 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;
3435
let hasLoggedWorldConstruction = false;
3536
let hasLoggedWorldStartGame = false;
3637

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

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+
138249
export default class AsteroidsWorld {
139250
constructor(bounds, { rng = Math.random, asteroidGeometryProfiles = null } = {}) {
140251
if (!hasLoggedWorldConstruction) {
@@ -809,7 +920,7 @@ export default class AsteroidsWorld {
809920
const shipPolygon = this.ship.getPoints();
810921
for (let asteroidIndex = this.asteroids.length - 1; asteroidIndex >= 0; asteroidIndex -= 1) {
811922
const asteroid = this.asteroids[asteroidIndex];
812-
if (arePolygonsColliding(shipPolygon, asteroid.getPoints())) {
923+
if (areVectorPolygonsOverlapping(shipPolygon, asteroid.getPoints())) {
813924
const result = this.splitAsteroid(asteroidIndex);
814925
this.destroyShip();
815926
events.scoreEvents.push(result.points);

tests/games/AsteroidsCollisionTimingStress.test.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,23 @@ export function run() {
9191
assert.equal(shipImpactEvents.shipDestroyed, true);
9292
assert.equal(shipImpactWorld.shipActive, false);
9393

94+
const shipConcaveEdgeWorld = new AsteroidsWorld({ width: 960, height: 720 }, { rng: () => 0.5, asteroidGeometryProfiles });
95+
shipConcaveEdgeWorld.ufoSpawnTimer = Number.POSITIVE_INFINITY;
96+
shipConcaveEdgeWorld.ship.invulnerable = 0;
97+
shipConcaveEdgeWorld.ship.x = 430;
98+
shipConcaveEdgeWorld.ship.y = 386;
99+
shipConcaveEdgeWorld.ship.vx = 0;
100+
shipConcaveEdgeWorld.ship.vy = 0;
101+
shipConcaveEdgeWorld.ship.angle = -Math.PI / 2;
102+
shipConcaveEdgeWorld.asteroids = [createStationaryAsteroid(shipConcaveEdgeWorld, {
103+
x: 480,
104+
y: 360,
105+
size: 3,
106+
})];
107+
const shipConcaveEdgeEvents = shipConcaveEdgeWorld.update(0, createInput());
108+
assert.equal(shipConcaveEdgeEvents.shipDestroyed, true);
109+
assert.equal(shipConcaveEdgeWorld.shipActive, false);
110+
94111
const ufoImpactWorld = new AsteroidsWorld({ width: 960, height: 720 }, { rng: () => 0.5, asteroidGeometryProfiles });
95112
ufoImpactWorld.ufoSpawnTimer = Number.POSITIVE_INFINITY;
96113
ufoImpactWorld.asteroids = [createStationaryAsteroid(ufoImpactWorld, { x: 480, y: 360, size: 3 })];

0 commit comments

Comments
 (0)