chrome-devtools-frontend
Version:
Chrome DevTools UI
606 lines (550 loc) • 23 kB
text/typescript
// Copyright 2024 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable rulesdir/no-imperative-dom-api */
import * as i18n from '../../../../core/i18n/i18n.js';
import * as WindowBounds from '../../../../services/window_bounds/window_bounds.js';
import * as ThemeSupport from '../../theme_support/theme_support.js';
import type {FlameChart} from './FlameChart.js';
const UIStrings = {
/**
*@description Message congratulating the user for having won a game.
*/
congrats: 'Congrats, you win!',
/**
*@description A Postscript hinting the user the possibility to open the game using a keycombo.
*/
ps: 'PS: You can also open the game by typing `fixme`',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/perf_ui/BrickBreaker.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
interface Brick {
x: number;
y: number;
width: number;
}
const MAX_DELTA = 16;
const MIN_DELTA = 10;
const MAX_PADDLE_LENGTH = 150;
const MIN_PADDLE_LENGTH = 85;
const PADDLE_HEIGHT = 15;
const BALL_RADIUS = 10;
interface ColorPalette {
light: string;
mediumLighter: string;
mediumDarker: string;
dark: string;
}
const colorPallettes: ColorPalette[] = [
// blues
{
light: 'rgb(224,240,255)',
mediumLighter: 'rgb(176,208,255)',
mediumDarker: 'rgb(112,160,221)',
dark: 'rgb(0,92,153)',
},
// pinks
{
light: 'rgb(253, 216, 229)',
mediumLighter: 'rgb(250, 157, 188)',
mediumDarker: 'rgb(249, 98, 154)',
dark: 'rgb(254, 5, 105)',
},
// pastel pinks
{
light: 'rgb(254, 234, 234)',
mediumLighter: 'rgb(255, 216, 216)',
mediumDarker: 'rgb(255, 195, 195)',
dark: 'rgb(235, 125, 138)',
},
// purples
{
light: 'rgb(226,183,206)',
mediumLighter: 'rgb(219,124,165)',
mediumDarker: 'rgb(146,60,129)',
dark: 'rgb(186, 85, 255)',
},
// greens
{
light: 'rgb(206,255,206)',
mediumLighter: 'rgb(128,255,128)',
mediumDarker: 'rgb(0,246,0)',
dark: 'rgb(0,187,0)',
},
// reds
{
light: 'rgb(255, 188, 181)',
mediumLighter: 'rgb(254, 170, 170)',
mediumDarker: 'rgb(215, 59, 43)',
dark: 'rgb(187, 37, 23)',
},
// aqua
{
light: 'rgb(236, 254, 250)',
mediumLighter: 'rgb(204, 255, 245)',
mediumDarker: 'rgb(164, 240, 233)',
dark: 'rgb(72,189,144)',
},
// yellow/pink
{
light: 'rgb(255, 225, 185)',
mediumLighter: 'rgb(255, 204, 141)',
mediumDarker: 'rgb(240, 140, 115)',
dark: 'rgb(211, 96, 117)',
},
// ocean breeze
{
light: 'rgb(218, 255, 248)',
mediumLighter: 'rgb(177, 235, 236)',
mediumDarker: 'rgb(112, 214, 214)',
dark: 'rgb(34, 205, 181)',
},
];
export class BrickBreaker extends HTMLElement {
#canvas: HTMLCanvasElement;
#ctx: CanvasRenderingContext2D;
#helperCanvas: HTMLCanvasElement;
#helperCanvasCtx: CanvasRenderingContext2D;
#scorePanel: HTMLElement;
#trackTimelineOffset = 0;
#visibleEntries = new Set<number>();
#brokenBricks = new Map<number, Brick>();
#keyDownHandlerBound = this.#keyDownHandler.bind(this);
#keyUpHandlerBound = this.#keyUpHandler.bind(this);
#keyPressHandlerBound = this.#keyPressHandler.bind(this);
#closeGameBound = this.#closeGame.bind(this);
#mouseMoveHandlerBound = this.#mouseMoveHandler.bind(this);
#boundingElement = WindowBounds.WindowBoundsService.WindowBoundsServiceImpl.instance().getDevToolsBoundingElement();
// Value by which we moved the game up relative to the viewport
#gameViewportOffset = 0;
#running = false;
#initialDPR = devicePixelRatio;
#ballX = 0;
#ballY = 0;
#ballDx = 0;
#ballDy = 0;
#paddleX = 0;
#rightPressed = false;
#leftPressed = false;
#brickHeight = 0;
#lives = 0;
#blockCount = 0;
#paddleLength = MAX_PADDLE_LENGTH;
#minScreenHeight = 150;
#maxScreenHeight = 1500;
#screenHeightDiff = this.#maxScreenHeight - this.#minScreenHeight;
// Value from 0.1 to 1 that multiplies speed depending on the screen height
#deltaMultiplier = 0;
#deltaVectorLength = 0;
#currentPalette: ColorPalette;
constructor(private timelineFlameChart: FlameChart) {
super();
this.#canvas = this.createChild('canvas', 'fill');
this.#ctx = this.#canvas.getContext('2d') as CanvasRenderingContext2D;
this.#helperCanvas = document.createElement('canvas');
this.#helperCanvasCtx = this.#helperCanvas.getContext('2d') as CanvasRenderingContext2D;
const randomPaletteIndex = Math.floor(Math.random() * colorPallettes.length);
this.#currentPalette = colorPallettes[randomPaletteIndex];
this.#scorePanel = this.createChild('div');
this.#scorePanel.classList.add('scorePanel');
this.#scorePanel.style.borderImage =
'linear-gradient(' + this.#currentPalette.mediumDarker + ',' + this.#currentPalette.dark + ') 1';
this.initButton();
}
initButton(): void {
const button = this.createChild('div');
button.classList.add('game-close-button');
button.innerHTML = '<b><span style=\'font-size: 1.2em; color: white\'>x</span></b>';
button.style.background = this.#currentPalette.dark;
button.style.boxShadow = this.#currentPalette.dark + ' 1px 1px, ' + this.#currentPalette.mediumDarker +
' 3px 3px, ' + this.#currentPalette.mediumLighter + ' 5px 5px';
button.addEventListener('click', this.#closeGame.bind(this));
this.appendChild(button);
}
connectedCallback(): void {
this.#running = true;
this.#setUpNewGame();
this.#boundingElement.addEventListener('keydown', this.#keyDownHandlerBound);
document.addEventListener('keydown', this.#keyDownHandlerBound, false);
document.addEventListener('keyup', this.#keyUpHandlerBound, false);
document.addEventListener('keypress', this.#keyPressHandlerBound, false);
window.addEventListener('resize', this.#closeGameBound);
document.addEventListener('mousemove', this.#mouseMoveHandlerBound, false);
this.tabIndex = 1;
this.focus();
}
disconnectedCallback(): void {
this.#boundingElement.removeEventListener('keydown', this.#keyDownHandlerBound);
window.removeEventListener('resize', this.#closeGameBound);
document.removeEventListener('keydown', this.#keyDownHandlerBound, false);
document.removeEventListener('keyup', this.#keyUpHandlerBound, false);
window.removeEventListener('resize', this.#closeGameBound);
document.removeEventListener('keypress', this.#keyPressHandlerBound, false);
document.removeEventListener('mousemove', this.#mouseMoveHandlerBound, false);
}
#resetCanvas(): void {
const dPR = window.devicePixelRatio;
const height = Math.round(this.offsetHeight * dPR);
const width = Math.round(this.offsetWidth * dPR);
this.#canvas.height = height;
this.#canvas.width = width;
this.#canvas.style.height = (height / dPR) + 'px';
this.#canvas.style.width = (width / dPR) + 'px';
}
#closeGame(): void {
this.#running = false;
this.remove();
}
#setUpNewGame(): void {
this.#resetCanvas();
this.#deltaMultiplier = Math.max(0.1, (this.offsetHeight - this.#minScreenHeight) / this.#screenHeightDiff);
this.#deltaVectorLength = MIN_DELTA * this.#deltaMultiplier;
const trackData = this.timelineFlameChart.drawTrackOnCanvas('Main', this.#ctx, BALL_RADIUS);
if (trackData === null || trackData.visibleEntries.size === 0) {
console.error('Could not draw game');
this.#closeGame();
return;
}
this.#trackTimelineOffset = trackData.top;
this.#visibleEntries = trackData.visibleEntries;
this.#gameViewportOffset = this.#trackTimelineOffset +
this.timelineFlameChart.getCanvas().getBoundingClientRect().top - this.timelineFlameChart.getScrollOffset();
requestAnimationFrame(() => this.#animateFlameChartTopPositioning(trackData.top, trackData.height));
}
#animateFlameChartTopPositioning(currentOffset: number, flameChartHeight: number): void {
if (currentOffset === 0) {
this.#createGame();
return;
}
const dPR = window.devicePixelRatio;
const currentOffsetOnDPR = Math.round(currentOffset * dPR);
const newOffset = Math.max(currentOffset - 4, 0);
const newOffsetOnDPR = Math.round(newOffset * dPR);
const baseCanvas = this.#canvas;
this.#helperCanvas.height = baseCanvas.height;
this.#helperCanvas.width = baseCanvas.width;
this.#helperCanvas.style.height = baseCanvas.style.height;
this.#helperCanvas.style.width = baseCanvas.style.width;
this.#helperCanvasCtx.drawImage(
baseCanvas, 0, currentOffsetOnDPR, baseCanvas.width, flameChartHeight * dPR, 0, newOffsetOnDPR,
baseCanvas.width, flameChartHeight * dPR);
this.#resetCanvas();
this.#ctx.drawImage(this.#helperCanvas, 0, 0);
requestAnimationFrame(() => this.#animateFlameChartTopPositioning(newOffset, flameChartHeight));
}
#keyUpHandler(event: KeyboardEvent): void {
if (event.key === 'Right' || event.key === 'ArrowRight' || event.key === 'd') {
this.#rightPressed = false;
event.preventDefault();
} else if (event.key === 'Left' || event.key === 'ArrowLeft' || event.key === 'a') {
this.#leftPressed = false;
event.preventDefault();
} else {
event.stopImmediatePropagation();
}
}
#keyPressHandler(e: Event): void {
e.stopImmediatePropagation();
e.preventDefault();
}
#keyDownHandler(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.#closeGame();
event.stopImmediatePropagation();
} else if (event.key === 'Right' || event.key === 'ArrowRight' || event.key === 'd') {
this.#rightPressed = true;
event.preventDefault();
} else if (event.key === 'Left' || event.key === 'ArrowLeft' || event.key === 'a') {
this.#leftPressed = true;
event.preventDefault();
} else {
event.preventDefault();
event.stopImmediatePropagation();
}
}
#mouseMoveHandler(e: MouseEvent): void {
this.#paddleX = Math.max(e.offsetX - this.#paddleLength / 2, 0);
this.#paddleX = Math.min(this.#paddleX, this.offsetWidth - this.#paddleLength);
}
#createGame(): void {
this.#ballX = this.offsetWidth / 2;
this.#ballY = this.offsetHeight - PADDLE_HEIGHT - BALL_RADIUS;
this.#ballDx = 0;
this.#ballDy = -Math.SQRT2 * this.#deltaVectorLength;
this.#paddleX = (this.#canvas.width - this.#paddleLength) / 2;
this.#rightPressed = false;
this.#leftPressed = false;
this.#brickHeight = this.timelineFlameChart.getBarHeight();
this.#blockCount = this.#visibleEntries.size;
this.#lives = Math.max(Math.round(this.#blockCount / 17), 2);
this.#draw();
}
#restartBall(): void {
this.#ballX = this.offsetWidth / 2;
this.#ballY = this.offsetHeight - PADDLE_HEIGHT - BALL_RADIUS;
this.#ballDx = 0;
this.#ballDy = -Math.SQRT2 * this.#deltaVectorLength;
}
#drawBall(): void {
if (!this.#ctx) {
return;
}
const gradient = this.#ctx.createRadialGradient(
this.#ballX + BALL_RADIUS / 4, // Offset towards the left
this.#ballY - BALL_RADIUS / 4, // Offset downwards
0,
this.#ballX + BALL_RADIUS / 4,
this.#ballY - BALL_RADIUS / 4,
BALL_RADIUS,
);
// stops for gradient
gradient.addColorStop(0.3, this.#currentPalette.mediumLighter);
gradient.addColorStop(0.6, this.#currentPalette.mediumDarker);
gradient.addColorStop(1, this.#currentPalette.dark);
this.#ctx.beginPath();
this.#ctx.arc(this.#ballX, this.#ballY, BALL_RADIUS, 0, Math.PI * 2);
this.#ctx.fillStyle = gradient;
this.#ctx.fill();
this.#ctx.closePath();
}
#drawPaddle(): void {
if (!this.#ctx) {
return;
}
const gradient = this.#ctx.createRadialGradient(
this.#paddleX + this.#paddleLength / 3,
this.offsetHeight - PADDLE_HEIGHT - PADDLE_HEIGHT / 4,
0,
this.#paddleX + this.#paddleLength / 3,
this.offsetHeight - PADDLE_HEIGHT - PADDLE_HEIGHT / 4,
this.#paddleLength / 2,
);
gradient.addColorStop(0.3, this.#currentPalette.dark); // Paddle color
gradient.addColorStop(1, this.#currentPalette.mediumDarker); // Lighter color
this.#ctx.beginPath();
this.#ctx.rect(this.#paddleX, this.offsetHeight - PADDLE_HEIGHT, this.#paddleLength, PADDLE_HEIGHT);
this.#ctx.fillStyle = gradient;
this.#ctx.fill();
this.#ctx.closePath();
}
#patchBrokenBricks(): void {
if (!this.#ctx) {
return;
}
for (const brick of this.#brokenBricks.values()) {
this.#ctx.beginPath();
// Extend the patch width an extra 0.5 px to ensure the
// entry is completely covered.
this.#ctx.rect(brick.x, brick.y, brick.width + 0.5, this.#brickHeight + 0.5);
this.#ctx.fillStyle =
ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-neutral-container', this);
this.#ctx.fill();
this.#ctx.closePath();
}
}
#draw(): void {
if (this.#initialDPR !== devicePixelRatio) {
this.#running = false;
}
if (this.#lives === 0) {
window.alert('GAME OVER');
this.#closeGame();
return;
}
if (this.#blockCount === 0) {
this.#party();
return;
}
this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height);
this.#ctx.drawImage(this.#helperCanvas, 0, 0);
this.#ctx.save();
this.#ctx.scale(devicePixelRatio, devicePixelRatio);
this.#helperCanvasCtx.save();
this.#helperCanvasCtx.scale(devicePixelRatio, devicePixelRatio);
this.#patchBrokenBricks();
this.#drawBall();
this.#drawPaddle();
this.#brickCollisionDetection();
const lives = `<div><b><span style='font-size: 1.3em; color: ${this.#currentPalette.dark}'>❤️ ${
this.#lives}</span></b></div>`;
const blocks = `<div><b><span style='font-size: 1.3em; color: ${this.#currentPalette.dark}'> 🧱 ${
this.#blockCount}</span></b></div>`;
this.#scorePanel.innerHTML = lives + blocks;
this.#blockCount = this.#visibleEntries.size - this.#brokenBricks.size;
this.#deltaVectorLength =
(MIN_DELTA + (MAX_DELTA - MIN_DELTA) * this.#brokenBricks.size / this.#visibleEntries.size) *
this.#deltaMultiplier;
this.#paddleLength = MAX_PADDLE_LENGTH -
(MAX_PADDLE_LENGTH - MIN_PADDLE_LENGTH) * this.#brokenBricks.size / this.#visibleEntries.size;
if (this.#ballX + this.#ballDx > this.offsetWidth - BALL_RADIUS || this.#ballX + this.#ballDx < BALL_RADIUS) {
// Ball bounces on a side wall.
this.#ballDx = -this.#ballDx;
}
if (this.#ballY + this.#ballDy < BALL_RADIUS) {
// Ball bounces on the top.
this.#ballDy = -this.#ballDy;
} else if (this.#ballY + this.#ballDy > this.offsetHeight - BALL_RADIUS && this.#ballDy > 0) {
// Ball is at the bottom, either on the paddle or in the
// void.
if (this.#ballX > (this.#paddleX - BALL_RADIUS) &&
this.#ballX < this.#paddleX + this.#paddleLength + BALL_RADIUS) {
// Ball bounces on the paddle, calculate this.ballDx and this.ballDy so that
// the speed remains constant.
// speed^2 = dx^2 + dy^2 = MAX_DELTA^2 + MAX_DELTA^2
// -> speed = MAX_DELTA * sqrt(2)
// (speed is measured in pixels / frame)
// The bouncing angle is determined by the portion of the
// paddle's length on which it falls and by the restriction
// -MAX_DELTA < this.ballDx < MAX_DELTA
// Since we allow for some margin of error (BALL_RADIUS), we need to
// round the ball x to be within the paddle.
let roundedBallX = Math.min(this.#ballX, this.#paddleX + this.#paddleLength);
roundedBallX = Math.max(roundedBallX, this.#paddleX);
const paddleLenghtPortion = (roundedBallX - this.#paddleX) * this.#deltaVectorLength * 2 / this.#paddleLength;
this.#ballDx = -this.#deltaVectorLength + paddleLenghtPortion;
// Solve for this.ballDy given the above equation and bounce up.
this.#ballDy = -Math.sqrt(2 * Math.pow(this.#deltaVectorLength, 2) - Math.pow(this.#ballDx, 2));
} else {
// Ball fell into oblivion, restart.
this.#restartBall();
this.#paddleX = (this.offsetWidth - this.#paddleLength) / 2;
this.#lives--;
}
}
const keyDelta = Math.round(this.clientWidth / 60);
if (this.#rightPressed && this.#paddleX < this.offsetWidth - this.#paddleLength) {
this.#paddleX += keyDelta;
} else if (this.#leftPressed && this.#paddleX > 0) {
this.#paddleX -= keyDelta;
}
this.#ballX += Math.round(this.#ballDx);
this.#ballY += Math.round(this.#ballDy);
this.#ctx.restore();
this.#helperCanvasCtx.restore();
if (this.#running) {
requestAnimationFrame(this.#draw.bind(this));
}
}
#brickCollisionDetection(): void {
// coordinatesToEntryIndex expects coordinates relative to the timeline canvas.
const timelineCanvasOffset = this.timelineFlameChart.getCanvas().getBoundingClientRect();
// Check collision if there is an entry on the top, bottom, left and right of the ball
const ballYRelativeToGame = this.#ballY + this.#gameViewportOffset - timelineCanvasOffset.top;
const entryIndexTop =
this.timelineFlameChart.coordinatesToEntryIndex(this.#ballX, ballYRelativeToGame + BALL_RADIUS);
const entryIndexBottom =
this.timelineFlameChart.coordinatesToEntryIndex(this.#ballX, ballYRelativeToGame - BALL_RADIUS);
const entryIndexRight =
this.timelineFlameChart.coordinatesToEntryIndex(this.#ballX + BALL_RADIUS, ballYRelativeToGame);
const entryIndexLeft =
this.timelineFlameChart.coordinatesToEntryIndex(this.#ballX - BALL_RADIUS, ballYRelativeToGame);
// Points on the 45 degree corners
const diffBetweenCornerandCircle = BALL_RADIUS / Math.SQRT2;
const entryIndexRightTop = this.timelineFlameChart.coordinatesToEntryIndex(
this.#ballX + diffBetweenCornerandCircle, ballYRelativeToGame + diffBetweenCornerandCircle);
const entryIndexLeftTop = this.timelineFlameChart.coordinatesToEntryIndex(
this.#ballX - diffBetweenCornerandCircle, ballYRelativeToGame + diffBetweenCornerandCircle);
const entryIndexRightBottom = this.timelineFlameChart.coordinatesToEntryIndex(
this.#ballX + diffBetweenCornerandCircle, ballYRelativeToGame - diffBetweenCornerandCircle);
const entryIndexLeftBottom = this.timelineFlameChart.coordinatesToEntryIndex(
this.#ballX - diffBetweenCornerandCircle, ballYRelativeToGame - diffBetweenCornerandCircle);
const breakBrick = (entryIndex: number): void => {
const entryCoordinates = this.timelineFlameChart.entryIndexToCoordinates(entryIndex);
if (entryCoordinates) {
// Cap entries starting before the visible window in the game.
const entryBegin = Math.max(entryCoordinates.x - timelineCanvasOffset.left, 0);
// Extend the patch width and height an extra 0.5 px to ensure the
// entry is completely covered.
this.#brokenBricks.set(entryIndex, {
x: entryBegin - 0.5,
y: entryCoordinates.y - this.#gameViewportOffset - 0.5,
width: this.timelineFlameChart.entryWidth(entryIndex),
});
}
};
if (entryIndexTop > -1 && !this.#brokenBricks.has(entryIndexTop) && this.#visibleEntries.has(entryIndexTop)) {
this.#ballDy = -this.#ballDy;
breakBrick(entryIndexTop);
return;
}
if (entryIndexBottom > -1 && !this.#brokenBricks.has(entryIndexBottom) &&
this.#visibleEntries.has(entryIndexBottom)) {
this.#ballDy = -this.#ballDy;
breakBrick(entryIndexBottom);
return;
}
if (entryIndexRight > -1 && !this.#brokenBricks.has(entryIndexRight) && this.#visibleEntries.has(entryIndexRight)) {
this.#ballDx = -this.#ballDx;
breakBrick(entryIndexRight);
return;
}
if (entryIndexLeft > -1 && !this.#brokenBricks.has(entryIndexLeft) && this.#visibleEntries.has(entryIndexLeft)) {
this.#ballDx = -this.#ballDx;
breakBrick(entryIndexLeft);
return;
}
// if the brick hits on 45 degrees, reverse both directions
const diagonalIndexes = [entryIndexRightTop, entryIndexLeftTop, entryIndexRightBottom, entryIndexLeftBottom];
for (const index of diagonalIndexes) {
if (index > -1 && !this.#brokenBricks.has(index) && this.#visibleEntries.has(index)) {
this.#ballDx = -this.#ballDx;
this.#ballDy = -this.#ballDy;
breakBrick(index);
return;
}
}
}
#random(min: number, max: number): number {
return Math.floor(Math.random() * (max - min) + min);
}
#party(): void {
this.#resetCanvas();
let count = 0;
const columnCount = 15;
const rowCount = 5;
const xSpacing = this.offsetWidth / columnCount;
const ySpacing = this.offsetHeight * 0.7 / columnCount;
const timeoutIDs: number[] = [];
const randomOffset = (): number => -20 + Math.random() * 40;
const drawConfetti = (): void => {
for (let i = 0; i < columnCount * rowCount; i++) {
const confettiContainerElement = document.createElement('span');
confettiContainerElement.className = 'confetti-100';
confettiContainerElement.append(this.#createConfettiElement(
(i % columnCount) * xSpacing + randomOffset(), (i % rowCount) * ySpacing + randomOffset()));
timeoutIDs.push(window.setTimeout(() => this.append(confettiContainerElement), Math.random() * 100));
timeoutIDs.push(window.setTimeout(() => {
confettiContainerElement.remove();
}, 1000));
}
if (++count < 6) {
setTimeout(drawConfetti, Math.random() * 100 + 400);
return;
}
window.alert(`${i18nString(UIStrings.congrats)}\n${i18nString(UIStrings.ps)}`);
timeoutIDs.forEach(id => clearTimeout(id));
this.#closeGame();
};
drawConfetti();
}
#createConfettiElement(x: number, y: number): HTMLElement {
const maxDistance = 400;
const maxRotation = 3;
const emojies = ['💯', '🎉', '🎊'];
const confettiElement = document.createElement('span');
confettiElement.textContent = emojies[this.#random(0, emojies.length)];
confettiElement.className = 'confetti-100-particle';
confettiElement.style.setProperty('--rotation', this.#random(-maxRotation * 360, maxRotation * 360) + 'deg');
confettiElement.style.setProperty('--to-X', this.#random(-maxDistance, maxDistance) + 'px');
confettiElement.style.setProperty('--to-Y', this.#random(-maxDistance, maxDistance) + 'px');
confettiElement.style.left = x + 'px';
confettiElement.style.top = y + 'px';
return confettiElement;
}
}
declare global {
interface HTMLElementTagNameMap {
'brick-breaker': BrickBreaker;
}
}
customElements.define('brick-breaker', BrickBreaker);