This commit is contained in:
265
src/components/game-canvas/GameCanvasEffectLayer.tsx
Normal file
265
src/components/game-canvas/GameCanvasEffectLayer.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import {motion} from 'motion/react';
|
||||
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
import type {Character, CombatVisualEffect, SceneHostileNpc} from '../../types';
|
||||
import {getEntityEffectBottom} from './GameCanvasShared';
|
||||
|
||||
interface GameCanvasEffectLayerProps {
|
||||
activeCombatEffects: CombatVisualEffect[];
|
||||
getPlayerEffectLeft: (effectX: number, offsetPx?: number) => string;
|
||||
getHostileNpcEffectLeft: (effectX: number, hostileNpcId?: string, offsetPx?: number) => string;
|
||||
sceneCombatants: SceneHostileNpc[];
|
||||
playerCharacter: Character | null;
|
||||
groundBottom: string;
|
||||
stageLiftPx: number;
|
||||
playerOffsetY: number;
|
||||
stageRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function useCombatEffectFrames(effect: CombatVisualEffect) {
|
||||
const [frameIndex, setFrameIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFrameIndex(0);
|
||||
|
||||
if (effect.frames.length <= 1) return;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setFrameIndex(prev => Math.min(prev + 1, effect.frames.length - 1));
|
||||
}, Math.max(50, Math.round(1000 / effect.fps)));
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [effect.fps, effect.frames, effect.id]);
|
||||
|
||||
return Math.min(frameIndex, Math.max(0, effect.frames.length - 1));
|
||||
}
|
||||
|
||||
function TravelingSpriteCombatEffect({
|
||||
effect,
|
||||
startLeft,
|
||||
endLeft,
|
||||
startBottom,
|
||||
endBottom,
|
||||
stageRef,
|
||||
}: {
|
||||
effect: CombatVisualEffect;
|
||||
startLeft: string;
|
||||
endLeft: string;
|
||||
startBottom: string;
|
||||
endBottom: string;
|
||||
stageRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const frameIndex = useCombatEffectFrames(effect);
|
||||
const startMarkerRef = useRef<HTMLDivElement>(null);
|
||||
const endMarkerRef = useRef<HTMLDivElement>(null);
|
||||
const [vector, setVector] = useState({x: 0, y: 0});
|
||||
const [measured, setMeasured] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setMeasured(false);
|
||||
let cancelled = false;
|
||||
|
||||
const measure = () => {
|
||||
const stage = stageRef.current;
|
||||
const startEl = startMarkerRef.current;
|
||||
const endEl = endMarkerRef.current;
|
||||
if (cancelled) return;
|
||||
if (!stage || !startEl || !endEl) {
|
||||
setVector({x: 0, y: 0});
|
||||
setMeasured(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const startRect = startEl.getBoundingClientRect();
|
||||
const endRect = endEl.getBoundingClientRect();
|
||||
const startX = startRect.left + startRect.width / 2 - stageRect.left;
|
||||
const startY = startRect.top + startRect.height / 2 - stageRect.top;
|
||||
const endX = endRect.left + endRect.width / 2 - stageRect.left;
|
||||
const endY = endRect.top + endRect.height / 2 - stageRect.top;
|
||||
setVector({x: endX - startX, y: endY - startY});
|
||||
setMeasured(true);
|
||||
};
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(measure);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [effect.id, endBottom, endLeft, stageRef, startBottom, startLeft]);
|
||||
|
||||
const half = effect.sizePx / 2;
|
||||
const markerBox: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
width: effect.sizePx,
|
||||
height: effect.sizePx,
|
||||
marginLeft: -half,
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
zIndex: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={startMarkerRef} aria-hidden style={{...markerBox, left: startLeft, bottom: startBottom}} />
|
||||
<div ref={endMarkerRef} aria-hidden style={{...markerBox, left: endLeft, bottom: endBottom}} />
|
||||
{measured && (
|
||||
<motion.div
|
||||
initial={{x: 0, y: 0, opacity: 0.98}}
|
||||
animate={{x: vector.x, y: vector.y, opacity: [1, 1, 0.94]}}
|
||||
transition={{duration: effect.durationMs / 1000, ease: 'linear'}}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: startLeft,
|
||||
bottom: startBottom,
|
||||
width: `${effect.sizePx}px`,
|
||||
height: `${effect.sizePx}px`,
|
||||
zIndex: effect.zIndex ?? 24,
|
||||
marginLeft: `-${half}px`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={effect.frames[frameIndex]}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
style={{
|
||||
imageRendering: 'pixelated',
|
||||
transform: effect.facing === 'left'
|
||||
? `scaleX(-1) scale(${effect.scale ?? 1})`
|
||||
: `scale(${effect.scale ?? 1})`,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SpriteCombatEffect({
|
||||
effect,
|
||||
startLeft,
|
||||
endLeft,
|
||||
startBottom,
|
||||
endBottom,
|
||||
}: {
|
||||
effect: CombatVisualEffect;
|
||||
startLeft: string;
|
||||
endLeft?: string;
|
||||
startBottom: string;
|
||||
endBottom?: string;
|
||||
}) {
|
||||
const frameIndex = useCombatEffectFrames(effect);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{left: startLeft, bottom: startBottom, opacity: 0.98}}
|
||||
animate={{
|
||||
left: endLeft ?? startLeft,
|
||||
bottom: endBottom ?? startBottom,
|
||||
opacity: [1, 1, 0.94],
|
||||
}}
|
||||
transition={{duration: effect.durationMs / 1000, ease: 'linear'}}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
width: `${effect.sizePx}px`,
|
||||
height: `${effect.sizePx}px`,
|
||||
zIndex: effect.zIndex ?? 24,
|
||||
marginLeft: `-${effect.sizePx / 2}px`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={effect.frames[frameIndex]}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
style={{
|
||||
imageRendering: 'pixelated',
|
||||
transform: effect.facing === 'left'
|
||||
? `scaleX(-1) scale(${effect.scale ?? 1})`
|
||||
: `scale(${effect.scale ?? 1})`,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameCanvasEffectLayer({
|
||||
activeCombatEffects,
|
||||
getPlayerEffectLeft,
|
||||
getHostileNpcEffectLeft,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
playerOffsetY,
|
||||
stageRef,
|
||||
}: GameCanvasEffectLayerProps) {
|
||||
return (
|
||||
<>
|
||||
{activeCombatEffects.map(effect => {
|
||||
const startLeft = effect.startOrigin === 'player'
|
||||
? getPlayerEffectLeft(effect.startX, effect.startOffsetX ?? 0)
|
||||
: getHostileNpcEffectLeft(effect.startX, effect.startHostileNpcId ?? effect.startMonsterId, effect.startOffsetX ?? 0);
|
||||
const endLeft = effect.endOrigin === 'player'
|
||||
? getPlayerEffectLeft(effect.endX ?? effect.startX, effect.endOffsetX ?? effect.startOffsetX ?? 0)
|
||||
: effect.endOrigin === 'hostile_npc' || effect.endOrigin === 'monster'
|
||||
? getHostileNpcEffectLeft(effect.endX ?? effect.startX, effect.endHostileNpcId ?? effect.endMonsterId, effect.endOffsetX ?? effect.startOffsetX ?? 0)
|
||||
: undefined;
|
||||
const startBottom = `calc(${getEntityEffectBottom({
|
||||
origin: effect.startOrigin,
|
||||
hostileNpcId: effect.startHostileNpcId ?? effect.startMonsterId,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
playerOffsetY,
|
||||
anchorOffsetY: effect.startAnchorOffsetY ?? 0,
|
||||
})} + ${effect.startYOffset}px)`;
|
||||
const endBottom = `calc(${getEntityEffectBottom({
|
||||
origin: effect.endOrigin ?? effect.startOrigin,
|
||||
hostileNpcId: effect.endHostileNpcId ?? effect.endMonsterId ?? effect.startHostileNpcId ?? effect.startMonsterId,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
playerOffsetY,
|
||||
anchorOffsetY: effect.endAnchorOffsetY ?? effect.startAnchorOffsetY ?? 0,
|
||||
})} + ${(effect.endYOffset ?? effect.startYOffset)}px)`;
|
||||
|
||||
const useTravelingPath = Boolean(
|
||||
effect.traveling
|
||||
&& endLeft
|
||||
&& endBottom
|
||||
&& (startLeft !== endLeft || startBottom !== endBottom),
|
||||
);
|
||||
|
||||
if (useTravelingPath && endLeft && endBottom) {
|
||||
return (
|
||||
<TravelingSpriteCombatEffect
|
||||
key={effect.id}
|
||||
effect={effect}
|
||||
startLeft={startLeft}
|
||||
endLeft={endLeft}
|
||||
startBottom={startBottom}
|
||||
endBottom={endBottom}
|
||||
stageRef={stageRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SpriteCombatEffect
|
||||
key={effect.id}
|
||||
effect={effect}
|
||||
startLeft={startLeft}
|
||||
endLeft={endLeft}
|
||||
startBottom={startBottom}
|
||||
endBottom={endBottom}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user