From 43f46371e52014dc145ea3d340830172259f7cab Mon Sep 17 00:00:00 2001 From: Sascha Makela Date: Fri, 31 Jul 2026 22:04:52 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=8E=AE=20Add=20GPT=205.6=20Luna=20Lun?= =?UTF-8?q?ar=20Shatter=20Breakout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: GPT 5.6 Luna --- README.md | 4 +- games-metadata.json | 13 + public/games/breakout/gpt-5-6-luna/index.html | 490 ++++++++++++++++++ tests/breakout-luna.spec.ts | 185 +++++++ 4 files changed, 690 insertions(+), 2 deletions(-) create mode 100644 public/games/breakout/gpt-5-6-luna/index.html create mode 100644 tests/breakout-luna.spec.ts diff --git a/README.md b/README.md index 23aa2e8..14a2aea 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A web platform for comparing fun, cool-looking AI-generated games. Play each ver | 💣 Minesweeper | Every click is a calculated gamble. Use logic to uncover safe tiles while avoiding hidden explosives. | 5 | | 🧱 Tetris | Falling tetrominoes demand split-second decisions. Stack them perfectly or watch entropy consume the board. | 6 | | ⚫ Reversi | A war of strategic placement. Flip your opponent's pieces and dominate the 8×8 battlefield. | 6 | -| 🏓 Breakout | Shatter neon bricks with a blazing ball. Catch power-ups, chain combos, and clear every level before your lives run out. | 6 | +| 🏓 Breakout | Shatter neon bricks with a blazing ball. Catch power-ups, chain combos, and clear every level before your lives run out. | 7 | | 🔢 2048 | Slide, merge, and strategize on a 4×4 grid. Chase the elusive 2048 tile before the board fills up. | 7 | | 🏃 Endless Runner 3D | A neon-soaked 3D sprint through an endless cyberpunk corridor with obstacles, collectibles, and power-ups. | 6 | | 🔮 Marble Madness | Roll a glowing marble across neon platforms suspended in space. Master physics, dodge hazards, and race the clock. | 6 | @@ -41,7 +41,7 @@ A web platform for comparing fun, cool-looking AI-generated games. Play each ver | OpenAI GPT 5.4 | 1,763 lines | 1,875 lines | 2,278 lines | 1,482 lines | 1,419 lines | 779 lines | 1,960 lines | 2,279 lines | 1,837 lines | 2,401 lines | 2,852 lines | 3,116 lines | 2,135 lines | 3,371 lines | N/A | 3,093 lines | N/A | N/A | N/A | | OpenAI GPT 5.5 | N/A | N/A | N/A | N/A | N/A | 2,031 lines | 2,369 lines | N/A | N/A | N/A | 2,413 lines | 1,643 lines | 2,058 lines | N/A | 1,398 lines | 2,155 lines | N/A | N/A | N/A | | OpenAI GPT 5.6 Terra | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | 930 lines | N/A | N/A | N/A | N/A | N/A | N/A | N/A | 147 lines | N/A | N/A | -| OpenAI GPT 5.6 Luna | N/A | N/A | 1,444 lines | N/A | N/A | N/A | N/A | N/A | N/A | N/A | 893 lines | N/A | N/A | N/A | N/A | N/A | 289 lines | N/A | N/A | +| OpenAI GPT 5.6 Luna | N/A | N/A | 1,444 lines | N/A | 491 lines | N/A | N/A | N/A | N/A | N/A | 893 lines | N/A | N/A | N/A | N/A | N/A | 289 lines | N/A | N/A | | OpenAI GPT 5.6 Sol | N/A | N/A | N/A | 1,736 lines | N/A | N/A | N/A | 304 lines | 2,421 lines | 2,134 lines | 3,263 lines | N/A | N/A | N/A | 278 lines | 1,542 lines | 2,447 lines | 715 lines | N/A | | OpenAI GPT 5.4 Mini | 1,309 lines | 1,223 lines | 1,267 lines | 1,389 lines | 570 lines | 541 lines | 1,309 lines | 1,687 lines | 1,486 lines | 1,345 lines | 2,093 lines | 1,035 lines | N/A | N/A | 2,734 lines | 3,965 lines | N/A | N/A | N/A | | Google Gemini 3.1 Pro | 60 lines | 184 lines | 394 lines | 230 lines | 63 lines | 58 lines | 44 lines | 46 lines | 65 lines | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | diff --git a/games-metadata.json b/games-metadata.json index 11eeb7d..9787c86 100644 --- a/games-metadata.json +++ b/games-metadata.json @@ -2154,6 +2154,19 @@ ] } ] + }, + { + "model": "GPT 5.6 Luna", + "modelId": "gpt-5-6-luna", + "date": "2026-07-31", + "linesOfCode": 491, + "path": "/games/breakout/gpt-5-6-luna/index.html", + "features": [ + "sound", + "music", + "particles", + "powerups" + ] } ] }, diff --git a/public/games/breakout/gpt-5-6-luna/index.html b/public/games/breakout/gpt-5-6-luna/index.html new file mode 100644 index 0000000..0ca2fb9 --- /dev/null +++ b/public/games/breakout/gpt-5-6-luna/index.html @@ -0,0 +1,490 @@ + + + + + + + LUNAR SHATTER // BREAKOUT + + + + + + +
+
+
+

