Last active
September 30, 2026 01:08
-
-
Save tisaconundrum2/15c9bc0f973bec4abee5c4ff4d1bb5a8 to your computer and use it in GitHub Desktop.
page-pinball.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| Add the following to a bookmark | |
| javascript: (async () => { try { const url = "https://gist.githubusercontent.com/tisaconundrum2/15c9bc0f973bec4abee5c4ff4d1bb5a8/raw/page-pinball.js"; const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const source = await response.text(); (0, eval)(source); } catch (error) { console.error("Page Pinball failed:", error); alert("Could not load Page Pinball. See the console for details."); }})(); | |
| */ | |
| (() => { | |
| "use strict"; | |
| const KEY = "__PAGE_PINBALL__"; | |
| if (window[KEY]) { | |
| window[KEY].destroy(); | |
| return; | |
| } | |
| if (!document.body) { | |
| return; | |
| } | |
| const CONFIG = { | |
| gravity: 1000, | |
| ballRadius: 9, | |
| maxSpeed: 1500, | |
| wallBounce: 0.94, | |
| targetBounce: 1.02, | |
| flipperBounce: 0.85, | |
| flipperSpeed: 17, | |
| physicsStep: 1 / 180, | |
| targetRefresh: 650, | |
| }; | |
| const canvas = document.createElement("canvas"); | |
| Object.assign(canvas.style, { | |
| position: "fixed", | |
| inset: "0", | |
| width: "100vw", | |
| height: "100vh", | |
| zIndex: "2147483647", | |
| pointerEvents: "none", | |
| }); | |
| canvas.setAttribute("aria-hidden", "true"); | |
| document.body.appendChild(canvas); | |
| const ctx = canvas.getContext("2d"); | |
| if (!ctx) { | |
| canvas.remove(); | |
| return; | |
| } | |
| const keys = new Set(); | |
| const pointers = new Map(); | |
| const removals = []; | |
| const particles = []; | |
| const trail = []; | |
| const listeners = []; | |
| const ball = { | |
| x: 0, | |
| y: 0, | |
| vx: 0, | |
| vy: 0, | |
| radius: CONFIG.ballRadius, | |
| active: false, | |
| }; | |
| let width = 0; | |
| let height = 0; | |
| let pixelRatio = 1; | |
| let flippers = []; | |
| let targets = []; | |
| let score = 0; | |
| let ballsLost = 0; | |
| let stopped = false; | |
| let paused = document.hidden; | |
| let frameId = 0; | |
| let lastTime = performance.now(); | |
| let lastTargetScan = -Infinity; | |
| let respawnDelay = 0; | |
| let targetsDirty = true; | |
| const clamp = (value, min, max) => { | |
| return Math.max(min, Math.min(max, value)); | |
| }; | |
| function listen(target, type, handler, options) { | |
| target.addEventListener(type, handler, options); | |
| listeners.push(() => { | |
| target.removeEventListener(type, handler, options); | |
| }); | |
| } | |
| function makeFlipper(x, side) { | |
| const length = Math.min(125, width * 0.115); | |
| const rest = side === "left" ? 0.36 : Math.PI - 0.36; | |
| const raised = side === "left" ? -0.52 : Math.PI + 0.52; | |
| return { | |
| x, | |
| y: height - Math.min(100, height * 0.2), | |
| length, | |
| radius: Math.max(7, Math.min(11, width * 0.025)), | |
| side, | |
| rest, | |
| raised, | |
| angle: rest, | |
| omega: 0, | |
| }; | |
| } | |
| function resize() { | |
| width = window.innerWidth; | |
| height = window.innerHeight; | |
| pixelRatio = Math.min(window.devicePixelRatio || 1, 2); | |
| canvas.width = Math.round(width * pixelRatio); | |
| canvas.height = Math.round(height * pixelRatio); | |
| ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); | |
| flippers = [ | |
| makeFlipper(width * 0.38, "left"), | |
| makeFlipper(width * 0.62, "right"), | |
| ]; | |
| ball.x = clamp(ball.x, ball.radius, width - ball.radius); | |
| ball.y = clamp(ball.y, ball.radius, height - ball.radius); | |
| targetsDirty = true; | |
| } | |
| function launch() { | |
| ball.x = width * 0.5; | |
| ball.y = Math.max(50, height * 0.4); | |
| ball.vx = (Math.random() < 0.5 ? -1 : 1) * 230; | |
| ball.vy = -650; | |
| ball.active = true; | |
| respawnDelay = 0; | |
| trail.length = 0; | |
| } | |
| function flipperPressed(side) { | |
| const keyboard = | |
| side === "left" | |
| ? keys.has("ArrowLeft") || keys.has("KeyA") | |
| : keys.has("ArrowRight") || keys.has("KeyD"); | |
| return keyboard || [...pointers.values()].includes(side); | |
| } | |
| const ignoredTags = new Set([ | |
| "SCRIPT", | |
| "STYLE", | |
| "LINK", | |
| "META", | |
| "NOSCRIPT", | |
| "TEMPLATE", | |
| "BR", | |
| "WBR", | |
| "OPTION", | |
| "OPTGROUP", | |
| ]); | |
| const mediaTags = new Set([ | |
| "IMG", | |
| "SVG", | |
| "CANVAS", | |
| "VIDEO", | |
| "IFRAME", | |
| "INPUT", | |
| "TEXTAREA", | |
| "SELECT", | |
| "BUTTON", | |
| "HR", | |
| ]); | |
| function isVisibleRect(rect) { | |
| return ( | |
| rect.width >= 3 && | |
| rect.height >= 3 && | |
| rect.right > 0 && | |
| rect.bottom > 0 && | |
| rect.left < width && | |
| rect.top < height | |
| ); | |
| } | |
| function scanTargets() { | |
| targetsDirty = false; | |
| lastTargetScan = performance.now(); | |
| targets = []; | |
| const elements = document.body.querySelectorAll("*"); | |
| for (const element of elements) { | |
| const tag = element.tagName.toUpperCase(); | |
| if ( | |
| element === canvas || | |
| ignoredTags.has(tag) || | |
| !element.isConnected || | |
| element.closest("[data-pinball-ignore]") | |
| ) { | |
| continue; | |
| } | |
| // Treat SVG graphics as one target, not individual SVG paths. | |
| const svg = element.closest("svg"); | |
| if (svg && svg !== element) { | |
| continue; | |
| } | |
| const rect = element.getBoundingClientRect(); | |
| if ( | |
| !isVisibleRect(rect) || | |
| rect.width * rect.height > width * height * 0.65 | |
| ) { | |
| continue; | |
| } | |
| const style = getComputedStyle(element); | |
| if ( | |
| style.display === "none" || | |
| style.visibility !== "visible" || | |
| Number(style.opacity) === 0 | |
| ) { | |
| continue; | |
| } | |
| // Prefer visible leaf elements, rather than deleting containers | |
| // full of content after a single hit. | |
| if (!mediaTags.has(tag)) { | |
| const hasVisibleChild = [...element.children].some((child) => { | |
| if (ignoredTags.has(child.tagName.toUpperCase())) { | |
| return false; | |
| } | |
| const childRect = child.getBoundingClientRect(); | |
| const childStyle = getComputedStyle(child); | |
| return ( | |
| childRect.width > 0 && | |
| childRect.height > 0 && | |
| childStyle.display !== "none" && | |
| childStyle.visibility === "visible" | |
| ); | |
| }); | |
| if (hasVisibleChild) { | |
| continue; | |
| } | |
| const hasText = element.textContent.trim().length > 0; | |
| const hasBackground = | |
| style.backgroundImage !== "none" || | |
| !["transparent", "rgba(0, 0, 0, 0)"].includes( | |
| style.backgroundColor, | |
| ); | |
| if (!hasText && !hasBackground) { | |
| continue; | |
| } | |
| } | |
| targets.push({ | |
| element, | |
| left: rect.left, | |
| right: rect.right, | |
| top: rect.top, | |
| bottom: rect.bottom, | |
| }); | |
| } | |
| } | |
| function burst(x, y) { | |
| for (let i = 0; i < 18; i++) { | |
| const angle = Math.random() * Math.PI * 2; | |
| const speed = 60 + Math.random() * 230; | |
| particles.push({ | |
| x, | |
| y, | |
| vx: Math.cos(angle) * speed, | |
| vy: Math.sin(angle) * speed, | |
| life: 0.5 + Math.random() * 0.3, | |
| color: Math.random() > 0.5 ? "#40e0ff" : "#ffce54", | |
| }); | |
| } | |
| } | |
| function breakTarget(target) { | |
| const element = target.element; | |
| if (!element.parentNode) { | |
| return; | |
| } | |
| // A comment remembers the exact location for best-effort restoration. | |
| const marker = document.createComment("page-pinball"); | |
| element.replaceWith(marker); | |
| removals.push({ element, marker }); | |
| score += 100; | |
| burst(ball.x, ball.y); | |
| targetsDirty = true; | |
| } | |
| function reflect(nx, ny, restitution, surfaceVX = 0, surfaceVY = 0) { | |
| const relativeVX = ball.vx - surfaceVX; | |
| const relativeVY = ball.vy - surfaceVY; | |
| const normalSpeed = relativeVX * nx + relativeVY * ny; | |
| if (normalSpeed >= 0) { | |
| return; | |
| } | |
| const impulse = -(1 + restitution) * normalSpeed; | |
| ball.vx += impulse * nx; | |
| ball.vy += impulse * ny; | |
| } | |
| function collideWithTarget(target) { | |
| const nearestX = clamp(ball.x, target.left, target.right); | |
| const nearestY = clamp(ball.y, target.top, target.bottom); | |
| let dx = ball.x - nearestX; | |
| let dy = ball.y - nearestY; | |
| let distance = Math.hypot(dx, dy); | |
| let penetration; | |
| if (distance >= ball.radius) { | |
| return false; | |
| } | |
| if (distance > 0.0001) { | |
| dx /= distance; | |
| dy /= distance; | |
| penetration = ball.radius - distance; | |
| } else { | |
| // If layout changes place a target around the ball, resolve | |
| // against the nearest edge instead of dividing by zero. | |
| const edges = [ | |
| { distance: ball.x - target.left, nx: -1, ny: 0 }, | |
| { distance: target.right - ball.x, nx: 1, ny: 0 }, | |
| { distance: ball.y - target.top, nx: 0, ny: -1 }, | |
| { distance: target.bottom - ball.y, nx: 0, ny: 1 }, | |
| ]; | |
| edges.sort((a, b) => a.distance - b.distance); | |
| dx = edges[0].nx; | |
| dy = edges[0].ny; | |
| penetration = ball.radius + edges[0].distance; | |
| } | |
| ball.x += dx * (penetration + 0.5); | |
| ball.y += dy * (penetration + 0.5); | |
| reflect(dx, dy, CONFIG.targetBounce); | |
| breakTarget(target); | |
| return true; | |
| } | |
| function updateFlipper(flipper, dt) { | |
| const desired = flipperPressed(flipper.side) | |
| ? flipper.raised | |
| : flipper.rest; | |
| const oldAngle = flipper.angle; | |
| const maxChange = CONFIG.flipperSpeed * dt; | |
| flipper.angle += clamp( | |
| desired - flipper.angle, | |
| -maxChange, | |
| maxChange, | |
| ); | |
| flipper.omega = (flipper.angle - oldAngle) / dt; | |
| } | |
| function collideWithFlipper(flipper) { | |
| const segmentX = Math.cos(flipper.angle) * flipper.length; | |
| const segmentY = Math.sin(flipper.angle) * flipper.length; | |
| const t = clamp( | |
| ((ball.x - flipper.x) * segmentX + | |
| (ball.y - flipper.y) * segmentY) / | |
| (flipper.length * flipper.length), | |
| 0, | |
| 1, | |
| ); | |
| const closestX = flipper.x + segmentX * t; | |
| const closestY = flipper.y + segmentY * t; | |
| let nx = ball.x - closestX; | |
| let ny = ball.y - closestY; | |
| const distance = Math.hypot(nx, ny); | |
| const minDistance = ball.radius + flipper.radius; | |
| if (distance >= minDistance) { | |
| return; | |
| } | |
| if (distance > 0.0001) { | |
| nx /= distance; | |
| ny /= distance; | |
| } else { | |
| nx = Math.sin(flipper.angle); | |
| ny = -Math.cos(flipper.angle); | |
| if (ny > 0) { | |
| nx = -nx; | |
| ny = -ny; | |
| } | |
| } | |
| ball.x = closestX + nx * (minDistance + 0.5); | |
| ball.y = closestY + ny * (minDistance + 0.5); | |
| // Velocity of the rotating paddle at the contact point. | |
| const contactX = closestX + nx * flipper.radius; | |
| const contactY = closestY + ny * flipper.radius; | |
| const surfaceVX = -flipper.omega * (contactY - flipper.y); | |
| const surfaceVY = flipper.omega * (contactX - flipper.x); | |
| reflect( | |
| nx, | |
| ny, | |
| CONFIG.flipperBounce, | |
| surfaceVX, | |
| surfaceVY, | |
| ); | |
| } | |
| function collideWithWalls() { | |
| const radius = ball.radius; | |
| if (ball.x < radius) { | |
| ball.x = radius; | |
| ball.vx = Math.abs(ball.vx) * CONFIG.wallBounce; | |
| } else if (ball.x > width - radius) { | |
| ball.x = width - radius; | |
| ball.vx = -Math.abs(ball.vx) * CONFIG.wallBounce; | |
| } | |
| if (ball.y < radius) { | |
| ball.y = radius; | |
| ball.vy = Math.abs(ball.vy) * CONFIG.wallBounce; | |
| } | |
| // The bottom is open: a missed ball drains and respawns. | |
| if (ball.y > height + radius * 3) { | |
| ball.active = false; | |
| ballsLost++; | |
| respawnDelay = 0.8; | |
| trail.length = 0; | |
| } | |
| } | |
| function getGuideRails() { | |
| const startY = Math.min( | |
| height * 0.56, | |
| flippers[0].y - 90, | |
| ); | |
| const endY = flippers[0].y - 16; | |
| const leftEndX = | |
| flippers[0].x - | |
| flippers[0].radius - | |
| ball.radius - | |
| 4; | |
| return { startY, endY, leftEndX }; | |
| } | |
| function collideWithGuideRails() { | |
| const { startY, endY, leftEndX } = getGuideRails(); | |
| if (ball.y < startY) { | |
| return; | |
| } | |
| const onSlope = ball.y < endY; | |
| const slope = onSlope | |
| ? leftEndX / (endY - startY) | |
| : 0; | |
| const leftX = onSlope | |
| ? slope * (ball.y - startY) | |
| : leftEndX; | |
| const rightX = width - leftX; | |
| // Allow for the ball's radius against the sloped wall. | |
| const clearance = | |
| ball.radius * Math.sqrt(1 + slope * slope); | |
| if (ball.x < leftX + clearance) { | |
| ball.x = leftX + clearance; | |
| const scale = Math.sqrt(1 + slope * slope); | |
| reflect( | |
| 1 / scale, | |
| -slope / scale, | |
| CONFIG.wallBounce, | |
| ); | |
| } else if (ball.x > rightX - clearance) { | |
| ball.x = rightX - clearance; | |
| const scale = Math.sqrt(1 + slope * slope); | |
| reflect( | |
| -1 / scale, | |
| -slope / scale, | |
| CONFIG.wallBounce, | |
| ); | |
| } | |
| } | |
| function physics(dt) { | |
| for (const flipper of flippers) { | |
| updateFlipper(flipper, dt); | |
| } | |
| if (!ball.active) { | |
| respawnDelay -= dt; | |
| if (respawnDelay <= 0) { | |
| launch(); | |
| } | |
| return; | |
| } | |
| ball.vy += CONFIG.gravity * dt; | |
| ball.x += ball.vx * dt; | |
| ball.y += ball.vy * dt; | |
| collideWithWalls(); | |
| if (!ball.active) { | |
| return; | |
| } | |
| collideWithGuideRails(); | |
| for (const flipper of flippers) { | |
| collideWithFlipper(flipper); | |
| } | |
| // Stop using old rectangles as soon as a removal changes layout. | |
| // A fresh index will be built at the start of the next frame. | |
| if (!targetsDirty) { | |
| for (let i = targets.length - 1; i >= 0; i--) { | |
| const target = targets[i]; | |
| if (!target.element.isConnected) { | |
| targets.splice(i, 1); | |
| continue; | |
| } | |
| if (collideWithTarget(target)) { | |
| targets.splice(i, 1); | |
| break; | |
| } | |
| } | |
| } | |
| const speed = Math.hypot(ball.vx, ball.vy); | |
| if (speed > CONFIG.maxSpeed) { | |
| ball.vx *= CONFIG.maxSpeed / speed; | |
| ball.vy *= CONFIG.maxSpeed / speed; | |
| } | |
| } | |
| function updateEffects(dt) { | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| const particle = particles[i]; | |
| particle.life -= dt; | |
| particle.vy += 400 * dt; | |
| particle.x += particle.vx * dt; | |
| particle.y += particle.vy * dt; | |
| if (particle.life <= 0) { | |
| particles.splice(i, 1); | |
| } | |
| } | |
| if (ball.active) { | |
| trail.push({ x: ball.x, y: ball.y }); | |
| if (trail.length > 12) { | |
| trail.shift(); | |
| } | |
| } | |
| } | |
| function drawFlipper(flipper) { | |
| const tipX = | |
| flipper.x + Math.cos(flipper.angle) * flipper.length; | |
| const tipY = | |
| flipper.y + Math.sin(flipper.angle) * flipper.length; | |
| ctx.lineCap = "round"; | |
| ctx.strokeStyle = "#102033"; | |
| ctx.lineWidth = flipper.radius * 2 + 5; | |
| ctx.beginPath(); | |
| ctx.moveTo(flipper.x, flipper.y); | |
| ctx.lineTo(tipX, tipY); | |
| ctx.stroke(); | |
| ctx.strokeStyle = flipperPressed(flipper.side) | |
| ? "#ffce54" | |
| : "#40e0ff"; | |
| ctx.lineWidth = flipper.radius * 2; | |
| ctx.stroke(); | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.beginPath(); | |
| ctx.arc(flipper.x, flipper.y, 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| function draw() { | |
| ctx.clearRect(0, 0, width, height); | |
| // Three-sided playfield; the bottom stays open. | |
| ctx.lineCap = "butt"; | |
| ctx.lineWidth = 4; | |
| ctx.strokeStyle = "rgba(64, 224, 255, 0.75)"; | |
| ctx.beginPath(); | |
| ctx.moveTo(2, height); | |
| ctx.lineTo(2, 2); | |
| ctx.lineTo(width - 2, 2); | |
| ctx.lineTo(width - 2, height); | |
| ctx.stroke(); | |
| for (const particle of particles) { | |
| ctx.globalAlpha = clamp(particle.life * 2, 0, 1); | |
| ctx.fillStyle = particle.color; | |
| ctx.fillRect(particle.x - 2, particle.y - 2, 4, 4); | |
| } | |
| ctx.globalAlpha = 1; | |
| const { startY, endY, leftEndX } = getGuideRails(); | |
| ctx.strokeStyle = "#ffce54"; | |
| ctx.lineWidth = 6; | |
| ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, startY); | |
| ctx.lineTo(leftEndX, endY); | |
| ctx.lineTo(leftEndX, height); | |
| ctx.moveTo(width, startY); | |
| ctx.lineTo(width - leftEndX, endY); | |
| ctx.lineTo(width - leftEndX, height); | |
| ctx.stroke(); | |
| for (const flipper of flippers) { | |
| drawFlipper(flipper); | |
| } | |
| trail.forEach((point, index) => { | |
| ctx.globalAlpha = (index / trail.length) * 0.35; | |
| ctx.fillStyle = "#40e0ff"; | |
| ctx.beginPath(); | |
| ctx.arc( | |
| point.x, | |
| point.y, | |
| ball.radius * (index / trail.length), | |
| 0, | |
| Math.PI * 2, | |
| ); | |
| ctx.fill(); | |
| }); | |
| ctx.globalAlpha = 1; | |
| if (ball.active) { | |
| ctx.shadowColor = "#40e0ff"; | |
| ctx.shadowBlur = 16; | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.strokeStyle = "#102033"; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| ctx.shadowBlur = 0; | |
| } | |
| const panelWidth = Math.min(460, width - 24); | |
| ctx.fillStyle = "rgba(9, 18, 32, 0.9)"; | |
| ctx.fillRect(12, 12, panelWidth, 74); | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.font = "bold 16px system-ui, sans-serif"; | |
| ctx.fillText( | |
| `PINBALL ${score.toLocaleString()} pts | Lost: ${ballsLost}`, | |
| 24, | |
| 38, | |
| panelWidth - 24, | |
| ); | |
| ctx.fillStyle = "#bcefff"; | |
| ctx.font = "12px system-ui, sans-serif"; | |
| ctx.fillText( | |
| "A / D or ← / →: flippers · Space: launch · Esc: exit", | |
| 24, | |
| 59, | |
| panelWidth - 24, | |
| ); | |
| ctx.fillStyle = "#ffce54"; | |
| ctx.fillText( | |
| targets.length | |
| ? `${targets.length} visible targets` | |
| : "Screen cleared! Scroll to find more targets.", | |
| 24, | |
| 76, | |
| panelWidth - 24, | |
| ); | |
| } | |
| function frame(time) { | |
| if (stopped) { | |
| return; | |
| } | |
| const dt = Math.min((time - lastTime) / 1000, 0.04); | |
| lastTime = time; | |
| if (!paused) { | |
| if ( | |
| targetsDirty || | |
| time - lastTargetScan > CONFIG.targetRefresh | |
| ) { | |
| scanTargets(); | |
| } | |
| // Small substeps reduce tunneling through thin page elements. | |
| const steps = Math.max( | |
| 1, | |
| Math.ceil(dt / CONFIG.physicsStep), | |
| ); | |
| const step = dt / steps; | |
| if (step > 0) { | |
| for (let i = 0; i < steps; i++) { | |
| physics(step); | |
| } | |
| } | |
| updateEffects(dt); | |
| draw(); | |
| } | |
| frameId = requestAnimationFrame(frame); | |
| } | |
| function destroy() { | |
| if (stopped) { | |
| return; | |
| } | |
| stopped = true; | |
| cancelAnimationFrame(frameId); | |
| for (const removeListener of listeners) { | |
| removeListener(); | |
| } | |
| // Restore in reverse order in case a removed parent contains | |
| // markers belonging to previously removed children. | |
| for (let i = removals.length - 1; i >= 0; i--) { | |
| const { element, marker } = removals[i]; | |
| if (marker.parentNode) { | |
| marker.replaceWith(element); | |
| } | |
| } | |
| canvas.remove(); | |
| delete window[KEY]; | |
| } | |
| const controlKeys = new Set([ | |
| "ArrowLeft", | |
| "ArrowRight", | |
| "KeyA", | |
| "KeyD", | |
| "Space", | |
| "Escape", | |
| ]); | |
| function handleKeyboard(event) { | |
| if (!controlKeys.has(event.code)) { | |
| return; | |
| } | |
| event.preventDefault(); | |
| event.stopImmediatePropagation(); | |
| if (event.type === "keyup") { | |
| keys.delete(event.code); | |
| return; | |
| } | |
| keys.add(event.code); | |
| if (event.code === "Escape") { | |
| destroy(); | |
| } else if (event.code === "Space" && !event.repeat) { | |
| launch(); | |
| } | |
| } | |
| listen(window, "keydown", handleKeyboard, true); | |
| listen(window, "keyup", handleKeyboard, true); | |
| listen( | |
| window, | |
| "pointerdown", | |
| (event) => { | |
| if (event.pointerType === "mouse") { | |
| return; | |
| } | |
| event.preventDefault(); | |
| event.stopImmediatePropagation(); | |
| pointers.set( | |
| event.pointerId, | |
| event.clientX < width / 2 ? "left" : "right", | |
| ); | |
| }, | |
| { capture: true, passive: false }, | |
| ); | |
| listen( | |
| window, | |
| "pointermove", | |
| (event) => { | |
| if (!pointers.has(event.pointerId)) { | |
| return; | |
| } | |
| event.preventDefault(); | |
| pointers.set( | |
| event.pointerId, | |
| event.clientX < width / 2 ? "left" : "right", | |
| ); | |
| }, | |
| { capture: true, passive: false }, | |
| ); | |
| const releasePointer = (event) => { | |
| pointers.delete(event.pointerId); | |
| }; | |
| listen(window, "pointerup", releasePointer, true); | |
| listen(window, "pointercancel", releasePointer, true); | |
| listen( | |
| window, | |
| "touchmove", | |
| (event) => { | |
| if (pointers.size) { | |
| event.preventDefault(); | |
| } | |
| }, | |
| { passive: false }, | |
| ); | |
| listen(window, "blur", () => { | |
| keys.clear(); | |
| pointers.clear(); | |
| }); | |
| listen(document, "visibilitychange", () => { | |
| paused = document.hidden; | |
| keys.clear(); | |
| pointers.clear(); | |
| lastTime = performance.now(); | |
| }); | |
| listen(window, "resize", resize); | |
| listen( | |
| window, | |
| "scroll", | |
| () => { | |
| targetsDirty = true; | |
| }, | |
| true, | |
| ); | |
| window[KEY] = { | |
| destroy, | |
| launch, | |
| refresh() { | |
| targetsDirty = true; | |
| }, | |
| get score() { | |
| return score; | |
| }, | |
| }; | |
| resize(); | |
| scanTargets(); | |
| launch(); | |
| frameId = requestAnimationFrame(frame); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment