diff --git a/addons/README.md b/addons/README.md deleted file mode 100644 index d40134b2e..000000000 --- a/addons/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# HollowEngine addons - -Every directory below `addons/` that contains a `build.gradle.kts` is discovered automatically as a Gradle subproject. Addon builds receive Minecraft, mappings, HollowEngine runtime, Kotlin, coroutines, and Koin on their compile classpath. - -Build every addon with: - -```shell -./gradlew buildAddons -``` - -The resulting platform artifacts are collected in `build/addon-jars/`: - -- `*-fabric.jar` is remapped to Fabric's intermediary namespace. -- `*-neoforge.jar` uses the official namespace used by NeoForge. - -Copy the artifact for the active loader to the game's `hollowengine/addons/` directory. The runtime watches this directory and reloads changed jars. - -Runtime diagnostics and lifecycle controls are available to operators: - -```text -/he addons list -/he addons enable -/he addons disable -/he addons reload -``` - -Disabled ids are persisted in `hollowengine/addons/.disabled-addons`. The `debug-command` example has no bootstrap libraries and can therefore be copied and loaded while Minecraft is running. It directly handles `RegisterCommandsEvent` and adds `/he addon-text `. - -Command addons use Brigadier directly from `@SubscribeEvent`. `RegisterCommandsEvent` replays the active dispatcher for a hot-loaded addon, and command nodes added by that scoped listener are removed automatically when the addon is disabled or reloaded. The video addon demonstrates the same mechanism with `/he video `. - -An addon's `build.gradle.kts` only needs its own settings and libraries. Dependencies added to `addonLibraries` are available during compilation, embedded as nested jars, and loaded in the addon's isolated classloader. Pure Java runtime-only libraries belong in `addonRuntimeLibraries`. - -Libraries that load native code or keep process-global state belong in `addonBootstrapLibraries`. They are loaded into HollowEngine's stable runtime classloader before addon initialization. A newly copied or updated addon that contains bootstrap libraries is deliberately not hot-loaded: `HollowAddonManager.restartRequired` reports it, the log asks for a restart, and it becomes available on the next game launch. - -```kotlin -base.archivesName.set("MyAddon") - -dependencies { - add("addonLibraries", "com.example:library:1.0.0") - add("addonRuntimeLibraries", "com.example:pure-java-runtime-library:1.0.0") - add("addonBootstrapLibraries", "com.example:native-library:1.0.0:windows-x86_64") -} -``` - -Do not bundle Minecraft-owned native stacks such as LWJGL, jemalloc, GLFW, OpenAL, OpenGL, STB, Vulkan, JNA, JInput, Netty, or OSHI. Addon builds reject them and the bootstrap validates external addon jars before loading. The game-provided versions must be used. - -Declare the addon in `src/main/resources/META-INF/plugin.properties`: - -```properties -id=my-addon -name=My Addon -version=${version} -entry=com.example.myaddon.MyAddon -dependsOn=another-addon -environment=common -``` - -Entrypoints receive a lifecycle `CoroutineScope`. Public `@SubscribeEvent` methods declared on the entrypoint, a Kotlin `object`, or as static/top-level functions are discovered automatically. HollowEngine registers them in that scope; cancelling the scope during unload removes all of them. - -```kotlin -class MyAddon : HollowAddonEntrypoint { - override suspend fun load(context: HollowAddonContext, scope: CoroutineScope) { - // Start addon coroutines and publish services here. - } - - @SubscribeEvent - fun onServerTick(event: TickEvent.Server) { - // Handle the event synchronously. - } -} -``` - -## Scripts - -An addon can ship `.kts` scripts of its own in `src/main/resources/scripts`. The build compiles them with the same compiler and the same remapping the game uses, and packs both the sources and the compiled artifacts into the platform variants of the addon jar, so the scripts run in a modpack that never installs the compiler addon. A compilation error fails the build. `debug-command` carries one as an example. - -Scripts belong to the namespace named by the addon's `id`, and are addressed with it everywhere a script path is accepted: - -```text -/he scripting run my-addon:nodes/quest.node.kts -``` - -The `hollowengine` directory is the same kind of thing - an unpacked addon. Its scripts live in `hollowengine/scripts` and its namespace comes from an optional `hollowengine/META-INF/plugin.properties`, defaulting to `hollowengine-sandbox`, which addons may not claim. Paths written without a namespace always mean that directory, so existing world saves and commands keep working whatever it calls itself. - -Scripts compile against the classpath of the namespace that owns them and run under its classloader, so an addon's scripts see the addon's own classes and its `addonLibraries`. `@file:Import("other-addon:shared.kts")` reaches into another namespace and requires it in `dependsOn`; a plain name is resolved next to the importing script. - -Enabling, reloading or disabling an addon starts and stops its scripts with it. A disabled addon's nodes keep the state they were stopped with, and resume from it when it comes back. - -Compiled scripts are also cached at runtime, in `hollowengine/cache/scripts`, keyed by the sources, the engine build, the Kotlin and Minecraft versions and the mapping namespace. Fill the cache for a whole pack before shipping it with: - -```text -/he scripting compile -``` - -If a cached or shipped artifact no longer matches its sources and no compiler is installed, it is used anyway and the log says so - a modpack without the compiler has nothing better to fall back on. - -To ship compiled scripts without their sources, build the addon with: - -```shell -./gradlew buildAddons -Phollowengine.scripts.includeSources=false -``` diff --git a/addons/acoustic/build.gradle.kts b/addons/acoustic/build.gradle.kts new file mode 100644 index 000000000..49edd1a61 --- /dev/null +++ b/addons/acoustic/build.gradle.kts @@ -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") +} diff --git a/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/AcousticAddon.kt b/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/AcousticAddon.kt new file mode 100644 index 000000000..e1a37b78e --- /dev/null +++ b/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/AcousticAddon.kt @@ -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(AcousticIntegrationAdapter()) + } +} diff --git a/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/AcousticIntegrationAdapter.kt b/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/AcousticIntegrationAdapter.kt new file mode 100644 index 000000000..4b3aa8808 --- /dev/null +++ b/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/AcousticIntegrationAdapter.kt @@ -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 +} diff --git a/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/HollowModelAcousticTarget.kt b/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/HollowModelAcousticTarget.kt new file mode 100644 index 000000000..4b020e469 --- /dev/null +++ b/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/HollowModelAcousticTarget.kt @@ -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 = 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') +} diff --git a/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/client/AcousticClientIntegration.kt b/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/client/AcousticClientIntegration.kt new file mode 100644 index 000000000..aed995e7c --- /dev/null +++ b/addons/acoustic/src/main/java/ru/hollowhorizon/hollowengine/addons/acoustic/client/AcousticClientIntegration.kt @@ -0,0 +1,255 @@ +package ru.hollowhorizon.hollowengine.addons.acoustic.client + +import net.minecraft.client.multiplayer.ClientLevel +import net.minecraft.resources.ResourceLocation +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.LivingEntity +import net.minecraft.world.level.Level +import net.minecraft.world.phys.Vec3 +import org.bmp.acoustic.client.AcousticClientManager +import org.bmp.acoustic.client.source.AcousticAttachmentHandle +import org.bmp.acoustic.client.source.AcousticClientAttachments +import org.bmp.acoustic.client.source.AcousticEmitterTransform +import ru.hollowhorizon.hollowengine.addons.acoustic.HollowModelAcousticTarget +import ru.hollowhorizon.hollowengine.addons.acoustic.HollowModelAnchorKind +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext +import ru.hollowhorizon.hollowengine.common.addons.extensions +import ru.hollowhorizon.hollowengine.common.addons.minecraft +import ru.hollowhorizon.hollowengine.common.addons.subscribe +import ru.hollowhorizon.hollowengine.common.attachments.api.findEntityByUuid +import ru.hollowhorizon.hollowengine.common.attachments.binding.NodeRuntimeState +import ru.hollowhorizon.hollowengine.common.attachments.binding.modelNodes +import ru.hollowhorizon.hollowengine.common.events.client.render.RenderLevelStageEvent +import ru.hollowhorizon.hollowengine.common.events.client.render.RenderStage +import ru.hollowhorizon.hollowengine.client.models.internal.v2.ModelAttachment +import ru.hollowhorizon.hollowengine.client.models.internal.v2.RuntimeNode +import ru.hollowhorizon.hollowengine.client.models.internal.v2.modelInstanceOrNull +import ru.hollowhorizon.hollowengine.client.render.resolveNodeWorldTransform +import ru.hollowhorizon.hollowengine.common.utils.math.MutableMat4f +import ru.hollowhorizon.hollowengine.common.utils.math.MutableVec3f +import ru.hollowhorizon.hollowengine.common.utils.math.Vec3f + +internal object AcousticClientIntegration { + fun install(context: HollowAddonContext) { + context.extensions.onUnload(AcousticModelAttachmentPublisher::close) + context.minecraft.subscribe(priority = -10) { event -> + if (event.stage == RenderStage.AFTER_SKY) { + AcousticModelAttachmentPublisher.update(event.partialTick) + } + } + } +} + +internal object AcousticModelAttachmentPublisher : AutoCloseable { + private val lock = Any() + private val tracked = LinkedHashMap() + private val playbackTargets = HashMap() + private var level: ClientLevel? = null + + fun setTarget(playbackId: String, target: HollowModelAcousticTarget?, sourceLevel: Level) { + val clientLevel = sourceLevel as? ClientLevel ?: return + synchronized(lock) { + changeLevel(clientLevel) + val previous = playbackTargets[playbackId] + if (target == null) { + removePlayback(playbackId) + return + } + if (previous?.attachmentId == target.attachmentId) return + + removePlayback(playbackId) + val trackedTarget = tracked.getOrPut(target.attachmentId) { + TrackedTarget(target, AcousticClientAttachments.register(target.attachmentId)) + } + trackedTarget.playbackIds += playbackId + playbackTargets[playbackId] = PlaybackTarget( + attachmentId = target.attachmentId, + hasPlayed = AcousticClientManager.isPlaying(playbackId), + ) + } + } + + fun update(partialTick: Float) { + val (currentLevel, snapshot) = synchronized(lock) { + val currentLevel = level ?: return + removeFinishedPlaybacks() + currentLevel to tracked.values.toList() + } + snapshot.forEach { trackedTarget -> update(currentLevel, trackedTarget, partialTick) } + } + + override fun close() { + synchronized(lock) { + tracked.values.forEach { target -> target.handle.close() } + tracked.clear() + playbackTargets.clear() + level = null + } + } + + private fun update(level: ClientLevel, trackedTarget: TrackedTarget, partialTick: Float) { + val previousEntity = synchronized(lock) { + if (tracked[trackedTarget.target.attachmentId] !== trackedTarget) return + trackedTarget.entity + } + if (previousEntity.isPermanentlyRemoved()) { + remove(trackedTarget) + return + } + + val entity = previousEntity + ?.takeUnless(Entity::isRemoved) + ?: level.findEntityByUuid(trackedTarget.target.entityUuid) + if (entity == null) { + markUnavailable(trackedTarget) + return + } + if (entity.isPermanentlyRemoved()) { + remove(trackedTarget) + return + } + synchronized(lock) { + if (tracked[trackedTarget.target.attachmentId] !== trackedTarget) return + trackedTarget.entity = entity + } + + val modelNode = NodeRuntimeState.service(level) + .snapshot(entity.uuid) + ?.modelNodes() + ?.singleOrNull { node -> node.nodeId == trackedTarget.target.nodeId } + if (modelNode == null) { + markUnavailable(trackedTarget) + return + } + val attachment = entity.modelInstanceOrNull(modelNode.nodeId, modelNode.model.model)?.attachment + if (attachment == null) { + markUnavailable(trackedTarget) + return + } + val localNode = attachment.resolve(trackedTarget.target) + if (trackedTarget.target.anchorKind != HollowModelAnchorKind.ROOT && localNode == null) { + markUnavailable(trackedTarget) + return + } + + val matrix = MutableMat4f().set(resolveNodeWorldTransform(entity, modelNode.transform, partialTick).matrixF) + localNode?.let { node -> matrix.mul(node.globalMatrix) } + val position = matrix.transform(Vec3f.ZERO, 1f, MutableVec3f()) + synchronized(lock) { + if (tracked[trackedTarget.target.attachmentId] !== trackedTarget) return + trackedTarget.handle.update( + AcousticEmitterTransform( + Vec3(position.x.toDouble(), position.y.toDouble(), position.z.toDouble()), + entity.deltaMovement, + null, + ), + ) + trackedTarget.hasPosition = true + trackedTarget.isUnavailable = false + } + } + + private fun markUnavailable(target: TrackedTarget) { + synchronized(lock) { + if (tracked[target.target.attachmentId] === target) target.markUnavailableIfNeeded() + } + } + + private fun remove(target: TrackedTarget) { + synchronized(lock) { + if (!tracked.remove(target.target.attachmentId, target)) return + target.playbackIds.forEach(playbackTargets::remove) + target.handle.close() + } + } + + private fun changeLevel(next: ClientLevel) { + if (level === next) return + tracked.values.forEach { target -> target.handle.close() } + tracked.clear() + playbackTargets.clear() + level = next + } + + private fun removeFinishedPlaybacks() { + val finished = buildList { + playbackTargets.forEach { (playbackId, target) -> + if (AcousticClientManager.isPlaying(playbackId)) { + target.hasPlayed = true + target.inactiveChecks = 0 + } else if (target.hasPlayed || ++target.inactiveChecks >= MAX_PENDING_CHECKS) { + add(playbackId) + } + } + } + finished.forEach(::removePlayback) + } + + private fun removePlayback(playbackId: String) { + val playbackTarget = playbackTargets.remove(playbackId) ?: return + val trackedTarget = tracked[playbackTarget.attachmentId] ?: return + trackedTarget.playbackIds -= playbackId + if (trackedTarget.playbackIds.isEmpty()) { + tracked.remove(playbackTarget.attachmentId) + trackedTarget.handle.close() + } + } + + private fun ModelAttachment.resolve(target: HollowModelAcousticTarget): RuntimeNode? = when (target.anchorKind) { + HollowModelAnchorKind.ROOT -> null + HollowModelAnchorKind.UNIQUE_BONE_NAME -> allNodes() + .filter { node -> node.name == target.anchorSegments.single() } + .singleOrNull() + HollowModelAnchorKind.BONE_PATH -> allNodePaths() + .filter { (_, path) -> + path == target.anchorSegments || path.size > 1 && path.drop(1) == target.anchorSegments + } + .map(Pair>::first) + .singleOrNull() + } + + private fun ModelAttachment.allNodes(): Sequence = + nodes.asSequence().flatMap { node -> node.walkNodes() } + + private fun RuntimeNode.walkNodes(): Sequence = sequence { + yield(this@walkNodes) + children.forEach { child -> yieldAll(child.walkNodes()) } + } + + private fun ModelAttachment.allNodePaths(): Sequence>> = sequence { + nodes.forEach { root -> yieldAll(root.walkPaths(emptyList())) } + } + + private fun RuntimeNode.walkPaths(parentPath: List): Sequence>> = sequence { + val path = parentPath + name + yield(this@walkPaths to path) + children.forEach { child -> yieldAll(child.walkPaths(path)) } + } + + private fun Entity?.isPermanentlyRemoved(): Boolean = + this?.removalReason == Entity.RemovalReason.KILLED || (this is LivingEntity && isDeadOrDying) + + private data class TrackedTarget( + val target: HollowModelAcousticTarget, + val handle: AcousticAttachmentHandle, + var entity: Entity? = null, + var hasPosition: Boolean = false, + var isUnavailable: Boolean = false, + val playbackIds: MutableSet = LinkedHashSet(), + ) { + fun markUnavailableIfNeeded() { + if (!hasPosition || isUnavailable) return + handle.markTemporarilyUnavailable() + isUnavailable = true + } + } + + private data class PlaybackTarget( + val attachmentId: ResourceLocation, + var hasPlayed: Boolean, + var inactiveChecks: Int = 0, + ) + + /** Drops a tracking packet only if its immediately following Acoustic play packet never arrives. */ + private const val MAX_PENDING_CHECKS = 600 +} diff --git a/addons/acoustic/src/main/resources/META-INF/plugin.properties b/addons/acoustic/src/main/resources/META-INF/plugin.properties new file mode 100644 index 000000000..d20d6ac10 --- /dev/null +++ b/addons/acoustic/src/main/resources/META-INF/plugin.properties @@ -0,0 +1,6 @@ +id=hollowengine-acoustic +name=HollowEngine Acoustic Integration +version=${version} +entry=ru.hollowhorizon.hollowengine.addons.acoustic.AcousticAddon +environment=common +requiredClasses=org.bmp.acoustic.AcousticApi,org.bmp.acoustic.client.source.AcousticClientAttachments diff --git a/addons/ide-example/build.gradle.kts b/addons/ide-example/build.gradle.kts new file mode 100644 index 000000000..9429e36f5 --- /dev/null +++ b/addons/ide-example/build.gradle.kts @@ -0,0 +1,3 @@ +base { + archivesName.set("HollowEngineIdeExample") +} diff --git a/addons/ide-example/src/main/java/ru/hollowhorizon/hollowengine/addons/ide/IdeExampleAddon.kt b/addons/ide-example/src/main/java/ru/hollowhorizon/hollowengine/addons/ide/IdeExampleAddon.kt new file mode 100644 index 000000000..12fc26572 --- /dev/null +++ b/addons/ide-example/src/main/java/ru/hollowhorizon/hollowengine/addons/ide/IdeExampleAddon.kt @@ -0,0 +1,63 @@ +package ru.hollowhorizon.hollowengine.addons.ide + +import kotlinx.coroutines.CoroutineScope +import ru.hollowhorizon.hollowengine.client.ui.Column +import ru.hollowhorizon.hollowengine.client.ui.Text +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeFileDocument +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeFileType +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeMenu +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdeMenuItem +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdePanel +import ru.hollowhorizon.hollowengine.client.ui.ide.registerIdeFileType +import ru.hollowhorizon.hollowengine.client.ui.ide.registerIdeMenuItem +import ru.hollowhorizon.hollowengine.client.ui.ide.registerIdePanel +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonContext +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonEntrypoint +import ru.hollowhorizon.hollowengine.common.addons.extensions + +class IdeExampleAddon : HollowAddonEntrypoint { + override suspend fun load(context: HollowAddonContext, scope: CoroutineScope) { + val overviewPanelId = context.extensions.qualify("overview") + context.extensions.registerIdePanel( + HollowIdePanel( + id = "overview", + title = "IDE Addon Example", + content = { ide -> + Column { + Text("This dock panel is owned by a hot-reloadable addon.") + Text("Focused file: ${ide.focusedFile?.path ?: "none"}") + } + }, + ), + ) + context.extensions.registerIdeMenuItem( + HollowIdeMenuItem( + id = "open-overview", + menu = HollowIdeMenu.TOOLS, + label = "Open IDE Addon Example", + run = { ide -> ide.openPanel(overviewPanelId) }, + ), + ) + context.extensions.registerIdeFileType( + HollowIdeFileType.extensions( + id = "quest-preview", + extensions = listOf(".quest"), + priority = 100, + loader = { _, bytes -> QuestPreviewDocument(bytes.toString(Charsets.UTF_8)) }, + editor = { file -> + val document = file.document as QuestPreviewDocument + Column { + Text("Quest preview") + Text(document.text) + } + }, + ), + ) + } +} + +private class QuestPreviewDocument(val text: String) : HollowIdeFileDocument { + override val readOnly: Boolean = true + + override fun encode(): ByteArray = text.toByteArray() +} diff --git a/addons/ide-example/src/main/resources/META-INF/plugin.properties b/addons/ide-example/src/main/resources/META-INF/plugin.properties new file mode 100644 index 000000000..3941e1a71 --- /dev/null +++ b/addons/ide-example/src/main/resources/META-INF/plugin.properties @@ -0,0 +1,5 @@ +id=hollowengine-ide-example +name=HollowEngine IDE Example +version=${version} +entry=ru.hollowhorizon.hollowengine.addons.ide.IdeExampleAddon +environment=client diff --git a/addons/ide-example/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/ide/HollowIdePanelAbiTest.kt b/addons/ide-example/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/ide/HollowIdePanelAbiTest.kt new file mode 100644 index 000000000..902ccea3e --- /dev/null +++ b/addons/ide-example/src/test/kotlin/ru/hollowhorizon/hollowengine/addons/ide/HollowIdePanelAbiTest.kt @@ -0,0 +1,18 @@ +package ru.hollowhorizon.hollowengine.addons.ide + +import ru.hollowhorizon.hollowengine.client.ui.ide.HollowIdePanel +import kotlin.test.Test +import kotlin.test.assertEquals + +class HollowIdePanelAbiTest { + @Test + fun `addon and runtime use the same compose panel ABI`() { + val panel = HollowIdePanel( + id = "abi-test", + title = "ABI test", + content = {}, + ) + + assertEquals("abi-test", panel.id) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 0977d022a..3430046ce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,7 @@ fun Project.configureHollowAddon() { plugins.apply("java-library") plugins.apply("org.jetbrains.kotlin.jvm") plugins.apply("org.jetbrains.kotlin.plugin.serialization") + plugins.apply("org.jetbrains.kotlin.plugin.compose") plugins.apply("architectury-plugin") plugins.apply("dev.architectury.loom") @@ -40,6 +41,7 @@ fun Project.configureHollowAddon() { val fabricLoaderVersion = rootProject.property("fabricLoaderVersion") as String val kotlinVersion = rootProject.property("kotlinVersion") as String val serializationVersion = rootProject.property("serializationVersion") as String + val composeRuntimeVersion = rootProject.property("composeRuntimeVersion") as String val koinVersion = rootProject.property("koinVersion") as String group = "$modGroup.addons" @@ -103,7 +105,9 @@ fun Project.configureHollowAddon() { add("compileOnly", "org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") add("compileOnly", "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") add("compileOnly", "io.insert-koin:koin-core:$koinVersion") + add("compileOnly", "androidx.compose.runtime:runtime:$composeRuntimeVersion") add("testImplementation", kotlin("test")) + add("testImplementation", "androidx.compose.runtime:runtime:$composeRuntimeVersion") } val namedClassesJar = tasks.named("jar") { diff --git a/libs/acoustic-0.2.0.jar b/libs/acoustic-0.2.0.jar new file mode 100644 index 000000000..7461d4e24 Binary files /dev/null and b/libs/acoustic-0.2.0.jar differ diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt index da385e329..dc0e44fcb 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/bootstrap/RuntimeBridgeEntrypoint.kt @@ -99,6 +99,7 @@ import ru.hollowhorizon.hollowengine.common.events.entity.player.PlayerInteractE import ru.hollowhorizon.hollowengine.common.events.item.ArrowEvent import ru.hollowhorizon.hollowengine.common.events.level.LevelEvent import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent +import ru.hollowhorizon.hollowengine.common.events.registry.RegisterClientCommandsEvent import ru.hollowhorizon.hollowengine.common.events.registry.RegisterParticlesEvent import ru.hollowhorizon.hollowengine.common.events.registry.RegisterResourcePacksEvent import ru.hollowhorizon.hollowengine.common.events.registry.RegisterTagsEvent @@ -522,7 +523,7 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { } override fun onServerStopped(server: MinecraftServer) { - RegisterCommandsEvent.clearReplay() + RegisterCommandsEvent.clearReplaySnapshot() RuntimeDispatcherState.stopServer(server) ServerRuntimeState.remove(server) clearCurrentServer(server) @@ -574,6 +575,7 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { } override fun onLevelClosed(level: Level) { + if (level.isClientSide) RegisterClientCommandsEvent.clearReplaySnapshot() AttachmentRegistry.close(level) } @@ -806,7 +808,9 @@ class RuntimeBridgeEntrypoint : RuntimeBridge { UiScriptHudHost.render(layer, HudPlacement.BEFORE, nowNanos) val skip = event.isCanceled || HudLayerRegistry.isHidden(layer) - if (skip) UiScriptHudHost.render(layer, HudPlacement.AFTER, nowNanos) + if (skip) { + UiScriptHudHost.render(layer, HudPlacement.AFTER, nowNanos) + } return skip } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt index c51a66036..b93c45b1b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeEditorAdapters.kt @@ -4,10 +4,12 @@ import kotlinx.coroutines.* import net.minecraft.client.Minecraft import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.client.ui.ide.files.EditorLanguageService +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeLanguageService import ru.hollowhorizon.hollowengine.client.ui.ide.files.PlainEditorLanguageService import ru.hollowhorizon.hollowengine.client.ui.UiColor import ru.hollowhorizon.hollowengine.client.ui.widgets.* import ru.hollowhorizon.hollowengine.client.utils.lang +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonExtension import ru.hollowhorizon.hollowengine.common.scripting.ide.* import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicLong @@ -560,11 +562,23 @@ internal fun shiftDiagnosticsForEditedText( } internal fun languageServiceForPath(path: String): EditorLanguageService { - val fileName = path.substringBefore('?').substringBefore('#').substringAfterLast('/') - val extension = fileName.substringAfterLast('.', "").lowercase() - return runCatching { - EditorLanguageService(extension) - }.getOrNull() ?: PlainEditorLanguageService + ensureBuiltinIdeLanguagesRegistered() + val contributed = HollowIdeExtensionPoints.LANGUAGES.extensions().firstOrNull { extension -> + runCatching { extension.invoke { language -> language.matches(path) } } + .onFailure { failure -> + HollowEngine.LOGGER.error("IDE language extension '{}' failed while matching '{}'", extension.qualifiedId, path, failure) + } + .getOrDefault(false) + } + if (contributed != null) return ExtensionEditorLanguageService(contributed) + return PlainEditorLanguageService +} + +private class ExtensionEditorLanguageService( + private val extension: HollowAddonExtension, +) : EditorLanguageService { + override val analyzer: ScriptingAnalyzer + get() = extension.invoke(HollowIdeLanguageService::analyzer) } private fun List.toHighlights(text: String, lineStarts: List): List { diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt new file mode 100644 index 000000000..a13a127f0 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensions.kt @@ -0,0 +1,188 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import androidx.compose.runtime.Composable +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.client.ui.docking.DockPlacement +import ru.hollowhorizon.hollowengine.client.ui.ide.files.BuiltinLanguages +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeLanguageService +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonExtensionPoint +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonExtensions +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonRegistration +import ru.hollowhorizon.hollowengine.common.addons.HostHollowAddonExtensions + +/** Typed extension points consumed by the in-game Hollow IDE. */ +object HollowIdeExtensionPoints { + val FILE_TYPES = HollowAddonExtensionPoint("hollowengine:ide/file-types", HollowIdeFileType::class) + val PANELS = HollowAddonExtensionPoint("hollowengine:ide/panels", HollowIdePanel::class) + val MENU_ITEMS = HollowAddonExtensionPoint("hollowengine:ide/menu-items", HollowIdeMenuItem::class) + val FILE_ACTIONS = HollowAddonExtensionPoint("hollowengine:ide/file-actions", HollowIdeFileActionProvider::class) + val PROJECT_ACTIONS = HollowAddonExtensionPoint( + "hollowengine:ide/project-actions", + HollowIdeProjectActionProvider::class, + ) + val LANGUAGES = HollowAddonExtensionPoint("hollowengine:ide/languages", HollowIdeLanguageService::class) +} + +/** Operations a contributed panel or menu action may request from the IDE host. */ +interface HollowIdeContext { + val focusedFile: HollowIdeOpenFile? + + fun openFile(path: String): Boolean + + fun openPanel(id: String): Boolean + + fun closePanel(id: String): Boolean + + fun isPanelOpen(id: String): Boolean + + fun saveAll(): Int + + fun refreshProject() + + fun setStatus(message: String) +} + +/** A contributed dock panel. [title] may be literal text or a Minecraft translation key. */ +class HollowIdePanel( + val id: String, + val title: String, + val icon: String? = null, + val closable: Boolean = true, + val minWidth: Float = 240f, + val minHeight: Float = 160f, + val placement: HollowIdePanelPlacement = HollowIdePanelPlacement(), + val showInWindowMenu: Boolean = true, + val content: @Composable (HollowIdeContext) -> Unit, +) { + init { + require(id.isNotBlank()) { "IDE panel ID cannot be blank" } + require(title.isNotBlank()) { "IDE panel title cannot be blank" } + require(minWidth > 0f && minHeight > 0f) { "IDE panel minimum size must be positive" } + } +} + +data class HollowIdePanelPlacement( + val anchor: HollowIdePanelAnchor = HollowIdePanelAnchor.Editor, + val placement: DockPlacement = DockPlacement.RIGHT, +) + +sealed interface HollowIdePanelAnchor { + data object Root : HollowIdePanelAnchor + data object Project : HollowIdePanelAnchor + data object Editor : HollowIdePanelAnchor + data object Console : HollowIdePanelAnchor + data class Panel(val id: String) : HollowIdePanelAnchor +} + +enum class HollowIdeMenu { + FILE, + WINDOW, + TOOLS, + HELP, +} + +enum class HollowIdeMenuMark { + CHECKBOX, + RADIO, +} + +/** A contributed toolbar item. [label] may be literal text or a Minecraft translation key. */ +class HollowIdeMenuItem( + val id: String, + val menu: HollowIdeMenu, + val label: String, + val icon: String? = null, + val mark: HollowIdeMenuMark? = null, + val closeOnClick: Boolean = true, + val isVisible: (HollowIdeContext) -> Boolean = { true }, + val isEnabled: (HollowIdeContext) -> Boolean = { true }, + val isChecked: (HollowIdeContext) -> Boolean = { false }, + val run: (HollowIdeContext) -> Unit, +) { + init { + require(id.isNotBlank()) { "IDE menu item ID cannot be blank" } + require(label.isNotBlank()) { "IDE menu item label cannot be blank" } + } +} + +fun interface HollowIdeFileActionProvider { + fun actions(context: HollowIdeFileActionContext): List +} + +class HollowIdeProjectAction( + val id: String, + val label: String, + val shortcut: String = "", + val icon: String? = null, + val isVisible: (HollowIdeProjectActionContext) -> Boolean = { true }, + val isEnabled: (HollowIdeProjectActionContext) -> Boolean = { true }, + val run: (HollowIdeProjectActionContext) -> Unit, +) { + init { + require(id.isNotBlank()) { "IDE project action ID cannot be blank" } + require(label.isNotBlank()) { "IDE project action label cannot be blank" } + } +} + +interface HollowIdeProjectActionContext { + val ide: HollowIdeContext + val path: String + val selectedPaths: List + val isDirectory: Boolean +} + +fun interface HollowIdeProjectActionProvider { + fun actions(context: HollowIdeProjectActionContext): List +} + +fun HollowAddonExtensions.registerIdeFileType(type: HollowIdeFileType): HollowAddonRegistration = + register(HollowIdeExtensionPoints.FILE_TYPES, type.id, type, type.priority) + +fun HollowAddonExtensions.registerIdePanel( + panel: HollowIdePanel, + priority: Int = 0, +): HollowAddonRegistration = register(HollowIdeExtensionPoints.PANELS, panel.id, panel, priority) + +fun HollowAddonExtensions.registerIdeMenuItem( + item: HollowIdeMenuItem, + priority: Int = 0, +): HollowAddonRegistration = register(HollowIdeExtensionPoints.MENU_ITEMS, item.id, item, priority) + +fun HollowAddonExtensions.registerIdeFileActions( + id: String, + provider: HollowIdeFileActionProvider, + priority: Int = 0, +): HollowAddonRegistration = register(HollowIdeExtensionPoints.FILE_ACTIONS, id, provider, priority) + +fun HollowAddonExtensions.registerIdeProjectActions( + id: String, + provider: HollowIdeProjectActionProvider, + priority: Int = 0, +): HollowAddonRegistration = register(HollowIdeExtensionPoints.PROJECT_ACTIONS, id, provider, priority) + +/** + * Registers a language service in the shared resolver. Higher-priority matching services override + * lower-priority addon and built-in services until this registration is closed. + */ +fun HollowAddonExtensions.registerIdeLanguage( + language: HollowIdeLanguageService, + priority: Int = 0, +): HollowAddonRegistration = register(HollowIdeExtensionPoints.LANGUAGES, language.id, language, priority) + +internal fun ensureBuiltinIdeLanguagesRegistered() { + BuiltinIdeLanguages.ensureRegistered() +} + +private object BuiltinIdeLanguages { + private const val BUILTIN_PRIORITY = Int.MIN_VALUE + private val extensions = HostHollowAddonExtensions( + HollowEngine.MODID, + HollowIdeLanguageService::class.java.classLoader, + ) + + init { + BuiltinLanguages.forEach { language -> extensions.registerIdeLanguage(language, BUILTIN_PRIORITY) } + } + + fun ensureRegistered() = Unit +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileActions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileActions.kt index 3ec4a0dfa..ab82d166b 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileActions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileActions.kt @@ -1,5 +1,7 @@ package ru.hollowhorizon.hollowengine.client.ui.ide +import ru.hollowhorizon.hollowengine.HollowEngine + /** * One entry of the menu a right click on a file tab opens. A file type contributes its own on top * of [HollowIdeStandardFileActions], so an editor can offer what only it can do without the IDE @@ -108,7 +110,18 @@ object HollowIdeStandardFileActions { } internal fun fileContextMenuActions(context: HollowIdeFileActionContext): List { - val declared = context.file.type.actions + val declared = context.file.type.actions + HollowIdeExtensionPoints.FILE_ACTIONS.extensions().flatMap { extension -> + runCatching { extension.invoke { provider -> provider.actions(context) } } + .onFailure { failure -> + HollowEngine.LOGGER.error( + "IDE file action extension '{}' failed for '{}'", + extension.qualifiedId, + context.file.path, + failure, + ) + } + .getOrDefault(emptyList()) + } if (declared.isEmpty()) return HollowIdeStandardFileActions.actions.filter { it.isVisible(context) } val overrides = declared.associateBy(HollowIdeFileAction::id) diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt index 2aa31bb93..cc733ab33 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeFileTypes.kt @@ -89,24 +89,46 @@ class HollowIdeFileType( } class HollowIdeFileTypeRegistry { - private data class Entry(val type: HollowIdeFileType, val order: Long) + private data class Entry( + val registrationId: String, + val type: HollowIdeFileType, + val order: Long, + ) private val entries = mutableListOf() private var nextOrder = 0L @Synchronized fun register(type: HollowIdeFileType) { - require(entries.none { it.type.id == type.id }) { "File type '${type.id}' is already registered" } - entries += Entry(type, nextOrder++) + register(type.id, type) + } + + @Synchronized + fun register(registrationId: String, type: HollowIdeFileType) { + require(registrationId.isNotBlank()) { "File type registration ID cannot be blank" } + require(entries.none { it.registrationId == registrationId }) { + "File type '$registrationId' is already registered" + } + entries += Entry(registrationId, type, nextOrder++) entries.sortWith(compareByDescending { it.type.priority }.thenBy { it.order }) } + @Synchronized + fun unregister(registrationId: String): HollowIdeFileType? { + val index = entries.indexOfFirst { it.registrationId == registrationId } + if (index < 0) return null + return entries.removeAt(index).type + } + @Synchronized fun find(path: String, bytes: ByteArray): HollowIdeFileType? = entries.firstOrNull { it.type.matches(path, bytes) }?.type @Synchronized - fun find(id: String): HollowIdeFileType? = entries.firstOrNull { it.type.id == id }?.type + fun find(id: String): HollowIdeFileType? { + entries.firstOrNull { it.registrationId == id }?.let { return it.type } + return entries.filter { it.type.id == id }.singleOrNull()?.type + } fun open(path: String, readContent: () -> ByteArray): HollowIdeOpenFile? { val types = synchronized(this) { entries.map(Entry::type) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeMenuItems.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeMenuItems.kt index 3b2576ab1..4384fb827 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeMenuItems.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeMenuItems.kt @@ -5,11 +5,7 @@ import ru.hollowhorizon.hollowengine.client.editor.GizmoEditMode import ru.hollowhorizon.hollowengine.client.editor.TransformGizmoEditor import ru.hollowhorizon.hollowengine.client.ui.HollowUiResourceAccess import ru.hollowhorizon.hollowengine.client.ui.UiProfiler -import ru.hollowhorizon.hollowengine.client.ui.docking.DockItem -import ru.hollowhorizon.hollowengine.client.ui.docking.DockPlacement -import ru.hollowhorizon.hollowengine.client.ui.docking.DockTarget import ru.hollowhorizon.hollowengine.client.ui.docking.DockingState -import ru.hollowhorizon.hollowengine.client.ui.ide.asset.AssetManagerLang import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownItem import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownMark import ru.hollowhorizon.hollowengine.client.ui.widgets.UiDropdownSlider @@ -23,7 +19,6 @@ private const val ReloadIcon = "hollowengine:textures/gui/icons/reload.svg" private const val ReformatIcon = "hollowengine:textures/gui/icons/code_editor.svg" private const val SaveIcon = "hollowengine:textures/gui/icons/save.svg" private const val DocsIcon = "hollowengine:textures/gui/icons/docs.svg" -private const val OptionsIcon = "hollowengine:textures/gui/icons/options.svg" internal fun hollowIdeFileMenuItems( model: HollowIdeModel, @@ -63,95 +58,11 @@ internal fun hollowIdeFileMenuItems( ) } -internal fun hollowIdeWindowMenuItems(model: HollowIdeModel, dock: DockingState): List { - return listOf( - UiDropdownItem("hollowengine.gui.ide.project_tree".lang, ProjectIcon) { - if (!dock.contains(ProjectTreeId)) { - dock.open(DockItem(ProjectTreeId, "hollowengine.gui.ide.project_tree".lang, ProjectIcon)) - } - dock.focus(ProjectTreeId) - }, - UiDropdownItem(AssetManagerLang.TITLE.lang, AssetManagerIcon) { - if (!dock.contains(AssetManagerId)) { - val anchor = ProjectTreeId.takeIf(dock::contains) - ?: model.files.values.firstOrNull { dock.contains(it.id) }?.id - dock.open( - DockItem( - AssetManagerId, - AssetManagerLang.TITLE.lang, - AssetManagerIcon, - closable = true, - minWidth = 520f, - minHeight = 260f, - ), - DockTarget(anchor, DockPlacement.RIGHT), - ) - } - dock.focus(AssetManagerId) - }, - UiDropdownItem("hollowengine.gui.ide.console".lang, ConsoleIcon) { - if (!dock.contains(ConsoleId)) { - val anchor = model.files.values.firstOrNull { dock.contains(it.id) }?.id - ?: ProjectTreeId.takeIf(dock::contains) - dock.open( - DockItem( - ConsoleId, - "hollowengine.gui.ide.console".lang, - ConsoleIcon, - closable = true, - minWidth = 360f, - minHeight = 180f, - ), - DockTarget(anchor, DockPlacement.BOTTOM), - ) - } - dock.focus(ConsoleId) - }, - UiDropdownItem("Cutscene Timeline", CutsceneIcon) { - if (!dock.contains(CutsceneTimelineId)) { - val anchor = model.files.values.firstOrNull { dock.contains(it.id) }?.id - ?: ProjectTreeId.takeIf(dock::contains) - dock.open( - DockItem(CutsceneTimelineId, "Cutscene Timeline", CutsceneIcon, closable = true, minWidth = 520f, minHeight = 260f), - DockTarget(anchor, DockPlacement.BOTTOM), - ) - } - dock.focus(CutsceneTimelineId) - }, - UiDropdownItem("Cutscene Properties", OptionsIcon) { - if (!dock.contains(CutscenePropertiesId)) { - val anchor = if (dock.contains(CutsceneTimelineId)) { - CutsceneTimelineId - } else { - model.files.values.firstOrNull { dock.contains(it.id) }?.id - ?: ProjectTreeId.takeIf(dock::contains) - } - dock.open( - DockItem(CutscenePropertiesId, "Cutscene Properties", OptionsIcon, closable = true, minWidth = 240f, minHeight = 260f), - DockTarget(anchor, DockPlacement.RIGHT), - ) - } - dock.focus(CutscenePropertiesId) - }, - UiDropdownItem("Cutscene Viewport", CutsceneIcon) { - if (!dock.contains(CutsceneViewportId)) { - val anchor = if (dock.contains(CutsceneTimelineId)) { - CutsceneTimelineId - } else { - model.files.values.firstOrNull { dock.contains(it.id) }?.id - ?: ProjectTreeId.takeIf(dock::contains) - } - dock.open( - DockItem(CutsceneViewportId, "Cutscene Viewport", CutsceneIcon, minWidth = 320f, minHeight = 180f), - DockTarget(anchor, DockPlacement.TOP), - ) - } - dock.focus(CutsceneViewportId) - }, - ) -} - -internal fun hollowIdeToolMenuItems(dock: DockingState, profiler: UiProfiler): List { +internal fun hollowIdeToolMenuItems( + context: HollowIdeContext, + dock: DockingState, + profiler: UiProfiler, +): List { return listOf( UiDropdownItem( label = "UI Profiler", @@ -160,19 +71,10 @@ internal fun hollowIdeToolMenuItems(dock: DockingState, profiler: UiProfiler): L closeOnClick = false, ) { if (dock.contains(UiProfilerId)) { - dock.close(UiProfilerId) + context.closePanel(UiProfilerId) profiler.enabled = false } else { - val anchor = if (dock.contains(CutsceneTimelineId)) { - CutsceneTimelineId - } else { - ProjectTreeId.takeIf(dock::contains) - } - dock.open( - DockItem(UiProfilerId, "UI Profiler", OptionsIcon, closable = true, minWidth = 360f, minHeight = 260f), - DockTarget(anchor, DockPlacement.BOTTOM), - ) - dock.focus(UiProfilerId) + context.openPanel(UiProfilerId) profiler.enabled = true } }, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt index cac8f074c..f9445bb27 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeModel.kt @@ -381,6 +381,21 @@ internal class HollowIdeModel( return count } + /** Releases every document backed by a file type which is about to leave an addon classloader. */ + fun closeFilesUsing(type: HollowIdeFileType): List { + val paths = files.values.filter { file -> file.type === type }.map(HollowIdeOpenFile::path) + paths.forEach { path -> + pendingSaves.remove(path)?.cancel() + files.remove(path)?.close() + onFileRemoved?.invoke(path) + } + return paths + } + + fun refreshProject() { + tree.refresh() + } + private fun scheduleSave(path: String) { val file = files[path]?.takeIf { it.dirty } ?: return val text = file.textOrNull ?: return diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt index 8bce73e2c..a65a846ea 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeOverlay.kt @@ -6,6 +6,7 @@ import net.minecraft.client.gui.screens.ChatScreen import org.lwjgl.glfw.GLFW import org.lwjgl.opengl.GL11 import org.lwjgl.opengl.GL30 +import ru.hollowhorizon.hollowengine.HollowEngine import ru.hollowhorizon.hollowengine.client.editor.TransformGizmoEditor import ru.hollowhorizon.hollowengine.client.ui.* import ru.hollowhorizon.hollowengine.client.ui.docking.* @@ -32,6 +33,10 @@ import ru.hollowhorizon.hollowengine.client.utils.IconHelper import ru.hollowhorizon.hollowengine.client.utils.lang import ru.hollowhorizon.hollowengine.common.config.EditMode import ru.hollowhorizon.hollowengine.common.config.HollowEngineConfig +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonExtension +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonExtensionChange +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonRegistration +import ru.hollowhorizon.hollowengine.common.addons.HostHollowAddonExtensions import ru.hollowhorizon.hollowengine.common.events.ClientOnly import ru.hollowhorizon.hollowengine.common.events.SubscribeEvent import ru.hollowhorizon.hollowengine.common.events.client.render.RenderTickEvent @@ -40,7 +45,7 @@ import ru.hollowhorizon.hollowengine.common.utils.DesktopUtil import ru.hollowhorizon.hollowengine.common.scripting.ide.DefinitionLocation import ru.hollowhorizon.hollowengine.common.scripting.ide.InlayAction import ru.hollowhorizon.hollowengine.common.scripting.ide.ResourceLocationTargets -import java.io.File +import java.util.concurrent.ConcurrentHashMap /** A file dragged out of the project tree; anything that accepts drops can look for this payload. */ data class HollowIdeFileDrag(val path: String, val isDirectory: Boolean = false) @@ -76,27 +81,9 @@ internal const val CutsceneIcon = "hollowengine:textures/gui/icons/film.svg" object HollowIdeOverlay { var useHollowUiOverlay: Boolean = true - private val fileTypes = HollowIdeFileTypeRegistry().apply { - registerBuiltinFileTypes( - modelEditor = { file -> ModelEditorPanel(file.path) }, - imageEditor = { file -> HollowIdeImageEditor(file, file::save) }, - videoEditor = { file -> - Video( - source = file.path, - fit = UiImageFit.CONTAIN, - modifier = Modifier.size(100.percent, 100.percent), - ) - }, - soundsEditor = { file -> HollowIdeSoundsEditor(file) }, - animatorEditor = { file -> HollowIdeAnimatorEditor(file) }, - textEditor = { file -> FileEditor(file) }, - ) - registerAssetFileTypes( - imageEditor = { file -> HollowIdeImageEditor(file, file::save) }, - textEditor = { file -> FileEditor(file) }, - jsonModelEditor = { file -> VanillaModelEditorPanel(file.path) }, - ) - } + private val builtinExtensions = HostHollowAddonExtensions(HollowEngine.MODID, HollowIdeOverlay::class.java.classLoader) + private val extensionObservers = mutableListOf() + private val fileTypes = HollowIdeFileTypeRegistry() private val model = HollowIdeModel(fileTypes) private val assetManagerState = AssetManagerState() private val dock = DockingState() @@ -159,6 +146,39 @@ object HollowIdeOverlay { private val findStates = mutableStateMapOf() private val search = HollowIdeSearchController { statusText = it } private var colorPicker by mutableStateOf(null) + private var extensionRevision by mutableStateOf(0L) + private val reportedExtensionFailures = ConcurrentHashMap.newKeySet() + private val ideContext = object : HollowIdeContext { + override val focusedFile: HollowIdeOpenFile? + get() = this@HollowIdeOverlay.focusedFile() + + override fun openFile(path: String): Boolean { + return when (val result = model.openFile(path)) { + HollowIdeOpenResult.Directory, + HollowIdeOpenResult.Unsupported, + -> false + + is HollowIdeOpenResult.File -> { + openFileDockItem(result.file) + true + } + } + } + + override fun openPanel(id: String): Boolean = openRegisteredPanel(id) + + override fun closePanel(id: String): Boolean = resolvePanel(id)?.let { dock.close(it.dockId) } ?: false + + override fun isPanelOpen(id: String): Boolean = resolvePanel(id)?.let { dock.contains(it.dockId) } == true + + override fun saveAll(): Int = model.saveAll() + + override fun refreshProject() = model.refreshProject() + + override fun setStatus(message: String) { + statusText = message + } + } private fun editorState(file: HollowIdeOpenFile): TextFieldState = editorStates.getOrPut(file.path) { TextFieldState( @@ -178,12 +198,203 @@ object HollowIdeOverlay { private var lastMouseY = 0f init { + ensureBuiltinIdeLanguagesRegistered() + installBuiltinExtensions() + HollowIdeExtensionPoints.FILE_TYPES.extensions().forEach(::installFileType) + extensionObservers += HollowIdeExtensionPoints.FILE_TYPES.observe(::onFileTypeChanged) + extensionObservers += HollowIdeExtensionPoints.PANELS.observe(::onPanelChanged) + extensionObservers += HollowIdeExtensionPoints.MENU_ITEMS.observe { extensionRevision++ } + extensionObservers += HollowIdeExtensionPoints.FILE_ACTIONS.observe { extensionRevision++ } + extensionObservers += HollowIdeExtensionPoints.PROJECT_ACTIONS.observe { extensionRevision++ } + extensionObservers += HollowIdeExtensionPoints.LANGUAGES.observe(::onLanguageExtensionChanged) initialize() } - /** Registers a new IDE file type. IDs must be unique; higher priorities are matched first. */ + /** Compatibility bridge. Dynamic addons should use `context.extensions.registerIdeFileType`. */ + @Deprecated("Use HollowAddonContext.extensions.registerIdeFileType so reload can remove the contribution") fun registerFileType(type: HollowIdeFileType) { - fileTypes.register(type) + builtinExtensions.registerIdeFileType(type) + } + + private fun installBuiltinExtensions() { + val builtins = HollowIdeFileTypeRegistry().apply { + registerBuiltinFileTypes( + modelEditor = { file -> ModelEditorPanel(file.path) }, + imageEditor = { file -> HollowIdeImageEditor(file, file::save) }, + videoEditor = { file -> + Video( + source = file.path, + fit = UiImageFit.CONTAIN, + modifier = Modifier.size(100.percent, 100.percent), + ) + }, + soundsEditor = { file -> HollowIdeSoundsEditor(file) }, + animatorEditor = { file -> HollowIdeAnimatorEditor(file) }, + textEditor = { file -> FileEditor(file) }, + ) + registerAssetFileTypes( + imageEditor = { file -> HollowIdeImageEditor(file, file::save) }, + textEditor = { file -> FileEditor(file) }, + jsonModelEditor = { file -> VanillaModelEditorPanel(file.path) }, + ) + } + builtins.registeredTypes().forEach { type -> builtinExtensions.registerIdeFileType(type) } + + listOf( + HollowIdePanel( + id = ProjectTreeId, + title = "hollowengine.gui.ide.project_tree", + icon = ProjectIcon, + minWidth = 220f, + placement = HollowIdePanelPlacement(HollowIdePanelAnchor.Root, DockPlacement.CENTER), + content = { ProjectTree() }, + ), + HollowIdePanel( + id = AssetManagerId, + title = AssetManagerLang.TITLE, + icon = AssetManagerIcon, + minWidth = 520f, + minHeight = 260f, + placement = HollowIdePanelPlacement(HollowIdePanelAnchor.Project, DockPlacement.RIGHT), + content = { AssetManagerPanel(::openAssetFile, ::requestSurfaceFocus) }, + ), + HollowIdePanel( + id = ConsoleId, + title = "hollowengine.gui.ide.console", + icon = ConsoleIcon, + minWidth = 360f, + minHeight = 180f, + placement = HollowIdePanelPlacement(HollowIdePanelAnchor.Editor, DockPlacement.BOTTOM), + content = { HollowIdeConsolePanel() }, + ), + HollowIdePanel( + id = CutsceneTimelineId, + title = "Cutscene Timeline", + icon = CutsceneIcon, + minWidth = 520f, + minHeight = 260f, + placement = HollowIdePanelPlacement(HollowIdePanelAnchor.Editor, DockPlacement.BOTTOM), + content = { + CutsceneTimelineDock( + session = CutsceneEditorSessions.default, + keyboardActive = dock.focusedItemId == CutsceneTimelineId, + ) + }, + ), + HollowIdePanel( + id = CutscenePropertiesId, + title = "Cutscene Properties", + icon = "hollowengine:textures/gui/icons/options.svg", + minWidth = 240f, + minHeight = 260f, + placement = HollowIdePanelPlacement( + HollowIdePanelAnchor.Panel(CutsceneTimelineId), + DockPlacement.RIGHT, + ), + content = { CutscenePropertiesDock(CutsceneEditorSessions.default) }, + ), + HollowIdePanel( + id = CutsceneViewportId, + title = "Cutscene Viewport", + icon = CutsceneIcon, + minWidth = 320f, + minHeight = 180f, + placement = HollowIdePanelPlacement( + HollowIdePanelAnchor.Panel(CutsceneTimelineId), + DockPlacement.TOP, + ), + content = { CutsceneViewportDock() }, + ), + HollowIdePanel( + id = UiProfilerId, + title = "UI Profiler", + icon = "hollowengine:textures/gui/icons/options.svg", + minWidth = 360f, + minHeight = 260f, + placement = HollowIdePanelPlacement(HollowIdePanelAnchor.Project, DockPlacement.BOTTOM), + showInWindowMenu = false, + content = { HollowIdeUiProfilerPanel(surface.runtime.profiler) }, + ), + ).forEach { panel -> builtinExtensions.registerIdePanel(panel) } + } + + private fun onFileTypeChanged(change: HollowAddonExtensionChange) { + when (change) { + is HollowAddonExtensionChange.Added -> installFileType(change.extension) + is HollowAddonExtensionChange.Removed -> { + model.closeFilesUsing(change.extension.value) + fileTypes.unregister(extensionUiId(change.extension)) + } + } + extensionRevision++ + } + + private fun installFileType(extension: HollowAddonExtension) { + fileTypes.register(extensionUiId(extension), extension.value) + } + + private fun onPanelChanged(change: HollowAddonExtensionChange) { + if (change is HollowAddonExtensionChange.Removed) { + dock.close(extensionUiId(change.extension)) + } + extensionRevision++ + } + + private fun onLanguageExtensionChanged(change: HollowAddonExtensionChange<*>) { + editorSessions.values.forEach(HollowIdeEditorSession::close) + editorSessions.clear() + editorAnalysisRevision++ + extensionRevision++ + } + + private fun extensionUiId(extension: HollowAddonExtension): String = + if (extension.ownerId == HollowEngine.MODID) extension.qualifiedId.substringAfter(':') else extension.qualifiedId + + private fun registeredPanels(): List = HollowIdeExtensionPoints.PANELS.extensions().map { extension -> + RegisteredIdePanel(extension, extensionUiId(extension)) + } + + private fun resolvePanel(id: String): RegisteredIdePanel? = registeredPanels().firstOrNull { panel -> + panel.dockId == id || panel.extension.qualifiedId == id + } + + private fun openRegisteredPanel(id: String): Boolean { + val registered = resolvePanel(id) ?: return false + if (!dock.contains(registered.dockId)) { + val panel = registered.extension.value + dock.open( + DockItem( + id = registered.dockId, + title = panel.title, + icon = panel.icon, + closable = panel.closable, + minWidth = panel.minWidth, + minHeight = panel.minHeight, + ), + panelTarget(panel.placement), + ) + } + dock.focus(registered.dockId) + return true + } + + private fun panelTarget(placement: HollowIdePanelPlacement): DockTarget { + if (placement.anchor == HollowIdePanelAnchor.Root) { + return DockTarget(placement = placement.placement) + } + val anchorItem = when (val anchor = placement.anchor) { + HollowIdePanelAnchor.Root -> null + HollowIdePanelAnchor.Project -> ProjectTreeId.takeIf(dock::contains) + HollowIdePanelAnchor.Console -> ConsoleId.takeIf(dock::contains) + HollowIdePanelAnchor.Editor -> model.files.values.firstOrNull { file -> dock.contains(file.id) }?.id + is HollowIdePanelAnchor.Panel -> resolvePanel(anchor.id)?.dockId?.takeIf(dock::contains) + } + val fallback = anchorItem + ?: model.files.values.firstOrNull { file -> dock.contains(file.id) }?.id + ?: ProjectTreeId.takeIf(dock::contains) + val stackId = fallback?.let(dock::stackIdOf) + return stackId?.let { DockTarget(it, placement.placement) } + ?: DockTarget(placement = placement.placement) } fun isVisible(): Boolean = useHollowUiOverlay && isAvailable() @@ -326,7 +537,7 @@ object HollowIdeOverlay { initialized = true dock.onTabContextMenu = ::openFileContextMenu model.onFileRemoved = ::forgetFile - dock.open(DockItem(ProjectTreeId, "hollowengine.gui.ide.project_tree".lang, ProjectIcon)) + check(openRegisteredPanel(ProjectTreeId)) { "The built-in project panel is not registered" } surface.setContent { Content() } } @@ -568,61 +779,140 @@ object HollowIdeOverlay { focusedFile = ::focusedFile, canReformat = { file -> fileActionContext(file).canFormat }, onReformat = ::formatFile, - ), + ) + contributedMenuItems(HollowIdeMenu.FILE), ) UiDropdown( id = "ide-windows-menu", label = "hollowengine.gui.ide.windows".lang, expanded = openDropdown == "windows", onExpandedChange = { openDropdown = if (it) "windows" else null }, - items = hollowIdeWindowMenuItems(model, dock), + items = windowMenuItems() + contributedMenuItems(HollowIdeMenu.WINDOW), ) UiDropdown( id = "ide-tools-menu", label = "hollowengine.gui.ide.tools".lang, expanded = openDropdown == "tools", onExpandedChange = { openDropdown = if (it) "tools" else null }, - items = hollowIdeToolMenuItems(dock, surface.runtime.profiler), + items = hollowIdeToolMenuItems(ideContext, dock, surface.runtime.profiler) + + contributedMenuItems(HollowIdeMenu.TOOLS), ) UiDropdown( id = "ide-help-menu", label = "hollowengine.gui.ide.help".lang, expanded = openDropdown == "help", onExpandedChange = { openDropdown = if (it) "help" else null }, - items = hollowIdeHelpMenuItems(), + items = hollowIdeHelpMenuItems() + contributedMenuItems(HollowIdeMenu.HELP), ) } @Composable private fun DockContent(item: DockItem) { - when (item.id) { - ProjectTreeId -> ProjectTree() - AssetManagerId -> AssetManagerPanel( - state = assetManagerState, - onOpenFile = ::openAssetFile, - onOverrideFile = ::overrideAssetFile, - onHideFile = ::hideAssetFile, - onRestoreFile = ::restoreAssetFile, - onFocusFilter = ::requestSurfaceFocus, - ) - ConsoleId -> HollowIdeConsolePanel() - CutsceneTimelineId -> CutsceneTimelineDock( - session = CutsceneEditorSessions.default, - keyboardActive = dock.focusedItemId == CutsceneTimelineId, - ) + extensionRevision + val panel = registeredPanels().firstOrNull { registered -> registered.dockId == item.id } + if (panel != null) { + PanelContent(panel.extension) + return + } + model.files.values.firstOrNull { it.id == item.id }?.let { file -> + file.type.editor(file) + LaunchedEffect(file.dirty) { + dock.updateItem(file.dockItem()) + } + } ?: EmptyEditor() + } - CutscenePropertiesId -> CutscenePropertiesDock(CutsceneEditorSessions.default) - CutsceneViewportId -> CutsceneViewportDock() - UiProfilerId -> HollowIdeUiProfilerPanel(surface.runtime.profiler) - else -> model.files.values.firstOrNull { it.id == item.id }?.let { file -> - file.type.editor(file) - LaunchedEffect(file.dirty) { - dock.updateItem(file.dockItem()) - } - } ?: EmptyEditor() + @Composable + private fun PanelContent(extension: HollowAddonExtension) { + extension.value.content(ideContext) + } + + private fun windowMenuItems(): List { + extensionRevision + return registeredPanels().filter { registered -> registered.extension.value.showInWindowMenu }.map { registered -> + val panel = registered.extension.value + UiDropdownItem(panel.title.lang, panel.icon) { + openRegisteredPanel(registered.extension.qualifiedId) + } + } + } + + private fun contributedMenuItems(menu: HollowIdeMenu): List { + extensionRevision + return HollowIdeExtensionPoints.MENU_ITEMS.extensions().mapNotNull { extension -> + val item = extension.value + if (item.menu != menu) return@mapNotNull null + val visible = runCatching { extension.invoke { it.isVisible(ideContext) } } + .onFailure { reportExtensionFailure(extension.qualifiedId, "menu-visibility", it) } + .getOrDefault(false) + if (!visible) return@mapNotNull null + val enabled = runCatching { extension.invoke { it.isEnabled(ideContext) } } + .onFailure { reportExtensionFailure(extension.qualifiedId, "menu-enabled", it) } + .getOrDefault(false) + val checked = runCatching { extension.invoke { it.isChecked(ideContext) } } + .onFailure { reportExtensionFailure(extension.qualifiedId, "menu-checked", it) } + .getOrDefault(false) + UiDropdownItem( + label = item.label.lang, + icon = item.icon, + enabled = enabled, + checked = checked, + mark = when (item.mark) { + HollowIdeMenuMark.CHECKBOX -> UiDropdownMark.CHECKBOX + HollowIdeMenuMark.RADIO -> UiDropdownMark.RADIO + null -> null + }, + closeOnClick = item.closeOnClick, + ) { + runCatching { extension.invoke { it.run(ideContext) } } + .onFailure { reportExtensionFailure(extension.qualifiedId, "menu-action", it) } + } } } + private fun projectActionContext(menu: ProjectContextMenu): HollowIdeProjectActionContext = + object : HollowIdeProjectActionContext { + override val ide: HollowIdeContext = ideContext + override val path: String = menu.path + override val selectedPaths: List = model.selectedOr(menu.path) + override val isDirectory: Boolean = menu.path.fromReadablePath().isDirectory + } + + private fun projectActionEntries(context: HollowIdeProjectActionContext): List { + extensionRevision + return HollowIdeExtensionPoints.PROJECT_ACTIONS.extensions().flatMap { extension -> + val actions = runCatching { extension.invoke { provider -> provider.actions(context) } } + .onFailure { reportExtensionFailure(extension.qualifiedId, "project-actions", it) } + .getOrDefault(emptyList()) + actions.mapNotNull { action -> + val visible = runCatching { extension.invoke { action.isVisible(context) } } + .onFailure { reportExtensionFailure(extension.qualifiedId, "project-action-visibility", it) } + .getOrDefault(false) + if (!visible) return@mapNotNull null + val enabled = runCatching { extension.invoke { action.isEnabled(context) } } + .onFailure { reportExtensionFailure(extension.qualifiedId, "project-action-enabled", it) } + .getOrDefault(false) + HollowIdeProjectMenuEntry( + action = HollowIdeProjectAction( + id = action.id, + label = action.label, + shortcut = action.shortcut, + icon = action.icon, + ) { actionContext -> + runCatching { extension.invoke { action.run(actionContext) } } + .onFailure { reportExtensionFailure(extension.qualifiedId, "project-action", it) } + }, + enabled = enabled, + ) + } + } + } + + private fun reportExtensionFailure(id: String, stage: String, failure: Throwable) { + val key = "$id:$stage:${failure::class.qualifiedName}:${failure.message}" + if (!reportedExtensionFailures.add(key)) return + HollowEngine.LOGGER.error("IDE extension '{}' failed during {}", id, stage, failure) + } + @Composable private fun ProjectTree() { val rootDrop = "ide-project-root-drop" @@ -673,8 +963,10 @@ object HollowIdeOverlay { } }, ) + val projectMenu = project.contextMenu + val projectActionContext = projectMenu?.let(::projectActionContext) HollowIdeProjectContextMenu( - menu = project.contextMenu, + menu = projectMenu, onCreateFile = project::openCreateFileDialog, onCreateFolder = project::openCreateFolderDialog, onCreateSoundEvents = project::createSoundEvents, @@ -684,6 +976,11 @@ object HollowIdeOverlay { onPaste = project::pasteInto, onShowInExplorer = project::showInExplorer, onDelete = project::delete, + additionalActions = projectActionContext?.let(::projectActionEntries).orEmpty(), + onAdditionalAction = { action -> + projectActionContext?.let { context -> action.run(context) } + project.closePopups() + }, onDismiss = { project.closePopups() }, ) val dialog = project.nameDialog @@ -795,9 +1092,9 @@ object HollowIdeOverlay { } @Composable - private fun EmptyEditor() { + private fun EmptyEditor(message: String = "Open a file from Project Tree") { Column(tags = listOf("ide-empty-editor")) { - Text("Open a file from Project Tree", tags = listOf("ide-empty-title")) + Text(message, tags = listOf("ide-empty-title")) Text(statusText, tags = listOf("ide-status")) } } @@ -1271,6 +1568,11 @@ object HollowIdeOverlay { } +private data class RegisteredIdePanel( + val extension: HollowAddonExtension, + val dockId: String, +) + internal object EditorLang { private const val ROOT = "hollowengine.gui.ide.editor." diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt index 16c67bc59..51cf9361f 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeProjectContextMenu.kt @@ -26,6 +26,8 @@ internal fun HollowIdeProjectContextMenu( onPaste: (String) -> Unit, onShowInExplorer: (String) -> Unit, onDelete: (String) -> Unit, + additionalActions: List = emptyList(), + onAdditionalAction: (HollowIdeProjectAction) -> Unit = {}, onDismiss: () -> Unit, ) { if (menu == null) return @@ -47,6 +49,16 @@ internal fun HollowIdeProjectContextMenu( ProjectMenuItem("Paste", "Ctrl+V", PASTE.toString()) { onPaste(menu.path) } ProjectMenuItem("Show in Explorer", "", FOLDER.toString()) { onShowInExplorer(menu.path) } ProjectMenuItem("Delete", "Del", REMOVE.toString()) { onDelete(menu.path) } + additionalActions.forEach { entry -> + ProjectMenuItem( + label = entry.action.label, + shortcut = entry.action.shortcut, + icon = entry.action.icon, + enabled = entry.enabled, + ) { + onAdditionalAction(entry.action) + } + } } } @@ -95,14 +107,24 @@ internal fun HollowIdeProjectNameDialog( } @Composable -private fun ProjectMenuItem(label: String, shortcut: String, icon: String? = null, action: () -> Unit) { +private fun ProjectMenuItem( + label: String, + shortcut: String, + icon: String? = null, + enabled: Boolean = true, + action: () -> Unit, +) { Row( - tags = listOf("dropdown-item", "project-context-menu-item"), - modifier = Modifier.input(hoverable = true, clickable = true) - .cursor(UiCursorShape.HAND) + tags = buildList { + add("dropdown-item") + add("project-context-menu-item") + if (!enabled) add("disabled") + }, + modifier = Modifier.input(hoverable = enabled, clickable = enabled) + .cursor(if (enabled) UiCursorShape.HAND else UiCursorShape.DEFAULT) .alignItems(vertical = UiAlign.CENTER) .onClick { event -> - action() + if (enabled) action() event.consume() } ) { @@ -112,6 +134,11 @@ private fun ProjectMenuItem(label: String, shortcut: String, icon: String? = nul } } +internal data class HollowIdeProjectMenuEntry( + val action: HollowIdeProjectAction, + val enabled: Boolean, +) + internal data class ProjectContextMenu( val path: String, val x: Float, diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt index bcbb06228..dfe7bf3a9 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/client/ui/ide/files/EditorLanguageService.kt @@ -1,5 +1,6 @@ package ru.hollowhorizon.hollowengine.client.ui.ide.files +import ru.hollowhorizon.hollowengine.client.ui.ide.languageServiceForPath import ru.hollowhorizon.hollowengine.common.scripting.ScriptingEnvironment import ru.hollowhorizon.hollowengine.common.scripting.ide.* import ru.hollowhorizon.hollowengine.common.scripting.ide.story.StoryScriptingAnalyzer @@ -9,17 +10,45 @@ interface EditorLanguageService { val analyzer: ScriptingAnalyzer } -fun EditorLanguageService(extension: String): EditorLanguageService { - return when (extension) { - "kt", "kts" -> KotlinEditorLanguageService - "java" -> JavaEditorLanguageService - "json" -> JsonEditorLanguageService - "hss" -> HssEditorLanguageService - "story" -> StoryEditorLanguageService - else -> error("Unsupported language: $extension") +class HollowIdeLanguageService( + val id: String, + private val matcher: (path: String) -> Boolean, + private val analyzerProvider: () -> ScriptingAnalyzer, +) : EditorLanguageService { + init { + require(id.isNotBlank()) { "IDE language ID cannot be blank" } + } + + override val analyzer: ScriptingAnalyzer + get() = analyzerProvider() + + fun matches(path: String): Boolean = matcher(path) + + companion object { + fun extensions( + id: String, + extensions: Collection, + analyzer: () -> ScriptingAnalyzer, + ): HollowIdeLanguageService { + val normalized = extensions.map { it.trim().removePrefix(".").lowercase() } + .filter(String::isNotBlank) + .distinct() + require(normalized.isNotEmpty()) { "At least one language extension is required" } + return HollowIdeLanguageService( + id = id, + matcher = { path -> path.fileExtension() in normalized }, + analyzerProvider = analyzer, + ) + } } } +fun EditorLanguageService(extension: String): EditorLanguageService { + val path = "file.$extension" + return languageServiceForPath(path).takeUnless { language -> language === PlainEditorLanguageService } + ?: error("Unsupported language: $extension") +} + object KotlinEditorLanguageService : EditorLanguageService { override val analyzer: ScriptingAnalyzer get() = ScriptingEnvironment.currentOrNull()?.analyzer ?: UnavailableKotlinScriptingAnalyzer @@ -49,3 +78,16 @@ object StoryEditorLanguageService : EditorLanguageService { override val analyzer: ScriptingAnalyzer get() = StoryScriptingAnalyzer } + +internal val BuiltinLanguages = listOf( + HollowIdeLanguageService.extensions("kotlin", listOf("kt", "kts")) { KotlinEditorLanguageService.analyzer }, + HollowIdeLanguageService.extensions("java", listOf("java")) { JavaEditorLanguageService.analyzer }, + HollowIdeLanguageService.extensions("json", listOf("json")) { JsonEditorLanguageService.analyzer }, + HollowIdeLanguageService.extensions("hss", listOf("hss")) { HssEditorLanguageService.analyzer }, + HollowIdeLanguageService.extensions("story", listOf("story")) { StoryEditorLanguageService.analyzer }, +) + +private fun String.fileExtension(): String { + val fileName = substringBefore('?').substringBefore('#').substringAfterLast('/') + return fileName.substringAfterLast('.', "").lowercase() +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonExtensions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonExtensions.kt new file mode 100644 index 000000000..fc48198ba --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonExtensions.kt @@ -0,0 +1,234 @@ +package ru.hollowhorizon.hollowengine.common.addons + +import net.minecraft.resources.ResourceLocation +import ru.hollowhorizon.hollowengine.HollowEngine +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import kotlin.reflect.KClass + +/** A reversible contribution owned by an addon or by the host. */ +interface HollowAddonRegistration : AutoCloseable { + val isActive: Boolean + + override fun close() +} + +/** + * One typed extension point exposed by HollowEngine. + * + * Contributions are ordered by priority and then by registration order. Their qualified identity is + * always `:`, so two addons may use the same local name without colliding. + */ +class HollowAddonExtensionPoint( + val id: String, + private val extensionType: KClass, +) { + private val entries = CopyOnWriteArrayList>() + private val listeners = CopyOnWriteArrayList<(HollowAddonExtensionChange) -> Unit>() + private val nextOrder = AtomicLong() + private val revisionCounter = AtomicLong() + + val revision: Long + get() = revisionCounter.get() + + init { + require(id.isNotBlank()) { "Extension point ID cannot be blank" } + } + + fun extensions(): List> = entries + .sortedWith(compareByDescending> { it.priority }.thenBy { it.order }) + + fun values(): List = extensions().map(HollowAddonExtension::value) + + /** Observes changes without retaining an addon scope. Intended for the subsystem owning this point. */ + fun observe(listener: (HollowAddonExtensionChange) -> Unit): HollowAddonRegistration { + listeners += listener + return CallbackRegistration { listeners -= listener } + } + + internal fun register( + ownerId: String, + localId: String, + classLoader: ClassLoader, + priority: Int, + extension: T, + ): HollowAddonRegistration { + require(extensionType.java.isInstance(extension)) { + "Extension ${extension::class.qualifiedName} is not an instance of ${extensionType.qualifiedName}" + } + val qualifiedId = qualifyExtensionId(ownerId, localId) + val entry = HollowAddonExtension( + ownerId = ownerId, + localId = localId, + qualifiedId = qualifiedId, + priority = priority, + value = extension, + classLoader = classLoader, + order = nextOrder.getAndIncrement(), + ) + synchronized(entries) { + require(entries.none { it.qualifiedId == qualifiedId }) { + "Extension '$qualifiedId' is already registered in '$id'" + } + entries += entry + } + revisionCounter.incrementAndGet() + notifyListeners(HollowAddonExtensionChange.Added(entry)) + return CallbackRegistration { + val removed = synchronized(entries) { entries.remove(entry) } + if (removed) { + revisionCounter.incrementAndGet() + notifyListeners(HollowAddonExtensionChange.Removed(entry)) + } + } + } + + private fun notifyListeners(change: HollowAddonExtensionChange) { + listeners.forEach { listener -> + runCatching { listener(change) } + .onFailure { failure -> + HollowEngine.LOGGER.error( + "Extension point '{}' listener failed while processing '{}'", + id, + change.extension.qualifiedId, + failure, + ) + } + } + } +} + +/** Metadata retained by the host for an installed extension. */ +class HollowAddonExtension internal constructor( + val ownerId: String, + val localId: String, + val qualifiedId: String, + val priority: Int, + val value: T, + internal val classLoader: ClassLoader, + internal val order: Long, +) { + /** Runs an extension callback with the classloader that defined it as the thread context loader. */ + fun invoke(block: (T) -> R): R = withHollowAddonClassLoader(classLoader) { block(value) } +} + +sealed interface HollowAddonExtensionChange { + val extension: HollowAddonExtension + + data class Added(override val extension: HollowAddonExtension) : HollowAddonExtensionChange + + data class Removed(override val extension: HollowAddonExtension) : HollowAddonExtensionChange +} + +/** Owner-bound registration facade available to an addon through [HollowAddonContext.extensions]. */ +interface HollowAddonExtensions { + val addonId: String + + fun qualify(localId: String): String = qualifyExtensionId(addonId, localId) + + fun register( + point: HollowAddonExtensionPoint, + id: String, + extension: T, + priority: Int = 0, + ): HollowAddonRegistration + + /** Registers arbitrary deterministic cleanup for resources which do not have an extension point. */ + fun onUnload(cleanup: () -> Unit): HollowAddonRegistration +} + +/** Source-compatible accessor which does not alter the binary constructor of [HollowAddonContext]. */ +val HollowAddonContext.extensions: HollowAddonExtensions + get() = koin.get() + +internal class OwnedHollowAddonExtensions( + override val addonId: String, + private val classLoader: ClassLoader, +) : HollowAddonExtensions { + private val registrationLock = Any() + private val registrations = mutableListOf() + private val closed = AtomicBoolean() + + override fun register( + point: HollowAddonExtensionPoint, + id: String, + extension: T, + priority: Int, + ): HollowAddonRegistration { + check(!closed.get()) { "Addon extension scope '$addonId' is already closed" } + return own(point.register(addonId, id, classLoader, priority, extension)) + } + + override fun onUnload(cleanup: () -> Unit): HollowAddonRegistration { + check(!closed.get()) { "Addon extension scope '$addonId' is already closed" } + return own(CallbackRegistration { withHollowAddonClassLoader(classLoader, cleanup) }) + } + + fun cleanup() { + val owned = synchronized(registrationLock) { + if (!closed.compareAndSet(false, true)) return + registrations.asReversed().toList().also { registrations.clear() } + } + owned.forEach { registration -> + runCatching { registration.close() } + .onFailure { failure -> + HollowEngine.LOGGER.error("Failed to remove an extension owned by addon '{}'", addonId, failure) + } + } + } + + private fun own(registration: HollowAddonRegistration): HollowAddonRegistration { + val accepted = synchronized(registrationLock) { + if (closed.get()) false else { + registrations += registration + true + } + } + if (!accepted) { + registration.close() + error("Addon extension scope '$addonId' was closed during registration") + } + return registration + } +} + +internal class HostHollowAddonExtensions( + ownerId: String, + classLoader: ClassLoader, +) : HollowAddonExtensions by OwnedHollowAddonExtensions(ownerId, classLoader) + +private class CallbackRegistration( + private val cleanup: () -> Unit, +) : HollowAddonRegistration { + private val active = AtomicBoolean(true) + + override val isActive: Boolean + get() = active.get() + + override fun close() { + if (active.compareAndSet(true, false)) cleanup() + } +} + +private fun qualifyExtensionId(ownerId: String, localId: String): String { + require(ownerId.isNotBlank()) { "Extension owner ID cannot be blank" } + require(localId.isNotBlank()) { "Extension ID cannot be blank" } + val qualified = if (':' in localId) localId else "$ownerId:$localId" + require(qualified.substringBefore(':') == ownerId) { + "Extension '$localId' must belong to addon '$ownerId'" + } + requireNotNull(ResourceLocation.tryParse(qualified)) { "Invalid extension ID '$qualified'" } + return qualified +} + +internal inline fun withHollowAddonClassLoader(classLoader: ClassLoader, block: () -> R): R { + val thread = Thread.currentThread() + val previous = thread.contextClassLoader + thread.contextClassLoader = classLoader + return try { + block() + } finally { + thread.contextClassLoader = previous + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonMinecraftApi.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonMinecraftApi.kt new file mode 100644 index 000000000..898c60e5f --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonMinecraftApi.kt @@ -0,0 +1,177 @@ +package ru.hollowhorizon.hollowengine.common.addons + +import com.mojang.brigadier.CommandDispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.isActive +import net.minecraft.client.Minecraft +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.SharedSuggestionProvider +import ru.hollowhorizon.hollowengine.HollowEngine +import ru.hollowhorizon.hollowengine.common.coroutines.RuntimeDispatcherState +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.eventListenerOf +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import ru.hollowhorizon.hollowengine.common.events.registry.RegisterClientCommandsEvent +import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent +import ru.hollowhorizon.hollowengine.common.utils.currentServerOrNull +import kotlin.reflect.KClass + +/** Reversible runtime interactions with Minecraft. Frozen game registries intentionally live outside this API. */ +interface HollowAddonMinecraftApi { + val addonId: String + val dispatchers: HollowAddonMinecraftDispatchers + + fun subscribe( + type: KClass, + priority: Int = 0, + listener: (T) -> Unit, + ): HollowAddonRegistration + + fun registerCommands( + priority: Int = 0, + registration: (CommandDispatcher) -> Unit, + ): HollowAddonRegistration + + fun registerClientCommands( + priority: Int = 0, + registration: (CommandDispatcher) -> Unit, + ): HollowAddonRegistration +} + +/** Dispatches addon work only while the owning addon is active. */ +interface HollowAddonMinecraftDispatchers { + fun serverOrNull(): CoroutineDispatcher? + + fun clientOrNull(): CoroutineDispatcher? + + fun executeServer(action: () -> Unit): Boolean + + fun executeClient(action: () -> Unit): Boolean +} + +val HollowAddonContext.minecraft: HollowAddonMinecraftApi + get() = koin.get() + +inline fun HollowAddonMinecraftApi.subscribe( + priority: Int = 0, + noinline listener: (T) -> Unit, +): HollowAddonRegistration = subscribe(T::class, priority, listener) + +internal class OwnedHollowAddonMinecraftApi( + override val addonId: String, + private val addonScope: CoroutineScope, + private val classLoader: ClassLoader, +) : HollowAddonMinecraftApi { + override val dispatchers: HollowAddonMinecraftDispatchers = OwnedMinecraftDispatchers(addonScope, classLoader) + + override fun subscribe( + type: KClass, + priority: Int, + listener: (T) -> Unit, + ): HollowAddonRegistration { + val parentJob = requireNotNull(addonScope.coroutineContext[Job]) { + "Addon '$addonId' scope must contain a Job" + } + val subscriptionJob = SupervisorJob(parentJob) + val subscriptionScope = CoroutineScope(addonScope.coroutineContext + subscriptionJob) + val eventListener = eventListenerOf(priority) { event: T -> + if (!subscriptionJob.isActive) return@eventListenerOf + withHollowAddonClassLoader(classLoader) { + runCatching { listener(event) } + .onFailure { failure -> + HollowEngine.LOGGER.error( + "Addon '{}' failed while handling event '{}'", + addonId, + type.qualifiedName, + failure, + ) + } + } + } + val handler = EventHandler.get(type) + return try { + handler.register(subscriptionScope, eventListener) + AddonScopeRegistration(subscriptionJob) + } catch (failure: Throwable) { + subscriptionJob.cancel() + throw failure + } + } + + override fun registerCommands( + priority: Int, + registration: (CommandDispatcher) -> Unit, + ): HollowAddonRegistration = subscribe(RegisterCommandsEvent::class, priority) { event -> + registration(event.dispatcher) + } + + override fun registerClientCommands( + priority: Int, + registration: (CommandDispatcher) -> Unit, + ): HollowAddonRegistration { + check(HollowAddonRuntimeEnvironment.isClient) { + "Client commands cannot be registered on a dedicated server" + } + return subscribe(RegisterClientCommandsEvent::class, priority) { event -> + registration(event.dispatcher) + } + } +} + +private class AddonScopeRegistration( + private val job: Job, +) : HollowAddonRegistration { + override val isActive: Boolean + get() = job.isActive + + override fun close() { + job.cancel() + } +} + +private class OwnedMinecraftDispatchers( + private val addonScope: CoroutineScope, + private val classLoader: ClassLoader, +) : HollowAddonMinecraftDispatchers { + override fun serverOrNull(): CoroutineDispatcher? = currentServerOrNull()?.let { server -> + runCatching { RuntimeDispatcherState.serverDispatcher(server) }.getOrNull() + } + + override fun clientOrNull(): CoroutineDispatcher? { + if (!HollowAddonRuntimeEnvironment.isClient) return null + return ClientAccess.dispatcherOrNull() + } + + override fun executeServer(action: () -> Unit): Boolean { + val server = currentServerOrNull() ?: return false + if (!addonScope.isActive) return false + val guarded = { + if (addonScope.isActive) withHollowAddonClassLoader(classLoader, action) + } + if (server.isSameThread) guarded() else server.execute(guarded) + return true + } + + override fun executeClient(action: () -> Unit): Boolean { + if (!HollowAddonRuntimeEnvironment.isClient || !addonScope.isActive) return false + return ClientAccess.execute(addonScope.coroutineContext[Job], classLoader, action) + } + + private object ClientAccess { + fun dispatcherOrNull(): CoroutineDispatcher? { + val client = Minecraft.getInstance() + return runCatching { RuntimeDispatcherState.clientDispatcher(client) }.getOrNull() + } + + fun execute(job: Job?, classLoader: ClassLoader, action: () -> Unit): Boolean { + val client = Minecraft.getInstance() + client.execute { + if (job?.isActive != false) withHollowAddonClassLoader(classLoader, action) + } + return true + } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt index 7876c6c26..8fe758779 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/addons/HollowAddonRuntime.kt @@ -378,6 +378,7 @@ internal class HollowAddonRuntime( dependencies = dependencyLoaders, ) val hostServices = services.ownedBy(descriptor.id) + val extensions = OwnedHollowAddonExtensions(descriptor.id, classLoader) var koinApplication: KoinApplication? = null var addonJob: Job? = null return runCatching { @@ -386,8 +387,20 @@ internal class HollowAddonRuntime( .asSubclass(HollowAddonEntrypoint::class.java) .getDeclaredConstructor() .newInstance() + val createdJob = SupervisorJob(runtimeJob) + addonJob = createdJob + val addonScope = CoroutineScope( + Dispatchers.Default + createdJob + CoroutineName("Addon ${descriptor.id}") + ClassLoaderContextElement(classLoader), + ) + val minecraftApi = OwnedHollowAddonMinecraftApi( + addonId = descriptor.id, + addonScope = addonScope, + classLoader = classLoader, + ) val bridgeModule = module { single { hostServices } + single { extensions } + single { minecraftApi } single { descriptor } } val createdKoinApplication = koinApplication { @@ -401,11 +414,6 @@ internal class HollowAddonRuntime( classLoader = classLoader, koin = createdKoinApplication.koin, ) - val createdJob = SupervisorJob(runtimeJob) - addonJob = createdJob - val addonScope = CoroutineScope( - Dispatchers.Default + createdJob + CoroutineName("Addon ${descriptor.id}") + ClassLoaderContextElement(classLoader), - ) withContext(Dispatchers.Default + ClassLoaderContextElement(classLoader)) { entrypoint.load(context, addonScope) HollowAddonEventRegistrar.register(candidate.classesFile, classLoader, descriptor.id, entrypoint, addonScope) @@ -419,6 +427,7 @@ internal class HollowAddonRuntime( job = createdJob, koinApplication = createdKoinApplication, hostServices = hostServices, + extensions = extensions, ) refreshSnapshot() HollowEngine.LOGGER.info("Loaded addon {} {}", descriptor.id, descriptor.version) @@ -428,6 +437,7 @@ internal class HollowAddonRuntime( ScriptRegistry.unregister(descriptor.id) addonJob?.cancelAndJoin() HollowAddonPacketRegistry.unregister(descriptor.id) + extensions.cleanup() koinApplication?.close() hostServices.cleanup() classLoader.close() @@ -469,6 +479,7 @@ internal class HollowAddonRuntime( addon.entrypoint.unload(addon.context) } }.onFailure { HollowEngine.LOGGER.error("Failed to run unload hook for addon '$id'", it) } + addon.extensions.cleanup() runCatching { addon.koinApplication.close() } .onFailure { HollowEngine.LOGGER.error("Failed to close Koin for addon '$id'", it) } addon.hostServices.cleanup() @@ -524,5 +535,6 @@ internal class HollowAddonRuntime( val job: Job, val koinApplication: KoinApplication, val hostServices: OwnedHollowAddonHostServices, + val extensions: OwnedHollowAddonExtensions, ) } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/registry/RegisterCommandsEvent.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/registry/RegisterCommandsEvent.kt index fc1d08cee..f8b30ba35 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/registry/RegisterCommandsEvent.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/events/registry/RegisterCommandsEvent.kt @@ -3,6 +3,7 @@ package ru.hollowhorizon.hollowengine.common.events.registry import com.mojang.brigadier.CommandDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import net.minecraft.client.Minecraft import net.minecraft.commands.CommandBuildContext import net.minecraft.commands.CommandSourceStack import net.minecraft.commands.Commands @@ -51,8 +52,12 @@ class RegisterCommandsEvent( return super.post(event) } + /** + * Drops the dispatcher snapshot used to replay registration to late subscribers. + * Scoped listeners remain registered and receive the next registration event. + */ @Synchronized - fun clearReplay() { + fun clearReplaySnapshot() { current = null } @@ -85,5 +90,55 @@ class RegisterClientCommandsEvent( val dispatcher: CommandDispatcher, val registryAccess: CommandBuildContext, ) : Event { - companion object : EventHandler() + companion object : EventHandler() { + @Volatile + private var current: RegisterClientCommandsEvent? = null + + @Synchronized + override fun register( + scope: CoroutineScope, + listener: EventListener, + ): EventListener { + val registration = ScopedCommandRegistration(scope, ::executeCommandMutation) + val trackedListener = object : EventListener { + override val priority = listener.priority + + override fun invoke(event: RegisterClientCommandsEvent) { + registration.register(event.dispatcher) { listener(event) } + } + } + val registered = super.register(scope, trackedListener) + val job = requireNotNull(scope.coroutineContext[Job]) + current?.let { event -> + executeCommandMutation { + if (job.isActive) trackedListener(event) + } + } + return registered + } + + @Synchronized + override fun post(event: RegisterClientCommandsEvent): RegisterClientCommandsEvent { + current = event + return super.post(event) + } + + /** + * Drops the dispatcher snapshot used to replay registration to late subscribers. + * Scoped listeners remain registered and receive the next registration event. + */ + @Synchronized + fun clearReplaySnapshot() { + current = null + } + + private fun executeCommandMutation(mutation: () -> Unit) { + if (current == null) { + mutation() + return + } + val minecraft = Minecraft.getInstance() + if (minecraft.isSameThread) mutation() else minecraft.execute(mutation) + } + } } diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/integrations/acoustic/AcousticIntegration.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/integrations/acoustic/AcousticIntegration.kt new file mode 100644 index 000000000..effb6c0e3 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/integrations/acoustic/AcousticIntegration.kt @@ -0,0 +1,237 @@ +package ru.hollowhorizon.hollowengine.common.integrations.acoustic + +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.EntityAttachment +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.common.addons.HollowAddonManager +import ru.hollowhorizon.hollowengine.common.attachments.binding.ROOT_COMPONENT_ID +import java.util.UUID + +/** + * Stable HollowEngine-facing contract implemented by the optional Acoustic addon. + * + * No Acoustic class crosses this boundary. Scripts therefore remain loadable when the mod or addon + * is absent, and a reloaded addon cannot leave target-mod objects in a script classloader. + */ +interface AcousticIntegration { + fun play(request: AcousticPlayRequest): AcousticPlayback + + fun update(request: AcousticUpdateRequest) + + fun stop(request: AcousticStopRequest) +} + +data class AcousticPlayback(val instanceId: String) { + init { + require(instanceId.isNotBlank()) { "Acoustic playback ID cannot be blank" } + } +} + +data class AcousticPlayRequest( + val sound: ResourceLocation, + val players: List, + val options: AcousticPlayOptions = AcousticPlayOptions(), +) + +data class AcousticUpdateRequest( + val playback: AcousticPlayback, + val players: List, + val options: AcousticUpdateOptions, +) + +data class AcousticStopRequest( + val playback: AcousticPlayback, + val players: List, + val fadeOutSeconds: Float = 0f, +) { + init { + requireNonNegativeFinite(fadeOutSeconds, "Acoustic stop fade-out duration") + } +} + +data class AcousticPlayOptions( + val loop: AcousticLoop? = null, + val startOffsetSeconds: Float? = null, + val endOffsetSeconds: Float? = null, + val volume: Float? = null, + val pitch: Float? = null, + val fadeIn: AcousticFade? = null, + val fadeOut: AcousticFade? = null, + val exclusive: Boolean? = null, + val priority: Int? = null, + val priorityFadeOutSeconds: Float? = null, + val source: AcousticSource? = null, + val range: Float? = null, + val sourceTimeoutSeconds: Float? = null, + val instanceId: String? = null, + val condition: ((ServerPlayer) -> Boolean)? = null, +) { + init { + validateOffsets(startOffsetSeconds, endOffsetSeconds) + volume?.let { value -> require(value.isFinite()) { "Acoustic volume must be finite" } } + pitch?.let { value -> require(value.isFinite()) { "Acoustic pitch must be finite" } } + priorityFadeOutSeconds?.let { requireNonNegativeFinite(it, "Acoustic priority fade-out duration") } + range?.let { requirePositiveFinite(it, "Acoustic range") } + sourceTimeoutSeconds?.let { requireNonNegativeFinite(it, "Acoustic source timeout") } + instanceId?.let { require(it.isNotBlank()) { "Acoustic playback ID cannot be blank" } } + } +} + +data class AcousticUpdateOptions( + val loop: AcousticLoop? = null, + val startOffsetSeconds: Float? = null, + val endOffsetSeconds: Float? = null, + val volume: AcousticFloatUpdate? = null, + val pitch: AcousticFloatUpdate? = null, + val fadeIn: AcousticFade? = null, + val fadeOut: AcousticFade? = null, + val exclusive: Boolean? = null, + val priority: Int? = null, + val priorityFadeOutSeconds: Float? = null, + val source: AcousticSource? = null, + val range: Float? = null, +) { + init { + validateOffsets(startOffsetSeconds, endOffsetSeconds) + priorityFadeOutSeconds?.let { requireNonNegativeFinite(it, "Acoustic priority fade-out duration") } + range?.let { requirePositiveFinite(it, "Acoustic range") } + } +} + +sealed interface AcousticLoop { + data object Infinite : AcousticLoop + + data class Count(val count: Int) : AcousticLoop { + init { + require(count > 0) { "Acoustic loop count must be positive" } + } + } +} + +data class AcousticFade( + val seconds: Float, + val repeatOnLoop: Boolean = false, +) { + init { + requireNonNegativeFinite(seconds, "Acoustic fade duration") + } +} + +data class AcousticFloatUpdate( + val value: Float, + val transitionSeconds: Float = 0f, +) { + init { + require(value.isFinite()) { "Acoustic value must be finite" } + requireNonNegativeFinite(transitionSeconds, "Acoustic transition duration") + } +} + +sealed interface AcousticSource { + data object Listener : AcousticSource + + data class Position(val position: Vec3) : AcousticSource { + init { + require(position.x.isFinite() && position.y.isFinite() && position.z.isFinite()) { + "Acoustic source position must be finite" + } + } + } + + data class EntityAnchor( + val entity: Entity, + val anchor: AcousticEntityAnchor = AcousticEntityAnchor.CENTER, + ) : AcousticSource + + data class VanillaAttachment( + val entity: Entity, + val attachment: EntityAttachment, + val index: Int = 0, + ) : AcousticSource { + init { + require(index >= 0) { "Entity attachment index cannot be negative" } + } + } + + /** A named attachment published through Acoustic's client attachment API. */ + data class NamedAttachment(val attachmentId: ResourceLocation) : AcousticSource + + /** A position evaluated from the posed HollowEngine model rather than the host entity origin. */ + data class HollowModel( + val entity: Entity, + val nodeId: UUID = ROOT_COMPONENT_ID, + val anchor: HollowModelAcousticAnchor = HollowModelAcousticAnchor.Root, + ) : AcousticSource +} + +enum class AcousticEntityAnchor { + FEET, + CENTER, + EYES, +} + +sealed interface HollowModelAcousticAnchor { + data object Root : HollowModelAcousticAnchor + + /** Resolves only when exactly one runtime node has this name. */ + data class BoneName(val name: String) : HollowModelAcousticAnchor { + init { + require(name.isNotBlank()) { "Bone name cannot be blank" } + } + } + + /** Resolves an exact slash-separated path through the runtime node hierarchy. */ + data class BonePath(val segments: List) : HollowModelAcousticAnchor { + init { + require(segments.isNotEmpty()) { "Bone path cannot be empty" } + require(segments.none(String::isBlank)) { "Bone path cannot contain blank segments" } + } + + constructor(path: String) : this(parseBonePath(path)) + } +} + +object HollowAcoustic { + val isAvailable: Boolean + get() = HollowAddonManager.find() != null + + fun play(request: AcousticPlayRequest): AcousticPlayback = integration().play(request) + + fun update(request: AcousticUpdateRequest) = integration().update(request) + + fun stop(request: AcousticStopRequest) = integration().stop(request) + + private fun integration(): AcousticIntegration = HollowAddonManager.find() + ?: error( + "Acoustic integration is unavailable. Install and enable the HollowEngine Acoustic addon " + + "and the Acoustic mod on both the server and the receiving clients.", + ) +} + +internal fun requireNonNegativeFinite(value: Float, name: String) { + require(value.isFinite() && value >= 0f) { "$name must be a finite non-negative value" } +} + +private fun requirePositiveFinite(value: Float, name: String) { + require(value.isFinite() && value > 0f) { "$name must be a finite positive value" } +} + +private fun validateOffsets(startOffsetSeconds: Float?, endOffsetSeconds: Float?) { + startOffsetSeconds?.let { requireNonNegativeFinite(it, "Acoustic start offset") } + endOffsetSeconds?.let { end -> + require(end.isFinite()) { "Acoustic end offset must be finite" } + require(end < 0f || end > (startOffsetSeconds ?: 0f)) { + "Acoustic end offset must be greater than the start offset or negative for EOF" + } + } +} + +private fun parseBonePath(path: String): List { + val normalized = path.replace('\\', '/').trim('/') + require(normalized.isNotBlank()) { "Bone path cannot be empty" } + return normalized.split('/').also { segments -> + require(segments.none(String::isBlank)) { "Bone path cannot contain blank segments" } + } +} diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt index c76a1e7ff..a20481d3e 100644 --- a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/DefaultScriptDefinitions.kt @@ -30,6 +30,7 @@ object DefaultScriptDefinitions { ResourceLocation::class.qualifiedName!!, ItemStack::class.qualifiedName!!, "net.minecraft.core.component.DataComponentPatch", + "net.minecraft.world.entity.EntityAttachment", SubscribeEvent::class.qualifiedName!!, Import::class.qualifiedName!!, "ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.item", @@ -43,6 +44,10 @@ object DefaultScriptDefinitions { "ru.hollowhorizon.hollowengine.common.dialogue.lang.number", "ru.hollowhorizon.hollowengine.common.dialogue.lang.signature", "ru.hollowhorizon.hollowengine.common.dialogue.lang.string", + "ru.hollowhorizon.hollowengine.common.integrations.acoustic.*", + "ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.playAcoustic", + "ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.updateAcoustic", + "ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.stopAcoustic", ) ) this += Provider( @@ -89,6 +94,7 @@ object DefaultScriptDefinitions { ResourceLocation::class.qualifiedName!!, "net.minecraft.nbt.CompoundTag", "net.minecraft.world.entity.Entity", + "net.minecraft.world.entity.EntityAttachment", "net.minecraft.world.entity.LivingEntity", "net.minecraft.world.entity.player.Player", "net.minecraft.world.entity.EquipmentSlot", @@ -99,6 +105,7 @@ object DefaultScriptDefinitions { "net.minecraft.core.BlockPos", "ru.hollowhorizon.hollowengine.common.scripting.story.functions.*", "ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects.*", + "ru.hollowhorizon.hollowengine.common.integrations.acoustic.*", "ru.hollowhorizon.hollowengine.common.scripting.story.functions.entities.*", "ru.hollowhorizon.hollowengine.common.scripting.story.functions.npcs.*", "ru.hollowhorizon.hollowengine.common.scripting.story.functions.player.*", diff --git a/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/effects/AcousticActions.kt b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/effects/AcousticActions.kt new file mode 100644 index 000000000..4a04d5933 --- /dev/null +++ b/runtime/src/main/java/ru/hollowhorizon/hollowengine/common/scripting/story/functions/effects/AcousticActions.kt @@ -0,0 +1,331 @@ +package ru.hollowhorizon.hollowengine.common.scripting.story.functions.effects + +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.Entity +import net.minecraft.world.entity.EntityAttachment +import net.minecraft.world.phys.Vec3 +import ru.hollowhorizon.hollowengine.common.attachments.binding.ROOT_COMPONENT_ID +import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticEntityAnchor +import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticFade +import ru.hollowhorizon.hollowengine.common.integrations.acoustic.AcousticFloatUpdate +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 +import ru.hollowhorizon.hollowengine.common.integrations.acoustic.HollowAcoustic +import ru.hollowhorizon.hollowengine.common.integrations.acoustic.HollowModelAcousticAnchor +import ru.hollowhorizon.hollowengine.common.integrations.acoustic.requireNonNegativeFinite +import ru.hollowhorizon.hollowengine.common.utils.rl +import java.util.UUID + +@DslMarker +annotation class AcousticDsl + +@AcousticDsl +open class AcousticSourceBuilder internal constructor() { + internal var source: AcousticSource? = null + + fun listener() { + source = AcousticSource.Listener + } + + fun at(position: Vec3) { + source = AcousticSource.Position(position) + } + + fun at(x: Double, y: Double, z: Double) = at(Vec3(x, y, z)) + + fun follow(entity: Entity, anchor: AcousticEntityAnchor = AcousticEntityAnchor.CENTER) { + source = AcousticSource.EntityAnchor(entity, anchor) + } + + fun attachTo(entity: Entity, attachment: EntityAttachment, index: Int = 0) { + source = AcousticSource.VanillaAttachment(entity, attachment, index) + } + + fun attachTo(attachmentId: ResourceLocation) { + source = AcousticSource.NamedAttachment(attachmentId) + } + + fun attachTo(attachmentId: String) = attachTo(attachmentId.rl) + + fun attachToModel(entity: Entity, nodeId: UUID = ROOT_COMPONENT_ID) { + source = AcousticSource.HollowModel(entity, nodeId) + } + + fun attachToBone(entity: Entity, boneName: String, nodeId: UUID = ROOT_COMPONENT_ID) { + source = AcousticSource.HollowModel( + entity = entity, + nodeId = nodeId, + anchor = HollowModelAcousticAnchor.BoneName(boneName), + ) + } + + fun attachToBonePath(entity: Entity, bonePath: String, nodeId: UUID = ROOT_COMPONENT_ID) { + attachToBonePath(entity, HollowModelAcousticAnchor.BonePath(bonePath).segments, nodeId) + } + + fun attachToBonePath(entity: Entity, bonePath: List, nodeId: UUID = ROOT_COMPONENT_ID) { + source = AcousticSource.HollowModel( + entity = entity, + nodeId = nodeId, + anchor = HollowModelAcousticAnchor.BonePath(bonePath), + ) + } +} + +@AcousticDsl +class AcousticPlayBuilder internal constructor() : AcousticSourceBuilder() { + private var loop: AcousticLoop? = null + private var startOffsetSeconds: Float? = null + private var endOffsetSeconds: Float? = null + private var volume: Float? = null + private var pitch: Float? = null + private var fadeIn: AcousticFade? = null + private var fadeOut: AcousticFade? = null + private var exclusive: Boolean? = null + private var priority: Int? = null + private var priorityFadeOutSeconds: Float? = null + private var range: Float? = null + private var sourceTimeoutSeconds: Float? = null + private var instanceId: String? = null + private var condition: ((ServerPlayer) -> Boolean)? = null + + fun loop() { + loop = AcousticLoop.Infinite + } + + fun loop(count: Int) { + loop = AcousticLoop.Count(count) + } + + fun once() = loop(1) + + fun start(seconds: Float) { + requireNonNegativeFinite(seconds, "Acoustic start offset") + startOffsetSeconds = seconds + } + + fun end(seconds: Float) { + require(seconds.isFinite()) { "Acoustic end offset must be finite" } + endOffsetSeconds = seconds + } + + fun volume(value: Float) { + require(value.isFinite()) { "Acoustic volume must be finite" } + volume = value + } + + fun pitch(value: Float) { + require(value.isFinite()) { "Acoustic pitch must be finite" } + pitch = value + } + + fun fadeIn(seconds: Float, repeatOnLoop: Boolean = false) { + fadeIn = AcousticFade(seconds, repeatOnLoop) + } + + fun fadeOut(seconds: Float, repeatOnLoop: Boolean = false) { + fadeOut = AcousticFade(seconds, repeatOnLoop) + } + + fun exclusive(value: Boolean = true) { + exclusive = value + } + + fun priority(value: Int) { + priority = value + } + + fun priorityFadeOut(seconds: Float) { + requireNonNegativeFinite(seconds, "Acoustic priority fade-out duration") + priorityFadeOutSeconds = seconds + } + + fun range(value: Float) { + require(value.isFinite() && value > 0f) { "Acoustic range must be a finite positive value" } + range = value + } + + fun sourceTimeout(seconds: Float) { + requireNonNegativeFinite(seconds, "Acoustic source timeout") + sourceTimeoutSeconds = seconds + } + + fun withId(value: String) { + require(value.isNotBlank()) { "Acoustic playback ID cannot be blank" } + instanceId = value + } + + fun condition(predicate: (ServerPlayer) -> Boolean) { + condition = predicate + } + + internal fun build(): AcousticPlayOptions = AcousticPlayOptions( + loop = loop, + startOffsetSeconds = startOffsetSeconds, + endOffsetSeconds = endOffsetSeconds, + volume = volume, + pitch = pitch, + fadeIn = fadeIn, + fadeOut = fadeOut, + exclusive = exclusive, + priority = priority, + priorityFadeOutSeconds = priorityFadeOutSeconds, + source = source, + range = range, + sourceTimeoutSeconds = sourceTimeoutSeconds, + instanceId = instanceId, + condition = condition, + ) +} + +@AcousticDsl +class AcousticUpdateBuilder internal constructor() : AcousticSourceBuilder() { + private var loop: AcousticLoop? = null + private var startOffsetSeconds: Float? = null + private var endOffsetSeconds: Float? = null + private var volume: AcousticFloatUpdate? = null + private var pitch: AcousticFloatUpdate? = null + private var fadeIn: AcousticFade? = null + private var fadeOut: AcousticFade? = null + private var exclusive: Boolean? = null + private var priority: Int? = null + private var priorityFadeOutSeconds: Float? = null + private var range: Float? = null + + fun loop() { + loop = AcousticLoop.Infinite + } + + fun loop(count: Int) { + loop = AcousticLoop.Count(count) + } + + fun once() = loop(1) + + fun start(seconds: Float) { + requireNonNegativeFinite(seconds, "Acoustic start offset") + startOffsetSeconds = seconds + } + + fun end(seconds: Float) { + require(seconds.isFinite()) { "Acoustic end offset must be finite" } + endOffsetSeconds = seconds + } + + fun volume(value: Float, transitionSeconds: Float = 0f) { + volume = AcousticFloatUpdate(value, transitionSeconds) + } + + fun pitch(value: Float, transitionSeconds: Float = 0f) { + pitch = AcousticFloatUpdate(value, transitionSeconds) + } + + fun fadeIn(seconds: Float, repeatOnLoop: Boolean = false) { + fadeIn = AcousticFade(seconds, repeatOnLoop) + } + + fun fadeOut(seconds: Float, repeatOnLoop: Boolean = false) { + fadeOut = AcousticFade(seconds, repeatOnLoop) + } + + fun exclusive(value: Boolean = true) { + exclusive = value + } + + fun priority(value: Int) { + priority = value + } + + fun priorityFadeOut(seconds: Float) { + requireNonNegativeFinite(seconds, "Acoustic priority fade-out duration") + priorityFadeOutSeconds = seconds + } + + fun range(value: Float) { + require(value.isFinite() && value > 0f) { "Acoustic range must be a finite positive value" } + range = value + } + + internal fun build(): AcousticUpdateOptions = AcousticUpdateOptions( + loop = loop, + startOffsetSeconds = startOffsetSeconds, + endOffsetSeconds = endOffsetSeconds, + volume = volume, + pitch = pitch, + fadeIn = fadeIn, + fadeOut = fadeOut, + exclusive = exclusive, + priority = priority, + priorityFadeOutSeconds = priorityFadeOutSeconds, + source = source, + range = range, + ) +} + +fun ServerPlayer.playAcoustic( + sound: String, + configure: AcousticPlayBuilder.() -> Unit = {}, +): AcousticPlayback = listOf(this).playAcoustic(sound.rl, configure) + +fun ServerPlayer.playAcoustic( + sound: ResourceLocation, + configure: AcousticPlayBuilder.() -> Unit = {}, +): AcousticPlayback = listOf(this).playAcoustic(sound, configure) + +fun Collection.playAcoustic( + sound: String, + configure: AcousticPlayBuilder.() -> Unit = {}, +): AcousticPlayback = playAcoustic(sound.rl, configure) + +fun Collection.playAcoustic( + sound: ResourceLocation, + configure: AcousticPlayBuilder.() -> Unit = {}, +): AcousticPlayback { + require(isNotEmpty()) { "Acoustic playback needs at least one receiving player" } + val players = distinctBy(ServerPlayer::getUUID) + val options = AcousticPlayBuilder().apply(configure).build() + return HollowAcoustic.play(AcousticPlayRequest(sound, players, options)) +} + +fun ServerLevel.playAcoustic( + sound: String, + configure: AcousticPlayBuilder.() -> Unit = {}, +): AcousticPlayback = players().playAcoustic(sound, configure) + +fun ServerLevel.playAcoustic( + sound: ResourceLocation, + configure: AcousticPlayBuilder.() -> Unit = {}, +): AcousticPlayback = players().playAcoustic(sound, configure) + +fun AcousticPlayback.updateAcoustic( + player: ServerPlayer, + configure: AcousticUpdateBuilder.() -> Unit, +) = updateAcoustic(listOf(player), configure) + +fun AcousticPlayback.updateAcoustic( + players: Collection, + configure: AcousticUpdateBuilder.() -> Unit, +) { + require(players.isNotEmpty()) { "Acoustic update needs at least one receiving player" } + val options = AcousticUpdateBuilder().apply(configure).build() + HollowAcoustic.update(AcousticUpdateRequest(this, players.distinctBy(ServerPlayer::getUUID), options)) +} + +fun AcousticPlayback.stopAcoustic(player: ServerPlayer, fadeOutSeconds: Float = 0f) = + stopAcoustic(listOf(player), fadeOutSeconds) + +fun AcousticPlayback.stopAcoustic(players: Collection, fadeOutSeconds: Float = 0f) { + require(players.isNotEmpty()) { "Acoustic stop needs at least one receiving player" } + requireNonNegativeFinite(fadeOutSeconds, "Acoustic stop fade-out duration") + HollowAcoustic.stop( + AcousticStopRequest(this, players.distinctBy(ServerPlayer::getUUID), fadeOutSeconds), + ) +} diff --git a/runtime/src/test/kotlin/HollowAddonExtensionsTests.kt b/runtime/src/test/kotlin/HollowAddonExtensionsTests.kt new file mode 100644 index 000000000..34e9a74ea --- /dev/null +++ b/runtime/src/test/kotlin/HollowAddonExtensionsTests.kt @@ -0,0 +1,103 @@ +package ru.hollowhorizon.hollowengine.common.addons + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class HollowAddonExtensionsTests { + @Test + fun `cleanup removes owned extensions in reverse order`() { + val point = HollowAddonExtensionPoint("test:point", TestExtension::class) + val scope = OwnedHollowAddonExtensions("demo", javaClass.classLoader) + val cleanupOrder = mutableListOf() + + scope.register(point, "first", TestExtension("first")) + scope.onUnload { cleanupOrder += "first-cleanup" } + scope.register(point, "second", TestExtension("second")) + scope.onUnload { cleanupOrder += "second-cleanup" } + + assertEquals(listOf("first", "second"), point.values().map(TestExtension::name)) + scope.cleanup() + + assertTrue(point.values().isEmpty()) + assertEquals(listOf("second-cleanup", "first-cleanup"), cleanupOrder) + } + + @Test + fun `qualified identities isolate owners while rejecting duplicate owner keys`() { + val point = HollowAddonExtensionPoint("test:point", TestExtension::class) + val first = OwnedHollowAddonExtensions("first-addon", javaClass.classLoader) + val second = OwnedHollowAddonExtensions("second-addon", javaClass.classLoader) + + first.register(point, "editor", TestExtension("first")) + second.register(point, "editor", TestExtension("second")) + + assertEquals( + listOf("first-addon:editor", "second-addon:editor"), + point.extensions().map { it.qualifiedId }, + ) + assertFailsWith { + first.register(point, "editor", TestExtension("duplicate")) + } + + first.cleanup() + second.cleanup() + } + + @Test + fun `priority wins before deterministic registration order`() { + val point = HollowAddonExtensionPoint("test:point", TestExtension::class) + val scope = OwnedHollowAddonExtensions("demo", javaClass.classLoader) + + scope.register(point, "normal", TestExtension("normal"), priority = 0) + scope.register(point, "first-high", TestExtension("first-high"), priority = 10) + scope.register(point, "second-high", TestExtension("second-high"), priority = 10) + + assertEquals(listOf("first-high", "second-high", "normal"), point.values().map(TestExtension::name)) + scope.cleanup() + } + + @Test + fun `manual registration close is idempotent`() { + val point = HollowAddonExtensionPoint("test:point", TestExtension::class) + val scope = OwnedHollowAddonExtensions("demo", javaClass.classLoader) + val registration = scope.register(point, "editor", TestExtension("editor")) + + registration.close() + registration.close() + + assertFalse(registration.isActive) + assertTrue(point.values().isEmpty()) + scope.cleanup() + } + + @Test + fun `closed scope rejects and rolls back late registrations`() { + val point = HollowAddonExtensionPoint("test:point", TestExtension::class) + val scope = OwnedHollowAddonExtensions("demo", javaClass.classLoader) + scope.cleanup() + + assertFailsWith { + scope.register(point, "late", TestExtension("late")) + } + assertTrue(point.values().isEmpty()) + } + + @Test + fun `extension callbacks use their defining context classloader`() { + val point = HollowAddonExtensionPoint("test:point", TestExtension::class) + val loader = object : ClassLoader(javaClass.classLoader) {} + val scope = OwnedHollowAddonExtensions("demo", loader) + scope.register(point, "editor", TestExtension("editor")) + + val observed = point.extensions().single().invoke { Thread.currentThread().contextClassLoader } + + assertSame(loader, observed) + scope.cleanup() + } + + private data class TestExtension(val name: String) +} diff --git a/runtime/src/test/kotlin/HollowAddonMinecraftApiTests.kt b/runtime/src/test/kotlin/HollowAddonMinecraftApiTests.kt new file mode 100644 index 000000000..1ce3cb551 --- /dev/null +++ b/runtime/src/test/kotlin/HollowAddonMinecraftApiTests.kt @@ -0,0 +1,108 @@ +package ru.hollowhorizon.hollowengine.common.addons + +import com.mojang.brigadier.CommandDispatcher +import com.mojang.brigadier.builder.LiteralArgumentBuilder.literal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import net.minecraft.commands.CommandBuildContext +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands +import net.minecraft.core.RegistryAccess +import net.minecraft.world.flag.FeatureFlags +import ru.hollowhorizon.hollowengine.common.events.Event +import ru.hollowhorizon.hollowengine.common.events.factory.EventHandler +import ru.hollowhorizon.hollowengine.common.events.registry.RegisterCommandsEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class HollowAddonMinecraftApiTests { + private val coroutineScope = CoroutineScope(Job()) + private val api = OwnedHollowAddonMinecraftApi(ADDON_ID, coroutineScope, javaClass.classLoader) + + @AfterTest + fun cleanup() { + coroutineScope.cancel() + TestEvent.clear() + RegisterCommandsEvent.clear() + RegisterCommandsEvent.clearReplaySnapshot() + } + + @Test + fun `explicit event subscription follows addon scope`() { + var calls = 0 + api.subscribe(TestEvent::class) { calls++ } + + TestEvent.post(TestEvent()) + coroutineScope.cancel() + TestEvent.post(TestEvent()) + + assertEquals(1, calls) + } + + @Test + fun `closing event registration removes its listener`() { + var calls = 0 + val registration = api.subscribe(TestEvent::class) { calls++ } + + TestEvent.post(TestEvent()) + registration.close() + TestEvent.post(TestEvent()) + + assertEquals(1, calls) + assertFalse(registration.isActive) + } + + @Test + fun `closing command registration removes nodes added by late replay`() { + val dispatcher = CommandDispatcher() + RegisterCommandsEvent.post(commandEvent(dispatcher)) + + val registration = api.registerCommands { commands -> + commands.register(literal(COMMAND_NAME).executes { 1 }) + } + assertNotNull(dispatcher.root.getChild(COMMAND_NAME)) + + registration.close() + assertNull(dispatcher.root.getChild(COMMAND_NAME)) + } + + @Test + fun `clearing replay snapshot preserves listener for the next dispatcher`() { + val previous = CommandDispatcher() + RegisterCommandsEvent.post(commandEvent(previous)) + RegisterCommandsEvent.clearReplaySnapshot() + + var calls = 0 + api.registerCommands { commands -> + calls++ + commands.register(literal(COMMAND_NAME).executes { 1 }) + } + assertEquals(0, calls) + assertNull(previous.root.getChild(COMMAND_NAME)) + + val current = CommandDispatcher() + RegisterCommandsEvent.post(commandEvent(current)) + assertEquals(1, calls) + assertNotNull(current.root.getChild(COMMAND_NAME)) + } + + private fun commandEvent(dispatcher: CommandDispatcher) = RegisterCommandsEvent( + dispatcher = dispatcher, + registryAccess = CommandBuildContext.simple(RegistryAccess.EMPTY, FeatureFlags.DEFAULT_FLAGS), + environment = Commands.CommandSelection.ALL, + ) + + class TestEvent : Event { + companion object : EventHandler() + } + + private companion object { + const val ADDON_ID = "minecraft-api-test" + const val COMMAND_NAME = "addon-test-command" + } +} diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/scripting/HollowIdeFileTypeRegistryTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/scripting/HollowIdeFileTypeRegistryTest.kt index 497c17cde..1f9781e84 100644 --- a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/scripting/HollowIdeFileTypeRegistryTest.kt +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/gui/scripting/HollowIdeFileTypeRegistryTest.kt @@ -50,6 +50,23 @@ class HollowIdeFileTypeRegistryTest { } } + @Test + fun `qualified registrations isolate equal local file type ids and can be removed`() { + val registry = HollowIdeFileTypeRegistry() + val first = fileType("preview", listOf(".first"), priority = 10) + val second = fileType("preview", listOf(".second"), priority = 20) + + registry.register("first-addon:preview", first) + registry.register("second-addon:preview", second) + + assertSame(first, registry.find("first-addon:preview")) + assertSame(second, registry.find("second-addon:preview")) + assertNull(registry.find("preview"), "an ambiguous local id must not select an arbitrary addon") + assertSame(first, registry.unregister("first-addon:preview")) + assertNull(registry.find("first-addon:preview")) + assertSame(second, registry.find("preview"), "the local id is usable again once it is unambiguous") + } + @Test fun `binary fallback rejects control-heavy data`() { val registry = HollowIdeFileTypeRegistry() diff --git a/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensionApiTest.kt b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensionApiTest.kt new file mode 100644 index 000000000..b5aa78ef3 --- /dev/null +++ b/runtime/src/test/kotlin/ru/hollowhorizon/hollowengine/client/ui/ide/HollowIdeExtensionApiTest.kt @@ -0,0 +1,67 @@ +package ru.hollowhorizon.hollowengine.client.ui.ide + +import ru.hollowhorizon.hollowengine.client.ui.ide.files.HollowIdeLanguageService +import ru.hollowhorizon.hollowengine.common.addons.OwnedHollowAddonExtensions +import ru.hollowhorizon.hollowengine.common.scripting.ide.JsonScriptingAnalyzer +import ru.hollowhorizon.hollowengine.common.scripting.ide.PlainTextScriptingAnalyzer +import ru.hollowhorizon.hollowengine.common.scripting.ide.ScriptingAnalyzer +import kotlin.test.Test +import kotlin.test.assertSame + +class HollowIdeExtensionApiTest { + @Test + fun `custom language is selected and removed with its addon scope`() { + val scope = OwnedHollowAddonExtensions("quest-addon", javaClass.classLoader) + val analyzer = PlainTextScriptingAnalyzer + val language = HollowIdeLanguageService.extensions("quest", listOf("quest")) { analyzer } + + try { + scope.registerIdeLanguage(language) + assertSame(analyzer, languageServiceForPath("quests/intro.quest").analyzer) + } finally { + scope.cleanup() + } + + assertSame(PlainTextScriptingAnalyzer, languageServiceForPath("quests/intro.quest").analyzer) + } + + @Test + fun `addon language overrides builtin and cleanup restores it`() { + val scope = OwnedHollowAddonExtensions("json-addon", javaClass.classLoader) + val analyzer = distinctAnalyzer() + val language = HollowIdeLanguageService.extensions("custom-json", listOf("json")) { analyzer } + + try { + scope.registerIdeLanguage(language) + assertSame(analyzer, languageServiceForPath("data/example.json").analyzer) + } finally { + scope.cleanup() + } + + assertSame(JsonScriptingAnalyzer, languageServiceForPath("data/example.json").analyzer) + } + + @Test + fun `higher priority language wins and cleanup restores previous match`() { + val lowerScope = OwnedHollowAddonExtensions("lower-addon", javaClass.classLoader) + val higherScope = OwnedHollowAddonExtensions("higher-addon", javaClass.classLoader) + val lowerAnalyzer = distinctAnalyzer() + val higherAnalyzer = distinctAnalyzer() + val lower = HollowIdeLanguageService.extensions("quest", listOf("quest")) { lowerAnalyzer } + val higher = HollowIdeLanguageService.extensions("quest", listOf("quest")) { higherAnalyzer } + + try { + lowerScope.registerIdeLanguage(lower, priority = 10) + higherScope.registerIdeLanguage(higher, priority = 20) + assertSame(higherAnalyzer, languageServiceForPath("quests/intro.quest").analyzer) + + higherScope.cleanup() + assertSame(lowerAnalyzer, languageServiceForPath("quests/intro.quest").analyzer) + } finally { + higherScope.cleanup() + lowerScope.cleanup() + } + } + + private fun distinctAnalyzer(): ScriptingAnalyzer = object : ScriptingAnalyzer by PlainTextScriptingAnalyzer {} +}