UNPKG

@maximeij/css-brickout

Version:

Classic Brickout Game Engine implemented in Typescript and rendered with CSS. No dependencies.

1,072 lines (1,071 loc) β€’ 51.8 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: !0, configurable: !0, writable: !0, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key != "symbol" ? key + "" : key, value); function createEvent(name, obj, bubbles = !0) { return new CustomEvent(name, { detail: obj, bubbles }); } function formatObjectTitle(object) { var _a; return `${object.toString()} ${(_a = object.bonuses) == null ? void 0 : _a.map((bonus) => `.${bonus.cssClass}`).join(` `)}`; } function msToString(ms) { const seconds = Math.floor(ms / 1e3), minutes = Math.floor(seconds / 60), secondsLeft = seconds % 60; return `${padZero(minutes)}:${padZero(secondsLeft)}`; } function padZero(num) { return num < 10 ? num.toString().padStart(2, "0") : num.toString(); } const clamp = (n, max = 1, min = 0) => Math.min(max, Math.max(min, n)), pythagoras = (a2, b) => Math.sqrt(a2 * a2 + b * b); function rotatePoint(x, y, originX, originY, angle) { const cosTheta = Math.cos(angle), sinTheta = Math.sin(angle), newX = cosTheta * (x - originX) - sinTheta * (y - originY) + originX, newY = sinTheta * (x - originX) + cosTheta * (y - originY) + originY; return { x: newX, y: newY }; } function rotateVector(x, y, angle) { const cos = Math.cos(angle), sin = Math.sin(angle); return { x: cos * x - sin * y, y: sin * x + cos * y }; } function normalize(vector) { const length = pythagoras(vector.x, vector.y); return length === 0 ? { x: 0, y: 0 } : { x: vector.x / length, y: vector.y / length }; } function projectRectangleOntoAxis(rectangleCorners, axis) { const dotProduct1 = rectangleCorners.topL.x * axis.x + rectangleCorners.topL.y * axis.y, dotProduct2 = rectangleCorners.topR.x * axis.x + rectangleCorners.topR.y * axis.y, dotProduct3 = rectangleCorners.bottomL.x * axis.x + rectangleCorners.bottomL.y * axis.y, dotProduct4 = rectangleCorners.bottomR.x * axis.x + rectangleCorners.bottomR.y * axis.y, min = Math.min(dotProduct1, dotProduct2, dotProduct3, dotProduct4), max = Math.max(dotProduct1, dotProduct2, dotProduct3, dotProduct4); return { min, max }; } function overlapOnAxis(circle, axis, rectangleCorners) { const circleProjection = circle.x * axis.x + circle.y * axis.y, rectProjection = projectRectangleOntoAxis(rectangleCorners, axis); return Math.max( 0, Math.min(rectProjection.max, circleProjection + circle.radius) - Math.max(rectProjection.min, circleProjection - circle.radius) ); } const EPSILON = 1e-8, MIN_PAST = -EPSILON; function rayAABB(localP0, localVelocity, halfExtents) { const invD = { x: 1 / 0, y: 1 / 0 }; Math.abs(localVelocity.x) > EPSILON && (invD.x = 1 / localVelocity.x), Math.abs(localVelocity.y) > EPSILON && (invD.y = 1 / localVelocity.y); const bounds = [ { x: -halfExtents.x, y: -halfExtents.y }, { x: halfExtents.x, y: halfExtents.y } ], signX = invD.x < 0 ? 1 : 0, signY = invD.y < 0 ? 1 : 0; let tmin = (bounds[signX].x - localP0.x) * invD.x; const tmax = (bounds[1 - signX].x - localP0.x) * invD.x, tymin = (bounds[signY].y - localP0.y) * invD.y, tymax = (bounds[1 - signY].y - localP0.y) * invD.y; return tmin > tymax || tymin > tmax ? null : tymin > tmin ? (tmin = tymin, { tmin, normal: { x: 0, y: invD.y < 0 ? 1 : -1 } }) : { tmin, normal: { x: invD.x < 0 ? 1 : -1, y: 0 } }; } function getCRCollisionPosition(rect, circle) { const P0 = { x: circle.x - rect.x, y: circle.y - rect.y }, localP0 = rotatePoint(P0.x, P0.y, 0, 0, -rect.angle), relativeVelocity = { x: (circle.dx ?? 0) - (rect.dx ?? 0), y: (circle.dy ?? 0) - (rect.dy ?? 0) }, localVelocity = rotateVector(relativeVelocity.x, relativeVelocity.y, -rect.angle), halfExtentsWithRadius = { x: rect.width / 2 + circle.radius, y: rect.height / 2 + circle.radius }, result = rayAABB(localP0, localVelocity, halfExtentsWithRadius); if (!result) return null; const { tmin, normal } = result; if (tmin < MIN_PAST || tmin > 1) return null; const hitPoint = { x: localP0.x + localVelocity.x * tmin, y: localP0.y + localVelocity.y * tmin }, rotatedHitPoint = rotatePoint(hitPoint.x, hitPoint.y, 0, 0, rect.angle), position = { x: rotatedHitPoint.x + rect.x, y: rotatedHitPoint.y + rect.y }, collisionWorldNormal = rotateVector(normal.x, normal.y, rect.angle); return { position, normal: collisionWorldNormal, tmin }; } function getCCCollisionPosition(lead, other) { const A0 = { x: lead.x, y: lead.y }, B0 = { x: other.x, y: other.y }, vA = { x: lead.dx ?? 0, y: lead.dy ?? 0 }, vB = { x: other.dx ?? 0, y: other.dy ?? 0 }, vRel = { x: vA.x - vB.x, y: vA.y - vB.y }, r = lead.radius + other.radius, d = { x: A0.x - B0.x, y: A0.y - B0.y }, a = vRel.x * vRel.x + vRel.y * vRel.y, b = 2 * (d.x * vRel.x + d.y * vRel.y), c = d.x * d.x + d.y * d.y - r * r, discriminant = b * b - 4 * a * c; if (discriminant < 0 || a === 0) return null; const sqrtDisc = Math.sqrt(discriminant), t1 = (-b - sqrtDisc) / (2 * a), t2 = (-b + sqrtDisc) / (2 * a), tmin = t1 >= MIN_PAST && t1 <= 1 ? t1 : t2 >= MIN_PAST && t2 <= 1 ? t2 : null; if (tmin === null) return null; const A_t = { x: A0.x + vA.x * tmin, y: A0.y + vA.y * tmin }, B_t = { x: B0.x + vB.x * tmin, y: B0.y + vB.y * tmin }, normal = normalize({ x: A_t.x - B_t.x, y: A_t.y - B_t.y }), contactPoint = { x: A_t.x - lead.radius * normal.x, y: A_t.y - lead.radius * normal.y }; return { tmin, position: contactPoint, normal }; } function normalizeAngle(angle) { let res = angle; for (; res > Math.PI; ) res -= 2 * Math.PI; for (; res <= -Math.PI; ) res += 2 * Math.PI; return res; } class GameObject { constructor({ game, parent = game, elementId, className, x, y, width = 0, height = 0, angle = 0, startingBonuses = [], showTitle = !1, permanent = !1, shape = "rectangle", ...rest // rest is used to allow for any other properties to be added to the object }) { __publicField(this, "x", 0); __publicField(this, "y", 0); __publicField(this, "width"); __publicField(this, "height"); __publicField(this, "area"); __publicField(this, "_angle"); __publicField(this, "bonuses"); __publicField(this, "element"); __publicField(this, "game"); __publicField(this, "parent"); __publicField(this, "boundingBox", { topL: { x: 0, y: 0 }, topR: { x: 0, y: 0 }, bottomL: { x: 0, y: 0 }, bottomR: { x: 0, y: 0 } }); __publicField(this, "permanent", !1); __publicField(this, "shape", "rectangle"); // for circle shapes __publicField(this, "radius", 0); __publicField(this, "rx", 0); if (this.width = width, this.height = height, shape === "circle" && (this.shape = "circle", this.radius = height / 2), this.area = width * height, this._angle = angle, this.game = game, this.parent = parent, this.bonuses = startingBonuses, this.element = document.createElement("div"), this.permanent = permanent, Object.keys(rest).length && Object.assign(this, rest), showTitle && (this.element.title = formatObjectTitle(this)), elementId && (this.element.id = elementId), className) { const classNames = className.trim().split(" ").filter(Boolean); classNames.length && this.element.classList.add(...classNames); } shape === "circle" && this.element.classList.add("circle"), this.parent.element.appendChild(this.element), this.updatePosition(x, y), this.angle = angle, this.updateElement(); } get angle() { return this._angle; } set angle(angle) { this._angle = angle, this.updateBoundingBox(), this.element.style.setProperty("--angle", `${angle}rad`); } updateCircleShape() { var _a, _b; if (this.rx = this.radius, !Number.isNaN((_a = this.parent) == null ? void 0 : _a.sizes.width) && ((_b = this.parent) == null ? void 0 : _b.sizes.width) > 0) { const pxRadius = Math.round(this.radius / 100 * this.parent.sizes.height); this.element.style.setProperty("--diameter", pxRadius * 2 + "px"), this.rx = this.radius * this.parent.sizes.height / this.parent.sizes.width; } this.width = this.rx * 2, this.height = this.radius * 2, this.updateBoundingBox(); } updateElementSize() { var _a; const { width, height } = ((_a = this.parent) == null ? void 0 : _a.sizes) ?? { width: 1, height: 1 }; this.shape === "circle" && this.updateCircleShape(), this.width && (this.element.style.width = `${Math.round(width * (this.width / 100))}px`), this.height && (this.element.style.height = `${height * (this.height / 100)}px`); } updateElement() { this.updateElementSize(), this.updateElementPosition(); } updateTitle() { this.element.title && (this.element.title = formatObjectTitle(this)); } applyBonuses() { this.bonuses.forEach((bonus) => { this.element.classList.add(bonus.cssClass); const undo = bonus.effect(this); bonus.duration && setTimeout(() => { undo(this), this.element.classList.remove(bonus.cssClass), this.bonuses.splice(this.bonuses.indexOf(bonus), 1); }, bonus.duration); }); } updatePosition(x, y) { this.x = x ?? this.x, this.y = y ?? this.y, this.width && this.height && this.updateBoundingBox(); } updateBoundingBox() { const halfWidth = this.width / 2, halfHeight = this.height / 2, args = [this.x, this.y, this.angle]; this.boundingBox = { bottomR: rotatePoint(this.x + halfWidth, this.y + halfHeight, ...args), bottomL: rotatePoint(this.x - halfWidth, this.y + halfHeight, ...args), topL: rotatePoint(this.x - halfWidth, this.y - halfHeight, ...args), topR: rotatePoint(this.x + halfWidth, this.y - halfHeight, ...args) }; } updateElementPosition() { const { width, height } = this.parent.sizes, absX = this.x / 100 * width, absY = this.y / 100 * height; this.setStyle( "transform", `translateX(calc(${absX}px - 50%)) translateY(calc(${absY}px - 50%)) rotateZ(var(--angle, 0rad))` ), this.element.style.setProperty("--xp", this.x.toFixed(2)), this.element.style.setProperty("--yp", this.y.toFixed(2)); } setStyle(style, value) { this.element.style[style] = value; } setContent(content) { this.element.innerHTML = content; } emitParticles(count, classNames, recycleCondition = "animationend", inheriteSize = !1) { const particles = []; for (let i = 0; i < count; i++) { const particle = this.game.level.getParticleElement(recycleCondition); classNames != null && classNames.length && particle.classList.add(...classNames), inheriteSize && (particle.style.setProperty("width", this.element.clientWidth + "px"), particle.style.setProperty("height", this.element.clientHeight + "px")), particle.style.setProperty("opacity", "1"), particle.style.setProperty("transform", this.element.style.transform), particles.push(particle); } return particles; } destroy() { this.element.remove(); } toString() { var _a, _b; return `${this.constructor.name}: ${this.element.id} (${((_a = this.x) == null ? void 0 : _a.toFixed(2)) ?? "?"}, ${((_b = this.y) == null ? void 0 : _b.toFixed(2)) ?? "?"})`; } } class MovingGameObject extends GameObject { constructor({ movement = { speed: 0, angle: 0 }, syncAngles, ...rest }) { var __super = (...args) => (super(...args), __publicField(this, "_speed", 0), __publicField(this, "_movementAngle", 0), __publicField(this, "turnSteps", []), __publicField(this, "dx", 0), __publicField(this, "dy", 0), __publicField(this, "active", !0), __publicField(this, "syncAngles", !1), this); Array.isArray(movement) || (movement == null ? void 0 : movement.speed) > 0 ? (__super({ ...rest, className: `moving-object ${rest.className ?? ""}` }), this.syncAngles = syncAngles ?? !1, this.updateElement(), Array.isArray(movement) ? this.movement = [...movement] : this.movement = { ...movement }) : (__super(rest), this.syncAngles = syncAngles ?? !1); } get fx() { var _a, _b; return ((_b = (_a = this.game) == null ? void 0 : _a.level) == null ? void 0 : _b.fx) ?? 1; } get fy() { var _a, _b; return ((_b = (_a = this.game) == null ? void 0 : _a.level) == null ? void 0 : _b.fy) ?? 1; } get speed() { return this._speed; } set speed(speed) { this._speed = speed, this.setD(); } get movementAngle() { return this._movementAngle; } set movementAngle(angle) { this._movementAngle = angle, this.syncAngles && (this.angle = -angle), this.setD(); } get movement() { return { angle: this.movementAngle, speed: this.speed }; } set movement(movementConfig) { if (Array.isArray(movementConfig)) { this.turnSteps = movementConfig; const firstStep = movementConfig[0]; firstStep && (this.movement = firstStep.movement); } else this.movementAngle = (movementConfig == null ? void 0 : movementConfig.angle) ?? 0, this.speed = (movementConfig == null ? void 0 : movementConfig.speed) ?? 0, this.setD(); } updatePosition(x, y, fraction = 1) { var _a, _b; if (super.updatePosition( (x ?? this.x ?? 0) + (this.dx ?? 0) * fraction, (y ?? this.y ?? 0) + (this.dy ?? 0) * fraction ), (_a = this.turnSteps) != null && _a.length) { const currentStep = this.turnSteps[0]; if ((_b = currentStep == null ? void 0 : currentStep.condition) != null && _b.call(currentStep, this)) { this.turnSteps.shift(), this.turnSteps.push(currentStep); const nextStep = this.turnSteps[0]; nextStep && (this.movement = nextStep.movement); } } } setD() { this.dx = this.fy * this.speed * Math.cos(this.movementAngle), this.dy = this.fx * -this.speed * Math.sin(this.movementAngle); } processFrame(frameFraction = 1) { this.active && this.updatePosition(void 0, void 0, frameFraction); } toString() { return this.movement.speed ? `${super.toString()} ${(this.movement.speed * 10).toFixed(2)} knots` : super.toString(); } } class Clickable extends GameObject { constructor({ x = 50, y = 50, onClick, ...rest }) { super({ ...rest, x, y, className: [rest.className ?? "", "clickable"].filter(Boolean).join(" ") }); __publicField(this, "onClick"); this.onClick = onClick, this.element.addEventListener("click", this.onClick); } destroy() { this.element.removeEventListener("click", this.onClick), super.destroy(); } } class Controls extends GameObject { constructor({ elementId = "controls", x = 0, y = 0, handleFullscreen, handlePause, handleDebug, ...rest }) { super({ elementId, x, y, ...rest }); __publicField(this, "fullscreen"); __publicField(this, "pause"); __publicField(this, "debug"); __publicField(this, "sizes", { width: 0, height: 0 }); this.fullscreen = new Clickable({ game: this.game, parent: this, elementId: "ctrl-fullscreen", x: 0, y: 0, onClick: handleFullscreen }), this.fullscreen.element.title = "Toggle fullscreen [F]", this.fullscreen.setContent("πŸ–₯️"), this.pause = new Clickable({ game: this.game, parent: this, elementId: "ctrl-pause", x: 20, y: 0, onClick: handlePause }), this.pause.element.title = "Pause [SPACE] [P]", this.pause.setContent("⏸️"), handleDebug && (this.debug = new Clickable({ game: this.game, parent: this, elementId: "ctrl-debug", x: 0, y: 0, onClick: handleDebug }), this.debug.element.title = "Toggle debug mode [D]", this.debug.setContent("🐞")); } updateElementPositions() { var _a; this.fullscreen.updateElementPosition(), (_a = this.debug) == null || _a.updateElementPosition(), this.pause.updateElementPosition(); } updateSizes() { this.sizes.width = this.element.offsetWidth, this.sizes.height = this.element.offsetHeight, this.updateElement(); } destroy() { var _a; this.fullscreen.destroy(), this.pause.destroy(), (_a = this.debug) == null || _a.destroy(), super.destroy(); } } class Ball extends MovingGameObject { constructor({ idx, radius, movement, damage = 1, ...objConfig }) { var _a; super({ ...objConfig, className: [...((_a = objConfig.className) == null ? void 0 : _a.split(" ")) ?? [], "ball"].join(" "), elementId: `ball-${idx}`, movement, showTitle: !0, shape: "circle" }); __publicField(this, "destroyed", !1); __publicField(this, "damage", 1); // Prevents the ball from hitting the same object twice in a row __publicField(this, "antiJuggling", !1); this.radius = radius, this.damage = damage, this.applyBonuses(), this.updateElementSize(), this.updateTitle(); } // Used for cosmetics, can move to MovingGameObject setD() { super.setD(), this.element.style.setProperty("--dx", this.dx + "px"), this.element.style.setProperty("--dy", this.dy + "px"); } /** * Detects collisions between this ball and the boundaries, level bricks, and paddle (in that order) * @param level The level with the bricks and strips * @param paddle The player's paddle * @returns true if the position has been updated already */ handleLevelCollision(level, paddle, frameFraction) { if (this.handleBoundaryCollision()) return !1; if (this.handlePaddleCollision(paddle, frameFraction)) return !0; let hitBrick = !1, i = 0; const nearby = level.getNearbyBricks(this); for (; i < nearby.length && !hitBrick; ) { const brick = nearby[i]; if (i++, brick.destroyed) continue; (brick.hitboxParts ?? [brick]).some((part) => { this.isColliding(part) && (hitBrick = !0, this.handleBrickCollision(part, frameFraction, brick)); }); } return !0; } handleBoundaryCollision() { const hitTop = this.y - this.radius <= 0; if (this.x - this.rx <= 0 || this.x + this.rx >= 100) return hitTop ? (this.movementAngle = this.movementAngle - Math.PI, this.y = this.radius) : this.movementAngle = Math.PI - this.movementAngle, this.x - this.rx <= 0 ? this.x = this.rx : this.x = 100 - this.rx, this.antiJuggling = !1, this.dispatchCollisionEvent(), !0; if (hitTop) return this.movementAngle = -this.movementAngle, this.y - this.radius <= 0 && (this.y = this.radius), this.antiJuggling = !1, this.dispatchCollisionEvent(), !0; if (this.y + this.radius >= 100) return this.speed = 0, this.destroy(!1), !0; } resolveCollision(object, frameFraction = 1) { const virtualCircle = { x: this.x - this.dx * frameFraction, y: this.y - this.dy * frameFraction, radius: this.radius, dx: this.dx * frameFraction, dy: this.dy * frameFraction }, castObj = object, baseVirtualObject = { angle: object.angle, boundingBox: object.boundingBox, width: object.width, height: object.height, radius: object.shape === "circle" ? object.radius : 0 }, virtualObject = object instanceof MovingGameObject ? { ...baseVirtualObject, x: object.x - castObj.dx * frameFraction, y: object.y - castObj.dy * frameFraction, dx: castObj.dx * frameFraction, dy: castObj.dy * frameFraction } : { ...baseVirtualObject, x: object.x, y: object.y, dx: 0, dy: 0 }, collision = object.shape === "rectangle" ? getCRCollisionPosition(virtualObject, virtualCircle) : getCCCollisionPosition(virtualCircle, virtualObject); if (collision) { const { normal, tmin } = collision, nextX = virtualCircle.x + virtualCircle.dx * tmin, nextY = virtualCircle.y + virtualCircle.dy * tmin; !Number.isNaN(nextX) && !Number.isNaN(nextY) && (this.x = nextX, this.y = nextY); const dot = virtualCircle.dx * normal.x + virtualCircle.dy * normal.y, reflectedDx = virtualCircle.dx - 2 * dot * normal.x, reflectedDy = virtualCircle.dy - 2 * dot * normal.y; return this.movementAngle = -Math.atan2(reflectedDy, reflectedDx), this.antiJuggling = object.element.id, !0; } } handleBrickCollision(brick, frameFraction = 1, composite) { const parentBrick = composite ?? brick; if (brick.breakthrough) { parentBrick.takeHit(this), this.dispatchCollisionEvent(parentBrick), this.antiJuggling = brick.element.id; return; } if (this.resolveCollision(brick, frameFraction)) return this.dispatchCollisionEvent(brick), parentBrick.takeHit(this), !0; } handlePaddleCollision(paddle, frameFraction = 1) { if (this.isColliding(paddle) && this.resolveCollision(paddle, frameFraction)) { const hitPosition = this.x - paddle.x, hitPositionNormalized = Math.min(1, Math.max(-1, hitPosition / (paddle.width / 2))), angleMultiplier = paddle.curveFactor ?? 0, hitPositionSkew = hitPositionNormalized * angleMultiplier * (Math.PI / 2); return this.movementAngle = normalizeAngle(this.movementAngle - hitPositionSkew), this.dispatchCollisionEvent(paddle), !0; } } isColliding(object) { if (this.antiJuggling === object.element.id) return !1; const cos = Math.cos(object.angle), sin = Math.sin(object.angle), axes = [ { x: cos, y: sin }, { x: -sin, y: cos } ]; for (const axis of axes) if (!overlapOnAxis(this, axis, object.boundingBox)) return !1; return !0; } processFrame(frameFraction = 1, level, paddle) { this.active && (this.updatePosition(void 0, void 0, frameFraction), level && paddle && this.handleLevelCollision(level, paddle, frameFraction)); } dispatchCollisionEvent(object) { const event = createEvent("ballcollision", { ball: this, object }); this.parent.element.dispatchEvent(event); } destroy(forReal = !0) { this.element.classList.add("ball--destroyed"), this.destroyed = !0; const event = createEvent("balldestroyed", this); this.parent.element.dispatchEvent(event), forReal && super.destroy(); } toString() { return `${super.toString()} ${this.damage ? `Damage: ${this.damage}` : ""}`; } } class Brick extends MovingGameObject { constructor({ hp = 1, breakthrough = !1, ignoreMobile = !1, ...config }) { super({ ...config, className: [config.className ?? "", "brick"].filter(Boolean).join(" "), showTitle: !0 }); __publicField(this, "breakthrough"); __publicField(this, "ignoreMobile"); __publicField(this, "destroyed", !1); __publicField(this, "hp"); __publicField(this, "maxHp"); __publicField(this, "containedBy"); this.hp = hp, this.maxHp = hp, this.breakthrough = breakthrough, this.ignoreMobile = ignoreMobile, this.applyBonuses(), this.updateTitle(); } takeHit(ball) { this.hp -= ball.damage, this.hp <= 0 && this.destroy(!this.permanent); } destroy(forReal = !0) { this.element.classList.add("brick--destroyed"), this.destroyed = !0, this.active = !1; const event = createEvent("brickdestroyed", this); this.parent.element.dispatchEvent(event), forReal && super.destroy(); } restore() { this.element.classList.remove("brick--destroyed"), this.destroyed = !1, this.hp = this.maxHp; } } class CompositeBrick extends Brick { constructor(config) { var _a; super(config); __publicField(this, "hitboxParts"); this.hitboxParts = (_a = config.hitboxParts) == null ? void 0 : _a.map( (part, idx) => new Brick({ ...part, game: config.game, parent: config.parent, elementId: `${config.elementId}-p${idx}` }) ); } // Always axis-aligned, contains all parts of the hitbox get compositeBoundingBox() { const allPoints = (this.hitboxParts ?? [this]).reduce( (acc, part) => { const partBox = part.boundingBox; return { x: [...acc.x, partBox.topL.x, partBox.topR.x, partBox.bottomL.x, partBox.bottomR.x], y: [...acc.y, partBox.topL.y, partBox.topR.y, partBox.bottomL.y, partBox.bottomR.y] }; }, { x: [], y: [] } ); return { topL: { x: Math.min(...allPoints.x), y: Math.min(...allPoints.y) }, topR: { x: Math.max(...allPoints.x), y: Math.min(...allPoints.y) }, bottomL: { x: Math.min(...allPoints.x), y: Math.max(...allPoints.y) }, bottomR: { x: Math.max(...allPoints.x), y: Math.max(...allPoints.y) } }; } updateElement() { var _a; super.updateElement(), (_a = this.hitboxParts) == null || _a.forEach((part) => { var _a2; return (_a2 = part.updateElement) == null ? void 0 : _a2.call(part); }); } updateElementPosition() { var _a; super.updateElementPosition(), (_a = this.hitboxParts) == null || _a.forEach((part) => { var _a2; return (_a2 = part.updateElementPosition) == null ? void 0 : _a2.call(part); }); } updateElementSize() { var _a; super.updateElementSize(), (_a = this.hitboxParts) == null || _a.forEach((part) => { var _a2; return (_a2 = part.updateElementSize) == null ? void 0 : _a2.call(part); }); } updateTitle() { var _a; super.updateTitle(), (_a = this.hitboxParts) == null || _a.forEach((part) => { var _a2; return (_a2 = part.updateTitle) == null ? void 0 : _a2.call(part); }); } takeHit(ball) { var _a; (_a = this.hitboxParts) == null || _a.forEach((part) => part.takeHit(ball)), super.takeHit(ball); } destroy(forReal = !0) { var _a; (_a = this.hitboxParts) == null || _a.forEach((part) => part.destroy(forReal)), super.destroy(forReal); } restore() { var _a; (_a = this.hitboxParts) == null || _a.forEach((part) => part.restore()), super.restore(); } toString() { return `${super.toString()} ${this.hp}/${this.maxHp} HP`; } } class Debug extends GameObject { constructor({ elementId = "debug", x = 50, y = 5, ...rest }) { super({ elementId, x, y, ...rest }); } updateElementPosition() { } setContent(content) { let fixed = content; content.includes(` `) || (fixed += ` &nbsp;`), super.setContent(fixed); } } const DEFAULT_OPTIONS = { fps: 60, capFps: !1, allowDebug: !1, nextLifeDelayMs: 500, mouseoutPauseDelayMs: 1e3, mouseoverResumeDelayMs: 1e3, showCursorInPlay: !1, demoMode: !1, updatesPerFrame: 1, skipDefaultRules: !1, columnAspectRatio: 1.618 // golden ratio }, PAUSABLE = ["playing", "debug"], RESUMABLE = ["paused", "away"]; class Game { constructor(params) { // Internals __publicField(this, "element"); __publicField(this, "sizes", { width: 0, height: 0 }); __publicField(this, "state", "starting"); __publicField(this, "debounceTimer"); __publicField(this, "ogParams"); // Debug __publicField(this, "debug"); __publicField(this, "lastFrameTime", performance.now()); __publicField(this, "lastFpsUpdate", performance.now()); // Gameplay __publicField(this, "options"); __publicField(this, "_speed", 1); __publicField(this, "fpsInterval"); __publicField(this, "fpsCap"); __publicField(this, "msSinceStart", 0); __publicField(this, "balls", []); __publicField(this, "level"); __publicField(this, "paddle"); __publicField(this, "hud"); __publicField(this, "controls"); __publicField(this, "lives", 0); __publicField(this, "score", 0); // Pause __publicField(this, "paused"); __publicField(this, "resumeLink"); // Create Ball objects based on ballConfig __publicField(this, "setBalls", () => { this.balls.filter((b) => b.active).forEach((ball) => ball.destroy()), this.balls = this.balls.filter((b) => !b.active), this.ogParams.ballConfigs.forEach((ballConfig, idx) => { const ball = new Ball({ ...ballConfig, idx, game: this, parent: this.level }); this.balls.push(ball); }); }); __publicField(this, "debounce", (func, timeout = 500) => () => { clearTimeout(this.debounceTimer), this.debounceTimer = setTimeout(() => { func.apply(this); }, timeout); }); __publicField(this, "start", () => { this.element.classList.add("paused"), this.createdPausedElement("Start"), this.dispatchGameEvent("gamestarted"); }); /** * @deprecated Set speed instead */ __publicField(this, "setOverallSpeed", (speed) => { this.speed = speed; }); __publicField(this, "update", () => { const now = performance.now(), msSinceLastFrame = now - this.lastFrameTime; if (PAUSABLE.includes(this.state)) { if (msSinceLastFrame >= this.fpsCap) { const speed = this._speed || 1, virtualMsSinceLastFrame = msSinceLastFrame * speed; if (this.msSinceStart += virtualMsSinceLastFrame, this.updateHUDTime(), this.lastFrameTime = now, this.debug && now > this.lastFpsUpdate + 1e3) { const fps = 1 + Math.round(1e3 / msSinceLastFrame); this.debug.setContent(`${this.options.demoMode ? "demo" : this.state} ${fps.toFixed(0)}fps`), this.lastFpsUpdate = now; } const frameFraction = virtualMsSinceLastFrame / (this.fpsInterval * this.options.updatesPerFrame * speed); for (let i = 0; i < Math.ceil(this.options.updatesPerFrame * speed); i++) { this.paddle.processFrame(frameFraction), this.level.mobileBricks.forEach((brick) => brick.processFrame(frameFraction)); for (const ball of this.balls) ball.destroyed || ball.processFrame(frameFraction, this.level, this.paddle); } this.paddle.updateElementPosition(), this.level.mobileBricks.forEach((brick) => brick.updateElementPosition()); for (const ball of this.balls) if (!ball.destroyed && (ball.updateElementPosition(), this.debug && ball.y > this.paddle.maxY - this.paddle.height && ball.y < this.paddle.maxY)) { const semiR = Math.round(ball.x - this.paddle.width / 2 + Math.random() * this.paddle.width / 2); this.paddle.handleMove(semiR, this.paddle.maxY ?? this.paddle.y); } } else this.debug && console.info("skipping frame", msSinceLastFrame, this.fpsCap); requestAnimationFrame(() => this.update()); } }); __publicField(this, "handleBallLost", () => { this.balls = this.balls.filter((ball) => !ball.destroyed), this.balls.filter((b) => b.active).length === 0 && (this.lives--, this.lives >= 0 ? (this.updateHUDLives(), setTimeout(() => { this.setBalls(); }, this.options.nextLifeDelayMs || 20)) : (this.state = "lost", this.createdPausedElement("Game Over", "final"), this.dispatchGameEvent("gamelost"))); }); __publicField(this, "handleBrickDestroyed", () => { this.level.isDone() && this.win(); }); __publicField(this, "win", () => { this.state = "won", this.createdPausedElement("Victory!", "final"), this.dispatchGameEvent("gamewon"); }); __publicField(this, "updateHUDLives", () => { var _a; (_a = this.hud) == null || _a.updateLives(this.lives); }); __publicField(this, "updateHUDScore", () => { var _a; (_a = this.hud) == null || _a.updateScore(this.score); }); __publicField(this, "updateHUDTime", () => { var _a; (_a = this.hud) == null || _a.updateTime(this.msSinceStart); }); __publicField(this, "toggleDebug", () => { this.options.allowDebug && (this.debug ? (this.debug.destroy(), this.debug = null, this.state === "debug" && (this.state = "playing")) : (this.debug = new Debug({ game: this }), this.state === "playing" && (this.state = "debug"), this.debug.setContent(this.options.demoMode ? "demo" : this.state), this.debug.updateElement())); }); __publicField(this, "toggleFullscreen", async () => { document.fullscreenElement ? await document.exitFullscreen() : this.element.requestFullscreen && await this.element.requestFullscreen(), this.handleResize(); }); __publicField(this, "togglePause", () => { this.paused ? this.resume() : this.pause(); }); __publicField(this, "updateSizes", (callResize = !1) => { const { width, height } = this.element.getBoundingClientRect(), isColumn = this.element.classList.contains("column"); let hasChanged = !1; return !isColumn && width / height < this.options.columnAspectRatio ? (this.element.classList.add("column"), hasChanged = !0) : isColumn && width / height >= this.options.columnAspectRatio && (this.element.classList.remove("column"), hasChanged = !0), this.sizes.width = width, this.sizes.height = height, callResize && this.handleResize(), hasChanged; }); __publicField(this, "handleResize", () => { this.debounce(() => { const layoutHasChanged = this.updateSizes(), updateOthers = () => { var _a, _b, _c, _d, _e; this.level.updateSizes(), (_a = this.paused) == null || _a.updateSizes(), (_b = this.resumeLink) == null || _b.updateElement(), (_c = this.debug) == null || _c.updateElement(), (_d = this.controls) == null || _d.updateSizes(), (_e = this.hud) == null || _e.updateSizes(), this.paddle.updateElement(), this.balls.forEach((ball) => ball.updateElement()); }; updateOthers(), layoutHasChanged && setTimeout(() => { updateOthers(); }, 1e3); })(); }); __publicField(this, "handleVisibilityChange", () => { document.hidden ? this.debounce(() => { this.pause("away"); })() : this.debounce(() => this.resume("away"), 1e3)(); }); __publicField(this, "handleKeyPress", (e) => { switch (e.code) { case "KeyP": case "Space": this.state === "starting" ? this.resume("starting") : this.togglePause(), e.preventDefault(), e.stopPropagation(); break; case "KeyD": this.toggleDebug(); break; case "KeyF": this.toggleFullscreen(); break; } }); __publicField(this, "handleMouseEnter", () => { this.debounce(() => this.resume("away"), this.options.mouseoverResumeDelayMs)(); }); __publicField(this, "handleMouseLeave", () => { this.options.mouseoutPauseDelayMs && this.debounce(() => this.pause("away"), this.options.mouseoutPauseDelayMs)(); }); __publicField(this, "createdPausedElement", (content, classes = "") => { var _a, _b; (_a = this.resumeLink) == null || _a.destroy(), (_b = this.paused) == null || _b.destroy(), this.paused = new Pause({ game: this, parent: this.level, className: classes }), this.resumeLink = new Clickable({ game: this, parent: this.paused, className: "resume-link", onClick: () => this.resume(this.state === "starting" ? "starting" : void 0) }), this.resumeLink.setContent(content), this.resumeLink.updateElementPosition(), this.element.classList.add("paused"); }); __publicField(this, "pause", (to) => { var _a; PAUSABLE.includes(this.state) && (this.createdPausedElement(to === "away" ? "Away" : "Resume"), this.state = to ?? "paused", (_a = this.debug) == null || _a.setContent(this.state), this.balls.forEach((ball) => ball.updateTitle()), this.paddle.updateTitle(), this.level.bricks.forEach((brick) => brick.updateTitle()), this.dispatchGameEvent("gamepaused")); }); __publicField(this, "resume", (from) => { var _a; (from ? from === this.state : RESUMABLE.includes(this.state)) && ((_a = this.paused) == null || _a.destroy(), this.paused = null, this.state = this.debug ? "debug" : "playing", this.element.classList.remove("paused"), this.lastFrameTime = performance.now(), this.dispatchGameEvent("gameresumed"), this.update()); }); __publicField(this, "dispatchGameEvent", (name) => { const event = createEvent(name, this); this.element.dispatchEvent(event); }); __publicField(this, "destroy", () => { var _a, _b, _c, _d, _e; document.removeEventListener("visibilitychange", this.handleVisibilityChange), document.removeEventListener("keyup", this.handleKeyPress), this.element.removeEventListener("balldestroyed", this.handleBallLost), this.element.removeEventListener("brickdestroyed", this.handleBrickDestroyed), this.element.removeEventListener("mouseenter", this.handleMouseEnter), this.element.removeEventListener("mouseleave", this.handleMouseLeave), this.paddle.destroy(), this.level.destroy(), (_a = this.hud) == null || _a.destroy(), (_b = this.controls) == null || _b.destroy(), this.state = "lost", this.lives = 0, this.balls.forEach((ball) => ball.destroy()), (_c = this.debug) == null || _c.destroy(), (_d = this.resumeLink) == null || _d.destroy(), (_e = this.paused) == null || _e.destroy(); }); var _a, _b, _c; this.ogParams = { ...params }, this.options = { ...DEFAULT_OPTIONS, ...params.options }, this.element = document.getElementById(params.parentId ?? "game"), this.element.classList.add("game"), (_a = params.options) != null && _a.showCursorInPlay || this.element.classList.add("hide-cursor"), this.level = new Level({ ...params.levelConfig, game: this, onLevelMounted: () => { params.playerConfig && (this.lives = params.playerConfig.lives, this.score = params.playerConfig.score ?? 0), this.setBalls(), this.updateSizes(!0), this.start(); } }), this.paddle = new Paddle({ ...params.paddleConfig, game: this, parent: this.level, elementId: "paddle", x: ((_b = params.paddleConfig) == null ? void 0 : _b.x) ?? 50, y: ((_c = params.paddleConfig) == null ? void 0 : _c.y) ?? 83 }), this.debug = null, this.paused = null, this.resumeLink = null, this.controls = new Controls({ game: this, handleFullscreen: () => this.toggleFullscreen(), handlePause: () => this.togglePause(), handleDebug: this.options.allowDebug ? () => this.toggleDebug() : void 0 }), this.controls.updateElementPosition(), this.hud = new HUD({ game: this }), this.updateHUDLives(), this.updateHUDScore(), this.updateHUDTime(), document.addEventListener("visibilitychange", this.handleVisibilityChange), this.options.skipDefaultRules || (this.element.addEventListener("balldestroyed", this.handleBallLost), this.element.addEventListener("brickdestroyed", this.handleBrickDestroyed)), this.element.addEventListener("mouseenter", this.handleMouseEnter), this.element.addEventListener("mouseleave", this.handleMouseLeave), ResizeObserver && new ResizeObserver(this.handleResize).observe(this.element), this.options.demoMode ? this.element.classList.add("demo") : document.addEventListener("keyup", this.handleKeyPress), this.fpsInterval = Math.floor(1e3 / (this.options.fps || 60)) || 1, this.fpsCap = this.options.capFps ? this.fpsInterval : 1; } get speed() { return this._speed ?? 1; } set speed(speed) { this._speed = Math.max(1 / 1e3, speed), this.element.style.setProperty("--game-speed", `${this._speed}`); } } class HUD extends GameObject { constructor({ elementId = "hud", x = 0, y = 0, ...rest }) { super({ elementId, x, y, ...rest }); __publicField(this, "lives"); __publicField(this, "time"); __publicField(this, "score"); __publicField(this, "sizes", { width: 0, height: 0 }); this.lives = new GameObject({ game: this.game, parent: this, elementId: "lives", x: 0, y: 0 }), this.time = new GameObject({ game: this.game, parent: this, elementId: "time", x: 0, y: 0 }), this.score = new GameObject({ game: this.game, parent: this, elementId: "score", x: 0, y: 0 }); } updateLives(lives) { this.lives.element.textContent = `🀍${lives}`; } updateScore(score) { this.score.element.textContent = "πŸ’Ž" + score.toString(); } updateTime(ms) { this.time.element.textContent = "⏳" + msToString(ms); } updateElementPositions() { super.updateElementPosition(), this.lives.updateElementPosition(), this.score.updateElementPosition(), this.time.updateElementPosition(); } updateSizes() { this.sizes.width = this.element.offsetWidth, this.sizes.height = this.element.offsetHeight, this.updateElement(); } destroy() { this.lives.destroy(), this.time.destroy(), this.score.destroy(), super.destroy(); } } class Level { constructor({ divisionFactor, enableContainment, layout, game, onLevelMounted }) { __publicField(this, "element"); __publicField(this, "game"); __publicField(this, "brickMap"); __publicField(this, "bricks"); __publicField(this, "mobileBricks"); __publicField(this, "_divisionFactor"); __publicField(this, "_hitZones"); __publicField(this, "fx", 1); __publicField(this, "fy", 1); __publicField(this, "sizes", { width: 0, height: 0 }); __publicField(this, "particles", []); __publicField(this, "totalParticles", 0); __publicField(this, "onLevelMounted"); __publicField(this, "brickCanCollide", (brick) => { var _a; return !brick.containedBy || ((_a = this.brickMap[brick.containedBy]) == null ? void 0 : _a.destroyed); }); this.bricks = [], this.mobileBricks = [], this._hitZones = [], this.game = game, this.onLevelMounted = onLevelMounted, this._divisionFactor = divisionFactor ?? 10; const exisitingLevel = this.game.element.getElementsByClassName("level")[0], frag = document.createDocumentFragment(); exisitingLevel ? this.element = exisitingLevel : (this.element = document.createElement("div"), this.element.classList.add("level")), frag.appendChild(this.element), layout instanceof Array ? layout.forEach((l) => this.layBricks(l, this.game)) : this.layBricks(layout, this.game); for (let divRow = 0; divRow < this._divisionFactor; divRow++) { this._hitZones.push([]); for (let divCol = 0; divCol < this._divisionFactor; divCol++) this._hitZones[divRow].push([]); } this.brickMap = {}, this.bricks.forEach((brick) => { var _a; if (brick.updateBoundingBox(), this.brickMap[brick.element.id] = brick, brick.speed || (_a = brick.hitboxParts) != null && _a.some((p) => p.speed)) this.mobileBricks.push(brick); else { const cbb = brick.compositeBoundingBox; if (enableContainment) { let smallestContainer; this.bricks.forEach((outerBrick) => { if (outerBrick !== brick && outerBrick.area > brick.area && (!smallestContainer || outerBrick.area < smallestContainer.area)) { const outerCbb = outerBrick.compositeBoundingBox; cbb.topL.x - 0.1 > outerCbb.topL.x && cbb.topL.y - 0.1 > outerCbb.topL.y && cbb.bottomR.x + 0.1 < outerCbb.bottomR.x && cbb.bottomR.y + 0.1 < outerCbb.bottomR.y && (smallestContainer = outerBrick); } }), smallestContainer !== void 0 && (brick.containedBy = smallestContainer.element.id); } for (let divRow = 0; divRow < this._divisionFactor; divRow++) for (let divCol = 0; divCol < this._divisionFactor; divCol++) { const x = divCol * (100 / this._divisionFactor), y = divRow * (100 / this._divisionFactor); cbb.topL.x < x + 100 / this._divisionFactor && cbb.topR.x > x && cbb.topL.y < y + 100 / this._divisionFactor && cbb.bottomL.y > y && this._hitZones[divRow][divCol].push(brick); } } }), requestAnimationFrame(() => { var _a; this.game.element.appendChild(frag), (_a = this.onLevelMounted) == null || _a.call(this); }); } getNearbyBricks(ball) { const res = /* @__PURE__ */ new Set(), minDivRow = Math.max(0, Math.floor((ball.y - ball.radius) / (100 / this._divisionFactor))), maxDivRow = Math.min( this._divisionFactor - 1, Math.floor((ball.y + ball.radius) / (100 / this._divisionFactor)) ), minDivCol = Math.max(0, Math.floor((ball.x - ball.radius) / (100 / this._divisionFactor))), maxDivCol = Math.min( this._divisionFactor - 1, Math.floor((ball.x + ball.radius) / (100 / this._divisionFactor)) ); for (let divRow = minDivRow; divRow <= maxDivRow; divRow++) for (let divCol = minDivCol; divCol <= maxDivCol; divCol++) this._hitZones[divRow][divCol].filter(this.brickCanCollide).forEach((brick) => res.add(brick)); return this.mobileBricks.forEach((brick) => res.add(brick)), Array.from(res); } updateElements() { this.bricks.forEach((brick) => { brick.updateElement(); }); } updateSizes() { this.sizes.width = this.element.offsetWidth, this.sizes.height = this.element.offsetHeight, this.updateSpeedRatios(), this.updateElements(); } updateSpeedRatios() { const hypo = pythagoras(this.sizes.width, this.sizes.height); this.fx = this.sizes.width / hypo, this.fy = this.sizes.height / hypo; } layBricks(layout, game) { if (layout instanceof Array && layout.forEach((layout2) => this.layBricks(layout2, game)), layout.type === "even") { const { y, height, rows, cols, hp = 1 } = layout; for (let i = 0; i < rows; i++) { const width = 100 / cols; for (let j = 0; j < cols; j++) this.bricks.push( new CompositeBrick({ game, parent: this, width, height, x: width * (j + 0.5), y: y + height * i, hp, elementId: `brick-${this.bricks.length}` }) ); } } else layout.type === "custom" && layout.bricks.forEach((brick) => { this.bricks.push( new CompositeBrick({ ...brick, game, parent: this, elementId: brick.elementId ?? `brick-${this.bricks.length}` }) ); }); } recycleParticle(particle) { return () => { particle.className = "particle", particle.style.cssText = "", particle.style.opacity = "0", particle.innerHTML = "", this.particles.push(particle); }; } // pops a particle from the particle pool, creates one if none exist getParticleElement(recycleCondition = "animationend") { let nextParticle = this.particles.pop(); nextParticle || (nextParticle = document.createElement("particle"), nextParticle.classList.add("particle"), this.totalParticles++, nextParticle.id = `${this.element.id}-particle-${this.totalParticles}`), this.element.appendChild(nextParticle); const recycler = this.recycleParticle(nextParticle); return typeof recycleCondition == "number" ? setTimeout(recycler, recycleCondition) : nextParticle.addEventListener(recycleCondition, recycler, { once: !0 }), nextParticle; } isDone() { return !this.bricks.some((b) => !b.permanent && !b.destroyed); } destroy() { this.bricks.forEach((brick) => brick.destroy()), this.particles.forEach((particle) => particle.remove()); } } class Paddle extends MovingGameObject { constructor({ angle, angleLimit, curveFactor, gripFactor, minY, maxY, ...config }) { var _a; super({ ...config, className: [...((_a = config.className) == null ? void 0 : _a.split(" ")) ?? [], "paddle"].join(" "), showTitle: !0 }); // How much ball angle is modified when it hits the paddle further from the center. 1 = pi/2 at the edge __publicField(this, "curveFactor", 0); // unused for now __publicField(this, "gripFactor", 0.05); __publicField(this, "minY"); __publicField(this, "maxY"); __publicField(this, "cursorX"); __publicField(this, "cursorY"); __publicField(this, "angleLimit", 0); __publicField(this, "vtBound", !0); __publicField(this, "handleMouseMove", ({ clientX, clientY, currentTarget }) => { if (this.game.state === "playing" && currentTarget instanceof HTMLElement) { const rect = currentTarget.getBoundingClientRect(), mouseX = clientX - rect.left, mouseY = clientY - rect.top; this.handleClientMove(mouseX, mouseY); } }); __publicField(this, "handleTouchMove", (e) => { if (this.game.state !== "playing") return; const touch = e.touches[0]; this.handleClientMove(touch.clientX, touch.clientY); }); __publicField(this, "handleClientMove", (x, y) => { const normX = x / this.parent.sizes.width * 100, normY = y / this.parent.sizes.height * 100; this.handleMove(normX, normY); }); __publicField(this, "handleMove", (x, y) => { const targetPaddleX = x; let targetPaddleY = this.y; if (this.vtBound || (targetPaddleY = clamp(y, this.maxY, this.minY)), !this.speed) this.updatePosition(targetPaddleX, targetPaddleY); else { const movement = { speed: this.speed, angle: -Math.atan2(targetPaddleY - this.y, targetPaddleX - this.x) }; let verifyX = (mgo) => mgo.x >= targetPaddleX; this.x > targetPaddleX && (verifyX = (mgo) => mgo.x <= targetPaddleX); let verifyY = (mgo) => mgo.y >= targetPaddleY; this.y > targetPaddleY && (verifyY = (mgo) => mgo.y <= targetPaddleY), this.movement = [ { condition: (mgo) => (verifyX(mgo) && verifyY(mgo) && (this.active = !1, this.x = targetPaddleX, this.y = targetPaddleY), !this.active), movement: { ...movement } } ], this.active = !0; } if (this.angleLimit !== 0) { const dx = x - this.cursorX, dy = y - this.cursorY, setAngle = () => { let dAngle = Math.atan2(dy, dx); dx < 0 && (dAngle > 0 ? dAngle -= Math.PI : dAngle += Math.PI), dAngle *= -1, dAngle = (dAngle + Math.PI / 2) % Math.PI - Math.PI / 2; const angle = 1 * (dAngle / (Math.PI / 2)) * this.angleLimit, currentAngle = this.angle ?? 0; this.angle = currentAngle - angle / 10, this.cursorX = x, this.cursorY = y; }; Math.abs(dx) > 2 && Math.abs(dy) > 0 && setAngle(); } }); curveFactor !== void 0 && (this.curveFactor = curveFactor), gripFactor !== void 0 && (this.gripFactor = gripFactor), this.angleLimit = angleLimit ?? 0, this.angle = angle ?? 0, this.mi