CooParticles API Mod (1.21.1) is a library that provides refactored data packages to allow clients to accept more Minecraft particles, enabling different players to synchronize their rendering and exceed the particle limit of 16384.

How to use:

Create a particle class that inherits from ControlableParticle.

Example Particle


class TestEndRodParticle(
    // Parameters required for Particle
    world: ClientWorld,
    pos: Vec3d,
    velocity: Vec3d,
    // Unique identifier to obtain ParticleControler
    controlUUID: UUID,
    val provider: SpriteProvider
) :
    // Must inherit from ControlableParticle
    ControlableParticle(world, pos, velocity, controlUUID) {
    override fun getType(): ParticleTextureSheet {
        return ParticleTextureSheet.PARTICLE_SHEET_OPAQUE
    }

    init {
        setSprite(provider.getSprite(0, 120))
        // Since ControlableParticle prohibits overriding the tick method
        // Use this method instead
        controler.addPreTickAction {
            setSpriteForAge(provider)
        }
    }

    // Basic particle registration
    class Factory(val provider: SpriteProvider) : ParticleFactory<TestEndRodEffect> {
        override fun createParticle(
            parameters: TestEndRodEffect,
            world: ClientWorld,
            x: Double,
            y: Double,
            z: Double,
            velocityX: Double,
            velocityY: Double,
            velocityZ: Double
        ): Particle {
            return TestEndRodParticle(
                world,
                Vec3d(x, y, z),
                Vec3d(velocityX, velocityY, velocityZ),
                parameters.controlUUID,
                provider
            )
        }
    }
}
    

To obtain the corresponding UUID, your ParticleEffect must also have a UUID.


// As a constructor parameter
class TestEndRodEffect(controlUUID: UUID) : ControlableParticleEffect(controlUUID) {
    companion object {
        @JvmStatic
        val codec: MapCodec<TestEndRodEffect> = RecordCodecBuilder.mapCodec {
            return@mapCodec it.group(
                Codec.BYTE_BUFFER.fieldOf("uuid").forGetter { effect ->
                    val toString = effect.controlUUID.toString()
                    val buffer = Unpooled.buffer()
                    buffer.writeBytes(toString.toByteArray())
                    buffer.nioBuffer()
                }
            ).apply(it) { buf ->
                TestEndRodEffect(
                    UUID.fromString(
                        String(buf.array())
                    )
                )
            }
        }

        @JvmStatic
        val packetCode: PacketCodec<RegistryByteBuf, TestEndRodEffect> = PacketCodec.of(
            { effect, buf ->
                buf.writeUuid(effect.controlUUID)
            }, {
                TestEndRodEffect(it.readUuid())
            }
        )
    }

    override fun getType(): ParticleType<*> {
        return ModParticles.testEndRod
    }
}
    

After registering the particle using the Fabric API on the client side, proceed to build a ControlableParticleGroup.

Building a ControlableParticleGroup

The role of ControlableParticleGroup is to render particle combinations on the player’s client.

Example code for building a basic ControlableParticleGroup: A magic circle at the center of the player’s field of view, rotating 10 degrees per tick.


class TestGroupClient(uuid: UUID, val bindPlayer: UUID) : ControlableParticleGroup(uuid) {
    // To allow the server to properly forward ParticleGroup data to each player
    // The server sends a PacketParticleGroupS2C packet
    // This is for decoding
    class Provider : ControlableParticleGroupProvider {
        override fun createGroup(
            uuid: UUID,
            // Args are parameters synchronized from the server to the client
            // Refer to the cn.coostack.network.packet.PacketParticleGroupS2C class for fields that are not recommended to override or need handling (already handled)
            args: Map<String, ParticleControlerDataBuffer<*>>
        ): ControlableParticleGroup {
            // Bound player
            val bindUUID = args["bindUUID"]!!.loadedValue as UUID
            return TestGroupClient(uuid, bindUUID)
        }
    }

    // Magic circle particle combination
    override fun loadParticleLocations(): Map<ParticleRelativeData, RelativeLocation> {
        // Magic circle on the XZ plane
        val list = Math3DUtil.getCycloidGraphic(3.0, 5.0, 2, -3, 360, 0.2).onEach { it.y += 6 }
        return list.associateBy {
            withEffect({
                // Provide ParticleEffect (used in world.addParticle in the display method)
                // 'it' is of type UUID
                // If you need to set a ParticleGroup at this position, use
                // ParticleDisplayer.withGroup(yourParticleGroup)
                ParticleDisplayer.withSingle(TestEndRodEffect(it))
            }) {
                // kt: this is ControlableParticle
                // java: this instanceof ControlableParticle
                // Used to initialize particle information
                // If the parameter is withGroup, this method does not need to be implemented
                color = Vector3f(230 / 255f, 130 / 255f, 60 / 255f)
                this.maxAliveTick = this.maxAliveTick
            }
        }
    }

