Skip to content

Tutorials · Crypt Knight · Chapter 14

Effects and sound

75 minutes Uses: ParticleEmitter, AudioSource, Spawner, spawn, play_sound, play_music, destroy_self, anneal_number, anneal_colour, set_number

By the end

  • Three artifacts of particles, dust, sparkle and burst, each removing itself with a short script
  • The knight’s sounds, the pickups’ and the beast’s, each an Audio Source’s named trigger a script plays
  • The hit flash on a creature and on the beast, and the beast’s death: a flash, a burst, a roar and a fade
  • The boss loop, which takes over the one music channel as the fight begins
On this page

Making it felt

The game plays now. This chapter makes it feel like something: little things that happen when the knight lands and when he takes a heart, a white flash where his sword lands, and a sound for every blow. Nothing in it changes how the game plays. The effects are the same two ideas as Ship Evader’s, which its effects chapter and its sound chapter cover in depth, so this one goes quickly through them:

  • Artifacts of particles. A one-shot Particle Emitter fires once as its node arrives. A script copies the node where something happened, with spawn, and the copy removes itself.
  • Named sounds. An Audio Source has a list of triggers, each a name and a clip, and a script plays one by name with play_sound.
  • A flash. A Sprite’s flash is how much of it is washed white, 0 to 1, which a script eases with an anneal.
  • Music. One channel, which a script hands a track on with play_music.

The sounds

Ten files: the boss loop, from MintoDog’s track, and nine sounds from two of Kenney’s packs. The packs have no creature’s voice, so the beast’s roar is a heavy bell, and a slam is a heavy punch.

  1. Copy the ten files above into audio, under their own names, and press Refresh in Assets.

Three puffs of particles

Each effect is a node with a Particle Emitter and one small script, Puff, that removes it. A one-shot emitter fires its whole burst once, as it arrives, and its particles die on their own: but the node stays, so something must remove it.

  1. In Assets, press Create, choose New script, type puff, and press Enter.
  2. In the Code tab’s Scripts list, click puff.rgs, replace everything in it with the script below, and press Ctrl+S.
rgs puff.rgs
/// A puff of particles: its Particle Emitter fires once as it arrives, and its
/// Audio Source plays if it has one, with nothing here to call; once its last
/// particle has died it removes itself.
component Puff {
    /// Seconds it stays: longer than its particles live.
    #[suffix("s")]
    lifetime: f32 = 1.0,

    fn on_create() {
        await wait(lifetime);
        destroy_self();
    }
}

lifetime is how long the node stays: longer than its particles live. destroy_self removes it after await wait(lifetime).

The dust is for the knight’s landing: eight grey puffs that rise a little and fall. The sparkle is for a pickup: fourteen warm lights that spread out and die, and its own Audio Source plays a chime the moment it arrives. The burst is for the beast’s death: forty-eight blue-white sparks in a ring.

  1. Click the Viewport tab, and open scenes/gate.ron. Select Gate, press + Add node, and choose Particles. Rename it dust.
  2. In its ParticleEmitterComponent, tick One Shot. Set Burst 8 and Capacity 8; Lifetime 0.35; Speed 28; Direction 0 and -1; Spread 2.4; Gravity 60; Start Color 0.55, 0.5, 0.45 with an alpha of 0.7; End Color 0.45, 0.42, 0.4 with an alpha of 0; Start Size 3 and End Size 1; Sort Layer 6. Leave Blend on Alpha.
  3. Press Add component and choose Puff. Set its Lifetime to 0.6.
  4. Right-click dust and choose Save as artifact…. In the save dialog, open artifacts and save the file as dust.artifact. Delete the dust left in the scene.
  5. Add another Particles node, sparkle, and tick One Shot. Set Burst 14 and Capacity 14; Lifetime 0.6; Speed 45; Direction 0 and -1; Spread 6.28, a whole circle; Gravity 20; Start Color 1, 0.9, 0.55, alpha 1; End Color 1, 0.75, 0.3, alpha 0; Start Size 3 and End Size 1; Sort Layer 9; Blend Add.
  6. Add an AudioSourceComponent to it: Clip audio/handleCoins.ogg, Bus Sfx, Volume 1, Autoplay ticked, and Positional unticked in its Spatial fold. Add Puff with Lifetime 0.9. Save it as artifacts/sparkle.artifact, and delete the sparkle left in the scene.
  7. Add a third Particles node, burst, and tick One Shot. Set Burst 48 and Capacity 48; Lifetime 1.2; Speed 90; Direction 0 and -1; Spread 6.28; Gravity 30; Start Color 0.55, 0.9, 1, alpha 1; End Color 0.2, 0.5, 1, alpha 0; Start Size 5 and End Size 1; Sort Layer 9; Blend Add. Add Puff with Lifetime 1.6. Save it as artifacts/burst.artifact, and delete the burst left in the scene.
  8. Press Ctrl+S.