LUNAR ARRAY / ORBITAL DEMOLITION

+

LUNAR SHATTER // BREAKOUT

+
+
AWAITING SIGNAL
+
+ +
+
+
Score
000000
+
Level
01
+
Lives
♥♥♥
+
Combo
+
+
+ + +
+
+
+ +
+
+ +

THE ECLIPSE
BEGINS

+

Break the crystal array. Build your combo.
Catch the lunar gifts before they fall into the void.

+ +
A / D OR ← → STEER · SPACE LAUNCH · P PAUSE · M AUDIO
PRISM · LENS · NOVA · TIDE · MAGNET · AEGIS
+
+
+
+

ORBIT
PAUSED

The observatory is holding position. Your combo is safe.

PRESS P TO RESUME
+
+
+
01

FIRST LIGHT

The first crystals are waiting in the dark.

CALIBRATING ORBITAL TRAJECTORY
+
+
+

SIGNAL
LOST

The array outlasted the pilot.

Score000000
Level01
Best000000
PRESS R TO TRY AGAIN
+
+
+
S-RANK

UMBRA
SHATTERED

Six arrays cleared. The lunar archive is yours.

Score000000
Best000000
Medals0 / 6
THE ARRAY WILL REMEMBER
+
+
+ + + + diff --git a/tests/breakout-luna.spec.ts b/tests/breakout-luna.spec.ts new file mode 100644 index 0000000..9366c5a --- /dev/null +++ b/tests/breakout-luna.spec.ts @@ -0,0 +1,185 @@ +import { expect, test, type Page } from "@playwright/test"; + +const GAME_PATH = "/games/breakout/gpt-5-6-luna/index.html"; +const TEST_PATH = `${GAME_PATH}?test=1`; + +type LunaSnapshot = { + screen: string; + score: number; + highScore: number; + level: number; + lives: number; + combo: number; + bricksRemaining: number; + paddle: { x: number; targetX: number; y: number; w: number }; + balls: Array<{ x: number; y: number; vx: number; vy: number; stuck: boolean; nova: boolean }>; + activePowerups: Record; + layout: { width: number; height: number; boardX: number; boardY: number; boardW: number; boardH: number; brickTop: number; paddleY: number }; + medals: string[]; +}; + +async function snapshot(page: Page): Promise { + return page.evaluate(() => { + const api = (window as typeof window & { + __lunaBreakoutTest: { snapshot: () => LunaSnapshot }; + }).__lunaBreakoutTest; + return api.snapshot(); + }); +} + +async function advance(page: Page, milliseconds: number) { + return page.evaluate((ms) => { + const api = (window as typeof window & { __lunaBreakoutTest: { advance: (value: number) => LunaSnapshot } }).__lunaBreakoutTest; + return api.advance(ms); + }, milliseconds); +} + +test.describe("GPT 5.6 Luna Breakout", { + tag: ["@spec:breakout-luna", "@game:breakout/gpt-5-6-luna"], +}, () => { + test("loads the lunar title state without page errors", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + + await page.goto(GAME_PATH); + + await expect(page).toHaveTitle("LUNAR SHATTER // BREAKOUT"); + await expect(page.locator('[data-testid="breakout-canvas"]')).toBeVisible(); + await expect(page.locator('[data-testid="start-button"]')).toBeVisible(); + expect(errors).toEqual([]); + }); + + test("starts audio, moves by pointer, and supports pause and mute", async ({ page }) => { + await page.addInitScript(() => { + const root = window as typeof window & { __lunaAudioStarts?: number }; + root.__lunaAudioStarts = 0; + if (!("OscillatorNode" in window)) return; + const proto = window.OscillatorNode.prototype; + const originalStart = proto.start; + proto.start = function (...args) { + root.__lunaAudioStarts = (root.__lunaAudioStarts ?? 0) + 1; + return originalStart.apply(this, args); + }; + }); + + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto(TEST_PATH); + await page.locator('[data-testid="start-button"]').click(); + await advance(page, 1600); + + const before = await snapshot(page); + await page.mouse.move(120, 400); + await page.waitForTimeout(100); + const after = await snapshot(page); + + expect(after.screen).toBe("playing"); + expect(after.paddle.targetX).toBeLessThan(before.paddle.targetX); + expect(after.paddle.x).toBeLessThan(before.paddle.x); + await page.keyboard.press("Space"); + await expect.poll(() => page.evaluate(() => (window as typeof window & { __lunaAudioStarts?: number }).__lunaAudioStarts ?? 0)).toBeGreaterThan(0); + + await page.locator('[data-testid="pause-button"]').click(); + await expect(page.locator("#statusText")).toHaveText("HOLDING POSITION"); + await page.locator("#resumeButton").click(); + await page.keyboard.press("m"); + expect((await snapshot(page)).muted).toBe(true); + await page.locator('[data-testid="audio-button"]').click(); + expect((await snapshot(page)).muted).toBe(false); + }); + + test("exposes deterministic collision, combo, power-up, shield, and game-over hooks", async ({ page }) => { + await page.goto(TEST_PATH); + await page.locator('[data-testid="start-button"]').click(); + await advance(page, 1600); + + const initial = await snapshot(page); + const brick = { + x: initial.layout.boardX + initial.layout.boardW / 2 - 32, + y: initial.layout.brickTop + 28, + w: 64, + h: 25, + type: "crystal", + hp: 1, + }; + await page.evaluate((entry) => { + const api = (window as typeof window & { __lunaBreakoutTest: { setBricks: (value: unknown[]) => void; setBall: (value: unknown) => void } }).__lunaBreakoutTest; + api.setBricks([entry]); + api.setBall({ x: entry.x + entry.w / 2, y: entry.y + 48, vx: 0, vy: -340, stuck: false }); + }, brick); + const collision = await advance(page, 220); + + expect(collision.score).toBeGreaterThan(0); + expect(collision.combo).toBe(1); + expect(collision.bricksRemaining).toBe(0); + expect(collision.highScore).toBe(collision.score); + + const effects = await page.evaluate(() => { + const api = (window as typeof window & { __lunaBreakoutTest: { forcePowerUp: (type: string) => LunaSnapshot } }).__lunaBreakoutTest; + const lens = api.forcePowerUp("lens"); + const nova = api.forcePowerUp("nova"); + return { lens, nova }; + }); + expect(effects.lens.activePowerups.lens).toBeGreaterThan(0); + expect(effects.lens.paddle.w).toBeGreaterThan(initial.paddle.w); + expect(effects.nova.activePowerups.nova).toBeGreaterThan(0); + expect(effects.nova.balls[0].nova).toBe(true); + + await page.evaluate(() => { + const api = (window as typeof window & { __lunaBreakoutTest: { setLives: (value: number) => void; forcePowerUp: (type: string) => void; setBall: (value: unknown) => void; clearAudioEvents: () => void } }).__lunaBreakoutTest; + api.setLives(1); + api.forcePowerUp("aegis"); + api.clearAudioEvents(); + api.setBall({ x: 640, y: 760, vx: 0, vy: 340, stuck: false }); + }); + const shieldSave = await advance(page, 40); + expect(shieldSave.screen).toBe("playing"); + expect(shieldSave.lives).toBe(1); + expect(shieldSave.activePowerups.aegis).toBeUndefined(); + expect(await page.evaluate(() => (window as typeof window & { __lunaBreakoutTest: { audioEvents: () => Array<{ name: string }> } }).__lunaBreakoutTest.audioEvents().some((event) => event.name === "shieldSave"))).toBe(true); + + await page.evaluate(() => { + const api = (window as typeof window & { __lunaBreakoutTest: { setBall: (value: unknown) => void } }).__lunaBreakoutTest; + api.setBall({ x: 640, y: 760, vx: 0, vy: 340, stuck: false }); + }); + const over = await advance(page, 40); + expect(over.screen).toBe("gameover"); + expect(over.lives).toBe(0); + await expect(page.locator('[data-testid="restart-button"]')).toBeVisible(); + }); + + test("presents the final rank and medal summary after the last stage clears", async ({ page }) => { + await page.goto(TEST_PATH); + await page.locator('[data-testid="start-button"]').click(); + await advance(page, 1600); + + await page.evaluate(() => { + const api = (window as typeof window & { __lunaBreakoutTest: { setLevel: (value: number) => void; setBricks: (value: unknown[]) => void; setBall: (value: unknown) => void } }).__lunaBreakoutTest; + api.setLevel(6); + api.setBricks([{ x: 600, y: 170, w: 64, h: 25, type: "standard", hp: 1 }]); + api.setBall({ x: 632, y: 220, vx: 0, vy: -340, stuck: false }); + }); + await advance(page, 260); + + await expect(page.locator("#victoryOverlay")).toBeVisible(); + await expect(page.locator("#finalRank")).toHaveText(/[B-S]-RANK/); + await expect(page.locator("#victoryMedals")).toHaveText("6 / 6"); + }); + + test("keeps the playfield inside desktop and compact viewports", async ({ page }) => { + for (const viewport of [{ width: 1280, height: 720 }, { width: 320, height: 480 }]) { + await page.setViewportSize(viewport); + await page.goto(TEST_PATH); + const canvas = page.locator('[data-testid="breakout-canvas"]'); + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + if (box) { + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width + 1); + expect(box.y + box.height).toBeLessThanOrEqual(viewport.height + 1); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(viewport.width); + expect(await page.evaluate(() => document.documentElement.scrollHeight)).toBeLessThanOrEqual(viewport.height); + } + }); +}); From 669053944dac98436ba940204f9a8004fdb9ed1f Mon Sep 17 00:00:00 2001 From: Sascha Makela Date: Fri, 31 Jul 2026 23:09:33 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20Address=20Breakout?= =?UTF-8?q?=20Luna=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: GPT 5.6 Luna --- README.md | 2 +- games-metadata.json | 2 +- public/games/breakout/gpt-5-6-luna/index.html | 19 ++++++------ tests/breakout-luna.spec.ts | 30 +++++++++++++++++-- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 14a2aea..52f13cf 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ A web platform for comparing fun, cool-looking AI-generated games. Play each ver | OpenAI GPT 5.4 | 1,763 lines | 1,875 lines | 2,278 lines | 1,482 lines | 1,419 lines | 779 lines | 1,960 lines | 2,279 lines | 1,837 lines | 2,401 lines | 2,852 lines | 3,116 lines | 2,135 lines | 3,371 lines | N/A | 3,093 lines | N/A | N/A | N/A | | OpenAI GPT 5.5 | N/A | N/A | N/A | N/A | N/A | 2,031 lines | 2,369 lines | N/A | N/A | N/A | 2,413 lines | 1,643 lines | 2,058 lines | N/A | 1,398 lines | 2,155 lines | N/A | N/A | N/A | | OpenAI GPT 5.6 Terra | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | 930 lines | N/A | N/A | N/A | N/A | N/A | N/A | N/A | 147 lines | N/A | N/A | -| OpenAI GPT 5.6 Luna | N/A | N/A | 1,444 lines | N/A | 491 lines | N/A | N/A | N/A | N/A | N/A | 893 lines | N/A | N/A | N/A | N/A | N/A | 289 lines | N/A | N/A | +| OpenAI GPT 5.6 Luna | N/A | N/A | 1,444 lines | N/A | 492 lines | N/A | N/A | N/A | N/A | N/A | 893 lines | N/A | N/A | N/A | N/A | N/A | 289 lines | N/A | N/A | | OpenAI GPT 5.6 Sol | N/A | N/A | N/A | 1,736 lines | N/A | N/A | N/A | 304 lines | 2,421 lines | 2,134 lines | 3,263 lines | N/A | N/A | N/A | 278 lines | 1,542 lines | 2,447 lines | 715 lines | N/A | | OpenAI GPT 5.4 Mini | 1,309 lines | 1,223 lines | 1,267 lines | 1,389 lines | 570 lines | 541 lines | 1,309 lines | 1,687 lines | 1,486 lines | 1,345 lines | 2,093 lines | 1,035 lines | N/A | N/A | 2,734 lines | 3,965 lines | N/A | N/A | N/A | | Google Gemini 3.1 Pro | 60 lines | 184 lines | 394 lines | 230 lines | 63 lines | 58 lines | 44 lines | 46 lines | 65 lines | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | diff --git a/games-metadata.json b/games-metadata.json index 9787c86..25003db 100644 --- a/games-metadata.json +++ b/games-metadata.json @@ -2159,7 +2159,7 @@ "model": "GPT 5.6 Luna", "modelId": "gpt-5-6-luna", "date": "2026-07-31", - "linesOfCode": 491, + "linesOfCode": 492, "path": "/games/breakout/gpt-5-6-luna/index.html", "features": [ "sound", diff --git a/public/games/breakout/gpt-5-6-luna/index.html b/public/games/breakout/gpt-5-6-luna/index.html index 0ca2fb9..eb0d9ca 100644 --- a/public/games/breakout/gpt-5-6-luna/index.html +++ b/public/games/breakout/gpt-5-6-luna/index.html @@ -293,7 +293,7 @@

THE ECLIPSE
BEGINS

return bricks; } function createBall(x, y, angle = -Math.PI / 2, speed = 330) { - return { x, y, r: state.layout.width < 650 ? 6 : 8, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, speed, stuck: true, dead: false, nova: false, trail: [], hue: rand() * 40 + 178 }; + return { x, y, r: state.layout.width < 650 ? 6 : 8, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, speed, stuck: true, captured: false, dead: false, nova: false, trail: [], hue: rand() * 40 + 178 }; } function loadLevel(index) { state.level = clamp(index, 0, LEVELS.length - 1); state.bricks = makeLevel(state.level); state.powerups = []; state.activeEffects = {}; state.aegis = false; state.paddle.w = state.paddle.baseW = state.layout.width < 650 ? 90 : 150; state.paddle.x = state.layout.width / 2; state.paddle.targetX = state.paddle.x; state.paddle.y = state.layout.paddleY; state.balls = [createBall(state.paddle.x, state.paddle.y - 22)]; state.stageScores[state.level] = 0; state.screen = "level"; state.phaseTimer = 1.45; state.pendingLevel = null; state.dirtyHud = true; showOverlays(); audioEvent("level", 247 + state.level * 32, .28, "triangle", .12); } @@ -306,7 +306,7 @@