    /**
     * When the particle is first rendered in the player’s view
     * This will also be called when the player moves out of render range and returns
     * Can be understood as particle group initialization
     */
    override fun onGroupDisplay() {
        MinecraftClient.getInstance().player?.sendMessage(Text.of("Sending particle: ${this::class.java.name} success"))
        addPreTickAction {
            // When the player can see the particle (this class will be instantiated)
            val bindPlayerEntity = world!!.getPlayerByUuid(bindPlayer) ?: let {
                return@addPreTickAction
            }
            teleportGroupTo(bindPlayerEntity.eyePos)
            rotateToWithAngle(
                RelativeLocation.of(bindPlayerEntity.rotationVector),
                Math.toRadians(10.0)
            )
        }
    }
}
    

After creating the ControlableParticleGroup, register it on the client side.


ClientParticleGroupManager.register(
    // If this particleGroup’s loadParticleLocations method includes a sub-ParticleGroup, the sub-Group does not need to be registered here
    // Unless you need to use ClientParticleGroupManager.addVisibleGroup(subGroup)
    TestGroupClient::class.java, TestGroupClient.Provider()
)
    

To allow other players to synchronize operations, set up a server-side ControlableParticleGroup example.


/**
 * No specific requirements for constructor parameters
 */
class TestParticleGroup(private val bindPlayer: ServerPlayerEntity) :
    // First parameter is the unique identifier for the ParticleGroup
    // This content will be synchronized to the client
    // Second parameter is the particle’s visible range
    // When the player exceeds this range, a packet to delete the particle group will be sent (invisible to that player)
    ServerParticleGroup(UUID.randomUUID(), 16.0) {
    override fun tick() {
        withPlayerStats(bindPlayer)
        setPosOnServer(bindPlayer.eyePos)
    }

    /**
     * These are the parameters you want to send to the client to build the ControlableParticleGroup
     * They will ultimately be passed to ControlableParticleGroupProvider.createGroup()
     */
    override fun otherPacketArgs(): Map<String, ParticleControlerDataBuffer<out Any>> {
        return mapOf(
            "bindUUID" to ParticleControlerDataBuffers.uuid(bindPlayer.uuid)
        )
    }

    override fun getClientType(): Class<out ControlableParticleGroup> {
        return TestGroupClient::class.java
    }
}
    

After completing the above setup, add the particle on the server.


val serverGroup = TestParticleGroup(user as ServerPlayerEntity)
ServerParticleGroupManager.addParticleGroup(
    // world must be ServerWorld
    serverGroup, user.pos, world as ServerWorld
)
    

For other advanced usage, refer to cn.coostack.particles.control.group.ControlableParticleGroup and cn.coostack.network.particle.ServerParticleGroup.

ParticleGroup Nesting Example

Main ParticleGroup:


class TestGroupClient(uuid: UUID, val bindPlayer: UUID) : ControlableParticleGroup(uuid) {
    class Provider : ControlableParticleGroupProvider {
        override fun createGroup(
            uuid: UUID,
            args: Map<String, ParticleControlerDataBuffer<*>>
        ): ControlableParticleGroup {
            val bindUUID = args["bindUUID"]!!.loadedValue as UUID
            return TestGroupClient(uuid, bindUUID)
        }

        /**
         * When ServerParticleGroup’s change method is called, apply changes to the group here
         * Parameters in PacketParticleGroupS2C.PacketArgsType as keys do not need to be handled here
         * But they will also be passed as args
         */
        override fun changeGroup(group: ControlableParticleGroup, args: Map<String, ParticleControlerDataBuffer<*>>) {
        }
    }

    override fun loadParticleLocations(): Map<ParticleRelativeData, RelativeLocation> {
        val r1 = 3.0
        val r2 = 5.0
        val w1 = -2
        val w2 = 3
        val scale = 1.0
        val count = 360
        val list = Math3DUtil.getCycloidGraphic(r1, r2, w1, w2, count, scale).onEach { it.y += 6 }
        val map = list.associateBy {
            withEffect({ ParticleDisplayer.withSingle(TestEndRodEffect(it)) }) {
                color = Vector3f(230 / 255f, 130 / 255f, 60 / 255f)
                this.maxAliveTick = this.maxAliveTick
            }
        }
        val mutable = map.toMutableMap()
        // Obtain vertices of the generated graphic under these parameters
        for (rel in Math3DUtil.computeCycloidVertices(r1, r2, w1, w2, count, scale)) {
            // Set a SubParticleGroup at these vertices
            mutable[withEffect({ u -> ParticleDisplayer.withGroup(TestSubGroupClient(u, bindPlayer)) }) {}] =
                rel.clone()
        }
        return mutable
    }

