266 lines
8.2 KiB
TypeScript
266 lines
8.2 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|