feat(effects): extinguish fire with water/rain and ambient SFX volume
Water and rain erase fire under a tight brush hit radius; add looping fire/rain ambients and an Effects sound slider that also scales one-shot effect SFX. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -102,7 +102,14 @@ function applyEvent(
|
|||||||
case 'field.erase':
|
case 'field.erase':
|
||||||
return bump({
|
return bump({
|
||||||
...state,
|
...state,
|
||||||
instances: applyFieldEffectEraserStroke(state.instances, event.points, event.radiusN, makeId),
|
instances: applyFieldEffectEraserStroke(
|
||||||
|
state.instances,
|
||||||
|
event.points,
|
||||||
|
event.radiusN,
|
||||||
|
makeId,
|
||||||
|
event.types,
|
||||||
|
event.hitMode ?? 'inclusive',
|
||||||
|
),
|
||||||
});
|
});
|
||||||
default: {
|
default: {
|
||||||
// Exhaustiveness
|
// Exhaustiveness
|
||||||
|
|||||||
@@ -179,11 +179,15 @@
|
|||||||
|
|
||||||
.radiusRow {
|
.radiusRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 100px 1fr 44px;
|
grid-template-columns: 120px 1fr 44px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.effectsSoundRow {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.radiusLabel {
|
.radiusLabel {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
|
|||||||
import styles from './ControlApp.module.css';
|
import styles from './ControlApp.module.css';
|
||||||
import { ControlAudioCard } from './ControlAudioCard';
|
import { ControlAudioCard } from './ControlAudioCard';
|
||||||
import { ControlScenePreview } from './ControlScenePreview';
|
import { ControlScenePreview } from './ControlScenePreview';
|
||||||
|
import { clampEffectsSfxGain, getEffectsSfxGain, setEffectsSfxGain } from './effectsSfxGain';
|
||||||
|
import { setFireAmbientActive, syncFireAmbientVolume } from './fireAmbientSfx';
|
||||||
|
import { setRainAmbientActive, syncRainAmbientVolume } from './rainAmbientSfx';
|
||||||
import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
|
import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
|
||||||
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
|
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
|
||||||
import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
|
import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
|
||||||
@@ -68,7 +71,7 @@ function lightningEffectSoundUrl(): string {
|
|||||||
function playLightningEffectSound(): void {
|
function playLightningEffectSound(): void {
|
||||||
try {
|
try {
|
||||||
const el = new Audio(lightningEffectSoundUrl());
|
const el = new Audio(lightningEffectSoundUrl());
|
||||||
el.volume = 0.88;
|
el.volume = Math.max(0, Math.min(1, 0.88 * getEffectsSfxGain()));
|
||||||
void el.play().catch(() => undefined);
|
void el.play().catch(() => undefined);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -108,6 +111,7 @@ export function ControlApp() {
|
|||||||
const tRef = useRef(t);
|
const tRef = useRef(t);
|
||||||
tRef.current = t;
|
tRef.current = t;
|
||||||
const [fxState, fx] = useEffectsState();
|
const [fxState, fx] = useEffectsState();
|
||||||
|
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
|
||||||
const [sdState, sd] = useSceneDarknessState();
|
const [sdState, sd] = useSceneDarknessState();
|
||||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||||
const [npcsOverlay, npcsApi] = useNpcsOverlayState();
|
const [npcsOverlay, npcsApi] = useNpcsOverlayState();
|
||||||
@@ -239,6 +243,29 @@ export function ControlApp() {
|
|||||||
void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
|
void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const hasFireOnScene = useMemo(
|
||||||
|
() => Boolean(fxState?.instances.some((i) => i.type === 'fire' && i.points.length > 0)),
|
||||||
|
[fxState?.instances],
|
||||||
|
);
|
||||||
|
const hasRainOnScene = useMemo(
|
||||||
|
() => Boolean(fxState?.instances.some((i) => i.type === 'rain' && i.points.length > 0)),
|
||||||
|
[fxState?.instances],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFireAmbientActive(hasFireOnScene);
|
||||||
|
return () => {
|
||||||
|
setFireAmbientActive(false);
|
||||||
|
};
|
||||||
|
}, [hasFireOnScene]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRainAmbientActive(hasRainOnScene);
|
||||||
|
return () => {
|
||||||
|
setRainAmbientActive(false);
|
||||||
|
};
|
||||||
|
}, [hasRainOnScene]);
|
||||||
|
|
||||||
const project = session?.project ?? null;
|
const project = session?.project ?? null;
|
||||||
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
||||||
const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
|
const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
|
||||||
@@ -911,9 +938,26 @@ export function ControlApp() {
|
|||||||
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
|
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchFieldEraserPoints(points: { x: number; y: number; tMs: number }[]): void {
|
function dispatchFieldEraserPoints(
|
||||||
|
points: { x: number; y: number; tMs: number }[],
|
||||||
|
opts?: {
|
||||||
|
types?: readonly ('fog' | 'fire' | 'rain' | 'water')[];
|
||||||
|
hitMode?: 'inclusive' | 'brush';
|
||||||
|
},
|
||||||
|
): void {
|
||||||
if (points.length === 0) return;
|
if (points.length === 0) return;
|
||||||
void fx.dispatch({ kind: 'field.erase', points, radiusN: toolRef.current.radiusN });
|
void fx.dispatch({
|
||||||
|
kind: 'field.erase',
|
||||||
|
points,
|
||||||
|
radiusN: toolRef.current.radiusN,
|
||||||
|
...(opts?.types ? { types: opts.types } : {}),
|
||||||
|
...(opts?.hitMode ? { hitMode: opts.hitMode } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вода/дождь гасят огонь только под самой кистью (не «аура» радиуса огня). */
|
||||||
|
function extinguishFireAlong(points: { x: number; y: number; tMs: number }[]): void {
|
||||||
|
dispatchFieldEraserPoints(points, { types: ['fire'], hitMode: 'brush' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryEraseActionEffect(p: { x: number; y: number }): void {
|
function tryEraseActionEffect(p: { x: number; y: number }): void {
|
||||||
@@ -968,6 +1012,13 @@ export function ControlApp() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (b.tool === 'rain' && b.points && b.points.length > 0) {
|
if (b.tool === 'rain' && b.points && b.points.length > 0) {
|
||||||
|
await fx.dispatch({
|
||||||
|
kind: 'field.erase',
|
||||||
|
points: b.points,
|
||||||
|
radiusN: tool.radiusN,
|
||||||
|
types: ['fire'],
|
||||||
|
hitMode: 'brush',
|
||||||
|
});
|
||||||
await fx.dispatch({
|
await fx.dispatch({
|
||||||
kind: 'instance.add',
|
kind: 'instance.add',
|
||||||
instance: {
|
instance: {
|
||||||
@@ -983,6 +1034,13 @@ export function ControlApp() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (b.tool === 'water' && b.points && b.points.length > 0) {
|
if (b.tool === 'water' && b.points && b.points.length > 0) {
|
||||||
|
await fx.dispatch({
|
||||||
|
kind: 'field.erase',
|
||||||
|
points: b.points,
|
||||||
|
radiusN: tool.radiusN,
|
||||||
|
types: ['fire'],
|
||||||
|
hitMode: 'brush',
|
||||||
|
});
|
||||||
await fx.dispatch({
|
await fx.dispatch({
|
||||||
kind: 'instance.add',
|
kind: 'instance.add',
|
||||||
instance: {
|
instance: {
|
||||||
@@ -1407,6 +1465,26 @@ export function ControlApp() {
|
|||||||
/>
|
/>
|
||||||
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
|
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={[styles.radiusRow, styles.effectsSoundRow].join(' ')}>
|
||||||
|
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={effectsSfxGainUi}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||||||
|
const next = setEffectsSfxGain(clampEffectsSfxGain(v));
|
||||||
|
setEffectsSfxGainUi(next);
|
||||||
|
syncFireAmbientVolume();
|
||||||
|
syncRainAmbientVolume();
|
||||||
|
}}
|
||||||
|
className={styles.range}
|
||||||
|
aria-label={t('control.effectsSound')}
|
||||||
|
/>
|
||||||
|
<div className={styles.radiusValue}>{Math.round(effectsSfxGainUi * 100)}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.spacer12} />
|
<div className={styles.spacer12} />
|
||||||
</>
|
</>
|
||||||
@@ -1523,16 +1601,20 @@ export function ControlApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 };
|
draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 };
|
||||||
|
const startPt = { x: p.x, y: p.y, tMs: Date.now() };
|
||||||
brushRef.current = {
|
brushRef.current = {
|
||||||
tool: tool.tool,
|
tool: tool.tool,
|
||||||
startN: p,
|
startN: p,
|
||||||
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
|
points: [startPt],
|
||||||
};
|
};
|
||||||
|
if (tool.tool === 'water' || tool.tool === 'rain') {
|
||||||
|
extinguishFireAlong([startPt]);
|
||||||
|
}
|
||||||
if (tool.tool === 'exploreBrush') {
|
if (tool.tool === 'exploreBrush') {
|
||||||
void sd.dispatch({
|
void sd.dispatch({
|
||||||
kind: 'draft.set',
|
kind: 'draft.set',
|
||||||
draft: {
|
draft: {
|
||||||
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
|
points: [startPt],
|
||||||
radiusN: tool.radiusN,
|
radiusN: tool.radiusN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1573,7 +1655,11 @@ export function ControlApp() {
|
|||||||
const dy = p.y - last.y;
|
const dy = p.y - last.y;
|
||||||
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
||||||
if (dx * dx + dy * dy < minStep * minStep) return;
|
if (dx * dx + dy * dy < minStep * minStep) return;
|
||||||
b.points.push({ x: p.x, y: p.y, tMs: Date.now() });
|
const pt = { x: p.x, y: p.y, tMs: Date.now() };
|
||||||
|
b.points.push(pt);
|
||||||
|
if (b.tool === 'water' || b.tool === 'rain') {
|
||||||
|
extinguishFireAlong([last, pt]);
|
||||||
|
}
|
||||||
scheduleDraftRepaint();
|
scheduleDraftRepaint();
|
||||||
}}
|
}}
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
|
|||||||
@@ -177,6 +177,30 @@ void test('ControlApp: весь контент скроллится в окне,
|
|||||||
assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s);
|
assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('ControlApp: вода/дождь гасят огонь; ambient огня/дождя + слайдер «Звук эффектов»', () => {
|
||||||
|
const src = readControlApp();
|
||||||
|
const fireSfx = fs.readFileSync(path.join(here, 'fireAmbientSfx.ts'), 'utf8');
|
||||||
|
const rainSfx = fs.readFileSync(path.join(here, 'rainAmbientSfx.ts'), 'utf8');
|
||||||
|
const i18n = fs.readFileSync(path.join(here, '../editor/i18n/editorMessages.ts'), 'utf8');
|
||||||
|
|
||||||
|
assert.ok(src.includes('extinguishFireAlong'));
|
||||||
|
assert.ok(src.includes("types: ['fire']"));
|
||||||
|
assert.ok(src.includes("hitMode: 'brush'"));
|
||||||
|
assert.ok(src.includes('effectsSoundRow'));
|
||||||
|
assert.ok(src.includes('setFireAmbientActive'));
|
||||||
|
assert.ok(src.includes('setRainAmbientActive'));
|
||||||
|
assert.ok(src.includes('hasFireOnScene'));
|
||||||
|
assert.ok(src.includes('hasRainOnScene'));
|
||||||
|
assert.ok(src.includes("t('control.effectsSound')"));
|
||||||
|
assert.ok(fireSfx.includes('fire-ambient.mp3'));
|
||||||
|
assert.ok(rainSfx.includes('rain-ambient.mp3'));
|
||||||
|
assert.ok(i18n.includes("'control.effectsSound': 'Звук эффектов'"));
|
||||||
|
|
||||||
|
const radius = src.indexOf("t('control.brushRadius')");
|
||||||
|
const effectsSound = src.indexOf("t('control.effectsSound')");
|
||||||
|
assert.ok(radius !== -1 && effectsSound !== -1 && radius < effectsSound);
|
||||||
|
});
|
||||||
|
|
||||||
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
|
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
|
||||||
const src = readControlApp();
|
const src = readControlApp();
|
||||||
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
|
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** Общая громкость звуков эффектов (огонь ambient + one-shot). 0…1. */
|
||||||
|
|
||||||
|
let effectsSfxGain = 0.75;
|
||||||
|
|
||||||
|
export function clampEffectsSfxGain(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 0.75;
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectsSfxGain(): number {
|
||||||
|
return effectsSfxGain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setEffectsSfxGain(v: number): number {
|
||||||
|
effectsSfxGain = clampEffectsSfxGain(v);
|
||||||
|
return effectsSfxGain;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/** Фоновый звук горения при наличии огня на сцене (`public/fire-ambient.mp3`). */
|
||||||
|
|
||||||
|
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||||
|
|
||||||
|
const FIRE_AMBIENT_BASE_VOLUME = 0.72;
|
||||||
|
|
||||||
|
export function fireAmbientSoundUrl(): string {
|
||||||
|
return new URL('fire-ambient.mp3', window.location.href).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ambientEl: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
|
function ensureAmbientEl(): HTMLAudioElement {
|
||||||
|
if (ambientEl) return ambientEl;
|
||||||
|
const el = new Audio(fireAmbientSoundUrl());
|
||||||
|
el.loop = true;
|
||||||
|
el.preload = 'auto';
|
||||||
|
ambientEl = el;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVolume(el: HTMLAudioElement): void {
|
||||||
|
el.volume = Math.max(0, Math.min(1, FIRE_AMBIENT_BASE_VOLUME * getEffectsSfxGain()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Включить/выключить loop горения. */
|
||||||
|
export function setFireAmbientActive(active: boolean): void {
|
||||||
|
try {
|
||||||
|
const el = ensureAmbientEl();
|
||||||
|
applyVolume(el);
|
||||||
|
if (active) {
|
||||||
|
if (el.paused) void el.play().catch(() => undefined);
|
||||||
|
} else if (!el.paused) {
|
||||||
|
el.pause();
|
||||||
|
el.currentTime = 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обновить громкость, если ambient уже играет. */
|
||||||
|
export function syncFireAmbientVolume(): void {
|
||||||
|
if (!ambientEl) return;
|
||||||
|
try {
|
||||||
|
applyVolume(ambientEl);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
/** Звук и длительность эффекта «Заморозка» (`public/zamorozka.mp3`). */
|
/** Звук и длительность эффекта «Заморозка» (`public/zamorozka.mp3`). */
|
||||||
|
|
||||||
|
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||||
|
|
||||||
const DEFAULT_FREEZE_LIFE_MS = 820;
|
const DEFAULT_FREEZE_LIFE_MS = 820;
|
||||||
|
|
||||||
const FREEZE_SFX_BASE_VOLUME = 0.88;
|
const FREEZE_SFX_BASE_VOLUME = 0.88;
|
||||||
/** На 25% тише базовой громкости эффекта. */
|
/** На 25% тише базовой громкости эффекта (до множителя «Звук эффектов»). */
|
||||||
export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75;
|
export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75;
|
||||||
|
|
||||||
export function freezeEffectSoundUrl(): string {
|
export function freezeEffectSoundUrl(): string {
|
||||||
@@ -44,7 +46,7 @@ export async function getFreezeEffectLifeMs(): Promise<number> {
|
|||||||
export function playFreezeEffectSound(): void {
|
export function playFreezeEffectSound(): void {
|
||||||
try {
|
try {
|
||||||
const el = new Audio(freezeEffectSoundUrl());
|
const el = new Audio(freezeEffectSoundUrl());
|
||||||
el.volume = FREEZE_SFX_VOLUME;
|
el.volume = Math.max(0, Math.min(1, FREEZE_SFX_VOLUME * getEffectsSfxGain()));
|
||||||
void el.play().catch(() => undefined);
|
void el.play().catch(() => undefined);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */
|
/** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */
|
||||||
|
|
||||||
|
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||||
|
|
||||||
const POISON_CLOUD_SFX_VOLUME = 0.92;
|
const POISON_CLOUD_SFX_VOLUME = 0.92;
|
||||||
|
|
||||||
/** Запас, если метаданные не прочитались. */
|
/** Запас, если метаданные не прочитались. */
|
||||||
@@ -50,7 +52,7 @@ export async function playPoisonCloudEffectSound(lifeMs: number): Promise<void>
|
|||||||
const target = Math.max(200, lifeMs);
|
const target = Math.max(200, lifeMs);
|
||||||
const rate = Math.max(0.25, Math.min(4, rawMs / target));
|
const rate = Math.max(0.25, Math.min(4, rawMs / target));
|
||||||
const el = new Audio(poisonCloudEffectSoundUrl());
|
const el = new Audio(poisonCloudEffectSoundUrl());
|
||||||
el.volume = POISON_CLOUD_SFX_VOLUME;
|
el.volume = Math.max(0, Math.min(1, POISON_CLOUD_SFX_VOLUME * getEffectsSfxGain()));
|
||||||
el.playbackRate = rate;
|
el.playbackRate = rate;
|
||||||
void el.play().catch(() => undefined);
|
void el.play().catch(() => undefined);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/** Фоновый звук дождя при наличии дождя на сцене (`public/rain-ambient.mp3`). */
|
||||||
|
|
||||||
|
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||||
|
|
||||||
|
const RAIN_AMBIENT_BASE_VOLUME = 0.7;
|
||||||
|
|
||||||
|
export function rainAmbientSoundUrl(): string {
|
||||||
|
return new URL('rain-ambient.mp3', window.location.href).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ambientEl: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
|
function ensureAmbientEl(): HTMLAudioElement {
|
||||||
|
if (ambientEl) return ambientEl;
|
||||||
|
const el = new Audio(rainAmbientSoundUrl());
|
||||||
|
el.loop = true;
|
||||||
|
el.preload = 'auto';
|
||||||
|
ambientEl = el;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVolume(el: HTMLAudioElement): void {
|
||||||
|
el.volume = Math.max(0, Math.min(1, RAIN_AMBIENT_BASE_VOLUME * getEffectsSfxGain()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Включить/выключить loop дождя. */
|
||||||
|
export function setRainAmbientActive(active: boolean): void {
|
||||||
|
try {
|
||||||
|
const el = ensureAmbientEl();
|
||||||
|
applyVolume(el);
|
||||||
|
if (active) {
|
||||||
|
if (el.paused) void el.play().catch(() => undefined);
|
||||||
|
} else if (!el.paused) {
|
||||||
|
el.pause();
|
||||||
|
el.currentTime = 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обновить громкость, если ambient уже играет. */
|
||||||
|
export function syncRainAmbientVolume(): void {
|
||||||
|
if (!ambientEl) return;
|
||||||
|
try {
|
||||||
|
applyVolume(ambientEl);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
/** Звук эффекта «Луч света» (`public/luch_sveta.mp3`). */
|
/** Звук эффекта «Луч света» (`public/luch_sveta.mp3`). */
|
||||||
|
|
||||||
|
import { getEffectsSfxGain } from './effectsSfxGain';
|
||||||
|
|
||||||
const SUNBEAM_SFX_VOLUME = 0.88;
|
const SUNBEAM_SFX_VOLUME = 0.88;
|
||||||
/** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */
|
/** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */
|
||||||
export const SUNBEAM_PLAYBACK_RATE = 1.5;
|
export const SUNBEAM_PLAYBACK_RATE = 1.5;
|
||||||
@@ -47,7 +49,7 @@ export async function getSunbeamEffectLifeMs(): Promise<number> {
|
|||||||
export function playSunbeamEffectSound(): void {
|
export function playSunbeamEffectSound(): void {
|
||||||
try {
|
try {
|
||||||
const el = new Audio(sunbeamEffectSoundUrl());
|
const el = new Audio(sunbeamEffectSoundUrl());
|
||||||
el.volume = SUNBEAM_SFX_VOLUME;
|
el.volume = Math.max(0, Math.min(1, SUNBEAM_SFX_VOLUME * getEffectsSfxGain()));
|
||||||
el.playbackRate = SUNBEAM_PLAYBACK_RATE;
|
el.playbackRate = SUNBEAM_PLAYBACK_RATE;
|
||||||
void el.play().catch(() => undefined);
|
void el.play().catch(() => undefined);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.freeze': 'Заморозка',
|
'control.freeze': 'Заморозка',
|
||||||
'control.poisonCloud': 'Облако яда',
|
'control.poisonCloud': 'Облако яда',
|
||||||
'control.brushRadius': 'Радиус кисти',
|
'control.brushRadius': 'Радиус кисти',
|
||||||
|
'control.effectsSound': 'Звук эффектов',
|
||||||
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
|
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
|
||||||
'control.gotoScene': 'Перейти к этой сцене',
|
'control.gotoScene': 'Перейти к этой сцене',
|
||||||
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
|
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
|
||||||
@@ -1080,6 +1081,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'control.freeze': 'Freeze',
|
'control.freeze': 'Freeze',
|
||||||
'control.poisonCloud': 'Poison cloud',
|
'control.poisonCloud': 'Poison cloud',
|
||||||
'control.brushRadius': 'Brush radius',
|
'control.brushRadius': 'Brush radius',
|
||||||
|
'control.effectsSound': 'Effects sound',
|
||||||
'control.storyLine': 'STORYLINE',
|
'control.storyLine': 'STORYLINE',
|
||||||
'control.gotoScene': 'Go to this scene',
|
'control.gotoScene': 'Go to this scene',
|
||||||
'control.currentSceneBadge': 'CURRENT SCENE',
|
'control.currentSceneBadge': 'CURRENT SCENE',
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -123,3 +123,69 @@ void test('applyFieldEffectEraserStroke: пустой штрих — без из
|
|||||||
assert.equal(next.length, 1);
|
assert.equal(next.length, 1);
|
||||||
assert.equal(next[0]?.id, 'w1');
|
assert.equal(next[0]?.id, 'w1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('applyFieldEffectEraserStroke: types=["fire"] гасит только огонь', () => {
|
||||||
|
const fire: EffectInstance = {
|
||||||
|
...base,
|
||||||
|
id: 'fire1',
|
||||||
|
type: 'fire',
|
||||||
|
points: [
|
||||||
|
{ x: 0.5, y: 0.5, tMs: 0 },
|
||||||
|
{ x: 0.55, y: 0.5, tMs: 50 },
|
||||||
|
],
|
||||||
|
radiusN: 0.03,
|
||||||
|
opacity: 1,
|
||||||
|
lifetimeMs: null,
|
||||||
|
};
|
||||||
|
const fog: EffectInstance = {
|
||||||
|
...base,
|
||||||
|
id: 'fog1',
|
||||||
|
type: 'fog',
|
||||||
|
points: [{ x: 0.5, y: 0.5, tMs: 0 }],
|
||||||
|
radiusN: 0.03,
|
||||||
|
opacity: 0.5,
|
||||||
|
lifetimeMs: null,
|
||||||
|
};
|
||||||
|
const next = applyFieldEffectEraserStroke(
|
||||||
|
[fire, fog],
|
||||||
|
[{ x: 0.5, y: 0.5, tMs: 0 }],
|
||||||
|
0.08,
|
||||||
|
makeId,
|
||||||
|
['fire'],
|
||||||
|
);
|
||||||
|
assert.equal(next.length, 1);
|
||||||
|
assert.equal(next[0]?.id, 'fog1');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('applyFieldEffectEraserStroke: hitMode=brush не гасит издалека', () => {
|
||||||
|
const fire: EffectInstance = {
|
||||||
|
...base,
|
||||||
|
id: 'fire1',
|
||||||
|
type: 'fire',
|
||||||
|
points: [{ x: 0.5, y: 0.5, tMs: 0 }],
|
||||||
|
radiusN: 0.08,
|
||||||
|
opacity: 1,
|
||||||
|
lifetimeMs: null,
|
||||||
|
};
|
||||||
|
// brush ≈ 0.38 * 0.08 ≈ 0.030; dist 0.06 — мимо, dist 0.02 — попадание.
|
||||||
|
const far = applyFieldEffectEraserStroke(
|
||||||
|
[fire],
|
||||||
|
[{ x: 0.5, y: 0.56, tMs: 0 }],
|
||||||
|
0.08,
|
||||||
|
makeId,
|
||||||
|
['fire'],
|
||||||
|
'brush',
|
||||||
|
);
|
||||||
|
assert.equal(far.length, 1);
|
||||||
|
assert.equal(far[0]?.id, 'fire1');
|
||||||
|
|
||||||
|
const near = applyFieldEffectEraserStroke(
|
||||||
|
[fire],
|
||||||
|
[{ x: 0.5, y: 0.52, tMs: 0 }],
|
||||||
|
0.08,
|
||||||
|
makeId,
|
||||||
|
['fire'],
|
||||||
|
'brush',
|
||||||
|
);
|
||||||
|
assert.equal(near.length, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,13 +47,34 @@ function minDistToEraserStroke(
|
|||||||
return Math.sqrt(bestSq);
|
return Math.sqrt(bestSq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `inclusive` — ластик: радиус кисти + радиус инстанса (снять всё пятно).
|
||||||
|
* `brush` — вода/дождь по огню: укороченный радиус кисти (спрайты огня шире radiusN, иначе гасится «издалека»).
|
||||||
|
*/
|
||||||
|
export type FieldEraseHitMode = 'inclusive' | 'brush';
|
||||||
|
|
||||||
|
/** Доля радиуса кисти для гашения огня водой/дождём (центр кисти ближе к точке огня). */
|
||||||
|
export const FIRE_EXTINGUISH_BRUSH_SCALE = 0.38;
|
||||||
|
|
||||||
|
function eraseThresholdN(
|
||||||
|
eraserRadiusN: number,
|
||||||
|
instRadiusN: number,
|
||||||
|
hitMode: FieldEraseHitMode,
|
||||||
|
): number {
|
||||||
|
if (hitMode === 'brush') {
|
||||||
|
return Math.max(0.006, eraserRadiusN * FIRE_EXTINGUISH_BRUSH_SCALE);
|
||||||
|
}
|
||||||
|
return eraserRadiusN + instRadiusN;
|
||||||
|
}
|
||||||
|
|
||||||
function isPointErased(
|
function isPointErased(
|
||||||
p: { x: number; y: number },
|
p: { x: number; y: number },
|
||||||
instRadiusN: number,
|
instRadiusN: number,
|
||||||
eraserPoints: readonly { x: number; y: number }[],
|
eraserPoints: readonly { x: number; y: number }[],
|
||||||
eraserRadiusN: number,
|
eraserRadiusN: number,
|
||||||
|
hitMode: FieldEraseHitMode,
|
||||||
): boolean {
|
): boolean {
|
||||||
const threshold = eraserRadiusN + instRadiusN;
|
const threshold = eraseThresholdN(eraserRadiusN, instRadiusN, hitMode);
|
||||||
return minDistToEraserStroke(p, eraserPoints) <= threshold;
|
return minDistToEraserStroke(p, eraserPoints) <= threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,11 +83,12 @@ function splitKeptPointRuns(
|
|||||||
eraserPoints: readonly { x: number; y: number }[],
|
eraserPoints: readonly { x: number; y: number }[],
|
||||||
eraserRadiusN: number,
|
eraserRadiusN: number,
|
||||||
instRadiusN: number,
|
instRadiusN: number,
|
||||||
|
hitMode: FieldEraseHitMode,
|
||||||
): NPoint[][] {
|
): NPoint[][] {
|
||||||
const runs: NPoint[][] = [];
|
const runs: NPoint[][] = [];
|
||||||
let current: NPoint[] = [];
|
let current: NPoint[] = [];
|
||||||
for (const p of points) {
|
for (const p of points) {
|
||||||
if (isPointErased(p, instRadiusN, eraserPoints, eraserRadiusN)) {
|
if (isPointErased(p, instRadiusN, eraserPoints, eraserRadiusN, hitMode)) {
|
||||||
if (current.length > 0) {
|
if (current.length > 0) {
|
||||||
runs.push(current);
|
runs.push(current);
|
||||||
current = [];
|
current = [];
|
||||||
@@ -83,14 +105,19 @@ function isFieldEffect(inst: EffectInstance): inst is FieldEffectInstance {
|
|||||||
return inst.type === 'fog' || inst.type === 'fire' || inst.type === 'rain' || inst.type === 'water';
|
return inst.type === 'fog' || inst.type === 'fire' || inst.type === 'rain' || inst.type === 'water';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FieldEffectType = FieldEffectInstance['type'];
|
||||||
|
|
||||||
/** Стирает эффекты поля (туман/дождь/огонь/вода) кистью, как «Кисть Открытия» для затемнения. */
|
/** Стирает эффекты поля (туман/дождь/огонь/вода) кистью, как «Кисть Открытия» для затемнения. */
|
||||||
export function applyFieldEffectEraserStroke(
|
export function applyFieldEffectEraserStroke(
|
||||||
instances: readonly EffectInstance[],
|
instances: readonly EffectInstance[],
|
||||||
eraserPoints: readonly NPoint[],
|
eraserPoints: readonly NPoint[],
|
||||||
eraserRadiusN: number,
|
eraserRadiusN: number,
|
||||||
makeId: (prefix: string) => string,
|
makeId: (prefix: string) => string,
|
||||||
|
targetTypes?: readonly FieldEffectType[],
|
||||||
|
hitMode: FieldEraseHitMode = 'inclusive',
|
||||||
): EffectInstance[] {
|
): EffectInstance[] {
|
||||||
if (eraserPoints.length === 0) return [...instances];
|
if (eraserPoints.length === 0) return [...instances];
|
||||||
|
const typeFilter = targetTypes && targetTypes.length > 0 ? new Set(targetTypes) : null;
|
||||||
|
|
||||||
const out: EffectInstance[] = [];
|
const out: EffectInstance[] = [];
|
||||||
for (const inst of instances) {
|
for (const inst of instances) {
|
||||||
@@ -98,8 +125,12 @@ export function applyFieldEffectEraserStroke(
|
|||||||
out.push(inst);
|
out.push(inst);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (typeFilter && !typeFilter.has(inst.type)) {
|
||||||
|
out.push(inst);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const runs = splitKeptPointRuns(inst.points, eraserPoints, eraserRadiusN, inst.radiusN);
|
const runs = splitKeptPointRuns(inst.points, eraserPoints, eraserRadiusN, inst.radiusN, hitMode);
|
||||||
if (runs.length === 0) continue;
|
if (runs.length === 0) continue;
|
||||||
if (runs.length === 1 && runs[0]?.length === inst.points.length) {
|
if (runs.length === 1 && runs[0]?.length === inst.points.length) {
|
||||||
out.push(inst);
|
out.push(inst);
|
||||||
|
|||||||
@@ -172,4 +172,15 @@ export type EffectsEvent =
|
|||||||
| { kind: 'instances.clear' }
|
| { kind: 'instances.clear' }
|
||||||
| { kind: 'instance.add'; instance: EffectInstance }
|
| { kind: 'instance.add'; instance: EffectInstance }
|
||||||
| { kind: 'instance.remove'; id: string }
|
| { kind: 'instance.remove'; id: string }
|
||||||
| { kind: 'field.erase'; points: NPoint[]; radiusN: number };
|
| {
|
||||||
|
kind: 'field.erase';
|
||||||
|
points: NPoint[];
|
||||||
|
radiusN: number;
|
||||||
|
/** Если задано — стираются только эти типы полевых эффектов. */
|
||||||
|
types?: readonly ('fog' | 'fire' | 'rain' | 'water')[];
|
||||||
|
/**
|
||||||
|
* `inclusive` (по умолчанию) — ластик: radius кисти + radius эффекта.
|
||||||
|
* `brush` — гашение огня водой/дождём: только radius кисти.
|
||||||
|
*/
|
||||||
|
hitMode?: 'inclusive' | 'brush';
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user