    override fun onGroupDisplay() {
        MinecraftClient.getInstance().player?.sendMessage(Text.of("Sending particle: ${this::class.java.name} success"))
        addPreTickAction {
            // This method causes the particle to appear above other players’ heads instead of the bound player’s head...
            val bindPlayerEntity = world!!.getPlayerByUuid(bindPlayer) ?: let {
                return@addPreTickAction
            }
            teleportTo(bindPlayerEntity.eyePos)
            rotateToWithAngle(
                RelativeLocation.of(bindPlayerEntity.rotationVector),
                Math.toRadians(10.0)
            )
        }
    }
}
    

Sub-ParticleGroup Example


class TestSubGroupClient(uuid: UUID, val bindPlayer: UUID) : ControlableParticleGroup(uuid) {
    override fun loadParticleLocations(): Map<ParticleRelativeData, RelativeLocation> {
        val list = Math3DUtil.getCycloidGraphic(2.0, 2.0, -1, 2, 360, 1.0).onEach { it.y += 6 }
        return list.associateBy {
            withEffect({ ParticleDisplayer.withSingle(TestEndRodEffect(it)) }) {
                color = Vector3f(100 / 255f, 100 / 255f, 255 / 255f)
                this.maxAliveTick = this.maxAliveTick
            }
        }
    }

    override fun onGroupDisplay() {
        addPreTickAction {
            val bindPlayerEntity = world!!.getPlayerByUuid(bindPlayer) ?: let {
                return@addPreTickAction
            }
            rotateToWithAngle(
                RelativeLocation.of(bindPlayerEntity.rotationVector),
                Math.toRadians(-10.0)
            )
        }
    }
}
    

Other Usage

SequencedParticleGroup Usage

This class addresses the need to control the order and speed of particle generation. It modifies some basic methods of ControlableParticleGroup. When using this class, use SequencedServerParticleGroup on the server side.


class SequencedMagicCircleClient(uuid: UUID, val bindPlayer: UUID) : SequencedParticleGroup(uuid) {
    // Test scaling
    var maxScaleTick = 36
    var current = 0

    // Provider is the same as usual
    class Provider : ControlableParticleGroupProvider {
        override fun createGroup(
            uuid: UUID,
            args: Map<String, ParticleControlerDataBuffer<*>>
        ): ControlableParticleGroup {
            val bindUUID = args["bind_player"]!!.loadedValue as UUID
            return SequencedMagicCircleClient(uuid, bindUUID)
        }

        override fun changeGroup(
            group: ControlableParticleGroup,
            args: Map<String, ParticleControlerDataBuffer<*>>
        ) {
        }
    }

    // To record the order of particles, use a sequence here
    override fun loadParticleLocationsWithIndex(): SortedMap<SequencedParticleRelativeData, RelativeLocation> {
        val res = TreeMap<SequencedParticleRelativeData, RelativeLocation>()
        val points = Math3DUtil.getCycloidGraphic(3.0, 5.0, -2, 3, 360, .5)
        points.forEachIndexed { index, it ->
            res[withEffect(
                { id -> ParticleDisplayer.withSingle(TestEndRodEffect(id)) }, {
                    color = Vector3f(100 / 255f, 100 / 255f, 255 / 255f)
                }, index // Particle order, ascending
            )] = it.also { it.y += 15.0 }
        }
        return res
    }

    override fun beforeDisplay(locations: SortedMap<SequencedParticleRelativeData, RelativeLocation>) {
        super.beforeDisplay(locations)
        // Set scaling
        scale = 1.0 / maxScaleTick
    }

    var toggle = false
    override fun onGroupDisplay() {
        addPreTickAction {
            // Set scaling, size loop
            if (current < maxScaleTick && !toggle) {
                current++
                scale(scale + 1.0 / maxScaleTick)
            } else if (current < maxScaleTick) {
                current++
                scale(scale - 1.0 / maxScaleTick)
            } else {
                toggle = !toggle
                current = 0
            }
            // Set rotation
            rotateParticlesAsAxis(Math.toRadians(10.0))
            val player = world!!.getPlayerByUuid(bindPlayer) ?: return@addPreTickAction
            val dir = player.rotationVector
            rotateParticlesToPoint(RelativeLocation.of(dir))
            teleportTo(player.eyePos)
        }
    }
}
    

