Files
JE-Skin/src/lib/components/NeonBreakoutArena.svelte

1451 lines
44 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import * as THREE from "three";
import { pressureColorPalettes } from "$lib/config/color-map";
import type { LocaleCode, PressureColorMapPreset } from "$lib/types/hud";
type CornerId = "tl" | "tr" | "bl" | "br";
interface CornerMap {
tl: number;
tr: number;
bl: number;
br: number;
}
interface Brick {
mesh: THREE.Mesh<THREE.BoxGeometry, THREE.MeshStandardMaterial>;
alive: boolean;
kind: "normal" | "bomb";
left: number;
right: number;
top: number;
bottom: number;
}
type GameState = "ready" | "running" | "paused" | "draft" | "over";
type UpgradeId = "overdrive" | "demolition" | "surge" | "stabilizer";
interface Copy {
title: string;
start: string;
restart: string;
pause: string;
resume: string;
running: string;
paused: string;
over: string;
score: string;
combo: string;
lives: string;
level: string;
bricks: string;
chase: string;
pausedOverlay: string;
ready: string;
draft: string;
startOverlayTitle: string;
startOverlayBody: string;
startOverlayHint: string;
overHint: string;
draftOverlayTitle: string;
draftOverlayHint: string;
skills: string;
}
interface UpgradeCardCopy {
id: UpgradeId;
title: string;
body: string;
}
export let locale: LocaleCode = "zh-CN";
export let pressureMatrix: number[] | null = null;
export let matrixRows = 12;
export let matrixCols = 7;
export let rangeMin = 0;
export let rangeMax = 16000;
export let colorMapPreset: PressureColorMapPreset = "emerald";
const FIELD_HALF_W = 46;
const FIELD_HALF_H = 62;
const PADDLE_Y = -53;
const PADDLE_W = 16;
const PADDLE_H = 2.6;
const PADDLE_SPEED = 74;
const BALL_RADIUS = 1.45;
const BASE_BALL_SPEED = 58;
const MAX_BALL_SPEED = 106;
const BRICK_COLS = 12;
const BRICK_ROWS = 7;
const BRICK_W = 6.2;
const BRICK_H = 2.9;
const BRICK_GAP_X = 0.8;
const BRICK_GAP_Y = 0.9;
const BRICK_TOP = 44;
const START_DELAY_MS = 520;
const PAUSE_COOLDOWN_MS = 640;
const UPPER_STALL_THRESHOLD_MS = 1700;
const UPPER_STALL_Y = 14;
const ANTI_STALL_COOLDOWN_MS = 420;
const MAX_BRICK_HITS_WITHOUT_PADDLE = 5;
const DRAFT_EVERY_BRICKS = 14;
const BASE_BOMB_CHANCE = 0.08;
const BASE_BOMB_RADIUS = 8.8;
const RUN_TARGET_LEVEL = 4;
const legacyCopyByLocale = {
"zh-CN": {
title: "NEON BREAKOUT",
start: "开始",
restart: "重开",
pause: "暂停",
resume: "继续",
running: "运行中",
paused: "已暂停",
over: "结束",
score: "得分",
combo: "连击",
lives: "生命",
level: "关卡",
bricks: "剩余",
chase: "追击",
pausedOverlay: "已暂停 / 顶部双角施压继续"
},
"en-US": {
title: "NEON BREAKOUT",
start: "Start",
restart: "Restart",
pause: "Pause",
resume: "Resume",
running: "Running",
paused: "Paused",
over: "Game Over",
score: "Score",
combo: "Combo",
lives: "Lives",
level: "Level",
bricks: "Bricks",
chase: "Chase",
pausedOverlay: "Paused / Top corners to resume"
}
};
const copyByLocale: Record<LocaleCode, Copy> = {
"zh-CN": {
title: "NEON BREAKOUT",
start: "开始",
restart: "重开",
pause: "暂停",
resume: "继续",
running: "进行中",
paused: "已暂停",
over: "本局结束",
score: "得分",
combo: "连击",
lives: "生命",
level: "阶段",
bricks: "剩余",
chase: "模式",
pausedOverlay: "已暂停 / 按上继续",
ready: "待开始",
draft: "选技能",
startOverlayTitle: "按上开始",
startOverlayBody: "左右移动挡板,别让球掉出场地。打掉砖块会触发更快的肉鸽强化,炸弹砖能连锁清场。",
startOverlayHint: "顶部双角施压或键盘 ArrowUp 开局;对局中同样用上方控制暂停 / 继续。",
overHint: "按上重新开一局,或者直接点重开。",
draftOverlayTitle: "选择强化",
draftOverlayHint: "这一局会越打越快。点一个技能,或者按数字 1 / 2 / 3 选择。",
skills: "技能"
},
"en-US": {
title: "NEON BREAKOUT",
start: "Start",
restart: "Restart",
pause: "Pause",
resume: "Resume",
running: "Running",
paused: "Paused",
over: "Game Over",
score: "Score",
combo: "Combo",
lives: "Lives",
level: "Level",
bricks: "Bricks",
chase: "Mode",
pausedOverlay: "Paused / Press up to resume",
ready: "Ready",
draft: "Draft",
startOverlayTitle: "Press Up To Start",
startOverlayBody: "Move the paddle left and right, keep the ball alive, and chain through bomb bricks to clear each run fast.",
startOverlayHint: "Use top pressure or ArrowUp to start. The same up control pauses and resumes mid-run.",
overHint: "Press up to jump right back in, or click restart.",
draftOverlayTitle: "Choose An Upgrade",
draftOverlayHint: "Each run speeds up quickly. Pick one perk, or press 1 / 2 / 3.",
skills: "Perks"
}
};
const upgradeCopyByLocale: Record<LocaleCode, Record<UpgradeId, UpgradeCardCopy>> = {
"zh-CN": {
overdrive: {
id: "overdrive",
title: "超频发球",
body: "发球更快,球速上限更高,整局节奏立刻提起来。"
},
demolition: {
id: "demolition",
title: "爆破砖块",
body: "炸弹砖出现更多,爆炸半径更大,更容易一口气清一片。"
},
surge: {
id: "surge",
title: "速度滚雪球",
body: "每打掉一块砖,球速涨得更多,越打越疯。"
},
stabilizer: {
id: "stabilizer",
title: "宽幅挡板",
body: "挡板更宽并回复 1 点生命,容错更稳,不容易断节奏。"
}
},
"en-US": {
overdrive: {
id: "overdrive",
title: "Overdrive",
body: "Faster launches and a higher speed cap so the whole run ramps up immediately."
},
demolition: {
id: "demolition",
title: "Demolition",
body: "More bomb bricks and a larger blast radius for faster board clears."
},
surge: {
id: "surge",
title: "Surge",
body: "Each brick hit adds more speed, turning every rally into a snowball."
},
stabilizer: {
id: "stabilizer",
title: "Stabilizer",
body: "A wider paddle plus one life to keep the run alive while the pace rises."
}
}
};
void legacyCopyByLocale;
let hostEl: HTMLElement | undefined;
let canvasEl: HTMLCanvasElement | undefined;
let rafId: number | null = null;
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.OrthographicCamera | null = null;
let paddle: THREE.Mesh<THREE.BoxGeometry, THREE.MeshStandardMaterial> | null = null;
let ball: THREE.Mesh<THREE.SphereGeometry, THREE.MeshStandardMaterial> | null = null;
let brickGroup: THREE.Group | null = null;
let borderLine: THREE.LineLoop | null = null;
let bgPlane: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial> | null = null;
let ambientLight: THREE.AmbientLight | null = null;
let keyLight: THREE.PointLight | null = null;
let rimLight: THREE.PointLight | null = null;
let flashSprite: THREE.Sprite | null = null;
let flashLife = 0;
let flashMaxLife = 0.2;
let keyLeft = false;
let keyRight = false;
let ballLaunched = false;
let nextLaunchAt = 0;
let paddleX = 0;
const ballPos = new THREE.Vector2(0, 0);
const ballVel = new THREE.Vector2(0, 0);
let bricks: Brick[] = [];
let gameState: GameState = "ready";
let score = 0;
let combo = 0;
let lives = 3;
let level = 1;
let bricksLeft = 0;
let lastFrameTs = 0;
let prevPauseGesture = false;
let pauseGestureLockUntil = 0;
let themedBgTexture: THREE.CanvasTexture | null = null;
let lastPaddleContactAt = 0;
let bricksSincePaddle = 0;
let lastAntiStallAt = 0;
let paddleWidth = PADDLE_W;
let launchSpeedBonus = 0;
let maxSpeedBonus = 0;
let brickSpeedGain = 0.014;
let bombBrickChance = BASE_BOMB_CHANCE;
let bombExplosionRadius = BASE_BOMB_RADIUS;
let bricksUntilDraft = DRAFT_EVERY_BRICKS;
let activeDraftOptions: UpgradeCardCopy[] = [];
let selectedUpgrades: UpgradeId[] = [];
let cornerForce: CornerMap = { tl: 0, tr: 0, bl: 0, br: 0 };
$: cornerForce = readCorners(pressureMatrix, matrixRows, matrixCols);
$: themePalette = pressureColorPalettes[colorMapPreset] ?? pressureColorPalettes.emerald;
$: ui = copyByLocale[locale];
$: leftForce = cornerForce.tl + cornerForce.bl;
$: rightForce = cornerForce.tr + cornerForce.br;
$: topForce = cornerForce.tl + cornerForce.tr;
$: pauseGestureThreshold = Math.max(420, Math.round(Math.max(1000, rangeMax - rangeMin) * 0.07));
$: upgradeCopy = upgradeCopyByLocale[locale] ?? upgradeCopyByLocale["en-US"];
$: statusText =
gameState === "running"
? ui.running
: gameState === "paused"
? ui.paused
: gameState === "draft"
? ui.draft
: gameState === "over"
? ui.over
: ui.ready;
$: if (scene && renderer) {
applyTheme();
}
function rgbTripletToColor(rgbTriplet: string): THREE.Color {
return new THREE.Color(`rgb(${rgbTriplet.replace(/\s+/g, ", ")})`);
}
function rgbaFromTriplet(rgbTriplet: string, alpha: number): string {
return `rgba(${rgbTriplet.replace(/\s+/g, ",")},${alpha})`;
}
function themedBrickPalette(): string[] {
const { rangeStops } = themePalette;
return [rangeStops[1], rangeStops[2], rangeStops[3], rangeStops[4], rangeStops[5], rangeStops[2], rangeStops[3]];
}
function applyTheme(): void {
if (!scene || !renderer) return;
const uiTheme = themePalette.uiTheme;
scene.background = new THREE.Color(uiTheme.bg30);
ambientLight?.color.copy(rgbTripletToColor(uiTheme.textMainRgb));
keyLight?.color.copy(rgbTripletToColor(uiTheme.glowAltRgb));
rimLight?.color.copy(rgbTripletToColor(uiTheme.glowRgb));
const borderMaterial = borderLine?.material as THREE.LineBasicMaterial | undefined;
if (borderMaterial) {
borderMaterial.color.copy(rgbTripletToColor(uiTheme.glowRgb));
borderMaterial.opacity = 0.48;
borderMaterial.needsUpdate = true;
}
if (paddle) {
paddle.material.color.copy(new THREE.Color(themePalette.rangeStops[2]));
paddle.material.emissive.copy(new THREE.Color(themePalette.rangeStops[2]));
paddle.material.emissiveIntensity = 0.72;
paddle.material.needsUpdate = true;
}
if (ball) {
ball.material.color.copy(new THREE.Color(themePalette.rangeStops[0]));
ball.material.emissive.copy(new THREE.Color(themePalette.rangeStops[3]));
ball.material.emissiveIntensity = 0.92;
ball.material.needsUpdate = true;
}
if (bgPlane) {
const material = bgPlane.material;
themedBgTexture?.dispose();
themedBgTexture = createGridTexture(themePalette);
material.map = themedBgTexture;
material.opacity = 0.78;
material.needsUpdate = true;
}
const colors = themedBrickPalette();
for (let index = 0; index < bricks.length; index += 1) {
const brick = bricks[index];
const color = brick.kind === "bomb" ? "#ff9d5c" : colors[index % colors.length] ?? "#7ad0ff";
brick.mesh.material.color.set(color);
brick.mesh.material.emissive.set(color);
brick.mesh.material.emissiveIntensity = brick.kind === "bomb" ? 0.96 : 0.7;
brick.mesh.material.needsUpdate = true;
}
}
onMount(() => {
if (!hostEl || !canvasEl) return;
const uiTheme = themePalette.uiTheme;
const paddleColor = themePalette.rangeStops[2];
const ballColor = themePalette.rangeStops[0];
const ballGlow = themePalette.rangeStops[3];
const nextScene = new THREE.Scene();
nextScene.background = new THREE.Color(uiTheme.bg30);
scene = nextScene;
const nextCamera = new THREE.OrthographicCamera(-60, 60, 70, -70, 0.1, 200);
nextCamera.position.set(0, 0, 54);
nextCamera.lookAt(0, 0, 0);
camera = nextCamera;
const nextRenderer = new THREE.WebGLRenderer({
canvas: canvasEl,
antialias: true,
alpha: true,
powerPreference: "high-performance"
});
renderer = nextRenderer;
nextRenderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const ambient = new THREE.AmbientLight(rgbTripletToColor(uiTheme.textMainRgb), 0.44);
const key = new THREE.PointLight(rgbTripletToColor(uiTheme.glowAltRgb), 1.2, 180, 1.8);
key.position.set(0, 20, 38);
const rim = new THREE.PointLight(rgbTripletToColor(uiTheme.glowRgb), 0.65, 160, 1.7);
rim.position.set(-22, -14, 26);
nextScene.add(ambient, key, rim);
ambientLight = ambient;
keyLight = key;
rimLight = rim;
const bgTexture = createGridTexture(themePalette);
themedBgTexture = bgTexture;
const bg = new THREE.Mesh(
new THREE.PlaneGeometry(160, 170),
new THREE.MeshBasicMaterial({
map: bgTexture,
transparent: true,
opacity: 0.78,
depthWrite: false
})
);
bg.position.set(0, 0, -12);
nextScene.add(bg);
bgPlane = bg;
const border = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-FIELD_HALF_W, -FIELD_HALF_H, 0),
new THREE.Vector3(FIELD_HALF_W, -FIELD_HALF_H, 0),
new THREE.Vector3(FIELD_HALF_W, FIELD_HALF_H, 0),
new THREE.Vector3(-FIELD_HALF_W, FIELD_HALF_H, 0)
]),
new THREE.LineBasicMaterial({ color: rgbTripletToColor(uiTheme.glowRgb), transparent: true, opacity: 0.46 })
);
nextScene.add(border);
borderLine = border;
const nextBrickGroup = new THREE.Group();
brickGroup = nextBrickGroup;
nextScene.add(nextBrickGroup);
const paddleMesh = new THREE.Mesh(
new THREE.BoxGeometry(PADDLE_W, PADDLE_H, 2.1),
new THREE.MeshStandardMaterial({
color: paddleColor,
emissive: paddleColor,
emissiveIntensity: 0.72,
roughness: 0.34,
metalness: 0.18
})
);
paddleMesh.position.set(0, PADDLE_Y, 0.4);
nextScene.add(paddleMesh);
paddle = paddleMesh;
const ballMesh = new THREE.Mesh(
new THREE.SphereGeometry(BALL_RADIUS, 22, 16),
new THREE.MeshStandardMaterial({
color: ballColor,
emissive: ballGlow,
emissiveIntensity: 0.88,
roughness: 0.2,
metalness: 0.04
})
);
nextScene.add(ballMesh);
ball = ballMesh;
const flashTexture = createFlashTexture(themePalette);
const flash = new THREE.Sprite(
new THREE.SpriteMaterial({
map: flashTexture,
color: "#74ffff",
transparent: true,
blending: THREE.AdditiveBlending,
opacity: 0,
depthWrite: false
})
);
flash.scale.set(1, 1, 1);
flash.visible = false;
nextScene.add(flash);
flashSprite = flash;
const onKeyDown = (event: KeyboardEvent) => {
if (event.code === "ArrowLeft" || event.code === "KeyA") keyLeft = true;
if (event.code === "ArrowRight" || event.code === "KeyD") keyRight = true;
if (event.code === "ArrowUp" || event.code === "KeyW") handleUpControl();
if (event.code === "Space" && gameState !== "running" && gameState !== "draft") restartGame();
if (gameState === "draft") {
const option1 = activeDraftOptions[0];
const option2 = activeDraftOptions[1];
const option3 = activeDraftOptions[2];
if (event.code === "Digit1" && option1) applyUpgrade(option1.id);
if (event.code === "Digit2" && option2) applyUpgrade(option2.id);
if (event.code === "Digit3" && option3) applyUpgrade(option3.id);
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.code === "ArrowLeft" || event.code === "KeyA") keyLeft = false;
if (event.code === "ArrowRight" || event.code === "KeyD") keyRight = false;
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
const resize = () => {
if (!hostEl || !renderer || !camera) return;
const width = hostEl.clientWidth;
const height = hostEl.clientHeight;
if (width <= 0 || height <= 0) return;
renderer.setSize(width, height, false);
const aspect = width / Math.max(1, height);
const halfH = 70;
const halfW = halfH * aspect;
camera.left = -halfW;
camera.right = halfW;
camera.top = halfH;
camera.bottom = -halfH;
camera.updateProjectionMatrix();
};
const observer = new ResizeObserver(() => resize());
observer.observe(hostEl);
resize();
enterReadyState();
lastFrameTs = performance.now();
const loop = (ts: number) => {
const dt = Math.min((ts - lastFrameTs) / 1000, 0.034);
lastFrameTs = ts;
update(dt, ts);
renderer?.render(nextScene, nextCamera);
rafId = window.requestAnimationFrame(loop);
};
rafId = window.requestAnimationFrame(loop);
return () => {
if (rafId != null) window.cancelAnimationFrame(rafId);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
observer.disconnect();
disposeBricks();
border.geometry.dispose();
(border.material as THREE.LineBasicMaterial).dispose();
bg.geometry.dispose();
(bg.material as THREE.MeshBasicMaterial).map?.dispose();
(bg.material as THREE.MeshBasicMaterial).dispose();
paddleMesh.geometry.dispose();
paddleMesh.material.dispose();
ballMesh.geometry.dispose();
ballMesh.material.dispose();
(flash.material as THREE.SpriteMaterial).map?.dispose();
(flash.material as THREE.SpriteMaterial).dispose();
renderer?.dispose();
};
});
function createGridTexture(palette = themePalette): THREE.CanvasTexture {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext("2d");
if (!ctx) return new THREE.CanvasTexture(canvas);
const { uiTheme } = palette;
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, uiTheme.bg20);
gradient.addColorStop(0.5, uiTheme.bg10);
gradient.addColorStop(1, uiTheme.bg30);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = rgbaFromTriplet(uiTheme.glowRgb, 0.18);
ctx.lineWidth = 1;
for (let y = 0; y < canvas.height; y += 24) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
ctx.strokeStyle = rgbaFromTriplet(uiTheme.glowAltRgb, 0.08);
for (let x = 0; x < canvas.width; x += 24) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
function createFlashTexture(palette = themePalette): THREE.CanvasTexture {
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext("2d");
if (!ctx) return new THREE.CanvasTexture(canvas);
const { uiTheme } = palette;
const gradient = ctx.createRadialGradient(64, 64, 8, 64, 64, 64);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(0.25, rgbaFromTriplet(uiTheme.glowAltRgb, 0.86));
gradient.addColorStop(1, rgbaFromTriplet(uiTheme.glowRgb, 0));
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 128, 128);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
function rand(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
function disposeBricks(): void {
if (!brickGroup) return;
for (const brick of bricks) {
brick.mesh.geometry.dispose();
brick.mesh.material.dispose();
brickGroup.remove(brick.mesh);
}
bricks = [];
bricksLeft = 0;
}
function buildBricks(): void {
if (!brickGroup) return;
disposeBricks();
const palette = themedBrickPalette();
const totalWidth = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP_X;
const startX = -totalWidth / 2 + BRICK_W / 2;
for (let row = 0; row < BRICK_ROWS; row += 1) {
for (let col = 0; col < BRICK_COLS; col += 1) {
const kind: Brick["kind"] = Math.random() < bombBrickChance ? "bomb" : "normal";
const color = kind === "bomb" ? "#ff9d5c" : palette[(row + col + level) % palette.length] ?? "#7ad0ff";
const material = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: kind === "bomb" ? 0.96 : 0.7,
roughness: 0.35,
metalness: 0.15
});
const mesh = new THREE.Mesh(new THREE.BoxGeometry(BRICK_W, BRICK_H, 2.4), material);
const x = startX + col * (BRICK_W + BRICK_GAP_X);
const y = BRICK_TOP - row * (BRICK_H + BRICK_GAP_Y);
mesh.position.set(x, y, 0.2);
brickGroup.add(mesh);
bricks.push({
mesh,
alive: true,
kind,
left: x - BRICK_W / 2,
right: x + BRICK_W / 2,
top: y + BRICK_H / 2,
bottom: y - BRICK_H / 2
});
}
}
bricksLeft = bricks.length;
}
function launchBall(nowMs: number): void {
const speed = Math.min(MAX_BALL_SPEED + maxSpeedBonus, BASE_BALL_SPEED + launchSpeedBonus + (level - 1) * 4.8);
const directionX = rand(-0.72, 0.72);
ballVel.set(directionX, 1).normalize().multiplyScalar(speed);
ballLaunched = true;
nextLaunchAt = nowMs;
lastPaddleContactAt = nowMs;
lastAntiStallAt = nowMs;
bricksSincePaddle = 0;
}
function prepareBall(nowMs: number, delayMs: number): void {
ballLaunched = false;
ballVel.set(0, 0);
ballPos.set(paddleX, PADDLE_Y + PADDLE_H * 0.5 + BALL_RADIUS + 0.4);
nextLaunchAt = nowMs + delayMs;
bricksSincePaddle = 0;
}
function syncPaddleVisual(): void {
if (!paddle) return;
paddle.scale.x = paddleWidth / PADDLE_W;
}
function resetRunState(): void {
score = 0;
combo = 0;
lives = 3;
level = 1;
launchSpeedBonus = 0;
maxSpeedBonus = 0;
brickSpeedGain = 0.014;
bombBrickChance = BASE_BOMB_CHANCE;
bombExplosionRadius = BASE_BOMB_RADIUS;
bricksUntilDraft = DRAFT_EVERY_BRICKS;
activeDraftOptions = [];
selectedUpgrades = [];
paddleWidth = PADDLE_W;
syncPaddleVisual();
}
function enterReadyState(): void {
if (!paddle || !ball) return;
resetRunState();
gameState = "ready";
prevPauseGesture = false;
pauseGestureLockUntil = 0;
lastPaddleContactAt = performance.now();
lastAntiStallAt = 0;
bricksSincePaddle = 0;
paddleX = 0;
paddle.position.x = 0;
buildBricks();
prepareBall(performance.now(), START_DELAY_MS);
ball.position.set(ballPos.x, ballPos.y, 1.2);
}
function startRun(): void {
if (gameState !== "ready" || !paddle || !ball) return;
gameState = "running";
prepareBall(performance.now(), 260);
}
function restartGame(): void {
enterReadyState();
}
function togglePause(): void {
if (gameState === "running") {
gameState = "paused";
return;
}
if (gameState === "paused") {
gameState = "running";
lastFrameTs = performance.now();
}
}
function readCorners(matrix: number[] | null, rows: number, cols: number): CornerMap {
if (!matrix || matrix.length === 0) return { tl: 0, tr: 0, bl: 0, br: 0 };
const r = Math.max(Math.floor(rows), 1);
const c = Math.max(Math.floor(cols), 1);
const rs = Math.min(2, r);
const cs = Math.min(2, c);
const avg = (r0: number, r1: number, c0: number, c1: number): number => {
let sum = 0;
let count = 0;
for (let i = r0; i < r1; i += 1) {
for (let j = c0; j < c1; j += 1) {
const v = Number(matrix[i * c + j] ?? 0);
if (!Number.isFinite(v)) continue;
sum += Math.max(0, v);
count += 1;
}
}
return count ? sum / count : 0;
};
return {
tl: avg(0, rs, 0, cs),
tr: avg(0, rs, c - cs, c),
bl: avg(r - rs, r, 0, cs),
br: avg(r - rs, r, c - cs, c)
};
}
function sensorControlAxis(): number {
const span = Math.max(1200, (rangeMax - rangeMin) * 0.22);
const raw = (rightForce - leftForce) / span;
if (Math.abs(raw) < 0.045) return 0;
return Math.min(1, Math.max(-1, raw));
}
function handleUpControl(): void {
if (gameState === "ready") {
startRun();
return;
}
if (gameState === "running" || gameState === "paused") {
togglePause();
return;
}
if (gameState === "over") {
restartGame();
}
}
function sampleUpgradeOptions(): UpgradeCardCopy[] {
const pool = Object.values(upgradeCopy);
const shuffled = [...pool].sort(() => Math.random() - 0.5);
return shuffled.slice(0, 3);
}
function applyUpgrade(id: UpgradeId): void {
if (gameState !== "draft") return;
if (id === "overdrive") {
launchSpeedBonus += 5;
maxSpeedBonus += 8;
if (ballLaunched) {
ballVel.multiplyScalar(1.08);
}
} else if (id === "demolition") {
bombBrickChance = Math.min(0.32, bombBrickChance + 0.07);
bombExplosionRadius += 2.8;
} else if (id === "surge") {
brickSpeedGain += 0.01;
if (ballLaunched) {
ballVel.multiplyScalar(1.05);
}
} else if (id === "stabilizer") {
lives += 1;
paddleWidth = Math.min(PADDLE_W + 8, paddleWidth + 2.8);
syncPaddleVisual();
}
selectedUpgrades = [...selectedUpgrades, id];
activeDraftOptions = [];
bricksUntilDraft = DRAFT_EVERY_BRICKS;
gameState = "running";
lastFrameTs = performance.now();
}
function openUpgradeDraft(): void {
if (gameState !== "running") return;
activeDraftOptions = sampleUpgradeOptions();
gameState = "draft";
}
function handleSensorPauseGesture(nowMs: number): void {
if (gameState !== "ready" && gameState !== "running" && gameState !== "paused" && gameState !== "over") {
prevPauseGesture = false;
return;
}
const active = topForce >= pauseGestureThreshold;
if (active && !prevPauseGesture && nowMs >= pauseGestureLockUntil) {
handleUpControl();
pauseGestureLockUntil = nowMs + PAUSE_COOLDOWN_MS;
}
prevPauseGesture = active;
}
function update(dt: number, nowMs: number): void {
if (!paddle || !ball) return;
handleSensorPauseGesture(nowMs);
const keyAxis = (keyRight ? 1 : 0) - (keyLeft ? 1 : 0);
const axis = keyAxis !== 0 ? keyAxis : sensorControlAxis();
paddleX += axis * PADDLE_SPEED * dt;
paddleX = Math.min(FIELD_HALF_W - paddleWidth * 0.5, Math.max(-FIELD_HALF_W + paddleWidth * 0.5, paddleX));
paddle.position.x = paddleX;
if (gameState !== "running") {
ball.position.set(ballPos.x, ballPos.y, 1.2);
updateFlash(dt);
return;
}
if (!ballLaunched) {
ballPos.set(paddleX, PADDLE_Y + PADDLE_H * 0.5 + BALL_RADIUS + 0.4);
if (nowMs >= nextLaunchAt) launchBall(nowMs);
} else {
ballPos.addScaledVector(ballVel, dt);
resolveWallCollision(nowMs);
resolvePaddleCollision();
resolveBrickCollision(nowMs);
applyAntiStall(nowMs);
}
ball.position.set(ballPos.x, ballPos.y, 1.2);
updateFlash(dt);
}
function resolveWallCollision(nowMs: number): void {
if (ballPos.x - BALL_RADIUS <= -FIELD_HALF_W) {
ballPos.x = -FIELD_HALF_W + BALL_RADIUS;
ballVel.x = Math.abs(ballVel.x);
} else if (ballPos.x + BALL_RADIUS >= FIELD_HALF_W) {
ballPos.x = FIELD_HALF_W - BALL_RADIUS;
ballVel.x = -Math.abs(ballVel.x);
}
if (ballPos.y + BALL_RADIUS >= FIELD_HALF_H) {
ballPos.y = FIELD_HALF_H - BALL_RADIUS;
ballVel.y = -Math.abs(ballVel.y);
if (nowMs - lastPaddleContactAt > UPPER_STALL_THRESHOLD_MS * 0.6) {
forceBallDownward(0.62);
}
}
if (ballPos.y - BALL_RADIUS < -FIELD_HALF_H) {
lives -= 1;
combo = 0;
if (lives <= 0) {
gameState = "over";
ballLaunched = false;
} else {
prepareBall(nowMs, 440);
}
}
}
function resolvePaddleCollision(): void {
if (!ballLaunched || ballVel.y >= 0) return;
const paddleTop = PADDLE_Y + PADDLE_H * 0.5;
const paddleBottom = PADDLE_Y - PADDLE_H * 0.5;
const left = paddleX - paddleWidth * 0.5;
const right = paddleX + paddleWidth * 0.5;
const hit =
ballPos.y - BALL_RADIUS <= paddleTop &&
ballPos.y + BALL_RADIUS >= paddleBottom &&
ballPos.x >= left - BALL_RADIUS &&
ballPos.x <= right + BALL_RADIUS;
if (!hit) return;
ballPos.y = paddleTop + BALL_RADIUS + 0.04;
const offset = (ballPos.x - paddleX) / (paddleWidth * 0.5);
const speed = Math.min(MAX_BALL_SPEED + maxSpeedBonus, Math.max(BASE_BALL_SPEED + launchSpeedBonus * 0.4, ballVel.length()));
ballVel.set(offset * speed * 0.9, Math.abs(ballVel.y) + 4).normalize().multiplyScalar(speed);
combo = 0;
lastPaddleContactAt = performance.now();
bricksSincePaddle = 0;
}
function destroyBrick(brick: Brick, nowMs: number, scaleBoost = 0): number {
if (!brick.alive) return 0;
brick.alive = false;
brick.mesh.visible = false;
bricksLeft -= 1;
combo += 1;
score += 40 + combo * 5;
bricksSincePaddle += 1;
bricksUntilDraft -= 1;
triggerFlash(brick.mesh.position.x, brick.mesh.position.y, 10 + combo * 0.4 + scaleBoost);
const nextSpeed = Math.min(MAX_BALL_SPEED + maxSpeedBonus, ballVel.length() * (1 + brickSpeedGain));
if (ballLaunched && ballVel.lengthSq() > 0) {
ballVel.normalize().multiplyScalar(nextSpeed);
}
if (bricksSincePaddle >= MAX_BRICK_HITS_WITHOUT_PADDLE && ballPos.y > UPPER_STALL_Y) {
forceBallDownward(1);
lastAntiStallAt = nowMs;
}
return 1;
}
function detonateBomb(x: number, y: number, nowMs: number): number {
let destroyed = 0;
for (const candidate of bricks) {
if (!candidate.alive) continue;
const dx = candidate.mesh.position.x - x;
const dy = candidate.mesh.position.y - y;
if (Math.hypot(dx, dy) > bombExplosionRadius) continue;
destroyed += destroyBrick(candidate, nowMs, 2.8);
}
triggerFlash(x, y, 18);
return destroyed;
}
function handleLevelClear(nowMs: number): void {
if (level >= RUN_TARGET_LEVEL) {
gameState = "over";
ballLaunched = false;
return;
}
level += 1;
buildBricks();
prepareBall(nowMs, 420);
if (level > 1) {
openUpgradeDraft();
}
}
function resolveBrickCollision(nowMs: number): void {
if (!ballLaunched) return;
for (const brick of bricks) {
if (!brick.alive) continue;
const closestX = Math.max(brick.left, Math.min(ballPos.x, brick.right));
const closestY = Math.max(brick.bottom, Math.min(ballPos.y, brick.top));
const dx = ballPos.x - closestX;
const dy = ballPos.y - closestY;
if (dx * dx + dy * dy > BALL_RADIUS * BALL_RADIUS) continue;
const overlapX = Math.min(ballPos.x + BALL_RADIUS - brick.left, brick.right - (ballPos.x - BALL_RADIUS));
const overlapY = Math.min(ballPos.y + BALL_RADIUS - brick.bottom, brick.top - (ballPos.y - BALL_RADIUS));
if (overlapX < overlapY) {
ballVel.x *= -1;
} else {
ballVel.y *= -1;
}
if (brick.kind === "bomb") {
detonateBomb(brick.mesh.position.x, brick.mesh.position.y, nowMs);
} else {
destroyBrick(brick, nowMs);
}
if (bricksLeft <= 0) {
handleLevelClear(nowMs);
} else if (bricksUntilDraft <= 0) {
openUpgradeDraft();
}
break;
}
}
function triggerFlash(x: number, y: number, scale: number): void {
if (!flashSprite) return;
flashLife = flashMaxLife;
flashSprite.visible = true;
flashSprite.position.set(x, y, 2.5);
flashSprite.scale.set(scale, scale, 1);
const material = flashSprite.material as THREE.SpriteMaterial;
material.opacity = 0.76;
}
function updateFlash(dt: number): void {
if (!flashSprite || flashLife <= 0) return;
flashLife -= dt;
const t = Math.max(0, flashLife / flashMaxLife);
const material = flashSprite.material as THREE.SpriteMaterial;
material.opacity = t * 0.78;
flashSprite.scale.multiplyScalar(1 + dt * 2.6);
if (flashLife <= 0) flashSprite.visible = false;
}
function forceBallDownward(forceScale: number): void {
const speed = Math.min(
MAX_BALL_SPEED + maxSpeedBonus,
Math.max(BASE_BALL_SPEED + launchSpeedBonus * 0.4, ballVel.length())
);
let dirX = ballVel.x + rand(-8, 8);
if (Math.abs(dirX) < 6) dirX = (Math.random() < 0.5 ? -1 : 1) * 6;
const dirY = -Math.abs(ballVel.y) - 6 * forceScale;
ballVel.set(dirX, dirY).normalize().multiplyScalar(speed);
}
function applyAntiStall(nowMs: number): void {
if (!ballLaunched) return;
if (ballPos.y < UPPER_STALL_Y) return;
if (nowMs - lastPaddleContactAt < UPPER_STALL_THRESHOLD_MS) return;
if (nowMs - lastAntiStallAt < ANTI_STALL_COOLDOWN_MS) return;
forceBallDownward(0.85);
lastAntiStallAt = nowMs;
}
</script>
<section class="breakout-root" bind:this={hostEl}>
<canvas class="breakout-canvas" bind:this={canvasEl} aria-label="Neon Breakout"></canvas>
<div class="breakout-overlay">
<header class="overlay-head">
<div class="title-block">
<p class="title">{ui.title}</p>
</div>
<div class="action-group">
<button type="button" on:click={gameState === "ready" ? startRun : restartGame}>
{gameState === "ready" ? ui.start : ui.restart}
</button>
<button
type="button"
on:click={handleUpControl}
disabled={gameState === "draft" || gameState === "over"}
>
{gameState === "paused" ? ui.resume : ui.pause}
</button>
</div>
</header>
<div class="hud-grid">
<article><span>{ui.score}</span><strong>{Math.round(score)}</strong></article>
<article><span>{ui.combo}</span><strong>{combo}</strong></article>
<article><span>{ui.lives}</span><strong>{lives}</strong></article>
<article><span>{ui.level}</span><strong>{level}</strong></article>
<article><span>{ui.bricks}</span><strong>{Math.max(0, bricksLeft)}</strong></article>
</div>
<footer class="overlay-foot">
<p class="status">{ui.chase} / {statusText}</p>
{#if selectedUpgrades.length > 0}
<p class="status skill-status">{ui.skills} / {selectedUpgrades.length}</p>
{/if}
</footer>
</div>
{#if gameState === "ready"}
<div class="pause-mask is-ready" aria-live="polite">
<div class="pause-panel is-ready">
<p>{ui.startOverlayTitle}</p>
<div class="overlay-copy">
<span>{ui.startOverlayBody}</span>
<span>{ui.startOverlayHint}</span>
</div>
</div>
</div>
{/if}
{#if gameState === "paused"}
<div class="pause-mask" aria-live="polite">
<div class="pause-panel">
<p>{ui.pausedOverlay}</p>
</div>
</div>
{/if}
{#if gameState === "draft"}
<div class="pause-mask is-draft" aria-live="polite">
<div class="pause-panel is-draft">
<p>{ui.draftOverlayTitle}</p>
<div class="overlay-copy">
<span>{ui.draftOverlayHint}</span>
</div>
<div class="draft-grid">
{#each activeDraftOptions as option, index}
<button type="button" class="draft-card" on:click={() => applyUpgrade(option.id)}>
<strong>{index + 1}. {option.title}</strong>
<span>{option.body}</span>
</button>
{/each}
</div>
</div>
</div>
{/if}
{#if gameState === "over"}
<div class="pause-mask is-over" aria-live="assertive">
<div class="pause-panel is-over">
<p>{ui.over}</p>
<div class="overlay-copy">
<span>{ui.overHint}</span>
</div>
<button type="button" class="overlay-restart-btn" on:click={restartGame}>{ui.restart}</button>
</div>
</div>
{/if}
</section>
<style>
.breakout-root {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 0.58rem;
border: 1px solid rgb(var(--hud-border-rgb) / 0.28);
background: linear-gradient(180deg, rgb(var(--hud-surface-deep-rgb) / 0.92), rgb(var(--hud-surface-rgb) / 0.86));
box-shadow:
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.08),
0 0 24px rgb(var(--hud-glow-rgb) / 0.12);
}
.breakout-canvas {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
display: block;
}
.breakout-overlay {
position: absolute;
inset: 0;
pointer-events: none;
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 0.42rem;
padding: 0.5rem 0.58rem 0.42rem;
}
.overlay-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.title {
margin: 0;
color: rgb(var(--hud-text-main-rgb) / 0.96);
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.action-group {
display: inline-flex;
gap: 0.28rem;
pointer-events: auto;
}
.action-group button {
min-block-size: 1.65rem;
border: 1px solid rgb(var(--hud-cyan-rgb) / 0.56);
border-radius: 999px;
padding: 0.14rem 0.52rem;
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.92), rgb(var(--hud-surface-rgb) / 0.86));
color: rgb(var(--hud-text-main-rgb) / 0.96);
font: inherit;
font-size: 0.6rem;
letter-spacing: 0.06em;
cursor: pointer;
}
.action-group button:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.hud-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.24rem;
}
.hud-grid article {
min-block-size: 1.88rem;
border: 1px solid rgb(var(--hud-border-rgb) / 0.24);
border-radius: 0.42rem;
padding: 0.16rem 0.34rem 0.24rem;
background: rgb(var(--hud-surface-rgb) / 0.68);
}
.hud-grid span {
display: block;
color: rgb(var(--hud-text-dim-rgb) / 0.82);
font-size: 0.5rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hud-grid strong {
color: rgb(var(--hud-text-main-rgb) / 0.98);
font-size: 0.84rem;
line-height: 1;
}
.overlay-foot {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 0.52rem;
}
.status {
margin: 0;
border: 1px solid rgb(var(--hud-border-rgb) / 0.3);
border-radius: 999px;
padding: 0.18rem 0.56rem;
background: rgb(var(--hud-surface-deep-rgb) / 0.66);
color: rgb(var(--hud-text-main-rgb) / 0.94);
font-size: 0.56rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.skill-status {
border-color: rgb(var(--hud-orange-rgb) / 0.34);
background: rgb(var(--hud-surface-rgb) / 0.58);
}
.pause-mask {
position: absolute;
inset: 0;
z-index: 12;
pointer-events: none;
display: grid;
place-items: center;
background:
radial-gradient(circle at center, rgb(var(--hud-surface-rgb) / 0.24), rgb(var(--hud-bg-30) / 0.72)),
repeating-linear-gradient(
180deg,
rgb(var(--hud-glow-alt-rgb) / 0.08) 0,
rgb(var(--hud-glow-alt-rgb) / 0.08) 1px,
transparent 1px,
transparent 4px
);
backdrop-filter: blur(3px);
}
.pause-panel {
border: 1px solid rgb(var(--hud-lime-rgb) / 0.56);
border-radius: 0.66rem;
padding: 0.62rem 0.92rem;
background: rgb(var(--hud-surface-deep-rgb) / 0.8);
box-shadow:
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.1),
0 0 26px rgb(var(--hud-lime-rgb) / 0.22);
pointer-events: auto;
}
.pause-panel p {
margin: 0;
color: rgb(var(--hud-text-main-rgb) / 0.98);
font-size: clamp(0.76rem, 1.5vw, 1.08rem);
letter-spacing: 0.08em;
text-transform: uppercase;
text-shadow: 0 0 10px rgb(var(--hud-lime-rgb) / 0.28);
}
.overlay-copy {
display: grid;
gap: 0.46rem;
margin-top: 0.7rem;
}
.overlay-copy span {
color: rgb(var(--hud-text-main-rgb) / 0.84);
font-size: clamp(0.68rem, 1.2vw, 0.86rem);
line-height: 1.55;
letter-spacing: 0.03em;
text-transform: none;
}
.pause-mask.is-ready {
background:
radial-gradient(circle at center, rgb(var(--hud-surface-rgb) / 0.18), rgb(var(--hud-bg-30) / 0.84)),
linear-gradient(135deg, rgb(var(--hud-cyan-rgb) / 0.08), transparent 42%, rgb(var(--hud-lime-rgb) / 0.08));
}
.pause-panel.is-ready {
border-color: rgb(var(--hud-cyan-rgb) / 0.64);
box-shadow:
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.1),
0 0 32px rgb(var(--hud-cyan-rgb) / 0.22);
max-inline-size: min(34rem, 82%);
}
.pause-mask.is-draft {
background:
radial-gradient(circle at center, rgb(var(--hud-surface-rgb) / 0.18), rgb(var(--hud-bg-30) / 0.86)),
linear-gradient(160deg, rgb(var(--hud-orange-rgb) / 0.08), transparent 48%, rgb(var(--hud-cyan-rgb) / 0.08));
}
.pause-panel.is-draft {
border-color: rgb(var(--hud-orange-rgb) / 0.58);
box-shadow:
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.1),
0 0 32px rgb(var(--hud-orange-rgb) / 0.22);
inline-size: min(42rem, 88%);
}
.draft-grid {
margin-top: 0.92rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.62rem;
pointer-events: auto;
}
.draft-card {
border: 1px solid rgb(var(--hud-border-rgb) / 0.38);
border-radius: 0.78rem;
padding: 0.8rem 0.86rem;
background:
linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.96), rgb(var(--hud-surface-rgb) / 0.88));
color: rgb(var(--hud-text-main-rgb) / 0.96);
font: inherit;
text-align: left;
display: grid;
gap: 0.48rem;
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.draft-card:hover {
transform: translateY(-2px);
border-color: rgb(var(--hud-cyan-rgb) / 0.56);
box-shadow: 0 0 18px rgb(var(--hud-cyan-rgb) / 0.16);
}
.draft-card strong {
font-size: 0.78rem;
line-height: 1.2;
letter-spacing: 0.04em;
}
.draft-card span {
color: rgb(var(--hud-text-main-rgb) / 0.82);
font-size: 0.66rem;
line-height: 1.5;
}
.pause-mask.is-over {
background:
radial-gradient(circle at center, rgb(var(--hud-surface-rgb) / 0.22), rgb(var(--hud-bg-30) / 0.8)),
repeating-linear-gradient(
180deg,
rgb(var(--hud-orange-rgb) / 0.1) 0,
rgb(var(--hud-orange-rgb) / 0.1) 1px,
transparent 1px,
transparent 4px
);
}
.pause-panel.is-over {
border-color: rgb(var(--hud-orange-rgb) / 0.62);
box-shadow:
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.1),
0 0 32px rgb(var(--hud-orange-rgb) / 0.24);
min-inline-size: min(24rem, 72%);
display: grid;
gap: 0.72rem;
justify-items: center;
}
.pause-panel.is-over p {
color: rgb(var(--hud-orange-rgb) / 0.98);
text-shadow: 0 0 12px rgb(var(--hud-orange-rgb) / 0.36);
}
.overlay-restart-btn {
min-block-size: clamp(2.2rem, 4.2vh, 2.8rem);
min-inline-size: clamp(8.8rem, 18vw, 13rem);
border: 1px solid rgb(var(--hud-cyan-rgb) / 0.62);
border-radius: 999px;
padding: 0.24rem 1rem;
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.96), rgb(var(--hud-surface-rgb) / 0.9));
color: rgb(var(--hud-text-main-rgb) / 0.98);
font: inherit;
font-size: clamp(0.74rem, 1.3vw, 0.96rem);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
box-shadow: 0 0 16px rgb(var(--hud-cyan-rgb) / 0.2);
transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease;
}
.overlay-restart-btn:hover {
transform: translateY(-1px);
border-color: rgb(var(--hud-lime-rgb) / 0.66);
box-shadow: 0 0 20px rgb(var(--hud-lime-rgb) / 0.28);
}
@media (max-width: 1120px) {
.hud-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.draft-grid {
grid-template-columns: 1fr;
}
}
</style>