Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spx-gui/src/components/editor/sprite/AnimationDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<AnimationPlayer
:costumes="animation.costumes"
:sound="sound"
:sound-playback="animation.soundPlayback"
:duration="animation.duration"
class="w-full flex-[1_1_0]"
/>
Expand Down
112 changes: 86 additions & 26 deletions spx-gui/src/components/editor/sprite/animation/AnimationPlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,35 @@ import { onUnmounted, ref, watchEffect } from 'vue'
import { registerPlayer as registerAudioPlayer } from '@/utils/player-registry'
import { useActivated } from '@/utils/utils'
import { Cancelled, capture } from '@/utils/exception'
import { AnimationSoundPlayback } from '@/models/spx/animation'
import type { Costume } from '@/models/spx/costume'
import type { Sound } from '@/models/spx/sound'
import { UILoading } from '@/components/ui'
import CostumesPlayer from '@/components/common/CostumesPlayer.vue'
import CheckerboardBackground from '../CheckerboardBackground.vue'
import MuteSwitch from './MuteSwitch.vue'

const props = defineProps<{
costumes: Costume[]
sound: Sound | null
duration: number
}>()
const props = withDefaults(
defineProps<{
costumes: Costume[]
sound: Sound | null
soundPlayback?: AnimationSoundPlayback
duration: number
}>(),
{
soundPlayback: AnimationSoundPlayback.Once
}
)

