|
|
|
@@ -1,5 +1,6 @@
|
|
|
|
|
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
|
|
|
|
|
|
|
|
import { decimateFieldStampPoints, fieldStampMinSpacingN } from '../../../shared/fieldEffectStampPoints';
|
|
|
|
|
import type {
|
|
|
|
|
EffectInstance,
|
|
|
|
|
EffectInstanceType,
|
|
|
|
@@ -9,6 +10,10 @@ import type {
|
|
|
|
|
|
|
|
|
|
import styles from './PxiEffectsOverlay.module.css';
|
|
|
|
|
|
|
|
|
|
function fieldStampPoints(inst: Extract<EffectInstance, { type: 'fog' | 'fire' | 'rain' }>) {
|
|
|
|
|
return decimateFieldStampPoints(inst.points, fieldStampMinSpacingN(inst.type, inst.radiusN));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type PixiEffectsOverlayHandle = {
|
|
|
|
|
/** Draft-штрих без React re-render родителя; null — сброс. */
|
|
|
|
|
setDraft: (instance: EffectInstance | null) => void;
|
|
|
|
@@ -193,6 +198,11 @@ type Props = {
|
|
|
|
|
style?: React.CSSProperties;
|
|
|
|
|
/** Область “контента” внутри контейнера (например при object-fit: contain). */
|
|
|
|
|
viewport?: { x: number; y: number; w: number; h: number } | undefined;
|
|
|
|
|
/**
|
|
|
|
|
* `preview` — пульт: ниже FPS, меньший backing store, без покачивания спрайтов.
|
|
|
|
|
* Презентация остаётся `live`.
|
|
|
|
|
*/
|
|
|
|
|
perfProfile?: 'live' | 'preview';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
@@ -203,7 +213,7 @@ type Props = {
|
|
|
|
|
* - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp.
|
|
|
|
|
*/
|
|
|
|
|
export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay(
|
|
|
|
|
{ state, interactive = false, style, viewport },
|
|
|
|
|
{ state, interactive = false, style, viewport, perfProfile = 'live' },
|
|
|
|
|
ref,
|
|
|
|
|
) {
|
|
|
|
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
@@ -320,17 +330,18 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|
|
|
|
const pixi = await import('pixi.js');
|
|
|
|
|
pixiRef.current = pixi;
|
|
|
|
|
if (destroyed) return;
|
|
|
|
|
const preview = perfProfile === 'preview';
|
|
|
|
|
app = new pixi.Application();
|
|
|
|
|
await app.init({
|
|
|
|
|
backgroundAlpha: 0,
|
|
|
|
|
antialias: false,
|
|
|
|
|
powerPreference: 'high-performance',
|
|
|
|
|
resolution: dpr,
|
|
|
|
|
resolution: preview ? 0.5 : dpr,
|
|
|
|
|
autoDensity: true,
|
|
|
|
|
preference: 'webgl',
|
|
|
|
|
});
|
|
|
|
|
// Меньше кадров — меньше CPU/GPU; анимации эффектов остаются плавными.
|
|
|
|
|
app.ticker.maxFPS = 32;
|
|
|
|
|
// live: 32 FPS; preview (пульт): 12 — второй WebGL поверх видео на Mac дешевле.
|
|
|
|
|
app.ticker.maxFPS = preview ? 12 : 32;
|
|
|
|
|
if (destroyed) return;
|
|
|
|
|
host.appendChild(app.canvas);
|
|
|
|
|
// Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху.
|
|
|
|
@@ -370,12 +381,15 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|
|
|
|
const s = stateRef.current;
|
|
|
|
|
if (!s || s.instances.length === 0) return;
|
|
|
|
|
const nowMs = Date.now() + timeOffsetRef.current;
|
|
|
|
|
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current);
|
|
|
|
|
const motion = !preview;
|
|
|
|
|
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current, {
|
|
|
|
|
motion,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Лёгкое “потряхивание” сцены в момент удара молнии.
|
|
|
|
|
// Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
|
|
|
|
|
const rootNode = rootRef.current;
|
|
|
|
|
if (rootNode) {
|
|
|
|
|
if (rootNode && motion) {
|
|
|
|
|
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
|
|
|
|
|
rootNode.x = x;
|
|
|
|
|
rootNode.y = y;
|
|
|
|
@@ -404,7 +418,7 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|
|
|
|
host.replaceChildren();
|
|
|
|
|
};
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [gpuBoot]);
|
|
|
|
|
}, [gpuBoot, perfProfile]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const host = hostRef.current;
|
|
|
|
@@ -420,9 +434,9 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|
|
|
|
}, [state]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
// Если viewport не задан — используем “весь холст”.
|
|
|
|
|
// Host уже стоит на contentRect — в Pixi локальные координаты с (0,0).
|
|
|
|
|
viewportProvidedRef.current = Boolean(viewport);
|
|
|
|
|
if (viewport) viewportRef.current = viewport;
|
|
|
|
|
if (viewport) viewportRef.current = { x: 0, y: 0, w: viewport.w, h: viewport.h };
|
|
|
|
|
else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
|
|
|
|
|
const pixi = pixiRef.current;
|
|
|
|
|
const root = rootRef.current;
|
|
|
|
@@ -437,9 +451,22 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|
|
|
|
}
|
|
|
|
|
}, [viewport]);
|
|
|
|
|
|
|
|
|
|
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
|
|
|
|
|
const hostClass = [
|
|
|
|
|
styles.host,
|
|
|
|
|
viewport ? styles.hostBound : styles.hostFill,
|
|
|
|
|
interactive ? styles.hostInteractive : styles.hostPassthrough,
|
|
|
|
|
].join(' ');
|
|
|
|
|
const hostStyle: React.CSSProperties = viewport
|
|
|
|
|
? {
|
|
|
|
|
...style,
|
|
|
|
|
left: viewport.x,
|
|
|
|
|
top: viewport.y,
|
|
|
|
|
width: viewport.w,
|
|
|
|
|
height: viewport.h,
|
|
|
|
|
}
|
|
|
|
|
: { ...style };
|
|
|
|
|
|
|
|
|
|
return <div ref={hostRef} className={hostClass} style={style} />;
|
|
|
|
|
return <div ref={hostRef} className={hostClass} style={hostStyle} />;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function syncNodes(
|
|
|
|
@@ -530,8 +557,9 @@ function createInstanceNode(
|
|
|
|
|
const r = inst.radiusN * minDim;
|
|
|
|
|
const textures = getFogVfxFrameTextures(pixi);
|
|
|
|
|
const c = new pixi.Container();
|
|
|
|
|
for (let i = 0; i < inst.points.length; i += 1) {
|
|
|
|
|
const p = inst.points[i];
|
|
|
|
|
const stamps = fieldStampPoints(inst);
|
|
|
|
|
for (let i = 0; i < stamps.length; i += 1) {
|
|
|
|
|
const p = stamps[i];
|
|
|
|
|
if (!p) continue;
|
|
|
|
|
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
|
|
|
|
s.anchor?.set?.(0.5, 0.5);
|
|
|
|
@@ -570,8 +598,9 @@ function createInstanceNode(
|
|
|
|
|
const r = inst.radiusN * minDim;
|
|
|
|
|
const textures = getGroundFireVfxFrameTextures(pixi);
|
|
|
|
|
const c = new pixi.Container();
|
|
|
|
|
for (let i = 0; i < inst.points.length; i += 1) {
|
|
|
|
|
const p = inst.points[i];
|
|
|
|
|
const stamps = fieldStampPoints(inst);
|
|
|
|
|
for (let i = 0; i < stamps.length; i += 1) {
|
|
|
|
|
const p = stamps[i];
|
|
|
|
|
if (!p) continue;
|
|
|
|
|
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
|
|
|
|
s.anchor?.set?.(0.5, 0.62);
|
|
|
|
@@ -613,8 +642,9 @@ function createInstanceNode(
|
|
|
|
|
const r = inst.radiusN * minDim;
|
|
|
|
|
const textures = getRainVfxFrameTextures(pixi);
|
|
|
|
|
const c = new pixi.Container();
|
|
|
|
|
for (let i = 0; i < inst.points.length; i += 1) {
|
|
|
|
|
const p = inst.points[i];
|
|
|
|
|
const stamps = fieldStampPoints(inst);
|
|
|
|
|
for (let i = 0; i < stamps.length; i += 1) {
|
|
|
|
|
const p = stamps[i];
|
|
|
|
|
if (!p) continue;
|
|
|
|
|
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
|
|
|
|
s.anchor?.set?.(0.5, 0.5);
|
|
|
|
@@ -885,6 +915,7 @@ function animateNodes(
|
|
|
|
|
nowMs: number,
|
|
|
|
|
size: { w: number; h: number },
|
|
|
|
|
viewport: { x: number; y: number; w: number; h: number },
|
|
|
|
|
opts?: { motion?: boolean },
|
|
|
|
|
) {
|
|
|
|
|
const instById = new Map(state.instances.map((i) => [i.id, i]));
|
|
|
|
|
void pixi;
|
|
|
|
@@ -940,12 +971,14 @@ function animateNodes(
|
|
|
|
|
child.texture = frame;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2;
|
|
|
|
|
child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble;
|
|
|
|
|
child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45;
|
|
|
|
|
const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56);
|
|
|
|
|
child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local;
|
|
|
|
|
child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7);
|
|
|
|
|
if (opts?.motion !== false) {
|
|
|
|
|
const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2;
|
|
|
|
|
child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble;
|
|
|
|
|
child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45;
|
|
|
|
|
const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56);
|
|
|
|
|
child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local;
|
|
|
|
|
child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7);
|
|
|
|
|
}
|
|
|
|
|
child.alpha = 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -991,9 +1024,11 @@ function animateNodes(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1;
|
|
|
|
|
const wob = typeof fx.drift === 'number' ? fx.drift : 0;
|
|
|
|
|
child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob;
|
|
|
|
|
child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35;
|
|
|
|
|
if (opts?.motion !== false) {
|
|
|
|
|
const wob = typeof fx.drift === 'number' ? fx.drift : 0;
|
|
|
|
|
child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob;
|
|
|
|
|
child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35;
|
|
|
|
|
}
|
|
|
|
|
child.width = typeof fx.w0 === 'number' ? fx.w0 : child.width;
|
|
|
|
|
child.height = typeof fx.h0 === 'number' ? fx.h0 : child.height;
|
|
|
|
|
child.alpha = Math.max(0.25, Math.min(1, inst.opacity * base * alphaMul));
|
|
|
|
@@ -1867,10 +1902,11 @@ function relayoutInstanceNode(
|
|
|
|
|
if (inst.type === 'fog') {
|
|
|
|
|
const r = inst.radiusN * minDim;
|
|
|
|
|
const fogSize = Math.max(4, r * 2);
|
|
|
|
|
const stamps = fieldStampPoints(inst);
|
|
|
|
|
const children = node.children ?? [];
|
|
|
|
|
let pi = 0;
|
|
|
|
|
for (const child of children) {
|
|
|
|
|
const p = inst.points[pi];
|
|
|
|
|
const p = stamps[pi];
|
|
|
|
|
pi += 1;
|
|
|
|
|
if (!p) continue;
|
|
|
|
|
const fx = (child as any).__fx ?? {};
|
|
|
|
@@ -1891,10 +1927,11 @@ function relayoutInstanceNode(
|
|
|
|
|
const r = inst.radiusN * minDim;
|
|
|
|
|
const flameWidth = Math.max(r * 3.2, minDim * 0.05);
|
|
|
|
|
const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
|
|
|
|
|
const stamps = fieldStampPoints(inst);
|
|
|
|
|
const children = node.children ?? [];
|
|
|
|
|
let pi = 0;
|
|
|
|
|
for (const child of children) {
|
|
|
|
|
const p = inst.points[pi];
|
|
|
|
|
const p = stamps[pi];
|
|
|
|
|
pi += 1;
|
|
|
|
|
if (!p) continue;
|
|
|
|
|
const fx = (child as any).__fx ?? {};
|
|
|
|
@@ -1915,10 +1952,11 @@ function relayoutInstanceNode(
|
|
|
|
|
const r = inst.radiusN * minDim;
|
|
|
|
|
const rainWidth = Math.max(r * 3.8, minDim * 0.08);
|
|
|
|
|
const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT;
|
|
|
|
|
const stamps = fieldStampPoints(inst);
|
|
|
|
|
const children = node.children ?? [];
|
|
|
|
|
let pi = 0;
|
|
|
|
|
for (const child of children) {
|
|
|
|
|
const p = inst.points[pi];
|
|
|
|
|
const p = stamps[pi];
|
|
|
|
|
pi += 1;
|
|
|
|
|
if (!p) continue;
|
|
|
|
|
const fx = (child as any).__fx ?? {};
|
|
|
|
|