BubbleEm

Table of Contents

{ "old_version": "1.1.0" }

1. About

With this add-on don't die, they're put in safe bubbles instead.

Features

  • cosmetic (doesn't affect gameplay much)
  • universal, see below
  • sound effects

Compatibility

  • should be compatible with every UZDoom game and mod. However, if death animations include body parts that fly away, with this mod they will still fly away.
  • Zrift - Chasm in Doom - LE: enemies are invisible inside bubbles (checked on v1.1).
  • mk-champions (Champions): invisible enemies (checked on v2.666).

2. Changelog

v1.1.0

  • always sync bubble and corpse position
  • use separate random numbers
  • fade out for destroyed actors
  • tweak bubble sizes

v1.0.1

  • prevent bubbling non-living things
  • fix boss death events
  • fix bubbles for some mods

v1.0

  • add pop sound

v0.2

  • fix VM abort on resurrection
  • add roll to bubbled enemies
  • add tag for bubble
  • new bubble graphics by Accensus

v0.1: initial release

3. License

GPL-3.0-only

SPDX-FileCopyrightText: © 2020 Alexander Kromm (m8f) <mmaulwurff@gmail.com>
SPDX-License-Identifier: GPL-3.0-only

4. Project setup

GameInfo
{
  EventHandlers = "be_EventHandler"
}
be/swoosh "sounds/be_swoosh.ogg"
be/pop    "sounds/be_pop.ogg"
Sprite be_2A0, 48, 40
{
  Patch be_1A0, 0, 0
  Offset 24, 20
  XScale 4
  YScale 4
}
version 4.14.3

#include "zscript/be_EventHandler.zs"
#include "zscript/be_Bubble.zs"
#include "zscript/be_Magic.zs"

5. Source

5.1. Event handler

class be_EventHandler : EventHandler
{
  override void WorldThingDied(WorldEvent event)
  {
    if (isValid(event))
    {
      inflate(event.thing);
    }
  }

  override void WorldThingRevived(WorldEvent event)
  {
    if (isValid(event))
    {
      deflate(event.thing);
    }
  }

  override void WorldThingSpawned(WorldEvent event)
  {
    if (event.thing)
    {
      event.thing.bNoExtremeDeath = true;
    }
  }

  override void WorldThingDestroyed(WorldEvent event)
  {
    if (isValid(event))
    {
      transferMagic(event.thing);
    }
  }

  private static void transferMagic(Actor destroyed)
  {
    let magic = findMagic(destroyed);
    if (magic)
    {
      magic.transfer();
    }
  }

  private static bool isValid(WorldEvent event)
  {
    if (event == NULL) return false;
    let thing = event.thing;
    return !(thing == NULL || thing is "PlayerPawn") && thing.bIsMonster;
  }

  private static void inflate(Actor died)
  {
    died.giveInventory(MAGIC_CLASS, 1);

    let magic = findMagic(died);
    if (magic)
    {
      magic.init(died);
    }
  }

  private static void deflate(Actor revived)
  {
    let magic = findMagic(revived);
    if (magic)
    {
      magic.restore();
    }
  }

  private static be_Magic findMagic(Actor a)
  {
    return be_Magic(a.findInventory(MAGIC_CLASS));
  }

  const MAGIC_CLASS = "be_Magic";
}

5.2. Bubble

class be_Bubble : Actor
{
  Default
  {
    +Nogravity;
    +NoInteraction;
    +ForceXYBillboard;

    FloatBobStrength 0.2;

    RenderStyle 'Stencil';
    Alpha  0;
    XScale 6.66;
    YScale 6.66;

    Tag "Bubble";
  }

  States
  {
    Spawn:
      BE_2 A 1;
      loop;
  }
}

5.3. Magic

class be_Magic : Inventory
{
  void init(Actor original)
  {
    initOriginal(original);
    initPretender();
    initBubble();

    mLifetime = 0;
    mDestroyTime = 0;
  }

  void restore()
  {
    mOriginal.A_SetRenderStyle(mOriginalAlpha, mOriginalRenderStyle);
    mOriginal.A_StartSound("be/pop");

    mPretender.destroy();
    mBubble.destroy();
    GoAwayAndDie();
  }

  void transfer()
  {
    mOriginal.RemoveInventory(self);
    callTryPickup(mPretender);
    mDestroyTime = mLifetime;
  }

  private void initPretender()
  {
    let pos       = makePretenderPos();
    let pretender = Actor.Spawn(mOriginal.getClass(), pos, false);

    mPretender = pretender;

    pretender.angle            = mOriginal.angle;
    pretender.floatBobStrength = 0.2;
    pretender.bFloatBob        = true;
    pretender.bShootable       = false;
    pretender.bNoInteraction   = true;
    pretender.bNogravity       = true;
    pretender.bIsMonster       = false;
    pretender.bForceXYBillboard = true;
    pretender.bRollSprite      = true;
    pretender.bRollCenter      = true;
    pretender.health = -1;
    pretender.A_SetTics(int.Max);
    mOrigScale = pretender.scale;
    mRoll = random[BubbleEm](-4, 4) / 10.0;

    level.total_monsters -= pretender.bCountKill;

    pretender.A_StartSound("be/swoosh");
  }

  private void initOriginal(Actor original)
  {
    mOriginal            = original;
    mOriginalRenderStyle = original.GetRenderStyle();
    mOriginalAlpha       = original.alpha;

    mOriginal.A_SetRenderStyle(0.0, STYLE_None);
    // no need to save, it won't be used anymore.
    mOriginal.deathSound = "";
  }

  private void initBubble()
  {
    Vector3 bubblePos = makeBubblePos();
    mBubble = Actor.Spawn("be_Bubble", bubblePos);
    double bubbleScale = max(mPretender.radius / 28, mPretender.height / 28);
    mOrigBubbleScale = mBubble.scale * bubbleScale;
    mBubble.floatBobPhase = mPretender.floatBobPhase;
    let shade = string.format("%x%x%x%x%x%x",
                              random[BubbleEm](9, 0xc),
                              random[BubbleEm](9, 0xc),
                              random[BubbleEm](9, 0xc),
                              random[BubbleEm](9, 0xc),
                              random[BubbleEm](9, 0xc),
                              random[BubbleEm](9, 0xc));
    mBubble.setShade(shade);
  }

  override void Tick()
  {
    if (mBubble == NULL || mPretender == NULL)
    {
      return;
    }

    if (mLifetime < DURATION)
    {
      double ratio      = double(mLifetime) / DURATION;
      double scaleRatio = (1 - ratio) * (1 - TARGET_SCALE) + TARGET_SCALE;

      mPretender.scale = scaleRatio * mOrigScale;
      mBubble.scale = scaleRatio * mOrigBubbleScale;
      mBubble.alpha = ratio * TARGET_ALPHA;
      mBubble.setOrigin(mPretender.pos + (0, 0, mPretender.height * scaleRatio / 2), true);
    }
    else if (mLifetime == DURATION)
    {
      mBubble.bFloatBob = true;
    }
    else if (mDestroyTime != 0)
    {
      if (mLifeTime - mDestroyTime < FADE_OUT_DURATION)
      {
        double ratio = 1 - double(mLifetime - mDestroyTime) / FADE_OUT_DURATION;
        // *= makes fading out faster than FADE_OUT_DURATION states. Oh welp.
        mBubble.alpha *= ratio;
        mPretender.a_setTranslucent(mPretender.alpha * ratio);
      }
      else
      {
        mPretender.destroy();
        mBubble.destroy();
        goAwayAndDie();
        return;
      }
    }

    ++mLifetime;

    // These things have to be set all the time.
    mPretender.A_SetTics(int.Max);
    mPretender.bFloatBob = true;
    mPretender.A_SetRoll(mPretender.roll + mRoll);

    if (mOriginal)
    {
      mPretender.setOrigin(makePretenderPos(), true);
      mBubble   .setOrigin(makeBubblePos(), true);
    }
  }

  private Vector3 makeBubblePos()
  {
    return mPretender.pos + (0, 0, mPretender.height * TARGET_SCALE / 2);
  }

  private Vector3 makePretenderPos()
  {
    return mOriginal.pos + (0, 0, 10);
  }

  const DURATION = TICRATE / 4;
  const FADE_OUT_DURATION = TICRATE * 60 * 3;
  const TARGET_ALPHA = 0.6;
  const TARGET_SCALE = 0.1;

  private Vector2   mOrigScale;
  private Vector2   mOrigBubbleScale;
  private int       mLifetime;
  private int       mDestroyTime;

  private int mOriginalRenderStyle;
  private double mOriginalAlpha;

  private Actor mBubble;
  private Actor mPretender;
  private Actor mOriginal;

  private double mRoll;
}

6. Test

Basic check: the mod can be loaded without errors, and bubbling doesn't cause errors.

wait 2; map map01
wait 4; summon doomimp
wait 6; mdk
wait 9; quit

Created: 2026-09-13 Sun 07:19