const registered = registerAudioPlayer(() => setMuted(true))
const audioRef = ref<HTMLAudioElement | null>(null)
const audios = new Set<HTMLAudioElement>()
const mutedRef = ref(true)
function setMuted(muted: boolean) {
mutedRef.value = muted
if (audioRef.value != null) {
audioRef.value.muted = muted
for (const audio of audios) {
audio.muted = muted
}
if (props.sound != null) {
Copy link
Copy Markdown
Collaborator Author

@nighca nighca May 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里是特意判断 props.sound 是否为空而不是判断 audios 是否为空;因为 audios 为空有可能是在两个 audio 播放的间隙(playback: Once 的情况下),并不代表没有声音

if (muted) registered.onStopped()
else registered.onStart()
}
Expand All @@ -50,25 +59,70 @@ async function loadAudio(sound: Sound, signal: AbortSignal) {
return audio
}

function playAudio(audio: HTMLAudioElement, duration: number, signal: AbortSignal) {
audio.muted = mutedRef.value
audioRef.value = audio
const playFromStart = () => {
try {
audio.currentTime = 0
audio.play()
} catch {
// We can get an error from `play()` if the sound is not loaded yet
// or if the sound is not allowed to play
}
// Limit the number of concurrently playing audio instances to prevent overwhelming the browser and causing performance issues or crashes.
const concurrentAudioLimit = 10

/**
* Play the audio with `playback: AnimationSoundPlayback.Once`.
* The sound is triggered once per animation cycle (duration).
* If the previous sound is still playing when the next cycle starts, it will keep playing.
* In that case, multiple sound instances can overlap.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIP: 意思是如果 animation duration 比 sound 时长短,就会有同一个 sound 的不同时段叠加播放,极限情况可能叠加 10 遍?这样设计是合理的吗

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

如果 animation duration 比 sound 时长短,就会有同一个 sound 的不同时段叠加播放

是的

极限情况可能叠加 10 遍

在编辑器这边限制为 10,在 spx 运行的时候我不确定,可能没有限制

这样设计是合理的吗

它针对的是某个动作,其音效比视觉上的时间略久的情况;比如人物做一次攻击,1s 就做完动作了,但是对应的声音有可能是 1.2s,这后边的 0.2s 有可能是动作的尾音,这从效果上是合理的。如果人物很快又做一次攻击动作,那么两个声音发生重叠也是预期的。

因此一般来说选择 playback: once 的应该是

  1. 像攻击、跳跃这种(区别于待机、行走等)不会默认循环播放的动画
  2. 比较短的、跟动画本身时长在一个数量级的音效

所以实际上不会有问题

如果这里真的达到了到 10 的限制,那大概率是配错了,我没想到有什么合理的场景,是需要为一个 animation 绑一个时长是它 10 倍的 playback: once 的 sound 的

*/
function playAudioWithPlaybackOnce(audio: HTMLAudioElement, duration: number, signal: AbortSignal) {
function playOnce() {
// Create a new audio element to play the sound so it plays independently
const newAudio = new Audio(audio.src)
newAudio.muted = mutedRef.value
audios.add(newAudio)
newAudio.addEventListener('ended', () => audios.delete(newAudio), { once: true })
signal.addEventListener(
'abort',
() => {
newAudio.pause()
audios.delete(newAudio)
},
{ once: true }
)
setTimeout(
() => {
// Stop the newAudio after some time to prevent too many overlapping audios
newAudio.pause()
audios.delete(newAudio)
},
duration * 1000 * concurrentAudioLimit
)
newAudio.play()
}
playOnce()
const timer = setInterval(playOnce, duration * 1000)
signal.addEventListener('abort', () => clearInterval(timer), { once: true })
}

/**
* Play the audio with `playback: AnimationSoundPlayback.Loop`.
* The sound loops continuously within each animation cycle.
* When a new cycle starts, playback is reset to the beginning.
* This prevents overlapping between cycles.
*/
function playAudioWithPlaybackLoop(audio: HTMLAudioElement, duration: number, signal: AbortSignal) {
function playFromStart() {
audio.currentTime = 0
audio.play()
}
audio.loop = true
audio.muted = mutedRef.value
audios.add(audio)
signal.addEventListener(
'abort',
() => {
audio.pause()
audios.delete(audio)
},
{ once: true }
)
playFromStart()
const timer = setInterval(playFromStart, duration * 1000)
signal.addEventListener('abort', async () => {
clearInterval(timer)
audio.pause()
audioRef.value = null
})
signal.addEventListener('abort', () => clearInterval(timer), { once: true })
}

const activatedRef = useActivated()
Expand All @@ -93,15 +147,21 @@ watchEffect(async () => {
if (costumesPlayer == null) return
try {
const signal = ctrl.signal
const { costumes, sound, duration } = props
const { costumes, sound, soundPlayback, duration } = props
const [, audio] = await Promise.all([
costumesPlayer.load(costumes, duration, signal),
sound != null ? loadAudio(sound, signal) : null
])
signal.throwIfAborted()

costumesPlayer.play(signal)
if (audio != null) playAudio(audio, duration, signal)
if (audio != null) {
if (soundPlayback === AnimationSoundPlayback.Once) {
playAudioWithPlaybackOnce(audio, duration, signal)
} else if (soundPlayback === AnimationSoundPlayback.Loop) {
playAudioWithPlaybackLoop(audio, duration, signal)
}
}
} catch (e) {
ctrl.abort(e)
capture(e, 'load and play animation failed')
Expand Down
73 changes: 67 additions & 6 deletions spx-gui/src/components/editor/sprite/animation/SoundEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
<UIDropdownForm
v-radar="{ name: 'Sound editor dropdown form', desc: 'Dropdown form for selecting animation sound' }"
:title="$t(actionName)"
style="width: 320px; max-height: 400px"
style="width: 408px; max-height: 400px"
@cancel="emit('close')"
@confirm="handleConfirm"
>
<ul class="flex-[1_1_0] flex flex-wrap content-start gap-3">
<ul class="flex-[1_1_0] flex flex-wrap content-start gap-2">
<SoundItem
v-for="sound in editorCtx.project.sounds"
:key="sound.id"
Expand Down Expand Up @@ -42,13 +42,63 @@
</UIMenu>
</UIDropdown>
</ul>
<template #footer-left>
<div v-if="selected != null" class="flex h-8 items-center gap-2">
<span class="text-base text-grey-900">
{{ $t({ en: 'Playback', zh: '播放' }) }}
</span>
<UITooltip>
{{
$t(
selectedPlayback === AnimationSoundPlayback.Loop
? {
en: 'Loop the sound during each animation playback and stop it when the animation stops',
zh: '声音在动画的单次播放周期内循环播放,并在动画停止时停止'
}
: {
en: 'Play the sound once and let it complete independently of the animation',
zh: '声音播放一次,并独立于动画完整播放'
}
)
}}
<template #trigger>
<UISelect
v-radar="{
name: 'Animation sound playback selector',
desc: 'Select how the selected sound plays with the animation'
}"
:value="selectedPlayback"
class="min-w-[80px]"
@update:value="handlePlaybackUpdate"
>
<UISelectOption :value="AnimationSoundPlayback.Once">
{{ $t({ en: 'Once', zh: '一次' }) }}
</UISelectOption>
<UISelectOption :value="AnimationSoundPlayback.Loop">
{{ $t({ en: 'Loop', zh: '循环' }) }}
</UISelectOption>
</UISelect>
</template>
</UITooltip>
</div>
</template>
</UIDropdownForm>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { Animation } from '@/models/spx/animation'
import { UIDropdownForm, UIDropdown, UIMenu, UIMenuItem, UIBlockItem, UIIcon } from '@/components/ui'
import { AnimationSoundPlayback, type Animation } from '@/models/spx/animation'
import {
UIDropdownForm,
UIDropdown,
UIMenu,
UIMenuItem,
UIBlockItem,
UIIcon,
UISelect,
UISelectOption,
UITooltip
} from '@/components/ui'
import { useEditorCtx } from '@/components/editor/EditorContextProvider.vue'
import SoundItem from '@/components/editor/stage/sound/SoundItem.vue'
import { useAddAssetFromLibrary, useAddSoundFromLocalFile, useAddSoundByRecording } from '@/components/asset'
Expand All @@ -67,8 +117,16 @@ const editorCtx = useEditorCtx()

const actionName = { en: 'Select sound', zh: '选择声音' }
const selected = ref(props.animation.sound)
const selectedPlayback = ref(props.animation.soundPlayback)

function handlePlaybackUpdate(playback: string | null) {
if (playback !== AnimationSoundPlayback.Once && playback !== AnimationSoundPlayback.Loop) return
selectedPlayback.value = playback
}

async function handleSoundClick(sound: string) {
selected.value = selected.value === sound ? null : sound
if (selected.value === sound) selected.value = null
else selected.value = sound
}

const addFromLocalFile = useAddSoundFromLocalFile()
Expand Down Expand Up @@ -102,7 +160,10 @@ const handleRecord = useMessageHandle(
).fn

async function handleConfirm() {
await editorCtx.state.history.doAction({ name: actionName }, () => props.animation.setSound(selected.value))
await editorCtx.state.history.doAction({ name: actionName }, () => {
props.animation.setSound(selected.value)
props.animation.setSoundPlayback(selectedPlayback.value)
})
emit('close')
}
</script>
35 changes: 20 additions & 15 deletions spx-gui/src/components/ui/UIDropdownForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,26 @@
<main class="flex-auto min-h-0 overflow-y-auto px-4 py-3">
<slot></slot>
</main>
<footer class="flex-none flex justify-end gap-3 p-4">
<UIButton
v-radar="{ name: 'Cancel button', desc: 'Click to cancel the operation in dropdown' }"
type="neutral"
@click="emit('cancel')"
>
{{ $t({ en: 'Cancel', zh: '取消' }) }}
</UIButton>
<UIButton
v-radar="{ name: 'Confirm button', desc: 'Click to submit the dropdown' }"
type="primary"
html-type="submit"
>
{{ $t({ en: 'Confirm', zh: '确认' }) }}
</UIButton>
<footer class="flex-none flex items-center justify-between gap-3 p-4">
<div class="min-w-0 flex items-center">
<slot name="footer-left"></slot>
</div>
<div class="flex justify-end gap-3">
<UIButton
v-radar="{ name: 'Cancel button', desc: 'Click to cancel the operation in dropdown' }"
type="neutral"
@click="emit('cancel')"
>
{{ $t({ en: 'Cancel', zh: '取消' }) }}
</UIButton>
<UIButton
v-radar="{ name: 'Confirm button', desc: 'Click to submit the dropdown' }"
type="primary"
html-type="submit"
>
{{ $t({ en: 'Confirm', zh: '确认' }) }}
</UIButton>
</div>
</footer>
</form>
</template>
Expand Down
Loading