mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
feat(app): turn the new session pins into a brick breaker easter egg (#57593)
This commit is contained in:
committed by
GitHub
Unverified
parent
2a0e28fce3
commit
634cf9b063
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user