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});