The dust artifact open in the editor, dust.artifact in the title bar: one node, dust, and its Particle Emitter narrowed in the Inspector: Emitting and One Shot ticked, Rate 20, Lifetime 0.35, Speed 28, Direction 0 and -1, Spread 2.4, Gravity 60, a grey Start Color fading to clear, Start Size 3, End Size 1, Burst 8, Capacity 8, Sort Layer Custom, 6, and Blend Alpha
The sparkle artifact open in the editor, sparkle.artifact in the title bar, its Audio Source narrowed in the Inspector: Clip handleCoins.ogg, Bus Sfx, Volume 1, Autoplay ticked, Positional unticked and no triggers

The knight’s sounds and his dust

The knight makes five sounds: his jump, his landing, his slash, the blow of a slash that lands, and his hurt. They are the triggers of one Audio Source on his node. The landing is the Body’s hook, on_land, which runs as it touches the ground, so the dust and the footstep are a script of their own, Footfall.

  1. In Assets, press Create, choose New script, type footfall, and press Enter. In the Code tab, replace everything in footfall.rgs with the script below, and press Ctrl+S.
rgs footfall.rgs
/// The knight's footfall: as its body lands, dust puffs up at its feet from
/// the knight's Spawner, and its Audio Source plays the landing.
component Footfall : Body {
    fn on_land() {
        spawn("dust", position());
        play_sound("land");
    }
}
  1. Click the Viewport tab, and in scenes/gate.ron select knight. Press Add component and choose AudioSourceComponent. Keep Bus Sfx and Volume 1, and untick Positional in its Spatial fold.
  2. Press + beside Triggers five times, and open the rows. In each, set Name and Clip, and leave the rest: jump, audio/cloth1.ogg; land, audio/footstep_concrete_000.ogg; slash, audio/knifeSlice.ogg; hit, audio/impactPunch_medium_000.ogg; hurt, audio/impactSoft_heavy_000.ogg.
  3. Press Add component and choose SpawnerComponent. Press + beside Entries, open the new [0] row, type dust as its Name, and drag artifacts/dust.artifact from Assets onto its Artifact.
  4. Press Add component and choose Footfall.
  5. Press Ctrl+S.
The knight’s Inspector narrowed to its Audio Source: Clip none, Bus Sfx, Volume 1, Pitch 1, Positional unticked, Max Voices 0, five Triggers, and the first open: Name jump, Clip cloth1.ogg, Mode Shuffle

The Crypt’s knight and the Hall’s are the Gate’s again, so they get the same three components. Copy them across rather than set them three times: each component’s ≡ menu has Copy values and Paste values, and pasting writes onto a component the other node already has.

  1. In gate.ron, with knight selected, open the ≡ menu at the right of the AudioSourceComponent header and choose Copy values.
  2. Open scenes/crypt.ron, select knight, press Add component, choose AudioSourceComponent, open its ≡ menu and choose Paste values. The five triggers come with it.
  3. Do the same for the SpawnerComponent: copy its values in gate.ron, and in crypt.ron add a SpawnerComponent and paste. Add Footfall.
  4. Press Ctrl+S, and do the three again in scenes/hall.ron.