Differences between the above particle and ControlableParticleGroup

  • By default, the number of particles generated is 0.
  • Use addSingle, addMultiple, addAll, removeSingle, removeAll, removeMultiple to control the particle generation order.
  • Use setSingleStatus to control the order of particles at a specific index.
  • It is recommended to use SequencedServerParticleGroup to control particle generation order.

Corresponding Server-Side


class SequencedMagicCircleServer(val bindPlayer: UUID) : SequencedServerParticleGroup(16.0) {
    val maxCount = maxCount()

    // Control particles to appear and disappear one by one
    var add = false

    // Control individual particle controller
    var st = 0
    val maxSt = 72
    var stToggle = false
    override fun tick() {
        val player = world!!.getPlayerByUuid(bindPlayer) ?: return
        setPosOnServer(player.pos)
        if (st++ > maxSt) {
            if (!stToggle) {
                stToggle = true
                // Set the display status of a particle on the server
                for (i in 0 until maxCount()) {
                    if (i <= 30) {
                        setDisplayed(i, true)
                    } else {
                        setDisplayed(i, false)
                    }
                }
                // Sync to client: particle count and particle status
                toggleCurrentCount()
            }
            return
        }
        if (add && serverSequencedParticleCount >= maxCount) {
            add = false
            serverSequencedParticleCount = maxCount
        } else if (!add && serverSequencedParticleCount <= 0) {
            add = true
            serverSequencedParticleCount = 0
        }
        // Server controls sub-particle generation
        if (add) {
            addMultiple(10)
        } else {
            removeMultiple(10)
        }
    }

    override fun otherPacketArgs(): Map<String, ParticleControlerDataBuffer<out Any>> {
        return mapOf(
            "bind_player" to ParticleControlerDataBuffers.uuid(bindPlayer),
            toggleArgLeastIndex(), // Sync particle count, generates from the 1st particle to the serverSequencedParticleCount-th particle
            toggleArgStatus() // After generating serverSequencedParticleCount particles, sync the status stored in clientIndexStatus
        )
    }

    override fun getClientType(): Class<out ControlableParticleGroup>? {
        return SequencedMagicCircleClient::class.java
    }

    /**
     * Must match the size of SequencedParticleGroup.loadParticleLocationsWithIndex()
     * If the particle count of your group is variable (e.g., flushed the particle style with a changed length)
     * Ensure proper data synchronization on the server side (size synchronization)
     * If maxCount > SequencedParticleGroup.loadParticleLocationsWithIndex().size, it will cause an array out-of-bounds exception
     * If maxCount < SequencedParticleGroup.loadParticleLocationsWithIndex().size, it will lead to incomplete particle control (some particles cannot be generated from the server)
     */
    override fun maxCount(): Int {
        return 360
    }
}
    

Using ParticleGroupStyle

Purpose of this class

When synchronizing rendering data between the client and server, it was found that each new operation required duplicating the same code on the server class and creating the same variables, which was quite cumbersome. Thus, this class was built based on ControlableParticleGroup and ServerParticleGroup.

Usage


