diff --git a/.gitignore b/.gitignore index 45a89c1b..245a2736 100644 --- a/.gitignore +++ b/.gitignore @@ -75,5 +75,7 @@ docs/decisions/.adr-toolkit.lock new/troubleshooting-notes.md +docs/notes/ + *storybook.log storybook-static diff --git a/src/app/dev/particle-bench/page.tsx b/src/app/dev/particle-bench/page.tsx index 020746de..cd18474c 100644 --- a/src/app/dev/particle-bench/page.tsx +++ b/src/app/dev/particle-bench/page.tsx @@ -10,7 +10,6 @@ import { LivePresenceBadge } from '@/features/realtime-presence/presentation/com import { WarmthReactionButton } from '@/features/realtime-presence/presentation/components/WarmthReactionButton'; import { type FrameStats, - type FxMode, type WarmthParticleEvent, WarmthParticleCanvas, } from '@/features/realtime-presence/presentation/components/WarmthParticleCanvas'; @@ -32,15 +31,9 @@ type Rate = (typeof RATES)[number]; type MockTransport = IPresenceTransport & MockPresenceTransportScenarios; const LOG_MAX = 6; -// ─── URL param helpers ──────────────────────────────────────────────────────── const PARAM_KEYS: Record = { - beeH: 'beeH', - meDurationMs: 'meMs', - otherDurationMs: 'otherMs', - tailRate: 'tailRate', - tailParticleSize: 'tailSize', - sineAmp: 'sineAmp', - startXJitter: 'xJitter', + sineAmp: 'sineAmp', + startXJitter: 'xJitter', }; function readMotionFromParams(params: URLSearchParams): Partial { @@ -55,15 +48,14 @@ function readMotionFromParams(params: URLSearchParams): Partial { return out; } -function motionToSearchString(fx: FxMode, m: MotionParams): string { - const p = new URLSearchParams({ fx }); +function motionToSearchString(m: MotionParams): string { + const p = new URLSearchParams(); for (const [key, urlKey] of Object.entries(PARAM_KEYS) as [keyof MotionParams, string][]) { p.set(urlKey, String(m[key])); } return p.toString(); } -// ─── Slider row ─────────────────────────────────────────────────────────────── function SliderRow({ label, value, min, max, step, onChange, }: { @@ -85,14 +77,12 @@ function SliderRow({ ); } -// ─── Main page ──────────────────────────────────────────────────────────────── function ParticleBenchPage() { const searchParams = useSearchParams(); const router = useRouter(); const [rate, setRate] = useState(30); const [pooling, setPooling] = useState(true); - const [fx, setFx] = useState(() => (searchParams.get('fx') === 'bee' ? 'bee' : 'light')); const [motion, setMotion] = useState(() => ({ ...DEFAULT_MOTION, ...readMotionFromParams(searchParams), @@ -136,7 +126,6 @@ function ParticleBenchPage() { }); }, [transport, addLog]); - // 자동 파티클 useEffect(() => { const t = setInterval(() => { const enqueue = enqueueRef.current; @@ -180,32 +169,29 @@ function ParticleBenchPage() { setTimeout(() => setBatchMsg(''), 3_000); }, [addLog]); - // URL 쿼리 동기화 (debounce 없이, motion은 slider가 완만하게 바뀜) useEffect(() => { - router.replace(`?${motionToSearchString(fx, motion)}`, { scroll: false }); - }, [fx, motion]); // eslint-disable-line react-hooks/exhaustive-deps + router.replace(`?${motionToSearchString(motion)}`, { scroll: false }); + }, [motion]); // eslint-disable-line react-hooks/exhaustive-deps const setParam = useCallback((key: keyof MotionParams, value: number) => { setMotion((prev) => ({ ...prev, [key]: value })); }, []); const handleCopyJson = useCallback(() => { - const json = JSON.stringify({ fx, ...motion }, null, 2); + const json = JSON.stringify(motion, null, 2); navigator.clipboard.writeText(json).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1_500); }); - }, [fx, motion]); + }, [motion]); const handleReset = useCallback(() => { setMotion({ ...DEFAULT_MOTION }); - setFx('light'); }, []); return (
- {/* 컨트롤 패널 */}
particle-bench @@ -225,18 +211,6 @@ function ParticleBenchPage() { 풀링 {pooling ? 'ON' : 'OFF'} - {/* fx 토글 */} - - @@ -252,7 +226,6 @@ function ParticleBenchPage() {
- {/* 디버그 패널 */} {debugOpen && (
@@ -266,21 +239,12 @@ function ParticleBenchPage() {
- setParam('beeH', v)} /> - setParam('meDurationMs', v)} /> - setParam('otherDurationMs', v)} /> - setParam('tailRate', v)} /> - setParam('tailParticleSize', v)} /> setParam('sineAmp', v)} /> setParam('startXJitter', v)} />
-
- light 모드: 벌 크기·꼬리·비행 슬라이더는 bee 모드에서만 유효 -
)} - {/* 배지 + 반응 버튼 + 시뮬레이션 */}
배지
@@ -314,27 +278,19 @@ function ParticleBenchPage() {
- {/* 캔버스 영역 */}
-
- {fx === 'light' ? '✨ light 모드 (흰 배경 확인)' : '🐝 bee 모드'} -
diff --git a/src/features/map/collaboration/CollabPinsOverlay.tsx b/src/features/map/collaboration/CollabPinsOverlay.tsx index 00193644..38c40843 100644 --- a/src/features/map/collaboration/CollabPinsOverlay.tsx +++ b/src/features/map/collaboration/CollabPinsOverlay.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import styled from '@emotion/styled'; import { HugeiconsIcon } from '@hugeicons/react'; -import { Location01Icon } from '@hugeicons/core-free-icons'; +import { Location01Icon, Cancel01Icon, UserMultiple02Icon } from '@hugeicons/core-free-icons'; import { toast } from 'sonner'; import { useSelf } from '@liveblocks/react'; import { useMapStore } from '../hooks/useMapStore'; @@ -28,6 +28,18 @@ function colorForUser(userId: string) { return USER_COLORS[hash % USER_COLORS.length]; } +const PLACE_NAME_MAX = 80; + +function isValidPin(pin: CollabPin): boolean { + return ( + typeof pin.placeId === 'string' && pin.placeId.length > 0 && + typeof pin.placeName === 'string' && pin.placeName.length > 0 && pin.placeName.length <= PLACE_NAME_MAX && + typeof pin.userId === 'string' && pin.userId.length > 0 && + Number.isFinite(pin.lat) && pin.lat >= -90 && pin.lat <= 90 && + Number.isFinite(pin.lng) && pin.lng >= -180 && pin.lng <= 180 + ); +} + function buildPinEl(pin: CollabPin): HTMLElement { const color = colorForUser(pin.userId); const pinIcon = mapIconSvg('mapPin', 14); @@ -137,20 +149,26 @@ export function CollabPinsOverlay() { const map = useMapStore((s) => s.map); const mode = useMapStore((s) => s.mode); const items = useMapStore((s) => s.items); + const viewportItems = useMapStore((s) => s.viewportItems); const detailId = useMapStore((s) => s.detailId); const me = useSelf(); const { pins, addPin, removePin } = useCollabPins(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const overlaysRef = useRef>(new Map()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const polylineRef = useRef(null); + const prevPinKeysRef = useRef>(new Set()); + const initializedRef = useRef(false); - // Sync Liveblocks pins → Kakao CustomOverlay instances + // Sync Liveblocks pins → Kakao CustomOverlay instances + Polyline useEffect(() => { if (!map || !pins) return; if (mode !== 'info') { overlaysRef.current.forEach((o) => o.setMap(null)); overlaysRef.current.clear(); + if (polylineRef.current) { polylineRef.current.setMap(null); polylineRef.current = null; } return; } @@ -166,13 +184,7 @@ export function CollabPinsOverlay() { // Add new overlays — validate each pin before rendering (untrusted shared state) pins.forEach((pin) => { - if ( - typeof pin.placeId !== 'string' || !pin.placeId || - typeof pin.placeName !== 'string' || !pin.placeName || - typeof pin.userId !== 'string' || !pin.userId || - !Number.isFinite(pin.lat) || pin.lat < -90 || pin.lat > 90 || - !Number.isFinite(pin.lng) || pin.lng < -180 || pin.lng > 180 - ) return; + if (!isValidPin(pin)) return; const key = `${pin.placeId}-${pin.userId}`; if (overlaysRef.current.has(key)) return; @@ -191,19 +203,76 @@ export function CollabPinsOverlay() { // Malformed pin — skip silently } }); + + // Draw polyline connecting all pins when 2+ + if (polylineRef.current) { polylineRef.current.setMap(null); polylineRef.current = null; } + const validPins = pins.filter(isValidPin); + if (validPins.length >= 2) { + try { + const path = validPins.map((p) => new window.kakao.maps.LatLng(p.lat, p.lng)); + const polyline = new window.kakao.maps.Polyline({ + path, + strokeWeight: 2, + strokeColor: '#4B8BFF', + strokeOpacity: 0.6, + strokeStyle: 'dash', + }); + polyline.setMap(map); + polylineRef.current = polyline; + } catch { /* kakao not ready */ } + } }, [map, pins, mode]); + // Notify other participants when someone adds a new pin + useEffect(() => { + if (pins === null) return; + + const myUserId = me?.presence?.userId ?? 'guest'; + const currentKeys = new Set(pins.map((p) => `${p.placeId}-${p.userId}`)); + + if (!initializedRef.current) { + initializedRef.current = true; + prevPinKeysRef.current = currentKeys; + return; + } + + for (const pin of pins) { + const key = `${pin.placeId}-${pin.userId}`; + if (!prevPinKeysRef.current.has(key) && pin.userId !== myUserId && isValidPin(pin)) { + toast(pin.placeName, { + icon: , + description: '함께하는 친구가 핀을 찍었어요!', + duration: 3000, + }); + } + } + + prevPinKeysRef.current = currentKeys; + }, [pins, me?.presence?.userId]); + // Cleanup on unmount useEffect(() => { return () => { overlaysRef.current.forEach((o) => o.setMap(null)); overlaysRef.current.clear(); + if (polylineRef.current) { polylineRef.current.setMap(null); polylineRef.current = null; } }; }, []); if (!map || mode !== 'info') return null; - const selectedPlace = detailId ? items.find((i) => i.id === detailId) : null; + // Find selected place from both items (non-info) and viewportItems (info mode) + const selectedPlace = detailId ? (() => { + const fromItems = items.find((i) => i.id === detailId); + if (fromItems) return fromItems; + + const vp = viewportItems.find((v) => v.type === 'PLACE' && v.placeId === detailId); + if (vp && vp.placeId && vp.name) { + return { id: vp.placeId, name: vp.name, lat: vp.center.lat, lng: vp.center.lng }; + } + return null; + })() : null; + const myUserId = me?.presence?.userId ?? 'guest'; const alreadyPinned = selectedPlace ? (pins ?? []).some((p) => p.placeId === selectedPlace.id && p.userId === myUserId) @@ -219,13 +288,17 @@ export function CollabPinsOverlay() { userId: myUserId, }; addPin(pin); - toast.success(`'${selectedPlace.name}' 핀을 공유했어요!`); + toast.success(`'${selectedPlace.name}' 핀을 공유했어요!`, { + icon: , + }); } function handleRemovePin() { if (!selectedPlace) return; removePin(selectedPlace.id); - toast.info(`핀을 제거했어요.`); + toast.info(`핀을 제거했어요.`, { + icon: , + }); } if (!selectedPlace) return null; diff --git a/src/features/realtime-presence/domain/presence.motion.ts b/src/features/realtime-presence/domain/presence.motion.ts index 3c0235d4..56947e07 100644 --- a/src/features/realtime-presence/domain/presence.motion.ts +++ b/src/features/realtime-presence/domain/presence.motion.ts @@ -1,18 +1,4 @@ -// Runtime-tunable motion constants — single source of truth. -// Structural constants (BEE_MAX_CONCURRENT, lantern fractions, bezier control points) -// remain in beeFlightPath.ts; behaviour-shaping values live here. - export interface MotionParams { - /** Bee sprite display height in CSS px */ - beeH: number; - /** "me" bee flight duration in ms */ - meDurationMs: number; - /** "other" bee flight duration in ms */ - otherDurationMs: number; - /** Tail particle emit probability per RAF frame (0–1) */ - tailRate: number; - /** Tail particle draw size in CSS px */ - tailParticleSize: number; /** Particle sway sine amplitude in px */ sineAmp: number; /** Horizontal spawn x random jitter radius in px */ @@ -20,21 +6,11 @@ export interface MotionParams { } export const DEFAULT_MOTION: Readonly = { - beeH: 72, - meDurationMs: 700, - otherDurationMs: 1_400, - tailRate: 0.3, - tailParticleSize: 20, - sineAmp: 6, - startXJitter: 20, + sineAmp: 6, + startXJitter: 20, }; export const MOTION_LIMITS = { - beeH: { min: 48, max: 120, step: 1 }, - meDurationMs: { min: 400, max: 2500, step: 50 }, - otherDurationMs: { min: 400, max: 2500, step: 50 }, - tailRate: { min: 0.05, max: 1.0, step: 0.05 }, - tailParticleSize: { min: 6, max: 40, step: 1 }, - sineAmp: { min: 0, max: 20, step: 0.5 }, - startXJitter: { min: 0, max: 80, step: 2 }, + sineAmp: { min: 0, max: 20, step: 0.5 }, + startXJitter: { min: 0, max: 80, step: 2 }, } as const satisfies Record; diff --git a/src/features/realtime-presence/presentation/beeFlightPath.test.ts b/src/features/realtime-presence/presentation/beeFlightPath.test.ts deleted file mode 100644 index 78f926a6..00000000 --- a/src/features/realtime-presence/presentation/beeFlightPath.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - BEE_MAX_CONCURRENT, - canSpawnBee, - getBeePosition, - beeWorldLantern, -} from './beeFlightPath'; - -const VP = { w: 1000, h: 800 }; - -describe('getBeePosition', () => { - it('t=0: starts near the bottom', () => { - const pos = getBeePosition(0, 0.5, 'me', VP); - expect(pos.y).toBeGreaterThan(VP.h * 0.7); - }); - - it('t=0: scale is 0 (spawn scale-in)', () => { - const pos = getBeePosition(0, 0.5, 'me', VP); - expect(pos.scale).toBeCloseTo(0); - }); - - it('t=1: ends near the top', () => { - const pos = getBeePosition(1, 0.5, 'me', VP); - expect(pos.y).toBeLessThan(VP.h * 0.3); - }); - - it('t=1: alpha is 0 (fade-out complete)', () => { - const pos = getBeePosition(1, 0.5, 'me', VP); - expect(pos.alpha).toBeCloseTo(0, 1); - }); - - it('t=0.15: scale reaches 1', () => { - const pos = getBeePosition(0.15, 0.5, 'me', VP); - expect(pos.scale).toBeCloseTo(1, 2); - }); - - it('me arcs to the right', () => { - const pos = getBeePosition(1, 0.5, 'me', VP); - expect(pos.x).toBeGreaterThan(VP.w * 0.5); - }); - - it('other arcs to the left', () => { - const pos = getBeePosition(1, 0.5, 'other', VP); - expect(pos.x).toBeLessThan(VP.w * 0.5); - }); - - it('tilt stays within ±15°', () => { - const maxTilt = (15 * Math.PI) / 180 + 1e-6; - for (let i = 0; i <= 10; i++) { - const t = i / 10; - expect(Math.abs(getBeePosition(t, 0.3, 'me', VP).angle)).toBeLessThanOrEqual(maxTilt); - expect(Math.abs(getBeePosition(t, 0.7, 'other', VP).angle)).toBeLessThanOrEqual(maxTilt); - } - }); - - it('mid-flight alpha is 1', () => { - expect(getBeePosition(0.5, 0.5, 'me', VP).alpha).toBe(1); - }); - - it('scale-in: t=0.075 → scale ≈ 0.5', () => { - const pos = getBeePosition(0.075, 0.5, 'me', VP); - expect(pos.scale).toBeCloseTo(0.5, 1); - }); - - it('handles startX extremes without NaN', () => { - const a = getBeePosition(0.5, 0, 'me', VP); - const b = getBeePosition(0.5, 1, 'other', VP); - expect(Number.isFinite(a.x)).toBe(true); - expect(Number.isFinite(b.x)).toBe(true); - }); -}); - -describe('canSpawnBee', () => { - it('allows spawn when count is 0', () => { - expect(canSpawnBee(0)).toBe(true); - }); - - it('allows spawn up to limit-1', () => { - expect(canSpawnBee(BEE_MAX_CONCURRENT - 1)).toBe(true); - }); - - it('blocks spawn at the limit', () => { - expect(canSpawnBee(BEE_MAX_CONCURRENT)).toBe(false); - }); - - it('blocks spawn above the limit', () => { - expect(canSpawnBee(BEE_MAX_CONCURRENT + 5)).toBe(false); - }); -}); - -describe('beeWorldLantern', () => { - it('returns finite coordinates', () => { - const pos = getBeePosition(0.5, 0.5, 'me', VP); - const lantern = beeWorldLantern(pos, 96, 96); - expect(Number.isFinite(lantern.x)).toBe(true); - expect(Number.isFinite(lantern.y)).toBe(true); - }); - - it('zero angle: lantern x equals bee x (horizontally centered)', () => { - // When angle=0 and BEE_LANTERN_X_FRAC=0.5 (center x), lantern.x === pos.x - const pos = { x: 400, y: 300, angle: 0, alpha: 1, scale: 1 }; - const lantern = beeWorldLantern(pos, 96, 96); - expect(lantern.x).toBeCloseTo(400, 5); - }); -}); diff --git a/src/features/realtime-presence/presentation/beeFlightPath.ts b/src/features/realtime-presence/presentation/beeFlightPath.ts deleted file mode 100644 index c5604275..00000000 --- a/src/features/realtime-presence/presentation/beeFlightPath.ts +++ /dev/null @@ -1,94 +0,0 @@ -export type BeeSide = 'me' | 'other'; - -export interface Viewport { - w: number; - h: number; -} - -export interface BeePosition { - x: number; // CSS pixels - y: number; // CSS pixels - angle: number; // radians, clockwise rotation from "up" - alpha: number; // 0–1 opacity - scale: number; // 0–1 sprite scale (ease-in on spawn) -} - -export const BEE_DISPLAY_H = 96; // CSS px target height -export const BEE_MAX_CONCURRENT = 3; -export const BEE_FLIGHT_MS = 2_000; - -// Lantern position relative to sprite top-left (fraction of cssW / cssH) -// 0.5 = horizontally centered; 0.78 = near bottom (firefly tail area) -export const BEE_LANTERN_X_FRAC = 0.5; -export const BEE_LANTERN_Y_FRAC = 0.78; - -const MAX_TILT_RAD = (15 * Math.PI) / 180; - -/** - * Quadratic Bezier position for a bee at normalized time t ∈ [0, 1]. - * 'me' → starts at (startX × w, bottom), arcs to upper-right. - * 'other' → starts at (startX × w, bottom), arcs to upper-left. - * Pure function — no side effects, no imports from React or browser APIs. - */ -export function getBeePosition( - t: number, - startX: number, // 0–1 normalized viewport x - side: BeeSide, - viewport: Viewport, -): BeePosition { - const { w, h } = viewport; - - const p0x = startX * w; - const p0y = h * 0.88; - - const p2x = side === 'me' ? w * 0.82 : w * 0.18; - const p2y = h * 0.08; - - // Control point bulges outward for a gentle arc - const p1x = side === 'me' ? w * 0.75 : w * 0.25; - const p1y = h * 0.50; - - const u = 1 - t; - const x = u * u * p0x + 2 * u * t * p1x + t * t * p2x; - const y = u * u * p0y + 2 * u * t * p1y + t * t * p2y; - - // Bezier tangent for tilt direction - const dx = 2 * u * (p1x - p0x) + 2 * t * (p2x - p1x); - const dy = 2 * u * (p1y - p0y) + 2 * t * (p2y - p1y); - const rawAngle = Math.atan2(dx, -dy); // angle from vertical axis - const damping = Math.max(0, 1 - t * 0.8); - const angle = Math.max(-MAX_TILT_RAD, Math.min(MAX_TILT_RAD, rawAngle)) * damping; - - // Scale-in: 0 → 1 during first 15% of flight - const scale = t < 0.15 ? t / 0.15 : 1; - - // Fade-out: 1 → 0 during last 20% of flight - const alpha = t > 0.8 ? Math.max(0, (1 - t) / 0.2) : 1; - - return { x, y, angle, alpha, scale }; -} - -/** Returns true when a new bee can be queued given the current active count. */ -export function canSpawnBee(activeBeeCount: number): boolean { - return activeBeeCount < BEE_MAX_CONCURRENT; -} - -/** - * Lantern world position given bee draw state. - * Properly rotates the sprite-local offset into world space. - */ -export function beeWorldLantern( - pos: BeePosition, - cssW: number, - cssH: number, -): { x: number; y: number } { - // Sprite-local offset from center, before scale & rotation - const lx0 = (BEE_LANTERN_X_FRAC - 0.5) * cssW * pos.scale; - const ly0 = (BEE_LANTERN_Y_FRAC - 0.5) * cssH * pos.scale; - const cos = Math.cos(pos.angle); - const sin = Math.sin(pos.angle); - return { - x: pos.x + lx0 * cos - ly0 * sin, - y: pos.y + lx0 * sin + ly0 * cos, - }; -} diff --git a/src/features/realtime-presence/presentation/components/PresenceDevCore.tsx b/src/features/realtime-presence/presentation/components/PresenceDevCore.tsx index 00708ab8..3e5b2094 100644 --- a/src/features/realtime-presence/presentation/components/PresenceDevCore.tsx +++ b/src/features/realtime-presence/presentation/components/PresenceDevCore.tsx @@ -9,7 +9,6 @@ import { LivePresenceBadge } from './LivePresenceBadge'; import { WarmthReactionButton } from './WarmthReactionButton'; import { WarmthParticleCanvas, - type FxMode, type WarmthParticleEvent, } from './WarmthParticleCanvas'; import type { MotionParams } from '../../domain/presence.motion'; @@ -17,18 +16,13 @@ import type { MotionParams } from '../../domain/presence.motion'; interface PresenceDevCoreProps { roomId: string; transport?: IPresenceTransport; - fx?: FxMode; motionParams?: Partial; - /** 배지 옆에 32px 벌 아이콘 표시 (기본 off) */ - showBeeIcon?: boolean; } export default function PresenceDevCore({ roomId, transport: externalTransport, - fx = 'light', motionParams, - showBeeIcon = false, }: PresenceDevCoreProps) { const transport = useMemo( () => @@ -61,19 +55,12 @@ export default function PresenceDevCore({ return ( <> - {/* - * 파티클 캔버스: 뷰포트 전체 덮음. - * pointer-events:none 으로 기존 카드·지도 조작 방해 없음. - * z-index 90: 콘텐츠 위, 헤더(z99) 아래. - */} - {/* aria-live: other batch 알림 (스크린 리더용) */}
{batchMsg}
{ enqueueRef.current = enqueue; }} - fx={fx} motionParams={motionParams} onOtherBatch={handleOtherBatch} style={{ position: 'fixed', inset: 0, zIndex: 90 }} @@ -91,15 +78,8 @@ export default function PresenceDevCore({ gap: 8, }} > -
- - {showBeeIcon && ( - // eslint-disable-next-line @next/next/no-img-element - - )} -
+ - {/* 반응 버튼 + 닫기: open 시 fade-in, closed 시 fade-out (항상 DOM에 있어 exit 애니메이션 작동) */}
- {/* 트리거: 반딧불 원형 버튼 */}
diff --git a/src/features/realtime-presence/presentation/components/WarmthParticleCanvas.tsx b/src/features/realtime-presence/presentation/components/WarmthParticleCanvas.tsx index dcdee935..934d14c6 100644 --- a/src/features/realtime-presence/presentation/components/WarmthParticleCanvas.tsx +++ b/src/features/realtime-presence/presentation/components/WarmthParticleCanvas.tsx @@ -3,13 +3,12 @@ import { useCallback, useEffect, useRef } from 'react'; import type { RefObject } from 'react'; import { - type FxMode, type WarmthParticleEvent, useWarmthParticles, } from '../hooks/useWarmthParticles'; import type { MotionParams } from '../../domain/presence.motion'; -export type { WarmthParticleEvent, FxMode }; +export type { WarmthParticleEvent }; export interface FrameStats { fps: number; @@ -22,8 +21,6 @@ export interface WarmthParticleCanvasProps { frameStats: RefObject, ) => void; poolingEnabled?: boolean; - /** 'light'(기본) = 작은 깜빡이는 입자, 'bee' = 벌 비행 연출 */ - fx?: FxMode; motionParams?: Partial; onOtherBatch?: (count: number) => void; className?: string; @@ -33,7 +30,6 @@ export interface WarmthParticleCanvasProps { export function WarmthParticleCanvas({ onMount, poolingEnabled = true, - fx = 'light', motionParams, onOtherBatch, className, @@ -41,10 +37,9 @@ export function WarmthParticleCanvas({ }: WarmthParticleCanvasProps) { const canvasRef = useRef(null); const { enqueueWarmth, frameStats } = useWarmthParticles(canvasRef, { - poolingEnabled, fx, motionParams, onOtherBatch, + poolingEnabled, motionParams, onOtherBatch, }); - // onMount 콜백은 한 번만 전달한다 const onMountRef = useRef(onMount); onMountRef.current = onMount; diff --git a/src/features/realtime-presence/presentation/hooks/useWarmthParticles.ts b/src/features/realtime-presence/presentation/hooks/useWarmthParticles.ts index fd11f749..91722745 100644 --- a/src/features/realtime-presence/presentation/hooks/useWarmthParticles.ts +++ b/src/features/realtime-presence/presentation/hooks/useWarmthParticles.ts @@ -5,56 +5,32 @@ import type { RefObject } from 'react'; import { MAX_SPAWNS_PER_FRAME } from '../../domain/presence.policy'; import { DEFAULT_MOTION, type MotionParams } from '../../domain/presence.motion'; import { type ParticleInit, createPool, spawnParticle, updatePool } from '../particlePool'; -import { - type BeeSide, - BEE_DISPLAY_H, - beeWorldLantern, - canSpawnBee, - getBeePosition, -} from '../beeFlightPath'; - -export type FxMode = 'light' | 'bee'; export interface WarmthParticleEvent { x: number; // 0–1 가로 위치 비율 isMine: boolean; } -// ─── Bee mode constants ─────────────────────────────────────────────────────── -const SPRITE_SIZE = 32; -const LIFE_BASE_MS = 1_200; -const LIFE_SPREAD_MS = 600; -const VX_SPREAD = 0.05; -const VY_BASE = 0.04; -const VY_SPREAD = 0.03; - // ─── Light mode constants ───────────────────────────────────────────────────── -const LIGHT_SPRITE_SIZE = 12; // pre-built at 12px, drawn at 3–6 CSS px +const LIGHT_SPRITE_SIZE = 12; const LIGHT_LIFE_MIN = 1_500; const LIGHT_LIFE_MAX = 2_500; -const LIGHT_VY_MIN = 0.020; // px/ms = 20px/s +const LIGHT_VY_MIN = 0.020; const LIGHT_VY_MAX = 0.040; -const LIGHT_VX_DRIFT = 0.015; // ±15px/s +const LIGHT_VX_DRIFT = 0.015; const LIGHT_ME_MIN = 3; const LIGHT_ME_MAX = 5; const LIGHT_OTHER_MIN = 1; const LIGHT_OTHER_MAX = 2; -// alpha sine flicker: ω = 0.010 rad/ms ≈ 1.6 Hz (< 3 Hz) const LIGHT_FLICKER_W = 0.010; - -// ─── Shared constants ───────────────────────────────────────────────────────── const REDUCED_LIFE_MS = 300; const MAX_DPR = 2; -const ME_BEE_RATE_LIMIT = 3; // me 10초 안에 3회 초과 시 제한 -const ME_BEE_RATE_WINDOW_MS = 10_000; -const OTHER_COALESCE_MS = 1_000; // other 1초 코얼레싱 창 +const ME_RATE_LIMIT = 3; +const ME_RATE_WINDOW_MS = 10_000; +const OTHER_COALESCE_MS = 1_000; type OtherBatchCallback = (count: number) => void; -function easeOut(t: number): number { - return 1 - (1 - t) * (1 - t); -} - type AnyCanvas = OffscreenCanvas | HTMLCanvasElement; type AnyCtx = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; @@ -72,42 +48,22 @@ function makeCanvas(size: number): { canvas: AnyCanvas; ctx: AnyCtx | null } { return { canvas, ctx }; } -function buildGlowSprite(isMine: boolean): AnyCanvas { - const { canvas, ctx } = makeCanvas(SPRITE_SIZE); - if (!ctx) return canvas; - const cx = SPRITE_SIZE / 2; - const g = ctx.createRadialGradient(cx, cx, 0, cx, cx, cx); - if (isMine) { - g.addColorStop(0, 'rgba(255,240,100,0.9)'); - g.addColorStop(0.4, 'rgba(255,200,50,0.5)'); - g.addColorStop(1, 'rgba(255,180,0,0)'); - } else { - g.addColorStop(0, 'rgba(255,200,120,0.6)'); - g.addColorStop(0.4, 'rgba(255,160,60,0.3)'); - g.addColorStop(1, 'rgba(255,140,0,0)'); - } - ctx.fillStyle = g; - ctx.fillRect(0, 0, SPRITE_SIZE, SPRITE_SIZE); - return canvas; -} - function buildLightSprite(isMine: boolean): AnyCanvas { const SZ = LIGHT_SPRITE_SIZE; const { canvas, ctx } = makeCanvas(SZ); if (!ctx) return canvas; const cx = SZ / 2; - // Tight radial gradient — no shadowBlur, no large spread const g = ctx.createRadialGradient(cx, cx, 0, cx, cx, cx); if (isMine) { - g.addColorStop(0, 'rgba(255,248,100,1)'); - g.addColorStop(0.35,'rgba(255,210,40,0.85)'); - g.addColorStop(0.65,'rgba(255,170,0,0.35)'); - g.addColorStop(1, 'rgba(255,140,0,0)'); + g.addColorStop(0, 'rgba(255,248,100,1)'); + g.addColorStop(0.35, 'rgba(255,210,40,0.85)'); + g.addColorStop(0.65, 'rgba(255,170,0,0.35)'); + g.addColorStop(1, 'rgba(255,140,0,0)'); } else { - g.addColorStop(0, 'rgba(255,235,80,0.78)'); - g.addColorStop(0.35,'rgba(255,185,30,0.52)'); - g.addColorStop(0.65,'rgba(255,145,0,0.2)'); - g.addColorStop(1, 'rgba(255,120,0,0)'); + g.addColorStop(0, 'rgba(255,235,80,0.78)'); + g.addColorStop(0.35, 'rgba(255,185,30,0.52)'); + g.addColorStop(0.65, 'rgba(255,145,0,0.2)'); + g.addColorStop(1, 'rgba(255,120,0,0)'); } ctx.fillStyle = g; ctx.fillRect(0, 0, SZ, SZ); @@ -116,61 +72,8 @@ function buildLightSprite(isMine: boolean): AnyCanvas { interface CanvasSize { w: number; h: number; dpr: number } -interface BeeSprite { - image: CanvasImageSource; - cssW: number; - cssH: number; -} - -interface BeeSpriteSet { - me: BeeSprite | null; - other: BeeSprite | null; -} - -interface BeeState { - startX: number; - side: BeeSide; - startTimeMs: number; - duration: number; -} - -async function loadBeeSprite(url: string, dpr: number, beeH = BEE_DISPLAY_H): Promise { - try { - let origW: number, origH: number, srcImg: CanvasImageSource; - if (typeof createImageBitmap !== 'undefined') { - const resp = await fetch(url); - if (!resp.ok) return null; - const bitmap = await createImageBitmap(await resp.blob()); - origW = bitmap.width; origH = bitmap.height; srcImg = bitmap; - } else { - const img = await new Promise((resolve, reject) => { - const el = new Image(); - el.onload = () => resolve(el); el.onerror = reject; el.src = url; - }); - origW = img.naturalWidth; origH = img.naturalHeight; srcImg = img; - } - if (origH === 0) return null; - const scaledDpr = Math.min(dpr, MAX_DPR); - const physH = Math.round(beeH * scaledDpr); - const physW = Math.round(origW * physH / origH); - const cssH = beeH; - const cssW = (origW / origH) * beeH; - try { - const oc = new OffscreenCanvas(physW, physH); - const octx = oc.getContext('2d')!; - octx.drawImage(srcImg, 0, 0, physW, physH); - if ('close' in srcImg && typeof (srcImg as ImageBitmap).close === 'function') { - (srcImg as ImageBitmap).close(); - } - return { image: oc, cssW, cssH }; - } catch { return { image: srcImg, cssW, cssH }; } - } catch { return null; } -} - export interface UseWarmthParticlesOptions { poolingEnabled?: boolean; - /** 'light' = 작은 깜빡이는 입자(기본), 'bee' = 벌 비행 */ - fx?: FxMode; motionParams?: Partial; onOtherBatch?: OtherBatchCallback; } @@ -184,8 +87,6 @@ export function useWarmthParticles( canvasRef: RefObject, opts: UseWarmthParticlesOptions = {}, ): UseWarmthParticlesResult { - const poolingEnabled = opts.poolingEnabled ?? true; - const poolRef = useRef(createPool()); const queueRef = useRef([]); const rafRef = useRef(0); @@ -195,23 +96,14 @@ export function useWarmthParticles( const frameStats = useRef<{ fps: number; avgFrameMs: number } | undefined>(undefined); const recentFramesRef = useRef([]); - // Motion params ref const motionRef = useRef({ ...DEFAULT_MOTION, ...opts.motionParams }); - const fxRef = useRef(opts.fx ?? 'light'); const onOtherBatchRef = useRef(opts.onOtherBatch); useEffect(() => { motionRef.current = { ...DEFAULT_MOTION, ...opts.motionParams }; }, [opts.motionParams]); - useEffect(() => { fxRef.current = opts.fx ?? 'light'; }, [opts.fx]); useEffect(() => { onOtherBatchRef.current = opts.onOtherBatch; }, [opts.onOtherBatch]); - // ─── Sprites ───────────────────────────────────────────────────────────── - const glowSpritesRef = useRef<{ mine: AnyCanvas; other: AnyCanvas } | null>(null); const lightSpritesRef = useRef<{ me: AnyCanvas; other: AnyCanvas } | null>(null); - const beeSpriteRef = useRef(null); - const beeActiveRef = useRef([]); - - // Rate limiting / coalescing - const meBeeTimestampsRef = useRef([]); + const meRateTimestampsRef = useRef([]); const otherCoalesceRef = useRef<{ count: number; timer: ReturnType | null; @@ -225,20 +117,6 @@ export function useWarmthParticles( return () => mq.removeEventListener('change', onChange); }, []); - // Bee 스프라이트 로드 (beeH 변경 시 재로드) - const beeH = opts.motionParams?.beeH ?? DEFAULT_MOTION.beeH; - useEffect(() => { - const dpr = Math.min(window.devicePixelRatio ?? 1, MAX_DPR); - beeSpriteRef.current = null; - Promise.all([ - loadBeeSprite('/images/me.png', dpr, motionRef.current.beeH), - loadBeeSprite('/images/other.png', dpr, motionRef.current.beeH), - ]).then(([me, other]) => { - beeSpriteRef.current = { me, other }; - }); - }, [beeH]); // eslint-disable-line react-hooks/exhaustive-deps - - // ─── Loop ──────────────────────────────────────────────────────────────── function stopLoop() { if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = 0; } } @@ -263,130 +141,52 @@ export function useWarmthParticles( const { w, h, dpr } = sizeRef.current; const motion = motionRef.current; - const fx = fxRef.current; - // ── Queue drain ────────────────────────────────────────────────────── const toSpawn = Math.min(queueRef.current.length, MAX_SPAWNS_PER_FRAME); if (toSpawn > 0) { + if (!lightSpritesRef.current) { + lightSpritesRef.current = { me: buildLightSprite(true), other: buildLightSprite(false) }; + } const events = queueRef.current.splice(0, toSpawn); + const reduced = reducedMotionRef.current; for (const evt of events) { - const reduced = reducedMotionRef.current; - if (fx === 'light') { - if (!lightSpritesRef.current) { - lightSpritesRef.current = { me: buildLightSprite(true), other: buildLightSprite(false) }; - } - const px = Math.max(4, Math.min(w - 4, - evt.x * w + (Math.random() - 0.5) * motion.startXJitter)); - spawnParticle(poolRef.current, { - x: px, - y: h * 0.88, - vx: reduced ? 0 : (Math.random() - 0.5) * LIGHT_VX_DRIFT * 2, - vy: reduced ? 0 : -(LIGHT_VY_MIN + Math.random() * (LIGHT_VY_MAX - LIGHT_VY_MIN)), - maxLife: reduced ? REDUCED_LIFE_MS : LIGHT_LIFE_MIN + Math.random() * (LIGHT_LIFE_MAX - LIGHT_LIFE_MIN), - isMine: evt.isMine, - drawSize: reduced ? 5 : 3 + Math.random() * 3, // 3–6 CSS px - }); - } else { - // Bee mode: 1 glow particle - if (!glowSpritesRef.current) { - glowSpritesRef.current = { mine: buildGlowSprite(true), other: buildGlowSprite(false) }; - } - spawnParticle(poolRef.current, { - x: Math.max(24, Math.min(w - 24, - evt.x * w + (Math.random() - 0.5) * motion.startXJitter)), - y: h * 0.75, - vx: reduced ? 0 : (Math.random() - 0.5) * VX_SPREAD, - vy: reduced ? 0 : -(VY_BASE + Math.random() * VY_SPREAD), - maxLife: reduced ? REDUCED_LIFE_MS : LIFE_BASE_MS + Math.random() * LIFE_SPREAD_MS, - isMine: evt.isMine, - drawSize: 0, // 0 = use glow sprite at SPRITE_SIZE - }); - } + const px = Math.max(4, Math.min(w - 4, + evt.x * w + (Math.random() - 0.5) * motion.startXJitter)); + spawnParticle(poolRef.current, { + x: px, + y: h * 0.88, + vx: reduced ? 0 : (Math.random() - 0.5) * LIGHT_VX_DRIFT * 2, + vy: reduced ? 0 : -(LIGHT_VY_MIN + Math.random() * (LIGHT_VY_MAX - LIGHT_VY_MIN)), + maxLife: reduced ? REDUCED_LIFE_MS : LIGHT_LIFE_MIN + Math.random() * (LIGHT_LIFE_MAX - LIGHT_LIFE_MIN), + isMine: evt.isMine, + drawSize: reduced ? 5 : 3 + Math.random() * 3, + } satisfies ParticleInit); } } const activeParticleCount = updatePool(poolRef.current, dt); - // 만료된 벌 제거 - beeActiveRef.current = beeActiveRef.current.filter( - (bee) => now - bee.startTimeMs < bee.duration, - ); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); - // ── Particle render ────────────────────────────────────────────────── for (const p of poolRef.current) { - if (!p.active) continue; - - if (p.drawSize > 0) { - // Light particle - if (!lightSpritesRef.current) continue; - const sz = p.drawSize; - const half = sz / 2; - const elapsed = p.maxLife - p.life; - const fadeAlpha = p.life / p.maxLife; - const flicker = 0.3 * Math.sin(elapsed * LIGHT_FLICKER_W) + 0.7; - const otherMul = p.isMine ? 1 : 0.72; - ctx.globalAlpha = fadeAlpha * flicker * otherMul; - const sway = Math.sin(elapsed * 0.005) * motion.sineAmp * 0.4; - ctx.drawImage( - (p.isMine ? lightSpritesRef.current.me : lightSpritesRef.current.other) as CanvasImageSource, - p.x + sway - half, p.y - half, sz, sz, - ); - } else { - // Glow particle (bee mode) - if (!glowSpritesRef.current) continue; - const sz = SPRITE_SIZE; - const half = sz / 2; - ctx.globalAlpha = p.life / p.maxLife; - const sway = Math.sin((p.maxLife - p.life) * 0.005) * motion.sineAmp; - ctx.drawImage( - (p.isMine ? glowSpritesRef.current.mine : glowSpritesRef.current.other) as CanvasImageSource, - p.x + sway - half, p.y - half, sz, sz, - ); - } + if (!p.active || !lightSpritesRef.current) continue; + const sz = p.drawSize; + const half = sz / 2; + const elapsed = p.maxLife - p.life; + const fadeAlpha = p.life / p.maxLife; + const flicker = 0.3 * Math.sin(elapsed * LIGHT_FLICKER_W) + 0.7; + const otherMul = p.isMine ? 1 : 0.72; + ctx.globalAlpha = fadeAlpha * flicker * otherMul; + const sway = Math.sin(elapsed * 0.005) * motion.sineAmp * 0.4; + ctx.drawImage( + (p.isMine ? lightSpritesRef.current.me : lightSpritesRef.current.other) as CanvasImageSource, + p.x + sway - half, p.y - half, sz, sz, + ); } ctx.globalAlpha = 1; - // ── Bee render (bee mode only) ─────────────────────────────────────── - const beeSprites = beeSpriteRef.current; - if (!reducedMotionRef.current && fx === 'bee' && beeSprites) { - for (const bee of beeActiveRef.current) { - const rawT = Math.min(1, (now - bee.startTimeMs) / bee.duration); - const t = bee.side === 'other' ? easeOut(rawT) : rawT; - const pos = getBeePosition(t, bee.startX, bee.side, { w, h }); - const sp = bee.side === 'me' ? beeSprites.me : beeSprites.other; - if (!sp) continue; - - ctx.save(); - ctx.globalAlpha = pos.alpha; - ctx.translate(pos.x, pos.y); - ctx.rotate(pos.angle); - ctx.scale(pos.scale, pos.scale); - ctx.drawImage(sp.image, -sp.cssW / 2, -sp.cssH / 2, sp.cssW, sp.cssH); - ctx.restore(); - - if (t < 0.9 && Math.random() < motion.tailRate) { - const lantern = beeWorldLantern(pos, sp.cssW, sp.cssH); - spawnParticle(poolRef.current, { - x: lantern.x, y: lantern.y, - vx: (Math.random() - 0.5) * 0.025, - vy: -(0.012 + Math.random() * 0.012), - maxLife: 450 + Math.random() * 300, - isMine: bee.side === 'me', - drawSize: motion.tailParticleSize, - }); - } - } - ctx.globalAlpha = 1; - } - - if ( - activeParticleCount === 0 && - queueRef.current.length === 0 && - beeActiveRef.current.length === 0 - ) { + if (activeParticleCount === 0 && queueRef.current.length === 0) { ctx.clearRect(0, 0, w, h); rafRef.current = 0; return; @@ -400,82 +200,30 @@ export function useWarmthParticles( const enqueueWarmth = useCallback((event: WarmthParticleEvent) => { const now = performance.now(); - const fx = fxRef.current; const reduced = reducedMotionRef.current; - if (fx === 'light') { - if (event.isMine) { - // me 속도제한: 10초 안에 3회 초과 → 개수 절반 - meBeeTimestampsRef.current = meBeeTimestampsRef.current.filter( - (t) => now - t < ME_BEE_RATE_WINDOW_MS, - ); - const overLimit = meBeeTimestampsRef.current.length >= ME_BEE_RATE_LIMIT; - meBeeTimestampsRef.current.push(now); + if (event.isMine) { + meRateTimestampsRef.current = meRateTimestampsRef.current.filter( + (t) => now - t < ME_RATE_WINDOW_MS, + ); + const overLimit = meRateTimestampsRef.current.length >= ME_RATE_LIMIT; + meRateTimestampsRef.current.push(now); - const count = reduced ? 1 : (overLimit - ? Math.max(1, Math.ceil((LIGHT_ME_MIN + LIGHT_ME_MAX) / 2 / 2)) - : LIGHT_ME_MIN + Math.floor(Math.random() * (LIGHT_ME_MAX - LIGHT_ME_MIN + 1))); - for (let i = 0; i < count; i++) queueRef.current.push({ x: event.x, isMine: true }); - } else { - // other 코얼레싱: 1초 창, 첫 이벤트만 파티클 - const coalesce = otherCoalesceRef.current; - coalesce.count++; - if (coalesce.timer === null) { - const count = reduced ? 1 : LIGHT_OTHER_MIN + Math.floor(Math.random() * (LIGHT_OTHER_MAX - LIGHT_OTHER_MIN + 1)); - for (let i = 0; i < count; i++) queueRef.current.push({ x: event.x, isMine: false }); - coalesce.timer = setTimeout(() => { - const total = coalesce.count; - coalesce.count = 0; coalesce.timer = null; - if (total > 1) onOtherBatchRef.current?.(total); - }, OTHER_COALESCE_MS); - } - } + const count = reduced ? 1 : (overLimit + ? Math.max(1, Math.ceil((LIGHT_ME_MIN + LIGHT_ME_MAX) / 2 / 2)) + : LIGHT_ME_MIN + Math.floor(Math.random() * (LIGHT_ME_MAX - LIGHT_ME_MIN + 1))); + for (let i = 0; i < count; i++) queueRef.current.push({ x: event.x, isMine: true }); } else { - // Bee mode - if (!glowSpritesRef.current) { - glowSpritesRef.current = { mine: buildGlowSprite(true), other: buildGlowSprite(false) }; - } - queueRef.current.push(event); - - if (event.isMine) { - // me 속도제한: 10초 안에 3회 초과 → 벌 생략 - if (!reduced && beeSpriteRef.current !== null) { - meBeeTimestampsRef.current = meBeeTimestampsRef.current.filter( - (t) => now - t < ME_BEE_RATE_WINDOW_MS, - ); - const sp = beeSpriteRef.current.me; - if ( - sp && - meBeeTimestampsRef.current.length < ME_BEE_RATE_LIMIT && - canSpawnBee(beeActiveRef.current.length) - ) { - meBeeTimestampsRef.current.push(now); - beeActiveRef.current.push({ - startX: event.x, side: 'me', startTimeMs: now, - duration: motionRef.current.meDurationMs, - }); - } - } - } else { - // other 코얼레싱: 1초 창, 첫 이벤트만 벌 - const coalesce = otherCoalesceRef.current; - coalesce.count++; - if (coalesce.timer === null) { - if (!reduced && beeSpriteRef.current !== null) { - const sp = beeSpriteRef.current.other; - if (sp && canSpawnBee(beeActiveRef.current.length)) { - beeActiveRef.current.push({ - startX: event.x, side: 'other', startTimeMs: now, - duration: motionRef.current.otherDurationMs, - }); - } - } - coalesce.timer = setTimeout(() => { - const total = coalesce.count; - coalesce.count = 0; coalesce.timer = null; - if (total > 1) onOtherBatchRef.current?.(total); - }, OTHER_COALESCE_MS); - } + const coalesce = otherCoalesceRef.current; + coalesce.count++; + if (coalesce.timer === null) { + const count = reduced ? 1 : LIGHT_OTHER_MIN + Math.floor(Math.random() * (LIGHT_OTHER_MAX - LIGHT_OTHER_MIN + 1)); + for (let i = 0; i < count; i++) queueRef.current.push({ x: event.x, isMine: false }); + coalesce.timer = setTimeout(() => { + const total = coalesce.count; + coalesce.count = 0; coalesce.timer = null; + if (total > 1) onOtherBatchRef.current?.(total); + }, OTHER_COALESCE_MS); } } @@ -507,8 +255,7 @@ export function useWarmthParticles( stopLoop(); } else { queueRef.current.length = 0; - beeActiveRef.current.length = 0; - meBeeTimestampsRef.current.length = 0; + meRateTimestampsRef.current.length = 0; if (otherCoalesceRef.current.timer !== null) { clearTimeout(otherCoalesceRef.current.timer); otherCoalesceRef.current.timer = null;