Now the knight’s script plays them. Open knight.rgs. The comment at the top says what plays:

rgs part of knight.rgs
/// With no hearts left, the level is lost. Each step it keeps where it is,
/// for the beast to aim at. Its jump, slash, hit and hurt are sounds its Audio
/// Source names; its landing is its Footfall's.

The hit, as a slash that lands pushes him back; first thing in the heard("recoil") branch:

rgs part of knight.rgs
        if heard("recoil") {
            play_sound("hit");
            set_number("CharacterBodyComponent", "velocity.x", -facing * recoil_speed);
            hopping = recoil_time;
        }

The jump, in the branch that jumps. It sounds for a jump from the ground, and for the second jump in the air, which the double jump gives; a third press in the air, which jumps nowhere, makes no sound:

rgs part of knight.rgs
            if pressed("jump") && !drop {
                jump();
                // A jump from the ground, or the second in the air, sounds.
                if grounded() || kept_number("double_jump", 0.0) > 0.0 {
                    play_sound("jump");
                }
            }

The slash, the last line of the block that starts one:

rgs part of knight.rgs
        if pressed("attack") && kept_number("sword", 0.0) > 0.0 && readying <= 0.0 && hurting <= 0.0 {
            play_clip("slash");
            shown = "slash";
            slashing = slash_time;
            readying = cooldown;
            play_sound("slash");
        }

And the hurt, in on_enter, after the line that starts the hurt clip:

rgs part of knight.rgs
                // The hurt clip plays over whatever the knight was doing.
                hurting = hurt_time;
                play_sound("hurt");

Press Ctrl+S. play_sound plays the trigger of this node’s Audio Source that has that name. A name the Audio Source does not have plays nothing, and the Console says which name, and on which node.

A sparkle where a pickup was

A heart, a relic and the sword vanish when they are taken, and a pickup that only vanishes does not feel taken. Each gets a Spawner with the sparkle, and its script spawns one before it hides.

  1. In heart.rgs, replace everything in the file with the script below, and press Ctrl+S. Do the same in relic.rgs with the one after it.
rgs heart.rgs
/// A heart lying in the crypt: it bobs, and the knight takes it by walking
/// into it, which heals one heart. Then it is gone in a sparkle.
component Heart : Area {
    fn on_create() {
        pulse_by(vec2(0.0, -3.0), 0.5, .InOutSine);
    }

    fn on_enter(other: NodeId) {
        if in_group(other, "knight") {
            // The knight is in the group "knight", and hears this a step
            // later.
            emit_to("knight", "heal");
            // A sparkle from this node's Spawner, which carries the pickup's
            // sound.
            spawn("sparkle", position());
            hide();
        }
    }
}
rgs relic.rgs
/// A relic on an altar: it glows, and the knight takes it by walking into it.
/// Taking it keeps 1 under `power` for the rest of the game, and the relic is
/// gone in a sparkle.
component Relic : Area {
    /// The kept value that taking it sets to 1.
    power: String = "double_jump",

    fn on_create() {
        pulse_colour("ShapeComponent", "color", vec4(1.0, 1.0, 1.0, 0.4), 0.6, .InOutSine);
    }

    fn on_enter(other: NodeId) {
        if in_group(other, "knight") {
            keep_number(power, 1.0);
            // A sparkle from this node's Spawner, which carries the pickup's
            // sound.
            spawn("sparkle", position());
            // A hidden Area meets nothing, so it cannot be taken twice.
            hide();
        }
    }
}
  1. In scenes/gate.ron, select relic, press Add component, and choose SpawnerComponent. Press + beside Entries, open the new row, type sparkle as its Name, and drag artifacts/sparkle.artifact onto its Artifact.
  2. In scenes/crypt.ron, do the same to heart_pickup_1, heart_pickup_2 and sword. In scenes/hall.ron, to heart_pickup.
  3. Press Ctrl+S in each scene.