class ExampleStyle(val bindPlayer: UUID, uuid: UUID = UUID.randomUUID()) :
    /**
     * First parameter represents the player’s visible range, default is 32.0
     * Second parameter is the unique identifier for this particle style
     * Use the default value (randomUUID) here
     */
    ParticleGroupStyle(16.0, uuid) {
    /**
     * Like ControlableParticleGroup, to build this class on the server, you also need to create a builder
     */
    class Provider : ParticleStyleProvider {
        override fun createStyle(
            uuid: UUID,
            args: Map<String, ParticleControlerDataBuffer<*>>
        ): ParticleGroupStyle {
            val player = args["bind_player"]!!.loadedValue as UUID
            return ExampleStyle(player, uuid)
        }
    }

    // Custom parameters
    val maxScaleTick = 60
    var scaleTick = 0
    val maxTick = 240
    var current = 0
    var angleSpeed = PI / 72
    
    init {
        // If you want to modify the base class (ParticleGroupStyle)
        // Do not modify in beforeDisplay, modify in the constructor
        // Otherwise, it will cause synchronization issues in multiplayer clients (or use change?)
        scale = 1.0 / maxScaleTick
    }

    /**
     * Corresponds to ControlableParticleGroup’s loadParticleLocations method
     */
    override fun getCurrentFrames(): Map<StyleData, RelativeLocation> {
        // Uses a custom point graphic builder, refer to cn.coostack.cooparticlesapi.utils.builder.PointsBuilder
        val res = mutableMapOf<StyleData, RelativeLocation>().apply {
            putAll(
                PointsBuilder()
                    .addDiscreteCircleXZ(8.0, 720, 10.0)
                    .createWithStyleData {
                        // Supports single particles
                        StyleData { ParticleDisplayer.withSingle(ControlableCloudEffect(it)) }
                            .withParticleHandler {
                                colorOfRGB(127, 139, 175)
                                this.scale(1.5f)
                                textureSheet = ParticleTextureSheet.PARTICLE_SHEET_LIT
                            }
                    })
            putAll(
                PointsBuilder()
                    .addCircle(6.0, 4)
                    .pointsOnEach { it.y -= 12.0 }
                    .addCircle(6.0, 4)
                    .pointsOnEach { it.y += 6.0 }
                    // Your Data builder here
                    .createWithStyleData {
                        // Equivalent to ControlableParticleGroup’s withEffect
                        StyleData {
                            // Also supports particle groups
                            // If there are other styles, you can change to ParticleDisplayer.withStyle(xxxStyle(it,...))
                            ParticleDisplayer.withGroup(
                                MagicSubGroup(it, bindPlayer)
                            )
                        }
                    }
            )
        }
        return res
    }
    
    override fun onDisplay() {
        // Enable automatic parameter synchronization
        autoToggle = true

        /**
         * To distinguish between client and server environments
         * This class provides the client property
         * Alternatively, use world!!.isClient to check if it’s the client
         */
        addPreTickAction {
            if (scaleTick++ >= maxScaleTick) {
                return@addPreTickAction
            }
            scale(scale + 1.0 / maxScaleTick)
        }
        addPreTickAction {
            current++
            if (current >= maxTick) {
                remove()
            }
            val player = world!!.getPlayerByUuid(bindPlayer) ?: return@addPreTickAction
            teleportTo(player.pos)
            rotateParticlesAsAxis(angleSpeed)
        }
    }
    
    // When automatic parameter synchronization is enabled, these server parameters will be automatically synced to each client
    override fun writePacketArgs(): Map<String, ParticleControlerDataBuffer<*>> {
        return mapOf(
            "current" to ParticleControlerDataBuffers.int(current),
            "angle_speed" to ParticleControlerDataBuffers.double(angleSpeed),
            "bind_player" to ParticleControlerDataBuffers.uuid(bindPlayer),
            "scaleTick" to ParticleControlerDataBuffers.int(scaleTick),
        )
    }
    // When receiving synchronized data from the server, execute this method
    override fun readPacketArgs(args: Map<String, ParticleControlerDataBuffer<*>>) {
        if (args.containsKey("current")) {
            current = args["current"]!!.loadedValue as Int
        }
        if (args.containsKey("angle_speed")) {
            angleSpeed = args["angle_speed"]!!.loadedValue as Double
        }
        if (args.containsKey("scaleTick")) {
            scaleTick = args["scaleTick"]!!.loadedValue as Int
        }
    }
}
    

When the class is built, register it in ClientModInitializer.


ParticleStyleManager.register(ExampleStyle::class.java, ExampleStyle.Provider())
    

How to spawn this particle style on the server? Example with an Item


class TestStyleItem : Item(Settings()) {
    override fun use(world: World, user: PlayerEntity, hand: Hand): TypedActionResult<ItemStack?>? {
        val res = super.use(world, user, hand)
        // If you spawn particles in a world.isClient == true environment
        // The spawn will only affect this client
        // Otherwise, it’s a server-side spawn – all eligible players can see it
        if (world.isClient) {
            return res
        }
        val style = ExampleStyle(user.uuid)
        // server world
        ParticleStyleManager.spawnStyle(world, user.pos, style)
        // Test delay for automatic synchronization
        CooParticleAPI.scheduler.runTask(30) {
            style.angleSpeed += PI / 72
        }
        return res
    }
}
    

Particle Style Helper Usage Guidelines

  • All Helpers must call loadControler in the constructor, otherwise, application failure bugs may occur (reason unknown).

Particle Emitters

Introduction

Since all previous examples pointed to a style with a limited number of particles, to better implement effects like explosions, shockwaves, or flames, this class was abstracted.

