import React, { useEffect, useRef, useState } from 'react'; import type { AssetId, NpcId, NpcsOverlayLayout, NpcsZoomTool } from '../../../shared/types'; import { DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../../shared/types'; import styles from '../materials/MaterialOverlay.module.css'; import { useAssetUrl } from '../useAssetImageUrl'; type Corner = 'nw' | 'ne' | 'sw' | 'se'; export type NpcsSceneOverlayItem = { npcId: NpcId; assetId: AssetId | null; layout: NpcsOverlayLayout; }; type NpcsSceneOverlayProps = { items: readonly NpcsSceneOverlayItem[]; editable?: boolean; zoomTool?: NpcsZoomTool; showClose?: boolean; onClose?: () => void; closeLabel?: string; onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void; onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void; }; function nextBaseSize( viewW: number, viewH: number, naturalW: number, naturalH: number, ): { w: number; h: number } { if (naturalW <= 0 || naturalH <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 }; const maxW = viewW * 0.92; const maxH = viewH * 0.88; const fit = Math.min(maxW / naturalW, maxH / naturalH); return { w: naturalW * fit, h: naturalH * fit }; } function NpcAvatarFrame({ item, view, rootRef, editable, zoomTool, onLayoutChange, }: { item: NpcsSceneOverlayItem; view: { w: number; h: number }; rootRef: React.RefObject; editable: boolean; zoomTool: NpcsZoomTool; onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void; }) { const url = useAssetUrl(item.assetId); const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 }); const dragRef = useRef< | { mode: 'move'; startX: number; startY: number; origin: NpcsOverlayLayout } | { mode: 'resize'; corner: Corner; startX: number; startY: number; origin: NpcsOverlayLayout; baseW: number; baseH: number; viewW: number; viewH: number; } | null >(null); if (!item.assetId || !url) return null; const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT; const base = nextBaseSize(view.w, view.h, natural.w, natural.h); const w = base.w * layout.scale; const h = base.h * layout.scale; const left = layout.cx * view.w - w / 2; const top = layout.cy * view.h - h / 2; const onPointerMove = (e: PointerEvent) => { const drag = dragRef.current; if (!drag || !onLayoutChange) return; const root = rootRef.current; if (!root) return; const r = root.getBoundingClientRect(); const dx = (e.clientX - drag.startX) / Math.max(1, r.width); const dy = (e.clientY - drag.startY) / Math.max(1, r.height); if (drag.mode === 'move') { onLayoutChange(item.npcId, { ...drag.origin, cx: drag.origin.cx + dx, cy: drag.origin.cy + dy, }); return; } const { baseW, baseH, viewW, viewH, origin, corner } = drag; const originW = baseW * origin.scale; const originH = baseH * origin.scale; const originLeft = origin.cx * viewW - originW / 2; const originTop = origin.cy * viewH - originH / 2; const originRight = originLeft + originW; const originBottom = originTop + originH; let anchorX = originLeft; let anchorY = originTop; if (corner === 'nw') { anchorX = originRight; anchorY = originBottom; } else if (corner === 'ne') { anchorX = originLeft; anchorY = originBottom; } else if (corner === 'sw') { anchorX = originRight; anchorY = originTop; } const pointerX = e.clientX - r.left; const pointerY = e.clientY - r.top; const newW = Math.max(8, Math.abs(pointerX - anchorX)); const newH = Math.max(8, Math.abs(pointerY - anchorY)); const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH)); const ww = baseW * nextScale; const hh = baseH * nextScale; let nextLeft = anchorX; let nextTop = anchorY; if (corner === 'nw') { nextLeft = anchorX - ww; nextTop = anchorY - hh; } else if (corner === 'ne') { nextLeft = anchorX; nextTop = anchorY - hh; } else if (corner === 'sw') { nextLeft = anchorX - ww; nextTop = anchorY; } onLayoutChange(item.npcId, { cx: (nextLeft + ww / 2) / Math.max(1, viewW), cy: (nextTop + hh / 2) / Math.max(1, viewH), scale: nextScale, }); }; const endDrag = () => { dragRef.current = null; window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', endDrag); }; const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => { if (!editable || !onLayoutChange || zoomTool) return; e.preventDefault(); e.stopPropagation(); dragRef.current = mode === 'move' ? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } } : { mode: 'resize', corner: corner ?? 'se', startX: e.clientX, startY: e.clientY, origin: { ...layout }, baseW: base.w, baseH: base.h, viewW: view.w, viewH: view.h, }; window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', endDrag); }; return (
{ if (zoomTool) return; startDrag(e, 'move'); }} > { const img = e.currentTarget; setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }); }} /> {editable && !zoomTool ? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
); } export function NpcsSceneOverlay({ items, editable = false, zoomTool = null, showClose = false, onClose, closeLabel = 'Close', onLayoutChange, onZoomAt, }: NpcsSceneOverlayProps) { const rootRef = useRef(null); const [view, setView] = useState({ w: 1, h: 1 }); useEffect(() => { const el = rootRef.current; if (!el) return; const sync = () => setView({ w: el.clientWidth, h: el.clientHeight }); sync(); const ro = new ResizeObserver(sync); ro.observe(el); return () => ro.disconnect(); }, [items.length]); if (items.length === 0) return null; const interactive = editable || showClose || Boolean(zoomTool); const zoomCursor = zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : ''; const toNorm = (clientX: number, clientY: number) => { const root = rootRef.current; if (!root) return { nx: 0.5, ny: 0.5 }; const r = root.getBoundingClientRect(); return { nx: (clientX - r.left) / Math.max(1, r.width), ny: (clientY - r.top) / Math.max(1, r.height), }; }; const npcIdFromTarget = (target: EventTarget | null): NpcId | undefined => { if (!(target instanceof Element)) return undefined; const frame = target.closest('[data-npc-id]'); const raw = frame?.getAttribute('data-npc-id'); return raw ? (raw as NpcId) : undefined; }; return (
{ if (!zoomTool || !onZoomAt) return; e.stopPropagation(); const { nx, ny } = toNorm(e.clientX, e.clientY); onZoomAt(npcIdFromTarget(e.target), nx, ny); }} >
{items.map((item) => onLayoutChange ? ( ) : ( ), )} {showClose ? ( ) : null}
); }