feat(scene): shared zoom/pan for control and presentation
Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -388,8 +388,15 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
||||
else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
|
||||
const pixi = pixiRef.current;
|
||||
const root = rootRef.current;
|
||||
const app = appRef.current;
|
||||
if (!pixi || !root) return;
|
||||
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
|
||||
// При остановленном ticker (или между кадрами) иначе зум/пан не отрисуется.
|
||||
try {
|
||||
app?.render?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [viewport]);
|
||||
|
||||
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
|
||||
@@ -421,7 +428,8 @@ function syncNodes(
|
||||
}
|
||||
if (!state) return;
|
||||
for (const inst of liveInstances) {
|
||||
const sig = instanceSig(inst, viewport);
|
||||
const contentSig = instanceContentSig(inst);
|
||||
const sig = `${contentSig}@${viewportSig(viewport)}`;
|
||||
const existing = nodes.get(inst.id);
|
||||
if (existing && (existing as any).__sig === sig) continue;
|
||||
// Water draft: перерисовываем Graphics in-place (без destroy/create на каждую точку).
|
||||
@@ -434,6 +442,16 @@ function syncNodes(
|
||||
const halfW = Math.max(1.5, inst.radiusN * Math.min(viewport.w, viewport.h));
|
||||
redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW);
|
||||
existing.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1));
|
||||
(existing as any).__contentSig = contentSig;
|
||||
(existing as any).__sig = sig;
|
||||
continue;
|
||||
}
|
||||
// Тот же инстанс, сменился только viewport (зум/пан) — двигаем in-place.
|
||||
if (
|
||||
existing &&
|
||||
(existing as any).__contentSig === contentSig &&
|
||||
relayoutInstanceNode(pixi, existing, inst, viewport)
|
||||
) {
|
||||
(existing as any).__sig = sig;
|
||||
continue;
|
||||
}
|
||||
@@ -449,6 +467,7 @@ function syncNodes(
|
||||
}
|
||||
const node = createInstanceNode(pixi, inst, size, viewport);
|
||||
if (!node) continue;
|
||||
(node as any).__contentSig = contentSig;
|
||||
(node as any).__sig = sig;
|
||||
nodes.set(inst.id, node);
|
||||
root.addChild(node);
|
||||
@@ -1720,7 +1739,12 @@ function redrawLightningVfx(
|
||||
}
|
||||
}
|
||||
|
||||
function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w: number; h: number }): string {
|
||||
function viewportSig(viewport: { x: number; y: number; w: number; h: number }): string {
|
||||
return `${Math.round(viewport.x)}:${Math.round(viewport.y)}:${Math.round(viewport.w)}:${Math.round(viewport.h)}`;
|
||||
}
|
||||
|
||||
/** Сигнатура содержимого инстанса без viewport — для in-place relayout при зуме/пане. */
|
||||
function instanceContentSig(inst: EffectInstance): string {
|
||||
if (inst.type === 'fog') {
|
||||
const last = inst.points[inst.points.length - 1];
|
||||
const lx = last ? Math.round(last.x * 1000) : 0;
|
||||
@@ -1741,7 +1765,7 @@ function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w:
|
||||
}
|
||||
if (inst.type === 'water') {
|
||||
const hp = hashWaterStroke(inst);
|
||||
return `water:${inst.points.length}:${hp}:${Math.round(inst.radiusN * 1000)}:${Math.round(inst.opacity * 1000)}:${Math.round(viewport.w)}:${Math.round(viewport.h)}`;
|
||||
return `water:${inst.points.length}:${hp}:${Math.round(inst.radiusN * 1000)}:${Math.round(inst.opacity * 1000)}`;
|
||||
}
|
||||
if (inst.type === 'lightning') {
|
||||
return `lt:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 1000)}`;
|
||||
@@ -1773,6 +1797,165 @@ function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w:
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересчитать экранные координаты/размер под новый contentRect (зум/пан).
|
||||
* false — нужна полная пересборка ноды.
|
||||
*/
|
||||
function relayoutInstanceNode(
|
||||
pixi: any,
|
||||
node: any,
|
||||
inst: EffectInstance,
|
||||
viewport: { x: number; y: number; w: number; h: number },
|
||||
): boolean {
|
||||
const { x: vx, y: vy, w, h } = viewport;
|
||||
const minDim = Math.min(w, h);
|
||||
|
||||
if (inst.type === 'fog') {
|
||||
const r = inst.radiusN * minDim;
|
||||
const fogSize = Math.max(4, r * 2);
|
||||
const children = node.children ?? [];
|
||||
let pi = 0;
|
||||
for (const child of children) {
|
||||
const p = inst.points[pi];
|
||||
pi += 1;
|
||||
if (!p) continue;
|
||||
const fx = (child as any).__fx ?? {};
|
||||
fx.bx = vx + p.x * w;
|
||||
fx.by = vy + p.y * h;
|
||||
fx.w0 = fogSize;
|
||||
fx.h0 = fogSize;
|
||||
(child as any).__fx = fx;
|
||||
child.x = fx.bx;
|
||||
child.y = fx.by;
|
||||
child.width = fogSize;
|
||||
child.height = fogSize;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'fire') {
|
||||
const r = inst.radiusN * minDim;
|
||||
const flameWidth = Math.max(r * 3.2, minDim * 0.05);
|
||||
const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
|
||||
const children = node.children ?? [];
|
||||
let pi = 0;
|
||||
for (const child of children) {
|
||||
const p = inst.points[pi];
|
||||
pi += 1;
|
||||
if (!p) continue;
|
||||
const fx = (child as any).__fx ?? {};
|
||||
fx.bx = vx + p.x * w;
|
||||
fx.by = vy + p.y * h;
|
||||
fx.w0 = flameWidth;
|
||||
fx.h0 = flameHeight;
|
||||
(child as any).__fx = fx;
|
||||
child.x = fx.bx;
|
||||
child.y = fx.by;
|
||||
child.width = flameWidth;
|
||||
child.height = flameHeight;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'rain') {
|
||||
const r = inst.radiusN * minDim;
|
||||
const rainWidth = Math.max(r * 3.8, minDim * 0.08);
|
||||
const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT;
|
||||
const children = node.children ?? [];
|
||||
let pi = 0;
|
||||
for (const child of children) {
|
||||
const p = inst.points[pi];
|
||||
pi += 1;
|
||||
if (!p) continue;
|
||||
const fx = (child as any).__fx ?? {};
|
||||
fx.bx = vx + p.x * w;
|
||||
fx.by = vy + p.y * h;
|
||||
fx.w0 = rainWidth;
|
||||
fx.h0 = rainHeight;
|
||||
(child as any).__fx = fx;
|
||||
child.x = fx.bx;
|
||||
child.y = fx.by;
|
||||
child.width = rainWidth;
|
||||
child.height = rainHeight;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'water') {
|
||||
const fx = (node as any).__fx;
|
||||
if (fx?.kind === 'waterDraft' && fx.g) {
|
||||
const halfW = Math.max(1.5, inst.radiusN * minDim);
|
||||
redrawWaterDraft(fx.g, inst, viewport, halfW);
|
||||
return true;
|
||||
}
|
||||
// Заливка воды строится в texture/mask под размер viewport — проще пересоздать.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (inst.type === 'lightning') {
|
||||
const life = Math.max(1, inst.lifetimeMs);
|
||||
const t = Math.max(0, Date.now() - inst.createdAtMs);
|
||||
redrawLightningVfx(node, inst, viewport, t, life);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'sunbeam') {
|
||||
const life = Math.max(1, inst.lifetimeMs);
|
||||
const t = Math.max(0, Date.now() - inst.createdAtMs);
|
||||
redrawPulseDischargeVfx(node, inst, viewport, t, life);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'poisonCloud') {
|
||||
const life = Math.max(1, inst.lifetimeMs);
|
||||
const t = Math.max(0, Date.now() - inst.createdAtMs);
|
||||
redrawPoisonCloud(node, inst, viewport, t, life);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'freeze') {
|
||||
const fx = (node as any).__fx ?? {};
|
||||
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
|
||||
node.texture = getFreezeScreenTexture(pixi, inst.seed, viewport);
|
||||
fx.vw = viewport.w;
|
||||
fx.vh = viewport.h;
|
||||
(node as any).__fx = fx;
|
||||
}
|
||||
node.x = viewport.x;
|
||||
node.y = viewport.y;
|
||||
node.width = viewport.w;
|
||||
node.height = viewport.h;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'darkness') {
|
||||
const fx = (node as any).__fx ?? {};
|
||||
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
|
||||
node.texture = getDarknessScreenTexture(pixi, inst.seed, viewport);
|
||||
fx.vw = viewport.w;
|
||||
fx.vh = viewport.h;
|
||||
(node as any).__fx = fx;
|
||||
}
|
||||
node.x = viewport.x;
|
||||
node.y = viewport.y;
|
||||
node.width = viewport.w;
|
||||
node.height = viewport.h;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inst.type === 'scorch' || inst.type === 'ice' || inst.type === 'shadow') {
|
||||
node.x = vx + inst.at.x * w;
|
||||
node.y = vy + inst.at.y * h;
|
||||
const r = inst.radiusN * minDim;
|
||||
const texW = node.texture?.width ?? node.width ?? 1;
|
||||
const scale = r / Math.max(1, texW * 0.5);
|
||||
node.scale?.set?.(scale, scale);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hash01(seed: number, n: number): number {
|
||||
// Дешёвый детерминированный шум 0..1 (без Math.random).
|
||||
let x = (seed ^ (n * 374761393)) >>> 0;
|
||||
|
||||
Reference in New Issue
Block a user