Skip to content
Open
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
101 changes: 0 additions & 101 deletions addons/README.md

This file was deleted.

13 changes: 13 additions & 0 deletions addons/acoustic/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
val acousticVersion: String by rootProject
val serializationVersion: String by rootProject
val acousticApi = rootProject.files("libs/acoustic-0.2.0.jar")

base {
archivesName.set("HollowEngineAcoustic")
}

dependencies {
add("modCompileOnly", acousticApi)
add("testImplementation", acousticApi)
add("testImplementation", "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package ru.hollowhorizon.hollowengine.addons.acoustic

import kotlinx.coroutines.CoroutineScope
import ru.hollowhorizon.hollowengine.addons.acoustic.client.AcousticClientIntegration
import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext
import ru.hollowhorizon.hollowengine.common.addons.HollowAddonEntrypoint
import ru.hollowhorizon.hollowengine.common.addons.publish
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticIntegration
import ru.hollowhorizon.hollowengine.common.utils.isPhysicalClient

class AcousticAddon : HollowAddonEntrypoint {
override suspend fun load(context: HollowAddonContext, scope: CoroutineScope) {
if (isPhysicalClient) AcousticClientIntegration.install(context)
context.hostServices.publish<AcousticIntegration>(AcousticIntegrationAdapter())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package ru.hollowhorizon.hollowengine.addons.acoustic

import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.entity.Entity
import org.bmp.acoustic.Acoustic
import org.bmp.acoustic.AcousticSourceBuilder
import org.bmp.acoustic.SoundBuilder
import org.bmp.acoustic.UpdateBuilder
import org.bmp.acoustic.source.AcousticEntityAnchor as TargetEntityAnchor
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticEntityAnchor
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticIntegration
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticLoop
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticPlayOptions
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticPlayRequest
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticPlayback
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticSource
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticStopRequest
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticUpdateOptions
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticUpdateRequest

internal class AcousticIntegrationAdapter : AcousticIntegration {
override fun play(request: AcousticPlayRequest): AcousticPlayback {
require(request.players.isNotEmpty()) { "Acoustic playback needs at least one receiving player" }
val players = request.players.distinctBy(ServerPlayer::getUUID)
val condition = request.options.condition
val recipients = condition?.let { predicate -> players.filter(predicate) } ?: players
val options = if (condition == null) request.options else request.options.copy(condition = null)
val builder = Acoustic.soundManager.play(request.sound, recipients)
builder.applyOptions(options)
val modelTarget = options.source?.hollowModelTargetOrNull()
if (modelTarget != null) {
SetHollowModelAcousticTargetPacket(builder.instanceId, modelTarget).send(recipients)
}
val playback = AcousticPlayback(builder.execute())
if (options.source != null && modelTarget == null) {
SetHollowModelAcousticTargetPacket(playback.instanceId, null).send(recipients)
}
return playback
}

override fun update(request: AcousticUpdateRequest) {
require(request.players.isNotEmpty()) { "Acoustic update needs at least one receiving player" }
val players = request.players.distinctBy(ServerPlayer::getUUID)
val modelTarget = request.options.source?.hollowModelTargetOrNull()
if (modelTarget != null) {
SetHollowModelAcousticTargetPacket(request.playback.instanceId, modelTarget).send(players)
}
Acoustic.soundManager.update(request.playback.instanceId, players)
.applyOptions(request.options)
.execute()
if (request.options.source != null && modelTarget == null) {
SetHollowModelAcousticTargetPacket(request.playback.instanceId, null).send(players)
}
}

override fun stop(request: AcousticStopRequest) {
require(request.players.isNotEmpty()) { "Acoustic stop needs at least one receiving player" }
Acoustic.soundManager.stop(
request.playback.instanceId,
request.players.distinctBy(ServerPlayer::getUUID),
)
.fadeOut(request.fadeOutSeconds)
.execute()
}
}

internal fun SoundBuilder.applyOptions(
options: AcousticPlayOptions,
): SoundBuilder = apply {
applyLoop(options.loop)
options.startOffsetSeconds?.let(::start)
options.endOffsetSeconds?.let(::end)
options.volume?.let(::volume)
options.pitch?.let(::pitch)
options.fadeIn?.let { fade -> fadeIn(fade.seconds, fade.repeatOnLoop) }
options.fadeOut?.let { fade -> fadeOut(fade.seconds, fade.repeatOnLoop) }
options.exclusive?.let(::exclusive)
options.priority?.let(::priority)
options.priorityFadeOutSeconds?.let(::priorityFadeOut)
options.source?.let(::applySource)
options.range?.let(::range)
options.sourceTimeoutSeconds?.let(::sourceTimeout)
options.instanceId?.let(::withId)
options.condition?.let { predicate -> condition { player -> predicate(player) } }
}

internal fun UpdateBuilder.applyOptions(
options: AcousticUpdateOptions,
): UpdateBuilder = apply {
applyLoop(options.loop)
options.startOffsetSeconds?.let(::start)
options.endOffsetSeconds?.let(::end)
options.volume?.let { update -> volume(update.value, update.transitionSeconds) }
options.pitch?.let { update -> pitch(update.value, update.transitionSeconds) }
options.fadeIn?.let { fade -> fadeIn(fade.seconds, fade.repeatOnLoop) }
options.fadeOut?.let { fade -> fadeOut(fade.seconds, fade.repeatOnLoop) }
options.exclusive?.let(::exclusive)
options.priority?.let(::priority)
options.priorityFadeOutSeconds?.let(::priorityFadeOut)
options.source?.let(::applySource)
options.range?.let(::range)
}

private fun SoundBuilder.applyLoop(loop: AcousticLoop?) {
when (loop) {
null -> Unit
AcousticLoop.Infinite -> loop()
is AcousticLoop.Count -> loop(loop.count)
}
}

private fun UpdateBuilder.applyLoop(loop: AcousticLoop?) {
when (loop) {
null -> Unit
AcousticLoop.Infinite -> loop()
is AcousticLoop.Count -> loop(loop.count)
}
}

private fun AcousticSourceBuilder<*>.applySource(source: AcousticSource) {
when (source) {
AcousticSource.Listener -> listener()
is AcousticSource.Position -> at(source.position)
is AcousticSource.EntityAnchor -> {
source.entity.requireLogicalServerSource()
follow(source.entity, source.anchor.toTarget())
}
is AcousticSource.VanillaAttachment -> {
source.entity.requireLogicalServerSource()
attachTo(source.entity, source.attachment, source.index)
}
is AcousticSource.NamedAttachment -> attachTo(source.attachmentId)
is AcousticSource.HollowModel -> {
source.entity.requireLogicalServerSource()
val target = HollowModelAcousticTarget.from(source)
attachTo(target.attachmentId)
}
}
}

private fun AcousticSource.hollowModelTargetOrNull(): HollowModelAcousticTarget? =
(this as? AcousticSource.HollowModel)?.let(HollowModelAcousticTarget::from)

private fun Entity.requireLogicalServerSource() {
require(!level().isClientSide) {
"An Acoustic entity source must be created from the logical server entity"
}
}

private fun AcousticEntityAnchor.toTarget(): TargetEntityAnchor = when (this) {
AcousticEntityAnchor.FEET -> TargetEntityAnchor.FEET
AcousticEntityAnchor.CENTER -> TargetEntityAnchor.CENTER
AcousticEntityAnchor.EYES -> TargetEntityAnchor.EYES
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package ru.hollowhorizon.hollowengine.addons.acoustic

import kotlinx.serialization.Serializable
import net.minecraft.resources.ResourceLocation
import net.minecraft.world.entity.player.Player
import ru.hollowhorizon.hollowengine.addons.acoustic.client.AcousticModelAttachmentPublisher
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticSource
import ru.hollowhorizon.hollowengine.common.integrations.acoustic.HollowModelAcousticAnchor
import ru.hollowhorizon.hollowengine.common.network.HollowAddonPacket
import ru.hollowhorizon.hollowengine.common.network.HollowPacketHandler
import ru.hollowhorizon.hollowengine.common.utils.nbt.ForUuid
import java.util.UUID

@Serializable
internal data class HollowModelAcousticTarget(
val entityUuid: @Serializable(ForUuid::class) UUID,
val nodeId: @Serializable(ForUuid::class) UUID,
val anchorKind: HollowModelAnchorKind,
val anchorSegments: List<String> = emptyList(),
) {
init {
when (anchorKind) {
HollowModelAnchorKind.ROOT -> require(anchorSegments.isEmpty()) {
"A model-root Acoustic target cannot contain bone segments"
}
HollowModelAnchorKind.UNIQUE_BONE_NAME -> require(anchorSegments.size == 1) {
"A bone-name Acoustic target needs exactly one name"
}
HollowModelAnchorKind.BONE_PATH -> require(anchorSegments.isNotEmpty()) {
"A bone-path Acoustic target cannot be empty"
}
}
require(anchorSegments.none(String::isBlank)) { "Acoustic bone segments cannot be blank" }
}

val attachmentId: ResourceLocation
get() = ResourceLocation.fromNamespaceAndPath(RESOURCE_NAMESPACE, buildString {
append("acoustic/model/")
append(entityUuid.compact())
append('/')
append(nodeId.compact())
append('/')
when (anchorKind) {
HollowModelAnchorKind.ROOT -> append("root")
HollowModelAnchorKind.UNIQUE_BONE_NAME -> {
append("bone/")
append(anchorSegments.single().utf8Hex())
}
HollowModelAnchorKind.BONE_PATH -> {
append("path")
anchorSegments.forEach { segment -> append('/').append(segment.utf8Hex()) }
}
}
})

companion object {
fun from(source: AcousticSource.HollowModel): HollowModelAcousticTarget {
val (kind, segments) = when (val anchor = source.anchor) {
HollowModelAcousticAnchor.Root -> HollowModelAnchorKind.ROOT to emptyList()
is HollowModelAcousticAnchor.BoneName ->
HollowModelAnchorKind.UNIQUE_BONE_NAME to listOf(anchor.name)
is HollowModelAcousticAnchor.BonePath -> HollowModelAnchorKind.BONE_PATH to anchor.segments
}
return HollowModelAcousticTarget(source.entity.uuid, source.nodeId, kind, segments)
}

private const val RESOURCE_NAMESPACE = "hollowengine"
}
}

@Serializable
internal enum class HollowModelAnchorKind {
ROOT,
UNIQUE_BONE_NAME,
BONE_PATH,
}

@Serializable
@HollowPacketHandler(HollowPacketHandler.Direction.TO_CLIENT)
internal data class SetHollowModelAcousticTargetPacket(
val playbackId: String,
val target: HollowModelAcousticTarget?,
) : HollowAddonPacket {
init {
require(playbackId.isNotBlank()) { "Acoustic playback ID cannot be blank" }
}

override fun handle(player: Player) {
if (!player.level().isClientSide) return
AcousticModelAttachmentPublisher.setTarget(playbackId, target, player.level())
}
}

private fun UUID.compact(): String = toString().replace("-", "")

private fun String.utf8Hex(): String = encodeToByteArray().joinToString("") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
Loading