TheBird

bird.ts

3.2 kB · typescript · 103 lines

1const SIZE = 56;2const SPEED = 240;3const TURN = 3;4const EDGE = 40;56let mark: HTMLElement | null = null;7let ghost: HTMLImageElement | null = null;8let raf = 0;9let last = 0;10let x = 0;11let y = 0;12let heading = 0;13let tx = 0;14let ty = 0;1516const shown = () => [...mark!.querySelectorAll("img")].find((one) => getComputedStyle(one).display !== "none") ?? mark!.querySelector("img")!;1718const pose = () => {19  const back = Math.cos(heading) < 0;20  const turn = back ? heading - Math.PI : heading;21  ghost!.style.transform = `translate(${x - SIZE / 2}px, ${y - SIZE / 2}px) rotate(${turn}rad)${back ? " scaleX(-1)" : ""}`;22};2324const aim = () => {25  tx = EDGE + Math.random() * (innerWidth - 2 * EDGE);26  ty = EDGE + Math.random() * (innerHeight - 2 * EDGE);27};2829const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a));3031const loop = (t: number) => {32  const dt = Math.min(0.05, (t - last) / 1000);33  last = t;34  if (Math.hypot(tx - x, ty - y) < EDGE) aim();35  const want = Math.atan2(ty - y, tx - x);36  const delta = wrap(want - heading);37  heading = wrap(heading + Math.max(-TURN * dt, Math.min(TURN * dt, delta)) + (Math.random() - 0.5) * dt);38  x = Math.max(EDGE, Math.min(innerWidth - EDGE, x + Math.cos(heading) * SPEED * dt));39  y = Math.max(EDGE, Math.min(innerHeight - EDGE, y + Math.sin(heading) * SPEED * dt));40  pose();41  raf = requestAnimationFrame(loop);42};4344const fly = () => {45  const image = shown();46  const rect = image.getBoundingClientRect();47  ghost = image.cloneNode() as HTMLImageElement;48  ghost.className = "ghost";49  ghost.removeAttribute("alt");50  ghost.style.width = `${SIZE}px`;51  ghost.style.height = `${SIZE}px`;52  x = rect.left + rect.width / 2;53  y = rect.top + rect.height / 2;54  heading = Math.PI / 4;55  document.body.append(ghost);56  mark!.classList.add("away");57  aim();58  last = performance.now();59  pose();60  raf = requestAnimationFrame(loop);61};6263const land = () => {64  cancelAnimationFrame(raf);65  const rect = shown().getBoundingClientRect();66  const hx = rect.left + rect.width / 2;67  const hy = rect.top + rect.height / 2;68  const dir = Math.atan2(hy - y, hx - x);69  const end = rect.width / SIZE;70  const at = (px: number, py: number, rest: string) => `translate(${px - SIZE / 2}px, ${py - SIZE / 2}px) ${rest}`;71  const gone = ghost!;72  const home = mark!;73  ghost = null;74  gone.animate(75    [76      { transform: gone.style.transform, offset: 0 },77      { transform: at(x + (hx - x) * 0.3, y + (hy - y) * 0.3, `rotate(${dir}rad) scale(1.15, 0.85)`), offset: 0.3 },78      { transform: at(x + (hx - x) * 0.75, y + (hy - y) * 0.75, `rotate(${dir}rad) scale(${end * 1.6}, ${end * 0.6})`), offset: 0.75 },79      { transform: at(hx, hy, `rotate(0rad) scale(${end})`), offset: 1 },80    ],81    { duration: 620, easing: "cubic-bezier(0.55, 0, 0.1, 1)", fill: "forwards" },82  ).onfinish = () => {83    gone.remove();84    home.classList.remove("away");85  };86};8788document.addEventListener("click", (event) => {89  if (ghost) {90    event.preventDefault();91    land();92    return;93  }94  const target = event.target instanceof Element ? event.target.closest<HTMLElement>(".mark[data-fly]") : null;95  if (!target || event.metaKey || event.ctrlKey) return;96  event.preventDefault();97  mark = target;98  fly();99});100101addEventListener("resize", () => {102  if (ghost) aim();103});