07フローフィールド
カーソルの移動方向が力になり、粒子の群れがその方向へ流れます。粒子はダッシュ形なので、進む方向へ rotate も合わせます。
使いどころランディング背景の流れ、スクロール誘導の矢印の群れ
requestAnimationFramepointermoverotate

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-wind" viewBox="0 0 256 256" fill="currentColor"><g><path d="M120 96a24 24 0 1 1 24-24a24 24 0 0 1-24 24m88-16a24 24 0 1 0 24 24a24 24 0 0 0-24-24m-56 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64"/></g></symbol>
<symbol id="ico-reset" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="ff is-demo" id="ff">
<p class="ff__hint"><svg class="ff__ico" aria-hidden="true"><use href="#ico-wind"/></svg><span>マウスを動かした方向が風になり、ダッシュの群れがその方へ流れます</span></p>
<div class="ff__field" id="field" role="application" aria-label="フローフィールドの実験台" tabindex="0">
<span class="ff__parts" id="parts" aria-hidden="true"></span>
</div>
<div class="ff__bar">
<p class="ff__dir"><span class="ff__dirLabel">今の風</span><b class="ff__dirBox"><span class="ff__dirVal" id="dirText">凪</span><span class="ff__dirLoop" aria-hidden="true"><i>右</i><i>左</i></span></b></p>
<button class="ff__reset" id="reset" type="button">
<svg class="ff__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>散らす</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 플로우 필드 — 커서의 이동 속도가 곧 바람 벡터. 모든 대시가 그 벡터로 조타(steer)되고
// 가장자리는 반대편으로 이어진다. 대시는 진행 방향으로 rotate 까지 맞춰 돈다.
// 격자 미리보기(.is-demo)는 결정적 난수로 정한 중심(var(--hx), var(--hy)) 을 도는
// 닫힌 타원 궤도를 키프레임으로 재생한다 — 궤도가 닫혀 있어 경계 순간이동이 없다.
$duration: $dur-loop;
$easing: linear; // 궤도 속도는 키프레임 표본 비율에 들어 있다
$color: $stage-yellow;
@include stage($stage-yellow);
.ff { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.ff__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.ff__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-orange; }
.ff__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($stage-paper, .42);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .14);
overflow: hidden; touch-action: none;
}
.ff__field:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ff__part {
position: absolute; left: 0; top: 0; width: 14px; height: 4px;
border-radius: $r-pill; background: $subject-ink;
}
.ff__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.ff__dir { margin: 0; display: flex; align-items: center; gap: $sp-1; }
.ff__dirLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: $subject-ink; }
.ff__dirBox {
position: relative; display: grid; place-items: center;
min-width: 52px; height: 20px; border-radius: $r-xs; padding: 0 $sp-1;
background: rgba($subject-ink, .12); color: $subject-ink;
font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.ff__dirLoop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.ff__dirLoop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
.ff__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $subject-ink; color: $color; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.ff__reset .ff__ico { color: $color; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 대시마다 타원 한 바퀴(2s) — 진행 방향 rotate 가 붙어 흐르는 결처럼 보인다.
// 위상은 -i * 56ms 로 흩어 떼가 한꺼번에 도는 게 아니라 출렁이며 흐른다 (음수 지연이라 닫혀 있다).
.ff.is-demo .ff__part {
animation-name: ffOrbit; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -56ms);
}
@keyframes ffOrbit {
0% { transform: translate(var(--hx), var(--hy)) rotate(0deg); }
25% { transform: translate(calc(var(--hx) + var(--rx)), calc(var(--hy) - var(--ry))) rotate(90deg); }
50% { transform: translate(var(--hx), calc(var(--hy) - var(--ry) * 2)) rotate(180deg); }
75% { transform: translate(calc(var(--hx) - var(--rx)), calc(var(--hy) - var(--ry))) rotate(270deg); }
100% { transform: translate(var(--hx), var(--hy)) rotate(360deg); }
}
// 바람 방향 표시 — 오른쪽/왼쪽이 1초씩 steps 컷으로 바뀐다
.ff.is-demo .ff__dirVal { color: transparent; }
.ff.is-demo .ff__dirLoop { visibility: visible; }
.ff.is-demo .ff__dirLoop i {
animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ff.is-demo .ff__dirLoop i:nth-child(1) { animation-name: ffDirR; }
.ff.is-demo .ff__dirLoop i:nth-child(2) { animation-name: ffDirL; }
@keyframes ffDirR { 0%, 49% { visibility: visible; } 50%, 100% { visibility: hidden; } }
@keyframes ffDirL { 0%, 49% { visibility: hidden; } 50%, 100% { visibility: visible; } }
@media (max-width: 340px) {
.ff { gap: $sp-2; }
.ff__field { height: 104px; }
.ff__part { width: 11px; height: 3px; }
.ff__dirBox { min-width: 44px; }
}
// reduce 에서는 흩어진 대시가 제자리에 멈춘 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.ff.is-demo .ff__part,
.ff.is-demo .ff__dirLoop i { animation: none !important; }
.ff.is-demo .ff__part { transform: translate(var(--hx), var(--hy)) !important; }
.ff.is-demo .ff__dirVal { color: $subject-ink !important; }
.ff.is-demo .ff__dirLoop { visibility: hidden !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ff {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.ff__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.ff__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff4d1f;
}
.ff__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.42);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.14);
overflow: hidden;
touch-action: none;
}
.ff__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ff__part {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 4px;
border-radius: 999px;
background: #17141a;
}
.ff__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.ff__dir {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
}
.ff__dirLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: #17141a;
}
.ff__dirBox {
position: relative;
display: grid;
place-items: center;
min-width: 52px;
height: 20px;
border-radius: 4px;
padding: 0 4px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.ff__dirLoop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.ff__dirLoop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.ff__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
color: #ffd23f;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.ff__reset .ff__ico {
color: #ffd23f;
}
.ff.is-demo .ff__part {
animation-name: ffOrbit;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -56ms);
}
@keyframes ffOrbit {
0% {
transform: translate(var(--hx), var(--hy)) rotate(0deg);
}
25% {
transform: translate(calc(var(--hx) + var(--rx)), calc(var(--hy) - var(--ry))) rotate(90deg);
}
50% {
transform: translate(var(--hx), calc(var(--hy) - var(--ry) * 2)) rotate(180deg);
}
75% {
transform: translate(calc(var(--hx) - var(--rx)), calc(var(--hy) - var(--ry))) rotate(270deg);
}
100% {
transform: translate(var(--hx), var(--hy)) rotate(360deg);
}
}
.ff.is-demo .ff__dirVal {
color: transparent;
}
.ff.is-demo .ff__dirLoop {
visibility: visible;
}
.ff.is-demo .ff__dirLoop i {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ff.is-demo .ff__dirLoop i:nth-child(1) {
animation-name: ffDirR;
}
.ff.is-demo .ff__dirLoop i:nth-child(2) {
animation-name: ffDirL;
}
@keyframes ffDirR {
0%, 49% {
visibility: visible;
}
50%, 100% {
visibility: hidden;
}
}
@keyframes ffDirL {
0%, 49% {
visibility: hidden;
}
50%, 100% {
visibility: visible;
}
}
@media (max-width: 340px) {
.ff {
gap: 8px;
}
.ff__field {
height: 104px;
}
.ff__part {
width: 11px;
height: 3px;
}
.ff__dirBox {
min-width: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.ff.is-demo .ff__part,
.ff.is-demo .ff__dirLoop i {
animation: none !important;
}
.ff.is-demo .ff__part {
transform: translate(var(--hx), var(--hy)) !important;
}
.ff.is-demo .ff__dirVal {
color: #17141a !important;
}
.ff.is-demo .ff__dirLoop {
visibility: hidden !important;
}
}var root = document.getElementById('ff');
var field = document.getElementById('field');
var partsBox = document.getElementById('parts');
var dirText = document.getElementById('dirText');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// 대시 36개 — 결정적 난수(사인 곱셈법)로 뿌린 자리가 시작점이다. 시드가 고정되어 매번 같은 배치로 재현된다
var N = 36, parts = [];
function rnd(seed) { var x = Math.sin(seed * 127.1) * 43758.5453; return x - Math.floor(x); }
function scatter() {
var w = field.clientWidth, h = field.clientHeight;
if (!parts.length) {
for (var i = 0; i < N; i++) {
var el = document.createElement('i');
el.className = 'ff__part';
el.style.setProperty('--i', String(i));
partsBox.appendChild(el);
parts.push({ el: el, x: 0, y: 0, vx: 12, vy: 0, ang: 0, seed: i + 1 });
}
}
for (var j = 0; j < parts.length; j++) {
var p = parts[j];
p.x = (0.06 + rnd(p.seed) * 0.88) * w;
p.y = (0.1 + rnd(p.seed + 60) * 0.8) * h;
p.vx = 12; p.vy = 0; p.ang = 0;
// 미리보기 궤도의 중심과 반지름 — 결정적 난수라 같은 배치가 매번 나온다
p.el.style.setProperty('--hx', p.x.toFixed(1) + 'px');
p.el.style.setProperty('--hy', p.y.toFixed(1) + 'px');
p.el.style.setProperty('--rx', (10 + rnd(p.seed + 7) * 26).toFixed(1) + 'px');
p.el.style.setProperty('--ry', (6 + rnd(p.seed + 13) * 16).toFixed(1) + 'px');
}
}
var wind = { x: 12, y: 0 }, windUntil = 0, last = 0, running = false;
var DIRS = { e: '右', w: '左', s: '下', n: '上', calm: '凪' };
function paint() {
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
p.el.style.transform = 'translate(' + p.x.toFixed(1) + 'px, ' + p.y.toFixed(1) + 'px) rotate(' + p.ang.toFixed(1) + 'deg)';
}
}
function dirName() {
var ax = Math.abs(wind.x), ay = Math.abs(wind.y);
if (ax < 8 && ay < 8) { return DIRS.calm; }
if (ax > ay * 1.2) { return wind.x > 0 ? DIRS.e : DIRS.w; }
if (ay > ax * 1.2) { return wind.y > 0 ? DIRS.s : DIRS.n; }
return wind.x > 0 ? DIRS.e : DIRS.w;
}
function wake() {
root.classList.remove('is-demo');
if (reduce.matches) { paint(); return false; }
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
return true;
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var w = field.clientWidth, h = field.clientHeight;
var gust = now - windUntil < 900; // 최근 입력 바람이 살아 있는 동안 조타력이 세다
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
// 흐름장: 모든 대시가 같은 방향으로 조타된다 — 힘은 커서 속도에서 온다
p.vx += (wind.x - p.vx) * (gust ? 2.4 : 0.4) * dt;
p.vy += (wind.y - p.vy) * (gust ? 2.4 : 0.4) * dt;
var sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (sp > 190) { p.vx *= 190 / sp; p.vy *= 190 / sp; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI;
// 가장자리에서 반대편으로 돌아온다 — 흐름장의 공간이 잇달아 이어진 셈이다
if (p.x > w + 8) { p.x = -8; } else if (p.x < -8) { p.x = w + 8; }
if (p.y > h + 8) { p.y = -8; } else if (p.y < -8) { p.y = h + 8; }
}
var name = dirName();
if (dirText.textContent !== name) { dirText.textContent = name; }
paint();
requestAnimationFrame(step);
}
var prev = null;
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
if (prev) {
var dx = x - prev.x, dy = y - prev.y;
if (Math.abs(dx) + Math.abs(dy) > 2) {
wind.x = dx * 9; wind.y = dy * 9;
windUntil = performance.now();
wake();
}
}
prev = { x: x, y: y };
});
field.addEventListener('keydown', function (e) {
var BLOW = 120;
if (e.key === 'ArrowLeft') { wind.x = -BLOW; wind.y = 0; }
else if (e.key === 'ArrowRight') { wind.x = BLOW; wind.y = 0; }
else if (e.key === 'ArrowUp') { wind.x = 0; wind.y = -BLOW; }
else if (e.key === 'ArrowDown') { wind.x = 0; wind.y = BLOW; }
else { return; }
e.preventDefault();
windUntil = performance.now();
wake();
});
document.getElementById('reset').addEventListener('click', function () {
wind.x = 12; wind.y = 0;
scatter(); paint();
});
window.addEventListener('resize', function () { scatter(); paint(); });
scatter(); paint();// FlowField — vanilla 판(run/292/demo/07-physics-flow-field/index.html) 의 React 포트.
// 커서의 이동 속도가 곧 바람 벡터다. 대시 36개가 모두 그 벡터로 조타(steer)되고 최고 속도는 190,
// 가장자리에서는 반대편으로 이어진다. 진행 방향으로 rotate 까지 맞춘다. 상수·식은 vanilla 와 같은 값이다.
// 자리는 결정적 난수(사인 곱셈법)로 뿌리므로 매번 같은 배치가 나온다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./FlowField.module.scss";
export type FlowFieldProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PartVars = CSSProperties & { "--i"?: string; "--hx"?: string; "--hy"?: string; "--rx"?: string; "--ry"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type Part = { x: number; y: number; vx: number; vy: number; ang: number; seed: number };
type Seed = { hx: number; hy: number; rx: number; ry: number };
const N = 36;
const BLOW = 120; // 키보드 한 번의 바람 세기
const HINT = "마우스를 움직인 방향이 바람이 되어 대시 떼가 그 쪽으로 흘러갑니다";
const FIELD_LABEL = "흐름장 실험판";
const DIR_LABEL = "지금 바람";
const RESET_LABEL = "흩어놓기";
const DIRS = { e: "오른쪽", w: "왼쪽", s: "아래", n: "위", calm: "쉬는 중" };
function rnd(seed: number): number {
const x = Math.sin(seed * 127.1) * 43758.5453;
return x - Math.floor(x);
}
function WindIcon({ className }: { className: string }) {
return (
<svg viewBox="0 0 256 256" className={className} aria-hidden="true" fill="currentColor"><g><path d="M120 96a24 24 0 1 1 24-24a24 24 0 0 1-24 24m88-16a24 24 0 1 0 24 24a24 24 0 0 0-24-24m-56 80a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64"/></g></svg>
);
}
function ResetIcon({ className }: { className: string }) {
return (
<svg viewBox="0 0 256 256" className={className} aria-hidden="true" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></svg>
);
}
export default function FlowField({
duration = 2000,
easing = "linear",
color = "#ffd23f",
}: FlowFieldProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const elsBox = useRef<Array<HTMLElement | null>>([]) as Box<Array<HTMLElement | null>>;
const partsBox = useRef<Part[]>([]) as Box<Part[]>;
const windBox = useRef({ x: 12, y: 0 }) as Box<{ x: number; y: number }>;
const windUntilBox = useRef(0) as Box<number>;
const prevBox = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
const lastBox = useRef(0) as Box<number>;
const runningBox = useRef(false) as Box<boolean>;
const frameBox = useRef(0) as Box<number>;
const [seeds, setSeeds] = useState<Seed[]>([]);
const [dir, setDir] = useState(DIRS.calm);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 대시를 판 안에 결정적 난수로 뿌린다 — 미리보기 궤도의 중심(hx·hy)과 반지름(rx·ry)도 여기서 나온다
function scatter(): void {
const field = fieldRef.current;
if (!field) { return; }
const w = field.clientWidth;
const h = field.clientHeight;
const parts = partsBox.current;
if (!parts.length) {
for (let i = 0; i < N; i++) {
parts.push({ x: 0, y: 0, vx: 12, vy: 0, ang: 0, seed: i + 1 });
}
}
const next: Seed[] = [];
for (let j = 0; j < parts.length; j++) {
const p = parts[j];
p.x = (0.06 + rnd(p.seed) * 0.88) * w;
p.y = (0.1 + rnd(p.seed + 60) * 0.8) * h;
p.vx = 12;
p.vy = 0;
p.ang = 0;
next.push({
hx: p.x,
hy: p.y,
rx: 10 + rnd(p.seed + 7) * 26,
ry: 6 + rnd(p.seed + 13) * 16,
});
}
setSeeds(next);
}
function paint(): void {
const parts = partsBox.current;
const els = elsBox.current;
for (let i = 0; i < parts.length; i++) {
const el = els[i];
if (!el) { continue; }
const p = parts[i];
el.style.transform =
"translate(" + p.x.toFixed(1) + "px, " + p.y.toFixed(1) + "px) rotate(" + p.ang.toFixed(1) + "deg)";
}
}
function dirName(): string {
const wind = windBox.current;
const ax = Math.abs(wind.x);
const ay = Math.abs(wind.y);
if (ax < 8 && ay < 8) { return DIRS.calm; }
if (ax > ay * 1.2) { return wind.x > 0 ? DIRS.e : DIRS.w; }
if (ay > ax * 1.2) { return wind.y > 0 ? DIRS.s : DIRS.n; }
return wind.x > 0 ? DIRS.e : DIRS.w;
}
function wake(): boolean {
setDemo(false);
if (reduced()) { paint(); return false; }
if (!runningBox.current) {
runningBox.current = true;
lastBox.current = performance.now();
frameBox.current = requestAnimationFrame(step);
}
return true;
}
function step(now: number): void {
const field = fieldRef.current;
if (!field) { runningBox.current = false; return; }
const dt = Math.min((now - lastBox.current) / 1000, 0.033);
lastBox.current = now;
const w = field.clientWidth;
const h = field.clientHeight;
const gust = now - windUntilBox.current < 900; // 최근 입력 바람이 살아 있는 동안 조타력이 세다
const parts = partsBox.current;
const wind = windBox.current;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
// 흐름장: 모든 대시가 같은 방향으로 조타된다 — 힘은 커서 속도에서 온다
p.vx += (wind.x - p.vx) * (gust ? 2.4 : 0.4) * dt;
p.vy += (wind.y - p.vy) * (gust ? 2.4 : 0.4) * dt;
const sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (sp > 190) { p.vx *= 190 / sp; p.vy *= 190 / sp; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.ang = (Math.atan2(p.vy, p.vx) * 180) / Math.PI;
// 가장자리에서 반대편으로 돌아온다 — 흐름장의 공간이 잇달아 이어진 셈이다
if (p.x > w + 8) { p.x = -8; } else if (p.x < -8) { p.x = w + 8; }
if (p.y > h + 8) { p.y = -8; } else if (p.y < -8) { p.y = h + 8; }
}
const name = dirName();
setDir(function (prev: string) { return prev === name ? prev : name; });
paint();
frameBox.current = requestAnimationFrame(step);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) { return; }
const r = field.getBoundingClientRect();
const x = event.clientX - r.left;
const y = event.clientY - r.top;
const prev = prevBox.current;
if (prev) {
const dx = x - prev.x;
const dy = y - prev.y;
if (Math.abs(dx) + Math.abs(dy) > 2) {
windBox.current.x = dx * 9;
windBox.current.y = dy * 9;
windUntilBox.current = performance.now();
wake();
}
}
prevBox.current = { x: x, y: y };
}
function onKeyDown(event: KeyEvt): void {
const wind = windBox.current;
if (event.key === "ArrowLeft") { wind.x = -BLOW; wind.y = 0; }
else if (event.key === "ArrowRight") { wind.x = BLOW; wind.y = 0; }
else if (event.key === "ArrowUp") { wind.x = 0; wind.y = -BLOW; }
else if (event.key === "ArrowDown") { wind.x = 0; wind.y = BLOW; }
else { return; }
event.preventDefault();
windUntilBox.current = performance.now();
wake();
}
function onReset(): void {
windBox.current.x = 12;
windBox.current.y = 0;
scatter();
}
useEffect(() => {
scatter();
const onResize = (): void => { scatter(); paint(); };
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(frameBox.current);
};
}, []);
// 대시가 새로 뿌려진 뒤에 현재 자리를 한 번 칠한다
useEffect(() => { paint(); }, [seeds]);
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<p className={styles.hint}><WindIcon className={styles.ico} /><span>{HINT}</span></p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onKeyDown={onKeyDown}
>
<span aria-hidden="true">
{seeds.map((seed, i) => (
<i
key={i}
className={styles.part}
ref={(el: HTMLElement | null) => { elsBox.current[i] = el; }}
style={{
"--i": String(i),
"--hx": seed.hx.toFixed(1) + "px",
"--hy": seed.hy.toFixed(1) + "px",
"--rx": seed.rx.toFixed(1) + "px",
"--ry": seed.ry.toFixed(1) + "px",
} as PartVars}
/>
))}
</span>
</div>
<div className={styles.bar}>
<p className={styles.dir}>
<span className={styles.dirLabel}>{DIR_LABEL}</span>
<b className={styles.dirBox}>
<span className={styles.dirVal}>{dir}</span>
<span className={styles.dirLoop} aria-hidden="true"><i>{DIRS.e}</i><i>{DIRS.w}</i></span>
</b>
</p>
<button className={styles.reset} type="button" onClick={onReset}>
<ResetIcon className={styles.ico} /><span>{RESET_LABEL}</span>
</button>
</div>
</div>
</main>
);
}07 フローフィールド — 計測 累積面積 19.074% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 物理シミュレーション 作り方 9選 — バネから慣性スクラブまで
このデモが属する記事
物理シミュレーション 作り方 9選 — バネから慣性スクラブまで「物理シミュレーション 作り方」の基本は、requestAnimationFrame が毎フレーム速度と位置を計算し、その結果を transform だけで画面に反映することです。ライブラリを使わない9つのデモは、ばね追従・重力・振り子から この記事のデモをすべて見る