This particle emitter mainly consists of three classes to implement functionality:

  • SimpleParticleEmitters
  • PhysicsParticleEmitters
  • ClassParticleEmitters

These three classes correspond to three different implementation methods.

First, let’s discuss the first two classes

SimpleParticleEmitters


/**
 * Particle emitter position offset expression, providing t as a parameter (t is generation time, int type)
 */
var evalEmittersXWithT = "0"
var evalEmittersYWithT = "0"
var evalEmittersZWithT = "0"

/**
 * Provides several presets
 * box: box emitter
 * point: point emitter
 * math: mathematical trajectory emitter
 */
var shootType = EmittersShootTypes.point()
private var bufferX = Expression(evalEmittersXWithT)
private var bufferY = Expression(evalEmittersYWithT)
private var bufferZ = Expression(evalEmittersZWithT)
var offset = Vec3d(0.0, 0.0, 0.0)
/**
 * Number of particles generated per tick
 */
var count = 1
/**
 * The actual number of particles generated per tick is affected by this random range (0 .. countRandom)
 */
var countRandom = 0
    

Construction Method


val emitters = SimpleParticleEmitters(position, serverWorld, particleInfo)
val emitters = PhysicsParticleEmitters(position, serverWorld, particleInfo)
    

Particle Information Synchronization

To facilitate synchronizing particle properties between servers, the ControlableParticleData class was built. If your ParticleEffect has additional properties, you can inherit this class and override the PacketCodec parser.


/**
 * Modifiable properties of this class
 */
// UUID is not recommended to be modified
var uuid = UUID.randomUUID()
var velocity: Vec3d = Vec3d.ZERO
var size = 0.2f
var color = Vector3f(1f, 1f, 1f)
var alpha = 1f
var age = 0
var maxAge = 120
// Particle visible range (not tested)
var visibleRange = 128f
// When you want to change to another Effect (only supports ControlableParticleEffect, see TestEndRodEffect for implementation)
var effect: ControlableParticleEffect = TestEndRodEffect(uuid)
var textureSheet = ParticleTextureSheet.PARTICLE_SHEET_TRANSLUCENT
/**
 * Particle movement speed, applied in SimpleParticleEmitter and PhysicsParticleEmitter
 */
var speed = 1.0
    

PhysicsParticleEmitters

This class provides some basic physical parameters: gravity, air density, mass, wind direction.


/**
 * Gravitational acceleration, time unit is tick
 */
var gravity = 0.0
/**
 * Air density
 */
var airDensity = 0.0
/**
 * Wind direction
 */
var wind: WindDirection = GlobalWindDirection(Vec3d.ZERO)
/**
 * Mass
 * Unit: g
 */
var mass = 1.0
    

Enable Emitter in the World


ParticleEmittersManager.spawnEmitters(emitter)
    

Modify Emitter Properties


var pos: Vec3d // Emitter position
var world: World? // World where the emitter resides (cannot be null during construction, nullable because world info is not needed during serialization)
var tick: Int // Generation time, tick
/**
 * When maxTick == -1
 * Indicates this particle is not controlled by lifecycle
 * Particle lifecycle
 */
var maxTick: Int
// Emission delay (delay after each emission)
var delay: Int
// Unique identifier for the emitter, not recommended to modify
var uuid: UUID
// If set to true, the emitter will become invalid
var cancelled: Boolean
// Whether the emitter has already been spawned in the world
var playing: Boolean
    

Note

  • Modify emitter properties only in the server environment.
  • Each tick automatically synchronizes emitter properties to all visible clients.

ClassParticleEmitters

This is an abstract class designed to allow developers (me) to avoid writing expressions by generating particles according to custom rules.