spawn("sparkle", position()) makes a copy of the artifact the node’s Spawner calls sparkle, where the pickup is. The copy’s own Audio Source is set to Autoplay, so the chime plays as it arrives, and hide() hides the pickup.

A flash where a blow lands

A Sprite’s flash washes it white, 0 for none and 1 for all of it. A creature that is struck goes white in a twentieth of a second and fades back in a tenth: an anneal to 1, awaited, and one to 0. Replace everything in struck.rgs with this:

rgs struck.rgs
/// A creature the knight's sword can strike: a slash that lands on it flashes
/// it white and pushes the knight back.
component Struck : Area {
    fn on_enter(other: NodeId) {
        if in_group(other, "slash") {
            // The knight is in the group "knight", and is pushed back a step
            // later.
            emit_to("knight", "recoil");
            // The hit flash: white, there and gone.
            await anneal_number("SpriteComponent", "flash", 1.0, 0.04, .Linear);
            anneal_number("SpriteComponent", "flash", 0.0, 0.12, .OutQuad);
        }
    }
}

anneal_number eases a number field of one of the node’s components toward a value over a time. The first is awaited, so the fade back starts when the flash is full; the second is not, so the hook finishes. Every creature of the Crypt has this script already.

The beast’s sounds and its death

The beast makes three sounds, a roar as it wakes, a slam as it lands from its leap, and a rush of fire as it breathes, and one effect more, the burst of its death.

  1. In scenes/hall.ron, select beast, press Add component and choose AudioSourceComponent: Bus Sfx, Volume 1, Positional unticked. Add three triggers: roar, audio/impactBell_heavy_000.ogg; slam, audio/impactPunch_heavy_000.ogg; fireball, audio/drawKnife1.ogg.
  2. In its SpawnerComponent, press + beside Entries and add a second: Name burst, and drag artifacts/burst.artifact onto its Artifact.
  3. Press Ctrl+S.

Then its script. The comment at the top, in beast.rgs:

rgs part of beast.rgs
/// three fireballs at once. Its health is its Wounds', kept as `boss_hp`. It
/// roars as it wakes, and its slam and its fire are sounds its Audio Source
/// names.

The roar, as it wakes, after awake = true;:

rgs part of beast.rgs
    fn loop() {
        if !awake {
            // The arena sends "begin" when the knight walks in.
            await signal("begin");
            awake = true;
            play_sound("roar");
            await wait(1.0);
        }

The wind-up changes too. A beast slain while it rests would otherwise start a wind-up pulse after it was dead, which would override the death’s fade, so the pulse now begins only while it lives:

rgs part of beast.rgs
            // The wind-up: it glows red, unless it was slain while it rested.
            if kept_number("boss_hp", full) > 0.0 {
                pulse_colour("SpriteComponent", "color", vec4(1.0, 0.4, 0.4, 1.0), 0.1, .Linear);
            }
            await wait(windup * calm);

The slam, as it lands from its leap, beside the shake; and the fire’s rush, after the fireballs are made:

rgs part of beast.rgs
                    await wait_until(grounded());
                    set_number("CharacterBodyComponent", "velocity.x", 0.0);
                    // The camera's node is in the group "camera".
                    play_sound("slam");
                    emit_to("camera", "shake");
rgs part of beast.rgs
                        spawn("fireball", mouth + vec2(aim.x * c + aim.y * s, aim.y * c - aim.x * s) * 8.0);
                    }
                    play_sound("fireball");
                    await wait(0.3);

Press Ctrl+S. Last, the beast’s Wounds, which now flashes it at each hit and gives its death its ending. Replace everything in wounds.rgs with this:

rgs wounds.rgs
/// The beast's wounds: each slash of the knight's that lands takes one of its
/// health, flashes it white and pushes the knight back. Its health is kept as
/// `boss_hp`, which the boss bar shows and the beast's fury reads. At none it
/// is slain: it stops, hurts nothing more, flashes, bursts and roars, fades
/// away, and a moment later the level is won.
component Wounds : Area {
    /// How many slashes it takes to slay.
    health: f32 = 20.0,
    /// Seconds from the last slash to the level won.
    #[suffix("s")]
    dying_time: f32 = 2.0,

    fn on_create() {
        keep_number("boss_hp", health);
    }

    fn on_enter(other: NodeId) {
        if in_group(other, "slash") && health > 0.0 {
            health -= 1.0;
            keep_number("boss_hp", health);
            // The knight is in the group "knight", and is pushed back a step
            // later.
            emit_to("knight", "recoil");
            if health <= 0.0 {
                // It stops where it is, and its Area is on no layer the
                // knight's looks for.
                set_number("CharacterBodyComponent", "velocity.x", 0.0);
                set_number("AreaComponent", "layer", 0.0);
                // The big flash, a burst from its Spawner, and its roar.
                set_number("SpriteComponent", "flash", 1.0);
                spawn("burst", position() + vec2(0.0, -40.0));
                play_sound("roar");
                // It fades from white to nothing as the level ends.
                anneal_colour("SpriteComponent", "color", vec4(1.0, 1.0, 1.0, 0.0), dying_time, .InQuad);
                await wait(dying_time);
                complete();
            } else {
                // The hit flash: white, there and gone.
                await anneal_number("SpriteComponent", "flash", 1.0, 0.04, .Linear);
                anneal_number("SpriteComponent", "flash", 0.0, 0.12, .OutQuad);
            }
        }
    }
}
The Code panel open on wounds.rgs, at the beast’s death: it stops, its Area leaves its layer, the big flash, a burst from its Spawner, the roar, the fade to clear over dying_time and the call to complete(), with the caret on that call and Up to date in the status line
The hit
A slash that lands flashes the beast white, in and out, as it does a creature. It is the else of if health <= 0.0, so the last blow does not.
The death
The last blow sets flash to 1 at once, and keeps it, since nothing eases it back; spawns the burst 40 units over the beast, at its chest; roars; and fades its Sprite’s colour to clear over dying_time, .InQuad, which starts slow and ends fast. anneal_colour is not awaited: the hook goes on to wait for the same two seconds, and then the level is won.

The boss loop

The castle loop plays on every level, from the Levels list. In the arena it gives way to the boss loop. The game has one music channel, and a track handed to it replaces the one playing: the same track again leaves it playing, as on Ship Evader’s title.

  1. In scenes/hall.ron, select arena, press Add component, and choose AudioSourceComponent. Set Bus to Music, keep Volume 1, and untick Positional.
  2. Press + beside Triggers, open the new row, and set Name to boss and Clip to audio/heavy_boss_battle_1_bpm200_0.ogg.
  3. Press Ctrl+S.
The arena’s Inspector narrowed to its Audio Source: Clip none, Bus Music, Volume 1, Positional unticked, one Trigger, open: Name boss, Clip heavy_boss_ba..., Mode Shuffle

And the arena plays it, as it sends its signal. Replace everything in arena.rgs with this:

rgs arena.rgs
/// The arena's threshold: the first time the knight walks in, it sends
/// "begin" to the group "arena", which drops the portcullis behind the knight,
/// turns the camera onto the arena, shows the boss bar and wakes the beast;
/// and the boss loop takes over from the castle loop.
component Arena : Area {
    /// Whether the fight has begun, so it begins once.
    #[hidden]
    begun: bool = false,

    fn on_enter(other: NodeId) {
        if in_group(other, "knight") && !begun {
            begun = true;
            emit_to("arena", "begin");
            // The track this node's Audio Source names "boss", on the one
            // music channel.
            play_music("boss");
        }
    }
}

play_music plays the trigger of this node’s Audio Source that has that name, on the music channel, and the boss loop takes over from the castle loop. When the Hall opens again after a lost fight, the level’s Music, the castle loop, plays as it opens.

Check it works

The knight just landed on the Gate’s start floor after a hop, with a small puff of grey dust at his feet, a pedestal behind him and the sign A / D to walk. above his head