THE ECLIPSE
BEGINS

function launch() { if (state.screen !== "playing" || !state.balls.length) return false; let launched = false; - state.balls.forEach((ball, index) => { if (ball.stuck) { const angle = (index % 2 ? -.28 : .28) - Math.PI / 2; const speed = ball.speed; ball.vx = Math.cos(angle) * speed; ball.vy = Math.sin(angle) * speed; ball.stuck = false; launched = true; } }); + state.balls.forEach((ball, index) => { if (ball.stuck || ball.captured) { const angle = (index % 2 ? -.28 : .28) - Math.PI / 2; const speed = ball.speed; ball.vx = Math.cos(angle) * speed; ball.vy = Math.sin(angle) * speed; ball.stuck = false; ball.captured = false; launched = true; } }); if (launched) audioEvent("launch", 392, .18, "square", .1); return launched; } @@ -346,14 +346,14 @@

THE ECLIPSE
BEGINS

if (!POWERUPS[type]) return; const spec = POWERUPS[type]; state.activeEffects[type] = type === "aegis" ? 1 : type === "prism" ? 0 : type === "nova" ? 10 : type === "tide" ? 8 : 10; if (type === "prism") { - const source = state.balls.find((ball) => !ball.dead); if (source) { const speed = Math.hypot(source.vx, source.vy); state.balls.push({ ...source, vx: Math.cos(-Math.PI / 2 - .48) * speed, vy: Math.sin(-Math.PI / 2 - .48) * speed, stuck: false, trail: [] }, { ...source, vx: Math.cos(-Math.PI / 2 + .48) * speed, vy: Math.sin(-Math.PI / 2 + .48) * speed, stuck: false, trail: [] }); } + const source = state.balls.find((ball) => !ball.dead); if (source) { const speed = Math.max(source.speed, Math.hypot(source.vx, source.vy)); state.balls.push({ ...source, vx: Math.cos(-Math.PI / 2 - .48) * speed, vy: Math.sin(-Math.PI / 2 - .48) * speed, stuck: false, captured: false, trail: [] }, { ...source, vx: Math.cos(-Math.PI / 2 + .48) * speed, vy: Math.sin(-Math.PI / 2 + .48) * speed, stuck: false, captured: false, trail: [] }); } } else if (type === "lens") state.paddle.w = state.paddle.baseW * 1.55; else if (type === "nova") state.balls.forEach((ball) => { ball.nova = true; }); else if (type === "aegis") state.aegis = true; spawnBurst(state.paddle.x, state.paddle.y, spec.color, 32, 210); state.shockwaves.push({ x: state.paddle.x, y: state.paddle.y, r: 12, life: 1, color: spec.color }); state.flash = .55; audioEvent("powerupCollect", 390 + Object.keys(POWERUPS).indexOf(type) * 62, .32, "sine", .16); spawnFloater(`${spec.glyph} ${spec.label}`, state.paddle.x, state.paddle.y - 24, spec.color); state.dirtyHud = true; } function decayEffects(dt) { - Object.keys(state.activeEffects).forEach((type) => { if (type === "aegis" || type === "prism") return; state.activeEffects[type] -= dt; if (state.activeEffects[type] <= 0) { delete state.activeEffects[type]; if (type === "lens") state.paddle.w = state.paddle.baseW; if (type === "nova") state.balls.forEach((ball) => { ball.nova = false; }); } }); + Object.keys(state.activeEffects).forEach((type) => { if (type === "aegis" || type === "prism") return; state.activeEffects[type] -= dt; if (state.activeEffects[type] <= 0) { delete state.activeEffects[type]; if (type === "lens") state.paddle.w = state.paddle.baseW; if (type === "nova") state.balls.forEach((ball) => { ball.nova = false; }); if (type === "magnet") state.balls.forEach((ball) => { if (ball.captured) { const angle = -.2 - Math.PI / 2; ball.vx = Math.cos(angle) * ball.speed; ball.vy = Math.sin(angle) * ball.speed; ball.captured = false; } }); } }); if (!state.activeEffects.nova && !state.activeEffects.prism) state.balls.forEach((ball) => { if (ball.nova) ball.nova = false; }); } function collectPowerups(dt) { @@ -371,14 +371,14 @@

THE ECLIPSE
BEGINS

} function updateBall(ball, dt) { if (ball.dead) return; - if (ball.stuck) { ball.x = state.paddle.x; ball.y = state.paddle.y - ball.r - 5; return; } + if (ball.stuck || ball.captured) { ball.x = state.paddle.x; ball.y = state.paddle.y - ball.r - 5; return; } const timeScale = state.activeEffects.tide ? .66 : 1; const vx = ball.vx * timeScale; const vy = ball.vy * timeScale; const oldX = ball.x; const oldY = ball.y; ball.x += vx * dt; ball.y += vy * dt; ball.trail.push({ x: oldX, y: oldY, life: 1 }); if (ball.trail.length > MAX_TRAIL) ball.trail.shift(); ball.trail.forEach((point) => { point.life -= dt * 2.6; }); const left = state.layout.boardX; const right = state.layout.boardX + state.layout.boardW; const top = state.layout.boardY + 5; if (ball.x - ball.r < left) { ball.x = left + ball.r; ball.vx = Math.abs(ball.vx); audioEvent("wall", 180, .045, "triangle", .04); spawnBurst(ball.x, ball.y, "#60f4ff", 3, 55); } else if (ball.x + ball.r > right) { ball.x = right - ball.r; ball.vx = -Math.abs(ball.vx); audioEvent("wall", 205, .045, "triangle", .04); spawnBurst(ball.x, ball.y, "#60f4ff", 3, 55); } if (ball.y - ball.r < top) { ball.y = top + ball.r; ball.vy = Math.abs(ball.vy); audioEvent("wall", 240, .045, "triangle", .04); spawnBurst(ball.x, ball.y, "#a88bff", 3, 55); } const paddleRect = { x: state.paddle.x - state.paddle.w / 2, y: state.paddle.y, w: state.paddle.w, h: state.paddle.h }; - if (ball.vy > 0 && circleRect(ball, paddleRect) && oldY <= paddleRect.y + 5) { ball.y = paddleRect.y - ball.r - 1; const relative = clamp((ball.x - state.paddle.x) / (state.paddle.w / 2), -1, 1); const angle = relative * 1.03 - Math.PI / 2; const speed = clamp(Math.max(ball.speed, Math.hypot(ball.vx, ball.vy)) * 1.012, 300, 590); ball.speed = speed; ball.vx = Math.cos(angle) * speed; ball.vy = Math.sin(angle) * speed; audioEvent("paddle", 260 + Math.abs(relative) * 160, .075, "sine", .08); spawnBurst(ball.x, paddleRect.y, "#f5d477", 7, 80); } + if (ball.vy > 0 && circleRect(ball, paddleRect) && oldY <= paddleRect.y + 5) { ball.y = paddleRect.y - ball.r - 1; const relative = clamp((ball.x - state.paddle.x) / (state.paddle.w / 2), -1, 1); const angle = relative * 1.03 - Math.PI / 2; const speed = clamp(Math.max(ball.speed, Math.hypot(ball.vx, ball.vy)) * 1.012, 300, 590); ball.speed = speed; if (state.activeEffects.magnet) { ball.vx = 0; ball.vy = 0; ball.captured = true; audioEvent("magnetCatch", 620, .16, "sine", .1); spawnFloater("MAGNET LOCK", ball.x, paddleRect.y - 30, "#c7a5ff"); } else { ball.vx = Math.cos(angle) * speed; ball.vy = Math.sin(angle) * speed; audioEvent("paddle", 260 + Math.abs(relative) * 160, .075, "sine", .08); } spawnBurst(ball.x, paddleRect.y, "#f5d477", 7, 80); } for (const brick of state.bricks) { if (brick.destroyed || !circleRect(ball, brick)) continue; const fromLeft = oldX + ball.r <= brick.x; const fromRight = oldX - ball.r >= brick.x + brick.w; const fromTop = oldY + ball.r <= brick.y; const fromBottom = oldY - ball.r >= brick.y + brick.h; if (!ball.nova) { if (fromLeft || fromRight) ball.vx *= -1; else if (fromTop || fromBottom) ball.vy *= -1; else ball.vy *= -1; } damageBrick(brick, ball); if (!ball.nova) break; } if (ball.y - ball.r > state.layout.height + 10) { if (state.aegis) { @@ -387,9 +387,10 @@

THE ECLIPSE
BEGINS

} } function updateGame(dt) { - state.time += dt; state.shake = Math.max(0, state.shake - dt * 22); state.flash = Math.max(0, state.flash - dt * 2.7); decayEffects(dt); collectPowerups(dt); + state.time += dt; state.shake = Math.max(0, state.shake - dt * 22); state.flash = Math.max(0, state.flash - dt * 2.7); if (state.screen === "level") { beginNextPhase(); return; } if (state.screen !== "playing") return; + decayEffects(dt); collectPowerups(dt); if (state.comboTimer > 0) { state.comboTimer -= dt; if (state.comboTimer <= 0) state.combo = 0; } const direction = (keyState.has("ArrowLeft") || keyState.has("a") ? -1 : 0) + (keyState.has("ArrowRight") || keyState.has("d") ? 1 : 0); if (direction) state.paddle.targetX += direction * 470 * dt; state.paddle.targetX = clamp(state.paddle.targetX, state.layout.boardX + state.paddle.w / 2, state.layout.boardX + state.layout.boardW - state.paddle.w / 2); state.paddle.x = lerp(state.paddle.x, state.paddle.targetX, 1 - Math.pow(.0001, dt)); @@ -452,12 +453,12 @@

THE ECLIPSE
BEGINS

function togglePause() { if (state.screen === "playing") { state.screen = "paused"; audioEvent("pause", 190, .08, "sine", .05); } else if (state.screen === "paused") { state.screen = "playing"; ensureAudio(); audioEvent("resume", 310, .08, "sine", .05); } state.dirtyHud = true; updateHud(); } function snapshot() { - return { screen: state.screen, score: state.score, highScore: state.best, level: state.level + 1, lives: state.lives, combo: state.combo, bricksRemaining: state.bricks.filter((brick) => !brick.destroyed).length, balls: state.balls.map((ball) => ({ x: ball.x, y: ball.y, vx: ball.vx, vy: ball.vy, stuck: ball.stuck, dead: ball.dead, nova: ball.nova })), paddle: { x: state.paddle.x, targetX: state.paddle.targetX, y: state.paddle.y, w: state.paddle.w }, activePowerups: Object.fromEntries(Object.entries(state.activeEffects)), layout: { ...state.layout }, medals: state.medals.slice(), particleCount: state.particles.length, powerupCount: state.powerups.length, muted: state.muted }; + return { screen: state.screen, score: state.score, highScore: state.best, level: state.level + 1, lives: state.lives, combo: state.combo, bricksRemaining: state.bricks.filter((brick) => !brick.destroyed).length, balls: state.balls.map((ball) => ({ x: ball.x, y: ball.y, vx: ball.vx, vy: ball.vy, stuck: ball.stuck, captured: ball.captured, dead: ball.dead, nova: ball.nova })), paddle: { x: state.paddle.x, targetX: state.paddle.targetX, y: state.paddle.y, w: state.paddle.w }, activePowerups: Object.fromEntries(Object.entries(state.activeEffects)), layout: { ...state.layout }, medals: state.medals.slice(), particleCount: state.particles.length, powerupCount: state.powerups.length, muted: state.muted }; } function setBricks(specs) { state.bricks = (specs || []).map((spec, index) => { const row = spec.row ?? index; const col = spec.col ?? index % 10; const kind = spec.type || spec.kind || "standard"; const hp = spec.hp ?? (kind === "armored" ? 2 : 1); const brick = brickFor(row, col, kind, hp, 1); if (spec.x !== undefined) brick.x = Number(spec.x); if (spec.y !== undefined) brick.y = Number(spec.y); if (spec.w !== undefined) brick.w = Number(spec.w); if (spec.h !== undefined) brick.h = Number(spec.h); return brick; }); state.dirtyHud = true; return snapshot(); } - function setBall(spec = {}) { const ball = state.balls[0] || createBall(state.paddle.x, state.paddle.y - 22); Object.assign(ball, { x: spec.x ?? ball.x, y: spec.y ?? ball.y, vx: spec.vx ?? ball.vx, vy: spec.vy ?? ball.vy, stuck: spec.stuck ?? false, dead: spec.dead ?? false, nova: spec.nova ?? false }); state.balls = [ball]; state.screen = "playing"; state.dirtyHud = true; return snapshot(); } + function setBall(spec = {}) { const ball = state.balls[0] || createBall(state.paddle.x, state.paddle.y - 22); Object.assign(ball, { x: spec.x ?? ball.x, y: spec.y ?? ball.y, vx: spec.vx ?? ball.vx, vy: spec.vy ?? ball.vy, stuck: spec.stuck ?? false, captured: spec.captured ?? false, dead: spec.dead ?? false, nova: spec.nova ?? false }); state.balls = [ball]; state.screen = "playing"; state.dirtyHud = true; return snapshot(); } function forcePowerUp(type) { applyPowerup(type); return snapshot(); } function advance(ms) { let remaining = Math.max(0, Number(ms) || 0); while (remaining > 0) { const delta = Math.min(remaining, STEP * 1000); updateGame(delta / 1000); remaining -= delta; } render(); updateHud(); return snapshot(); } diff --git a/tests/breakout-luna.spec.ts b/tests/breakout-luna.spec.ts index 9366c5a..f4f7994 100644 --- a/tests/breakout-luna.spec.ts +++ b/tests/breakout-luna.spec.ts @@ -12,10 +12,11 @@ type LunaSnapshot = { combo: number; bricksRemaining: number; paddle: { x: number; targetX: number; y: number; w: number }; - balls: Array<{ x: number; y: number; vx: number; vy: number; stuck: boolean; nova: boolean }>; + balls: Array<{ x: number; y: number; vx: number; vy: number; stuck: boolean; captured: boolean; nova: boolean }>; activePowerups: Record; layout: { width: number; height: number; boardX: number; boardY: number; boardW: number; boardH: number; brickTop: number; paddleY: number }; medals: string[]; + muted: boolean; }; async function snapshot(page: Page): Promise { @@ -69,7 +70,7 @@ test.describe("GPT 5.6 Luna Breakout", { const before = await snapshot(page); await page.mouse.move(120, 400); - await page.waitForTimeout(100); + await advance(page, 120); const after = await snapshot(page); expect(after.screen).toBe("playing"); @@ -78,9 +79,18 @@ test.describe("GPT 5.6 Luna Breakout", { await page.keyboard.press("Space"); await expect.poll(() => page.evaluate(() => (window as typeof window & { __lunaAudioStarts?: number }).__lunaAudioStarts ?? 0)).toBeGreaterThan(0); + await page.evaluate(() => { + const api = (window as typeof window & { __lunaBreakoutTest: { forcePowerUp: (type: string) => LunaSnapshot } }).__lunaBreakoutTest; + api.forcePowerUp("lens"); + }); await page.locator('[data-testid="pause-button"]').click(); await expect(page.locator("#statusText")).toHaveText("HOLDING POSITION"); + const lensTimer = (await snapshot(page)).activePowerups.lens; + await advance(page, 1000); + expect((await snapshot(page)).activePowerups.lens).toBe(lensTimer); await page.locator("#resumeButton").click(); + await advance(page, 1000); + expect((await snapshot(page)).activePowerups.lens).toBeLessThan(lensTimer); await page.keyboard.press("m"); expect((await snapshot(page)).muted).toBe(true); await page.locator('[data-testid="audio-button"]').click(); @@ -124,6 +134,22 @@ test.describe("GPT 5.6 Luna Breakout", { expect(effects.nova.activePowerups.nova).toBeGreaterThan(0); expect(effects.nova.balls[0].nova).toBe(true); + await page.evaluate(() => { + const api = (window as typeof window & { __lunaBreakoutTest: { setLevel: (value: number) => LunaSnapshot; forcePowerUp: (type: string) => LunaSnapshot; setBall: (value: unknown) => LunaSnapshot } }).__lunaBreakoutTest; + const level = api.setLevel(1); + api.forcePowerUp("magnet"); + return api.setBall({ x: level.paddle.x, y: level.paddle.y - 60, vx: 0, vy: 340, stuck: false, captured: false }); + }); + const magnetCatch = await advance(page, 220); + expect(magnetCatch.balls[0].captured).toBe(true); + expect(magnetCatch.balls[0].vx).toBe(0); + const relaunched = await page.evaluate(() => { + const api = (window as typeof window & { __lunaBreakoutTest: { launch: () => boolean } }).__lunaBreakoutTest; + return api.launch(); + }); + expect(relaunched).toBe(true); + expect((await snapshot(page)).balls[0].captured).toBe(false); + await page.evaluate(() => { const api = (window as typeof window & { __lunaBreakoutTest: { setLives: (value: number) => void; forcePowerUp: (type: string) => void; setBall: (value: unknown) => void; clearAudioEvents: () => void } }).__lunaBreakoutTest; api.setLives(1);