abstract class ClassParticleEmitters(
    override var pos: Vec3d,
    override var world: World?,
) : ParticleEmitters {
    override var tick: Int = 0
    override var maxTick: Int = 120
    override var delay: Int = 0
    override var uuid: UUID = UUID.randomUUID()
    override var cancelled: Boolean = false
    override var playing: Boolean = false
    var airDensity = 0.0
    var gravity: Double = 0.0

    companion object {
        fun encodeBase(data: ClassParticleEmitters, buf: RegistryByteBuf) {
            buf.writeVec3d(data.pos)
            buf.writeInt(data.tick)
            buf.writeInt(data.maxTick)
            buf.writeInt(data.delay)
            buf.writeUuid(data.uuid)
            buf.writeBoolean(data.cancelled)
            buf.writeBoolean(data.playing)
            buf.writeDouble(data.gravity)
            buf.writeDouble(data.airDensity)
            buf.writeDouble(data.mass)
            buf.writeString(data.wind.getID())
            data.wind.getCodec().encode(buf, data.wind)
        }

        /**
         * Implementation
         * First, create this object in the codec’s decode method
         * Then pass buf and container to this method
         * Then continue decoding your own parameters
         */
        fun decodeBase(container: ClassParticleEmitters, buf: RegistryByteBuf) {
            val pos = buf.readVec3d()
            val tick = buf.readInt()
            val maxTick = buf.readInt()
            val delay = buf.readInt()
            val uuid = buf.readUuid()
            val canceled = buf.readBoolean()
            val playing = buf.readBoolean()
            val gravity = buf.readDouble()
            val airDensity = buf.readDouble()
            val mass = buf.readDouble()
            val id = buf.readString()
            val wind = WindDirections.getCodecFromID(id)
                .decode(buf)
            container.apply {
                this.pos = pos
                this.tick = tick
                this.maxTick = maxTick
                this.delay = delay
                this.uuid = uuid
                this.cancelled = canceled
                this.airDensity = airDensity
                this.gravity = gravity
                this.mass = mass
                this.playing = playing
                this.airDensity = airDensity
                this.wind = wind
            }
        }
    }

    /**
     * Wind direction
     */
    var wind: WindDirection = GlobalWindDirection(Vec3d.ZERO).also {
        it.loadEmitters(this)
    }

    /**
     * Mass
     * Unit: g
     */
    var mass: Double = 1.0
    override fun start() {
        if (playing) return
        playing = true
        if (world?.isClient == false) {
            ParticleEmittersManager.updateEmitters(this)
        }
    }

    override fun stop() {
        cancelled = true
        if (world?.isClient == false) {
            ParticleEmittersManager.updateEmitters(this)
        }
    }

    override fun tick() {
        if (cancelled || !playing) {
            return
        }
        if (tick++ >= maxTick && maxTick != -1) {
            stop()
        }

        world ?: return
        doTick()
        if (!world!!.isClient) {
            return
        }

        if (tick % max(1, delay) == 0) {
            // Perform particle change operations
            // Generate new particles
            spawnParticle()
        }
    }

    override fun spawnParticle() {
        if (!world!!.isClient) {
            return
        }
        val world = world as ClientWorld
        // Generate particle styles
        genParticles().forEach {
            spawnParticle(world, pos.add(it.value.toVector()), it.key)
        }
    }

    /**
     * Both server and client execute this method
     * To check server, use if(!world!!.isClient)
     */
    abstract fun doTick()

    /**
     * Particle style generator
     */
    abstract fun genParticles(): List<Pair<ControlableParticleData, RelativeLocation>>

    /**
     * To modify particle position, velocity, or properties
     * Modify ControlableParticleData directly
     * @param data Class for manipulating single particle properties
     * To execute tick, use controler.addPreTickAction
     */
    abstract fun singleParticleAction(
        controler: ParticleControler,
        data: ControlableParticleData,
        spawnPos: RelativeLocation,
        spawnWorld: World
    )

    private fun spawnParticle(world: ClientWorld, pos: Vec3d, data: ControlableParticleData) {
        val effect = data.effect
        effect.controlUUID = data.uuid
        val displayer = ParticleDisplayer.withSingle(effect)
        val control = ControlParticleManager.createControl(effect.controlUUID)
        control.initInvoker = {
            this.size = data.size
            this.color = data.color
            this.currentAge = data.age
            this.maxAge = data.maxAge
            this.textureSheet = data.textureSheet
            this.particleAlpha = data.alpha
        }
        singleParticleAction(control, data, pos, world)
        control.addPreTickAction {
            // Simulate particle motion speed
            teleportTo(
                this.pos.add(data.velocity)
            )
            if (currentAge++ >= maxAge) {
                markDead()
            }
        }
        displayer.display(pos, world)
    }

    protected fun updatePhysics(pos: Vec3d, data: ControlableParticleData) {
        val m = mass / 1000
        val v = data.velocity
        val speed = v.length()
        val gravityForce = Vec3d(0.0, -m * gravity, 0.0)
        val airResistanceForce = if (speed > 0.01) {
            val dragMagnitude = 0.5 * airDensity * DRAG_COEFFICIENT *
                    CROSS_SECTIONAL_AREA * speed.pow(2) * 0.05
            v.normalize().multiply(-dragMagnitude)
        } else {
            Vec3d.ZERO
        }
        val windForce = WindDirections.handleWindForce(
            wind, pos,
            airDensity, DRAG_COEFFICIENT, CROSS_SECTIONAL_AREA, v
        )

        val a = gravityForce
            .add(airResistanceForce)
            .add(windForce)
            .multiply(1.0 / m)

        data.velocity = v.add(a)
    }

    /**
     * Optional override
     * If overridden, ensure to call super.update() (this method)
     * Or replicate the exact same code
     * Otherwise, update failures may occur
     * 
     * Implementation Notes
     * If your ClassParticleEmitters has new parameters
     * And these parameters may change externally during use
     * You must assign them in update
     * The input parameter emitters receives the updated emitters (used only for parameter transfer)
     */
    override fun update(emitters: ParticleEmitters) {
        if (emitters !is ClassParticleEmitters) return
        this.pos = emitters.pos
        this.world = emitters.world
        this.tick = emitters.tick
        this.maxTick = emitters.maxTick
        this.delay = emitters.delay
        this.uuid = emitters.uuid
        this.cancelled = emitters.cancelled
        this.playing = emitters.playing
    }
}
    

