feat(app): turn the new session pins into a brick breaker easter egg (#57593)

This commit is contained in:
Luke Parker
2026-10-21 11:57:32 +10:00
committed by GitHub
Unverified
parent 2a0e28fce3
commit 634cf9b063
6 changed files with 797 additions and 18 deletions
@@ -0,0 +1,471 @@
import { For, onCleanup, onMount } from "solid-js"
import type { PinBoardHandle } from "./pin-board"
import { PinSound } from "./pin-sound"
import type { LodgerBall } from "./wordmark"
const LIVES = 3
const ENTER = 0.85
const EXIT = 1
type Phase = "enter" | "serve" | "play" | "miss" | "exit"
type Box = { x: number; y: number; w: number; h: number; r: number }
/**
* A brick breaker homage. The lodger curls into the ball, the pins are the bricks, and the composer shrinks
* into the paddle. Like Arkanoid, the ball waits on the paddle until it is served with a click or Space.
*/
export function NewSessionBrickBreaker(props: {
panel: HTMLElement
board: PinBoardHandle
ball: LodgerBall
sound: () => boolean
onDone: () => void
}) {
let paddle!: HTMLDivElement
let hud!: HTMLDivElement
let score!: HTMLSpanElement
const spares: HTMLSpanElement[] = []
const walls: HTMLSpanElement[] = []
onMount(() =>
onCleanup(
mountGame({ panel: props.panel, paddle, hud, score, spares, walls }, props.board, props.ball, props.sound, props.onDone),
),
)
return (
<>
<div
ref={paddle}
aria-hidden="true"
data-slot="brick-breaker-paddle"
class="pointer-events-none absolute left-0 top-0 -z-[5] origin-center"
/>
<div
ref={hud}
aria-hidden="true"
data-slot="brick-breaker-hud"
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300"
>
<span ref={(el) => (walls[0] = el)} class="absolute inset-y-0 left-0 w-px bg-v2-border-border-base" />
<span ref={(el) => (walls[1] = el)} class="absolute inset-y-0 left-0 w-px bg-v2-border-border-base" />
<span
ref={score}
class="absolute right-4 top-3 font-mono text-[11px] leading-[16px] tabular-nums text-v2-text-text-faint"
/>
<div class="absolute bottom-4 left-4 flex gap-1.5">
<For each={Array.from({ length: LIVES - 1 })}>
{(_, i) => (
<span
ref={(el) => (spares[i()] = el)}
class="h-1.5 w-5 rounded-full bg-v2-icon-icon-faint transition-[opacity,transform] duration-300"
/>
)}
</For>
</div>
</div>
</>
)
}
function mountGame(
parts: {
panel: HTMLElement
paddle: HTMLDivElement
hud: HTMLDivElement
score: HTMLSpanElement
spares: HTMLSpanElement[]
walls: HTMLSpanElement[]
},
board: PinBoardHandle,
ball: LodgerBall,
sound: () => boolean,
onDone: () => void,
) {
const panel = parts.panel
const wrap = panel.querySelector<HTMLElement>('[data-slot="new-session-composer"]')
const form = wrap?.querySelector<HTMLElement>('[data-component="composer"]')
const tip = panel.querySelector<HTMLElement>('[data-component="new-session-tip"]')
const surface = form ? getComputedStyle(form).backgroundColor : ""
// The arena is exactly as wide as the wordmark, like Arkanoid's tall, narrow playfield.
const view = { left: 0, top: 0, w: 0, h: 0, l: 0, r: 0 }
const g = { phase: "enter" as Phase, t0: performance.now() / 1000, last: 0, raf: 0, lives: LIVES, score: 0 }
const rally = { combo: 0, hits: 0, base: 0, speed: 0 }
const pad = { x: 0, target: 0, v: 0, w: 0, h: 12, y: 0, tilt: 0, kickAt: -1, steer: 1 }
const b = { x: 0, y: 0, vx: 0, vy: 0, hitAt: -1, hitAngle: 0, hitAmp: 0, squintUntil: 0, curl: 0.85, landed: false }
const keys = { left: false, right: false }
const start = { ball: ball.at(), composer: composerBox(), from: composerBox() }
// The paddle's drawn shape, so the exit morph starts from wherever the paddle is.
const shape: Box = { ...start.composer }
const written = { w: -1, h: -1, r: -1, transform: "", score: "" }
const layout = () => {
const r = panel.getBoundingClientRect()
view.left = r.left
view.top = r.top
view.w = r.width
view.h = r.height
// Walls sit on the wordmark's outer pin edges, using the board's integer pitch and rounded origin, so the
// ball can only reach the top by breaking through.
const logo = panel.querySelector('[data-slot="wordmark-box"]')?.getBoundingClientRect()
const pitch = logo ? Math.max(3, Math.round(logo.width / 78)) : 0
const x0 = logo ? Math.round(logo.left - r.left + (logo.width - pitch * 78) / 2) : 0
const c = composerBox()
view.l = logo ? x0 : Math.max(0, c.x)
view.r = logo ? x0 + pitch * 78 : Math.min(view.w, c.x + c.w)
parts.walls[0].style.transform = `translateX(${view.l - 1}px)`
parts.walls[1].style.transform = `translateX(${view.r}px)`
pad.w = Math.max(84, Math.min(136, (view.r - view.l) * 0.15))
pad.y = view.h - 64
rally.base = Math.max(460, Math.min(760, view.h * 0.7))
rally.speed = rally.base * (1 + Math.min(0.55, rally.hits * 0.025))
}
function composerBox(): Box {
const p = panel.getBoundingClientRect()
const r = form?.getBoundingClientRect()
if (!r) return { x: p.width / 2 - 60, y: p.height - 64, w: 120, h: 12, r: 6 }
const radius = form ? parseFloat(getComputedStyle(form).borderTopLeftRadius) : 12
return { x: r.left - p.left, y: r.top - p.top, w: r.width, h: r.height, r: radius || 12 }
}
const paddleBox = (): Box => ({ x: pad.x - pad.w / 2, y: pad.y, w: pad.w, h: pad.h, r: pad.h / 2 })
const frame = (ms: number) => {
g.raf = 0
const now = ms / 1000
const dt = Math.min(1 / 30, Math.max(1 / 240, now - g.last))
g.last = now
const t = now - g.t0
movePaddle(dt, now)
if (g.phase === "enter") enter(t)
if (g.phase === "serve") serve(t)
if (g.phase === "play") play(dt, now)
if (g.phase === "miss") miss(t)
if (g.phase === "exit") return exit(t)
pose(now)
board.steer(b.x, b.y)
g.raf = requestAnimationFrame(frame)
}
const movePaddle = (dt: number, now: number) => {
if (g.phase === "enter" || g.phase === "exit") return
if (keys.left !== keys.right) pad.target += (keys.right ? 1 : -1) * 1100 * dt
pad.target = Math.max(view.l + pad.w / 2 + 4, Math.min(view.r - pad.w / 2 - 4, pad.target))
const prev = pad.x
pad.x += (pad.target - pad.x) * (1 - Math.exp(-dt * 30))
pad.v = (pad.x - prev) / dt
pad.tilt += (Math.max(-5, Math.min(5, pad.v / 260)) - pad.tilt) * (1 - Math.exp(-dt * 14))
const k = now - pad.kickAt
const kick = k < 0.5 ? Math.exp(-k * 12) * Math.sin(k * 34) * 4 : 0
draw({ ...paddleBox(), y: pad.y + kick }, pad.tilt)
}
// The composer content fades, then its box springs down and narrows into the paddle while the ball rides on top.
const enter = (t: number) => {
const u = seg(t, 0.1, ENTER)
const e = spring(u)
const to = paddleBox()
const box = {
x: lerp(start.from.x + start.from.w / 2, to.x + to.w / 2, e),
y: lerp(start.from.y, to.y, e),
w: Math.max(pad.h, lerp(start.from.w, to.w, e)),
h: Math.max(4, lerp(start.from.h, to.h, e)),
r: lerp(start.from.r, to.r, Math.min(1, u * 1.6)),
}
draw({ ...box, x: box.x - box.w / 2 }, 0)
const k = easeOut(seg(t, 0, 0.38))
const r = ball.radius()
b.x = lerp(start.ball.x, box.x, k)
b.y = lerp(start.ball.y, box.y - r, k) - Math.sin(Math.PI * k) * 28
b.curl = lerp(0.85, 1, k)
if (k >= 1 && !b.landed) {
b.landed = true
bump(Math.PI / 2, 0.32)
if (sound()) PinSound.paddle(b.x, view.w)
}
// Once it has shrunk enough to read as a paddle, it takes on the colour of the pins it is about to break.
if (t >= 0.3) parts.paddle.style.background = "var(--v2-icon-icon-muted)"
if (t >= 0.5) parts.hud.style.opacity = "1"
if (t < ENTER) return
pad.target = pad.x
setPhase("serve")
panel.style.cursor = "none"
}
// The ball waits on the paddle like Arkanoid's; after a miss it drops back onto it first.
const serve = (t: number) => {
const r = ball.radius()
const drop = g.lives < LIVES ? seg(t, 0, 0.42) : 1
b.x = pad.x
b.y = pad.y - r - Math.pow(1 - drop, 2) * 110
b.vx = 0
b.vy = 0
b.curl = 1
if (drop >= 1 && !b.landed) {
b.landed = true
bump(Math.PI / 2, 0.36)
if (sound()) PinSound.paddle(b.x, view.w)
}
}
const launch = () => {
if (g.phase !== "serve" || !b.landed) return
// Serve away from the side the paddle is moving toward, up and to the right when still.
const lean = Math.abs(pad.v) > 40 ? Math.sign(pad.v) : 1
const a = lean * (0.32 + Math.random() * 0.12)
b.vx = Math.sin(a) * rally.speed
b.vy = -Math.cos(a) * rally.speed
rally.combo = 0
pad.kickAt = performance.now() / 1000
bump(Math.PI / 2, 0.3)
if (sound()) PinSound.paddle(b.x, view.w)
setPhase("play")
}
const play = (dt: number, now: number) => {
const r = ball.radius()
// Substeps keep the ball from tunnelling through a pin between frames at high speed.
const steps = Math.max(1, Math.ceil((rally.speed * dt) / (r * 0.35)))
const sdt = dt / steps
for (let i = 0; i < steps; i++) {
b.x += b.vx * sdt
b.y += b.vy * sdt
if (b.x < view.l + r || b.x > view.r - r) {
b.x = Math.max(view.l + r, Math.min(view.r - r, b.x))
b.vx = b.x <= view.l + r ? Math.abs(b.vx) : -Math.abs(b.vx)
bump(0, 0.22)
if (sound()) PinSound.bounce(b.x, view.w, 4000)
}
if (b.y < r) {
b.y = r
b.vy = Math.abs(b.vy)
bump(Math.PI / 2, 0.22)
if (sound()) PinSound.bounce(b.x, view.w, 4000)
}
if (
b.vy > 0 &&
b.y + r >= pad.y &&
b.y - r < pad.y + pad.h &&
Math.abs(b.x - pad.x) <= pad.w / 2 + r * 0.6
) {
// Classic paddle control: where the ball lands sets its angle, up to 60° from vertical.
const off = Math.max(-1, Math.min(1, (b.x - pad.x) / (pad.w / 2)))
const a = off * (Math.PI / 3) + Math.max(-0.15, Math.min(0.15, pad.v / 6000))
rally.hits++
rally.speed = rally.base * (1 + Math.min(0.55, rally.hits * 0.025))
rally.combo = 0
b.vx = Math.sin(a) * rally.speed
b.vy = -Math.cos(a) * rally.speed
b.y = pad.y - r
pad.kickAt = now
bump(Math.PI / 2, 0.34)
if (sound()) PinSound.paddle(b.x, view.w)
}
// Each hit carves a round bite out of the wordmark rather than a single pin.
const hit = board.collide(b.x, b.y, r, r * 0.55, b.vx, b.vy)
if (hit) {
const side = Math.abs(hit.nx) > Math.abs(hit.ny)
if (side && b.vx * hit.nx < 0) b.vx = -b.vx
if (!side && b.vy * hit.ny < 0) b.vy = -b.vy
rally.combo++
g.score += hit.count * 10 * Math.min(8, rally.combo)
b.squintUntil = now + 0.14
bump(side ? 0 : Math.PI / 2, 0.26)
if (sound()) PinSound.brick(b.x, view.w, rally.combo)
if (!board.left()) return finish("win")
}
// A nearly flat rally never comes back down, so keep at least a quarter of the speed vertical.
const min = rally.speed * 0.25
if (Math.abs(b.vy) < min) {
b.vy = (b.vy < 0 ? -1 : 1) * min
b.vx = Math.sign(b.vx || 1) * Math.sqrt(rally.speed * rally.speed - min * min)
}
if (b.y - r > view.h) {
setPhase("miss")
if (sound()) PinSound.sweep({ duration: 0.45, columns: 78, reverse: true })
return
}
}
}
const miss = (t: number) => {
if (t < 0.55) return
g.lives--
parts.spares.forEach((el, i) => {
el.style.opacity = i < g.lives - 1 ? "1" : "0"
el.style.transform = i < g.lives - 1 ? "" : "translateY(4px)"
})
if (!g.lives) return finish("lose")
b.landed = false
setPhase("serve")
}
const finish = (kind: "win" | "lose" | "quit") => {
if (g.phase === "exit") return
start.composer = composerBox()
start.from = { ...shape }
setPhase("exit")
panel.style.cursor = ""
parts.hud.style.opacity = "0"
parts.paddle.style.background = surface
board.restore()
ball.done()
if (sound()) PinSound.sweep({ duration: 0.9, columns: 78, reverse: kind !== "win" })
}
// The paddle grows back into the composer, then the composer content fades in over it.
const exit = (t: number) => {
const e = easeInOut(seg(t, 0.15, 0.75))
const to = start.composer
draw(
{
x: lerp(start.from.x, to.x, e),
y: lerp(start.from.y, to.y, e),
w: lerp(start.from.w, to.w, e),
h: lerp(start.from.h, to.h, e),
r: lerp(start.from.r, to.r, e),
},
pad.tilt * (1 - e),
)
if (t >= 0.75) {
if (wrap) wrap.style.opacity = ""
if (tip) tip.style.opacity = ""
parts.paddle.style.opacity = "0"
}
if (t < EXIT) {
g.raf = requestAnimationFrame(frame)
return
}
onDone()
}
const setPhase = (phase: Phase) => {
g.phase = phase
g.t0 = performance.now() / 1000
}
const bump = (angle: number, amp: number) => {
b.hitAt = performance.now() / 1000
b.hitAngle = angle
b.hitAmp = amp
}
const pose = (now: number) => {
const t = now - b.hitAt
const jiggle = t < 0.45 ? b.hitAmp * Math.exp(-t * 12) * Math.cos(t * 38) : 0
const moving = g.phase === "play" || g.phase === "miss"
const heading = Math.atan2(b.vy, b.vx)
const squash = Math.abs(jiggle) > 0.015
ball.place(b.x, b.y, {
look: moving ? 1 : 0.8,
lookAngle: moving ? heading : -Math.PI / 2,
stretch: squash ? -jiggle : moving ? 0.07 : 0,
angle: ((squash ? b.hitAngle : heading) * 180) / Math.PI,
squint: now < b.squintUntil ? 0.45 : 1,
curl: b.curl,
})
const text = `${g.score}`.padStart(5, "0")
if (text !== written.score) {
written.score = text
parts.score.textContent = text
}
}
const draw = (box: Box, tilt: number) => {
Object.assign(shape, box)
const el = parts.paddle
const w = Math.round(box.w * 10) / 10
const h = Math.round(box.h * 10) / 10
if (w !== written.w) el.style.width = `${(written.w = w)}px`
if (h !== written.h) el.style.height = `${(written.h = h)}px`
if (box.r !== written.r) el.style.borderRadius = `${(written.r = box.r)}px`
const transform = `translate(${box.x.toFixed(1)}px, ${box.y.toFixed(1)}px) rotate(${tilt.toFixed(2)}deg)`
if (transform !== written.transform) el.style.transform = written.transform = transform
}
const onMove = (event: PointerEvent) => {
pad.target = event.clientX - view.left
}
const onDown = (event: PointerEvent) => {
if (event.button !== 0 || g.phase === "exit") return
event.preventDefault()
event.stopPropagation()
launch()
}
const onKey = (event: KeyboardEvent) => {
if (g.phase === "exit") return
const down = event.type === "keydown"
if (event.key === "Escape" && down) finish("quit")
else if (event.key === "ArrowLeft" || event.key === "a") keys.left = down
else if (event.key === "ArrowRight" || event.key === "d") keys.right = down
else if ((event.key === " " || event.key === "ArrowUp") && down) launch()
else return
event.preventDefault()
event.stopPropagation()
}
const onBlur = () => {
keys.left = false
keys.right = false
}
layout()
pad.x = start.composer.x + start.composer.w / 2
pad.target = pad.x
b.x = start.ball.x
b.y = start.ball.y
parts.paddle.style.background = surface
parts.paddle.style.boxShadow = "var(--v2-elevation-raised)"
parts.paddle.style.transition = "opacity 180ms ease-out, background-color 360ms ease-out"
// The provider tip sits where the paddle plays, so it steps aside for the game.
if (tip) {
tip.style.transition = "opacity 200ms ease-out"
tip.style.opacity = "0"
}
draw(start.composer, 0)
if (wrap) {
if (wrap.contains(document.activeElement) && document.activeElement instanceof HTMLElement)
document.activeElement.blur()
wrap.style.transition = "opacity 160ms ease-out"
wrap.style.opacity = "0"
wrap.style.pointerEvents = "none"
wrap.inert = true
}
board.play()
const resize = new ResizeObserver(layout)
resize.observe(panel)
panel.addEventListener("pointermove", onMove, { passive: true })
panel.addEventListener("pointerdown", onDown, { capture: true })
window.addEventListener("keydown", onKey, { capture: true })
window.addEventListener("keyup", onKey, { capture: true })
window.addEventListener("blur", onBlur)
g.last = performance.now() / 1000 - 1 / 60
g.raf = requestAnimationFrame(frame)
return () => {
cancelAnimationFrame(g.raf)
resize.disconnect()
panel.removeEventListener("pointermove", onMove)
panel.removeEventListener("pointerdown", onDown, { capture: true })
window.removeEventListener("keydown", onKey, { capture: true })
window.removeEventListener("keyup", onKey, { capture: true })
window.removeEventListener("blur", onBlur)
panel.style.cursor = ""
if (tip) {
tip.style.opacity = ""
tip.style.transition = ""
}
if (!wrap) return
wrap.style.opacity = ""
wrap.style.transition = ""
wrap.style.pointerEvents = ""
wrap.inert = false
}
}
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
const seg = (t: number, from: number, to: number) => Math.max(0, Math.min(1, (t - from) / (to - from)))
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3)
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
// An underdamped spring that settles by t = 1, overshooting about 9% on the way.
const spring = (t: number) => (t >= 1 ? 1 : 1 - Math.exp(-7 * t) * Math.cos(9 * t))
+137 -8
View File
@@ -27,9 +27,34 @@ const HOME = 0
const AIR = 1
const REST = 2
const RETURN = 3
// Knocked out by the brick breaker ball and fallen off the panel; the socket stays empty until the game ends.
const OUT = 4
export type PinEntrance = "sweep" | "chain"
/** Lets the brick breaker treat the pins as bricks. Coordinates are relative to the new session panel. */
export type PinBoardHandle = {
/**
* When the ball touches a pin, knocks out every pin within `blast` of the ball's edge. Returns the average
* offset from the touched pins to the ball and how many pins were knocked out.
*/
collide: (
x: number,
y: number,
radius: number,
blast: number,
vx: number,
vy: number,
) => { nx: number; ny: number; count: number } | undefined
/** Pins still in their sockets. */
left: () => number
/** Starts game mode: pins ignore the pointer, follow the ball, and knocked pins fall off the panel. */
play: () => void
steer: (x: number, y: number) => void
/** Ends game mode and flies every pin home. */
restore: () => void
}
const REST_TILT = [0.42, 0.7] as const
const LIGHT = [0.55 / Math.hypot(0.55, 0.83), -0.83 / Math.hypot(0.55, 0.83)] as const
const SHADES = 16
@@ -79,12 +104,14 @@ export function NewSessionPinBoard(props: {
sound: () => boolean
entrance?: PinEntrance
closing?: boolean
ref?: (board: PinBoardHandle) => void
}) {
let back!: HTMLCanvasElement
let front!: HTMLCanvasElement
onMount(() => {
const board = mountPinBoard(back, front, props.logo, props.sound, props.entrance)
props.ref?.(board.handle)
createEffect(() => {
if (props.closing) board.close()
})
@@ -141,6 +168,7 @@ function mountPinBoard(
composer: undefined as { left: number; right: number; top: number } | undefined,
region: { x: 0, y: 0, w: 0, h: 0, full: false },
frontOn: true,
game: false,
}
const pointer = { x: 0, y: 0, lx: 0, ly: 0, vx: 0, vy: 0, inside: false, down: false, dragX: 0, dragY: 0 }
const tilt = { x: REST_TILT[0], y: REST_TILT[1] }
@@ -334,8 +362,10 @@ function mountPinBoard(
const cx = view.x0 + (COLS * view.pitch) / 2
const cy = view.y0 + (ROWS * view.pitch) / 2
const tx = pointer.inside ? lerp(REST_TILT[0], clamp(((cx - pointer.x) / (view.w * 0.5)) * 1.4), 0.6) : REST_TILT[0]
const ty = pointer.inside ? lerp(REST_TILT[1], clamp(((cy - pointer.y) / (view.h * 0.5)) * 1.4), 0.6) : REST_TILT[1]
// In game mode the board leans only slightly after the ball, so the bricks stay readable.
const lean = view.game ? 0.12 : 0.6
const tx = pointer.inside ? lerp(REST_TILT[0], clamp(((cx - pointer.x) / (view.w * 0.5)) * 1.4), lean) : REST_TILT[0]
const ty = pointer.inside ? lerp(REST_TILT[1], clamp(((cy - pointer.y) / (view.h * 0.5)) * 1.4), lean) : REST_TILT[1]
const ease = 1 - Math.exp(-dt * 4)
tilt.x += (tx - tilt.x) * ease
tilt.y += (ty - tilt.y) * ease
@@ -349,13 +379,15 @@ function mountPinBoard(
const cell = view.pitch
const counts = { air: 0, loose: 0 }
const sink = view.game ? 36 : 55
view.activity = Math.abs(tx - tilt.x) + Math.abs(ty - tilt.y) + (Math.abs(pointer.vx) + Math.abs(pointer.vy)) / 1000
for (const p of pins) {
if (p.state === OUT) continue
p.e *= Math.exp(-dt * 1.8)
if (p.pop && now - p.popAt > 1.5) p.pop = 0
if (p.state === HOME) {
const dist = pointer.inside ? Math.hypot(p.hx - pointer.x, p.hy - pointer.y) : Infinity
const f = dist < 55 ? 1 - dist / 55 : 0
const f = dist < sink ? 1 - dist / sink : 0
const target = f * f * 0.9
p.zv += ((target - p.z) * 260 - p.zv * 14) * dt
p.z = Math.max(-0.8, Math.min(1, p.z + p.zv * dt))
@@ -401,6 +433,10 @@ function mountPinBoard(
p.rot += p.spin * dt
if (p.x < cell) Object.assign(p, { x: cell, vx: Math.abs(p.vx) * 0.5 })
if (p.x > view.w - cell) Object.assign(p, { x: view.w - cell, vx: -Math.abs(p.vx) * 0.5 })
if (view.game) {
if (p.y > view.h + cell * 4) p.state = OUT
continue
}
if (p.vy <= 0 || popOf(p, now) >= 0.1) continue
const bucket = Math.max(0, Math.min(piles.top.length - 1, Math.floor(p.x / cell)))
const topSurface = composerSurface() - piles.top[bucket] * cell
@@ -419,15 +455,16 @@ function mountPinBoard(
}
const idle = now - clock.knocked
if (!pointer.down && counts.loose && ((counts.air === 0 && idle > 2) || idle > 6)) reassemble(now)
if (!view.game && !pointer.down && counts.loose && ((counts.air === 0 && idle > 2) || idle > 6)) reassemble(now)
if (view.region.full && pins.every((p) => p.state === HOME)) setRegion(false)
}
const revealSpan = entrance === "sweep" ? REVEAL : 1.3
const resting = (p: Pin) => p.state === HOME || p.state === OUT
const settled = (now: number) => {
if (clock.closed) return now - clock.closed >= 1.2 && pins.every((p) => p.state === HOME)
if (now - clock.born < revealSpan || pointer.down || view.activity > 1e-3) return false
return pins.every((p) => p.state === HOME)
return pins.every(resting)
}
const draw = (now: number) => {
@@ -435,7 +472,7 @@ function mountPinBoard(
back.begin()
drawBoard(now)
const loose = pins.filter((p) => p.state !== HOME)
const loose = pins.filter((p) => !resting(p))
const popped = loose.filter((p) => popOf(p, now) > 0.06).sort((a, b) => popOf(a, now) - popOf(b, now))
const flat = loose.filter((p) => popOf(p, now) <= 0.06)
@@ -631,6 +668,7 @@ function mountPinBoard(
pointer.y = event.clientY - view.top
}
const onEnter = (event: PointerEvent) => {
if (view.game) return
const s = stage.getBoundingClientRect()
view.left = s.left
view.top = s.top
@@ -641,19 +679,21 @@ function mountPinBoard(
wake()
}
const onMove = (event: PointerEvent) => {
if (view.game) return
toStage(event)
pointer.inside = true
wake()
}
const onLeave = () => {
if (pointer.down) return
if (pointer.down || view.game) return
pointer.inside = false
wake()
}
// Capturing the pointer keeps a long drag alive; without it the browser can start a text
// selection or native drag mid-gesture and cancel the pointer stream.
const onDown = (event: PointerEvent) => {
if (event.button !== 0 || (event.target instanceof Element && event.target.closest(INTERACTIVE))) return
if (view.game || event.button !== 0 || (event.target instanceof Element && event.target.closest(INTERACTIVE)))
return
event.preventDefault()
stage.setPointerCapture(event.pointerId)
toStage(event)
@@ -702,7 +742,96 @@ function mountPinBoard(
// The reveal staggers columns over 0.45 s and each rises over 0.3 s; the sweep spans the same window.
if (!still && sound()) PinSound.sweep({ duration: entrance === "sweep" ? REVEAL : 0.9, columns: COLS })
const handle: PinBoardHandle = {
collide: (x, y, radius, blast, vx, vy) => {
const half = view.size / 2
const reach = radius + blast + half
const now = performance.now() / 1000
const hit = { nx: 0, ny: 0, count: 0 }
const struck: Pin[] = []
for (const p of pins) {
if (p.state !== HOME) continue
const dx = x - p.hx
const dy = y - p.hy
if (Math.abs(dx) > reach || Math.abs(dy) > reach) continue
const gap = Math.hypot(dx - Math.max(-half, Math.min(half, dx)), dy - Math.max(-half, Math.min(half, dy)))
if (gap > radius + blast) continue
struck.push(p)
if (gap > radius) continue
hit.nx += dx
hit.ny += dy
hit.count++
}
if (!hit.count) return
hit.nx /= hit.count
hit.ny /= hit.count
hit.count = struck.length
for (const p of struck) {
const dx = x - p.hx
const dy = y - p.hy
const dist = Math.hypot(dx, dy) || 1
// Bricks burst away from the ball toward the viewer, then fall off the panel.
p.state = AIR
p.e = 1
p.vx = vx * 0.35 - (dx / dist) * 140 + (Math.random() - 0.5) * 160
p.vy = vy * 0.35 - (dy / dist) * 140 - 180 - Math.random() * 180
p.spin += (Math.random() - 0.5) * 22
p.pop = 0.45 + Math.random() * 0.4
p.popAt = now
}
clock.knocked = now
setRegion(true)
wake()
return hit
},
left: () => pins.reduce((n, p) => (p.state === HOME ? n + 1 : n), 0),
play: () => {
const now = performance.now() / 1000
view.game = true
pointer.down = false
pointer.inside = false
piles.top.fill(0)
piles.floor.fill(0)
// Pins still loose from earlier clicks hurry home so the wall is whole when the ball is served.
for (const p of pins) {
if (p.state === HOME) continue
Object.assign(p, { state: RETURN, x0: p.x, y0: p.y, rot0: p.rot, t0: now + (p.c / COLS) * 0.2 })
p.duration = 0.35 + Math.random() * 0.1
p.lift = 20 + Math.random() * 30
}
wake()
},
steer: (x, y) => {
pointer.x = x
pointer.y = y
pointer.inside = true
wake()
},
restore: () => {
const now = performance.now() / 1000
view.game = false
pointer.inside = false
clock.knocked = now
// Lost bricks fly back up from below the panel, sweeping in column by column.
for (const p of pins) {
if (p.state === HOME) continue
const out = p.state === OUT
Object.assign(p, {
state: RETURN,
x0: out ? p.hx + (Math.random() - 0.5) * view.pitch * 6 : p.x,
y0: out ? view.h + view.pitch * 2 : p.y,
rot0: out ? (Math.random() - 0.5) * 6 : p.rot,
t0: now + 0.15 + (p.c / COLS) * 0.55 + Math.random() * 0.08,
duration: 0.55 + Math.random() * 0.2,
lift: 20 + Math.random() * 50,
})
}
wake()
},
}
return {
handle,
close: () => {
if (clock.closed) return
const now = performance.now() / 1000
+16
View File
@@ -149,6 +149,22 @@ export const PinSound = {
pebble(a, a.context.currentTime, { freq: 1100, q: 2.2, gain: 0.14, decay: 0.05, pan: panOf(x, width), thud: 95 })
},
/** The brick breaker ball smashing pins: a clack whose ring climbs the scale with each hit before the paddle. */
brick(x: number, width: number, combo: number) {
const a = audio()
if (!a) return
const at = a.context.currentTime
pebble(a, at, { freq: 1300, q: 2, gain: 0.1, decay: 0.04, pan: panOf(x, width), thud: 110 })
pebble(a, at + 0.004, { freq: note(Math.min(14, combo)), q: 16, gain: 0.06, decay: 0.09, pan: panOf(x, width) })
},
/** The brick breaker ball landing on the paddle: a soft, low knock. */
paddle(x: number, width: number) {
const a = audio()
if (!a) return
pebble(a, a.context.currentTime, { freq: 520, q: 1.4, gain: 0.12, decay: 0.05, pan: panOf(x, width), thud: 72 })
},
/** A loose pin bouncing off the composer or the floor. */
bounce(x: number, width: number, speed: number) {
const a = audio()
+27 -2
View File
@@ -31,8 +31,9 @@ import { Persistence } from "@/runtime/persistence/schema"
import { useSettings } from "@/settings/model"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionBranch } from "./workspace/selector"
import { NewSessionPinBoard } from "./pin-board"
import { NewSessionWordmark } from "./wordmark"
import { NewSessionBrickBreaker } from "./brick-breaker"
import { NewSessionPinBoard, type PinBoardHandle } from "./pin-board"
import { NewSessionWordmark, type LodgerBall } from "./wordmark"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -61,11 +62,16 @@ export function NewSessionView(props: {
const [wordmark, setWordmark] = createStore({
box: undefined as HTMLDivElement | undefined,
mode: "flat" as "flat" | "open" | "closing",
game: false,
})
// Imperative handles for the brick breaker; they are not reactive state.
const handles: { board?: PinBoardHandle; ball?: LodgerBall } = {}
let panel!: HTMLDivElement
return (
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
ref={panel}
data-component="new-session"
class="relative isolate flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
@@ -81,6 +87,10 @@ export function NewSessionView(props: {
onOpen={() => setWordmark("mode", "open")}
onClose={() => setWordmark("mode", "closing")}
onFlat={() => setWordmark("mode", "flat")}
onPlay={(ball) => {
handles.ball = ball
setWordmark("game", true)
}}
/>
<div class="mt-8 flex flex-col gap-3">
<div data-slot="new-session-composer">
@@ -119,6 +129,21 @@ export function NewSessionView(props: {
sound={settings.sounds.interfaceEnabled}
entrance="chain"
closing={wordmark.mode === "closing"}
ref={(board) => (handles.board = board)}
/>
)}
</Show>
<Show when={wordmark.game && handles.board && handles.ball && { board: handles.board, ball: handles.ball }}>
{(game) => (
<NewSessionBrickBreaker
panel={panel}
board={game().board}
ball={game().ball}
sound={settings.sounds.interfaceEnabled}
onDone={() => {
setWordmark("game", false)
props.composer.restoreFocus()
}}
/>
)}
</Show>
+14
View File
@@ -27,6 +27,20 @@
}
}
/* The composer gives a little under the lodger while it is carried over it, hinting it can be dropped there. */
[data-component="new-session"] [data-slot="new-session-composer"] {
transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
[data-component="new-session"] [data-slot="new-session-composer"][data-lodger-over] {
transform: translateY(3px) scale(0.985);
}
[data-component="new-session-wordmark"][data-lifted] {
position: relative;
z-index: 20;
}
[data-color-scheme="light"] [data-component="new-session-wordmark"] {
--icon-weak-base: color-mix(in srgb, var(--icon-base) 30%, transparent);
}
+132 -8
View File
@@ -68,7 +68,21 @@ const POP = 26
const SLIP = 60
const HOLE_MAGNET = 24
type Phase = "hidden" | "drag" | "snap" | "show" | "idle" | "hop" | "carry" | "return" | "pack"
type Phase = "hidden" | "drag" | "snap" | "show" | "idle" | "hop" | "carry" | "return" | "pack" | "game"
/** The lodger curled up as the brick breaker ball. Positions are the ball's centre, relative to the new session panel. */
export type LodgerBall = {
radius: () => number
at: () => { x: number; y: number }
place: (
x: number,
y: number,
pose: { look: number; lookAngle: number; stretch: number; angle: number; squint: number; curl: number },
) => void
blink: () => void
/** Uncurls and hops back to its perch beside the pin board. */
done: () => void
}
type LetterPose = {
x: number
@@ -105,6 +119,7 @@ export function NewSessionWordmark(props: {
onOpen?: () => void
onClose?: () => void
onFlat?: () => void
onPlay?: (ball: LodgerBall) => void
}) {
// Read once: the lodger stays mounted after handing over to the pin board, and can be dragged back into the hole.
const lodger = !props.pins
@@ -131,6 +146,7 @@ export function NewSessionWordmark(props: {
onOpen={() => props.onOpen?.()}
onClose={() => props.onClose?.()}
onFlat={() => props.onFlat?.()}
onPlay={props.onPlay}
/>
</Show>
</div>
@@ -149,6 +165,7 @@ function WordmarkLodger(props: {
onOpen: () => void
onClose: () => void
onFlat: () => void
onPlay?: (ball: LodgerBall) => void
}) {
let stage!: HTMLDivElement
let svg!: SVGSVGElement
@@ -171,6 +188,7 @@ function WordmarkLodger(props: {
props.onOpen,
props.onClose,
props.onFlat,
props.onPlay,
),
),
)
@@ -249,6 +267,7 @@ function mountLodger(
onOpen: () => void,
onClose: () => void,
onFlat: () => void,
onPlay?: (ball: LodgerBall) => void,
) {
const still = prefersReducedMotion()
const poses: LetterPose[] = LETTERS.map(() => ({ x: 0, y: 0, sx: 1, sy: 1, opacity: 1 }))
@@ -279,13 +298,20 @@ function mountLodger(
pinY: PIN_SLOT.y,
pinRot: 0,
pinScale: 1,
ball: 0,
stretch: 0,
angle: 0,
dur: 0.34,
arc: 10,
}
const pointer = { x: HOLE.x, y: HOLE.y, inside: false, downX: HOLE.x, downY: HOLE.y }
const impacts: { at: number; amp: number }[] = []
const timers = { raf: 0, blink: 0, last: 0 }
const panel = parts.box.closest<HTMLElement>('[data-component="new-session"]') ?? document.documentElement
// The lodger's screen rect, read on resize and pointer entry so pointer moves never force a layout.
const rect = { left: 0, top: 0, scale: 1 }
const rect = { left: 0, top: 0, scale: 1, px: 0, py: 0 }
// The composer, in lodger units while carrying; dropping the lodger on it starts the brick breaker.
const drop = { el: undefined as HTMLElement | undefined, l: 0, t: 0, r: 0, b: 0, over: false }
// Last written DOM values, so frames and pointer moves only touch what changed.
const written = new Map<string, string>()
const put = (key: string, write: (value: string) => void, value: string) => {
@@ -298,6 +324,9 @@ function mountLodger(
rect.left = r.left
rect.top = r.top
rect.scale = r.width / W || 1
const p = panel.getBoundingClientRect()
rect.px = r.left - p.left
rect.py = r.top - p.top
}
// Snaps the flat wordmark stage to the exact integer-pitch footprint and rounded origin used by `NewSessionPinBoard`.
@@ -352,7 +381,7 @@ function mountLodger(
if (c.phase === "return") returnHome(c.now - c.t0)
if (c.phase === "pack") pack(c.now - c.t0)
render()
if (c.phase !== "hidden" && c.phase !== "idle") timers.raf = requestAnimationFrame(frame)
if (c.phase !== "hidden" && c.phase !== "idle" && c.phase !== "game") timers.raf = requestAnimationFrame(frame)
}
const dragOut = (dt: number) => {
@@ -430,6 +459,8 @@ function mountLodger(
neck: 0,
notch: 0,
pinOpacity: 0,
ball: 0,
stretch: 0,
eyes: c.near * 0.55,
})
look()
@@ -542,6 +573,8 @@ function mountLodger(
neck: 0,
notch: 0,
pinOpacity: 0,
ball: 0,
stretch: 0,
})
look()
render()
@@ -577,17 +610,69 @@ function mountLodger(
c.pinY = PIN_SLOT.y
c.pinRot = 0
c.pinScale = 1 + magnet * 0.25
// Over the composer it curls up and peers down at it, and the composer sinks a little under it.
drop.over =
!!onPlay && !!drop.el && pointer.x > drop.l && pointer.x < drop.r && pointer.y > drop.t && pointer.y < drop.b
c.ball += ((drop.over ? 0.85 : 0) - c.ball) * (1 - Math.exp(-dt * 14))
if (!drop.over) return
c.lookX = 0
c.lookY = 0.6
c.squint = 0.8
c.rot *= 0.4
}
const play = () => {
drop.over = false
render()
if (!onPlay) return
Object.assign(c, { phase: "game", rot: 0, crouch: 0, wide: 1, squint: 1 })
placeGrip("none")
measure()
onPlay(ball)
}
const ball: LodgerBall = {
radius: () => 5 * rect.scale,
at: () => ({ x: rect.px + c.x * rect.scale, y: rect.py + (c.y - 5) * rect.scale }),
place: (x, y, pose) => {
c.now = performance.now() / 1000
c.x = (x - rect.px) / rect.scale
c.y = (y - rect.py) / rect.scale + 5
c.lookX = Math.cos(pose.lookAngle) * pose.look
c.lookY = Math.sin(pose.lookAngle) * pose.look * 0.6
c.stretch = pose.stretch
c.angle = pose.angle
c.squint = pose.squint
c.ball = pose.curl
render()
},
blink: () => blinkOnce(),
done: () => {
measure()
Object.assign(c, {
phase: "return",
fromX: c.x,
fromY: c.y,
t0: performance.now() / 1000,
stretch: 0,
squint: 1,
dur: 0.7,
arc: 36,
})
wake()
},
}
const returnHome = (t: number) => {
const u = seg(t, 0, 0.34)
const u = seg(t, 0, c.dur)
c.x = lerp(c.fromX, HOME, easeOut(u))
c.y = lerp(c.fromY, GROUND, u) - Math.sin(Math.PI * u) * 10
c.y = lerp(c.fromY, GROUND, u) - Math.sin(Math.PI * u) * c.arc
c.rot = lerp(c.rot, 0, u)
c.crouch = 0
c.wide = 1
c.pinOpacity = 0
if (t < 0.34) return
c.ball = Math.min(c.ball, 1 - easeInOut(u))
if (t < c.dur) return
tap(0.25)
settleIdle()
}
@@ -773,8 +858,34 @@ function mountLodger(
const opacity = (el: SVGElement) => (value: string) => {
el.style.opacity = value
}
put("critter", attr(parts.critter, "transform"), `translate(${c.x} ${c.y}) rotate(${c.rot} 0 -5) scale(${sx} ${sy})`)
// Stretching along an angle and rotating back keeps the eyes upright while the ball squashes on impact.
const stretch = c.stretch
? ` rotate(${c.angle} 0 -5) scale(${1 + c.stretch} ${1 - c.stretch * 0.8}) rotate(${-c.angle} 0 -5)`
: ""
put(
"critter",
attr(parts.critter, "transform"),
`translate(${c.x} ${c.y})${stretch} rotate(${c.rot} 0 -5) scale(${sx} ${sy})`,
)
put("body", opacity(parts.body), `${c.body}`)
put(
"curl",
(value) => {
const width = 12 - 2 * Number(value)
parts.body.setAttribute("x", `${-width / 2}`)
parts.body.setAttribute("width", `${width}`)
parts.body.setAttribute("rx", `${1.5 + 3.5 * Number(value)}`)
},
`${Math.round(c.ball * 100) / 100}`,
)
if (drop.el)
put("drop", (value) => drop.el?.toggleAttribute("data-lodger-over", value === "1"), drop.over ? "1" : "0")
// Carried, returning, or in play, the lodger crosses the composer, so it is lifted above it.
put(
"lifted",
(value) => parts.box.parentElement?.toggleAttribute("data-lifted", value === "1"),
c.phase === "carry" || c.phase === "return" || c.phase === "game" ? "1" : "0",
)
put("eyes", opacity(parts.eyes), `${c.eyes}`)
put(
"look",
@@ -847,6 +958,17 @@ function mountLodger(
event.stopPropagation()
parts.grip.setPointerCapture(event.pointerId)
measure()
if (c.phase === "idle") {
drop.el = panel.querySelector<HTMLElement>('[data-slot="new-session-composer"]') ?? undefined
const r = drop.el?.querySelector('[data-component="composer"]')?.getBoundingClientRect()
if (r) {
const p = panel.getBoundingClientRect()
drop.l = (r.left - p.left - rect.px) / rect.scale
drop.r = (r.right - p.left - rect.px) / rect.scale
drop.t = (r.top - p.top - rect.py) / rect.scale
drop.b = (r.bottom - p.top - rect.py) / rect.scale
}
}
toUnits(event)
Object.assign(pointer, { downX: pointer.x, downY: pointer.y })
c.phase = c.phase === "hidden" ? "drag" : "carry"
@@ -868,6 +990,8 @@ function mountLodger(
wake()
return
}
if (drop.over && !still) return play()
drop.over = false
const dropX = HOME + (pointer.x - pointer.downX)
const dropY = GROUND + (pointer.y - pointer.downY) - 5
if (
@@ -877,7 +1001,7 @@ function mountLodger(
)
return startPack()
if (still) return settleIdle()
Object.assign(c, { phase: "return", fromX: c.x, fromY: c.y, t0: performance.now() / 1000 })
Object.assign(c, { phase: "return", fromX: c.x, fromY: c.y, t0: performance.now() / 1000, dur: 0.34, arc: 10 })
wake()
}