Implementation Example


class ExampleClassParticleEmitters(pos: Vec3d, world: World?) : ClassParticleEmitters(pos, world) {
    var moveDirection = Vec3d.ZERO
    var templateData = ControlableParticleData()

    companion object {
        // Must provide emitter ID for serialization
        const val ID = "example-class-particle-emitters"

        @JvmStatic
        // Build your own CODEC for synchronizing your data
        val CODEC = PacketCodec.ofStatic<RegistryByteBuf, ParticleEmitters>(
            { buf, data ->
                data as ExampleClassParticleEmitters
                // Must call this method (from ClassParticleEmitters) to sync parent class parameters
                encodeBase(data, buf)
                buf.writeVec3d(data.moveDirection)
                ControlableParticleData.PACKET_CODEC.encode(buf, data.templateData)
            }, {
                val instance = ExampleClassParticleEmitters(Vec3d.ZERO, null)
                // Must call this method (from ClassParticleEmitters) to deserialize parent class parameters
                decodeBase(instance, it)
                instance.moveDirection = it.readVec3d()
                instance.templateData = ControlableParticleData.PACKET_CODEC.decode(it)
                instance
            }
        )
    }
    
    // Your emitter tick
    override fun doTick() {
        pos = pos.add(moveDirection)
    }

    /**
     * Called after every delay tick
     * delay is a parameter provided by ParticleEmitters, same meaning as above
     * Get particle generation position
     */
    override fun genParticles(): List<Pair<ControlableParticleData, RelativeLocation>> {
        return PointsBuilder()
            .addBall(2.0, 20)
            .create().associateBy {
                // Copy input particle data
                templateData.clone()
                    .apply {
                        // Modify initial particle velocity (similar to ball contraction)
                        this.velocity = it.normalize().multiplyClone(-0.1).toVector()
                    }
            }
    }

    override fun singleParticleAction(
        controler: ParticleControler,
        data: ControlableParticleData,
        spawnPos: RelativeLocation,
        spawnWorld: World
    ) {
        // Executed for each generated particle
        // To set motion, opacity, or color changes for individual particles
        // Use controler.addPreTickAction to set per-tick particle change methods
        // Changes to data will also be applied to the particle
        // spawnPos is the initial generation position
        // spawnWorld is the initial generation world (unchanging)
    }

    override fun update(emitters: ParticleEmitters) {
        super.update(emitters)
        if (emitters !is ExampleClassParticleEmitters) {
            return
        }
        this.templateData = emitters.templateData
        this.moveDirection = emitters.moveDirection
    }

    override fun getEmittersID(): String {
        return ID
    }

    override fun getCodec(): PacketCodec<RegistryByteBuf, ParticleEmitters> {
        return CODEC
    }
}
    

After implementation, don’t forget to register.


ParticleEmittersManager.register(ExampleClassParticleEmitters.ID, ExampleClassParticleEmitters.CODEC)
    

Requires:

Fabric API or NeoForge Installer

Fabric Language Kotlin Mod

Kotlin for Forge Mod

How to install:

How To Download & Install Mods with Minecraft Forge

How To Download & Install Fabric Mods

Don’t miss out today’s latest Minecraft Mods

CooParticles API Mod (1.21.1) Download Links

For Minecraft 1.21.1, 1.21

NeoForge version: Download from Server 1Download from Server 2

Fabric version: Download from Server 1Download from Server 2

Click to rate this post!
[Total: 0 Average: 0]