Graveyard

Table of Contents

{
  "old_version": "1.2.1",
  "depends": {
    "StringUtils":     "gy_",
    "VmAbortReporter": "gy_"
  }
}

1. About

gravestone with "RIP" on it

Graveyard places gravestones where you die.

Gravestones are carried over play sessions, so:

  1. You die.
  2. You start a new game, or load the savegame.
  3. You see a gravestone where you died.

Features

  • keys to remove gravestones on this map or all gravestones.
  • doesn't affect gameplay.
  • multiplayer compatible (players see other player's gravestones).

2. License

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

3. Changelog

v1.2.1

  • fixed double last death mark after loading a save
  • spawn gravestones directly on the floor
  • translatable new obituaries

v1.2.0

New:

  • spawn gravestones immediately on death
  • show last death on the automap
  • add size variance to tombstones

Fixes:

  • add tag to gravestone class
  • use slightly less storage
  • update VM abort handler

v1.1.1

  • update VM abort handler

v1.1.0

  • make stones fall to floor faster when spawning
  • update Readme
  • add vm abort handler
  • rename patches directory to add-ons so GZDoom doesn't confuse it with textures
  • spawn gravestones immediately in multiplayer

v1.0

  • add real time to obituaries, fix code style
  • add soulstones patch
  • allow replacing stones with custom ones

v0.3.2

  • fix crash when gravestones are on some locations on the map
  • fix gravestones not loading even more

v0.3.1

  • fix gravestones not loading sometimes more

v0.3

  • fix gravestones not loading sometimes
  • add randomized gravestone graphics
  • add timestamp to grave description

v0.2

  • fix new stones overwriting old ones after clearing stones on a map

v0.1: initial release

4. Keys

Alias "gy_remove_all"  "netevent gy_remove_all"
Alias "gy_remove_map"  "netevent gy_remove_map"

AddKeySection "$GY_TITLE" "gy_keys"

AddMenuKey "$GY_REMOVE_ALL"  "gy_remove_all"
AddMenuKey "$GY_REMOVE_MAP"  "gy_remove_map"
[enu default]

GY_TITLE = "Graveyard";

5. Project setup

Modules used:

GameInfo
{
  EventHandlers = "gy_EventHandler", "gy_VmAbortHandler"
}
version 4.14.3

#include "zscript/gy_death.zs"
#include "zscript/gy_event_handler.zs"
#include "zscript/gy_stone.zs"
#include "zscript/gy_storage.zs"

#include "zscript/gy_StringUtils.zs"
#include "zscript/gy_VmAbortReporter.zs"
user bool gy_vm_abort_report_enabled = true;

6. Code

6.1. Event handler

class gy_EventHandler : EventHandler
{
  override void worldTick()
  {
    if (_isFired)
    {
      return;
    }

    _isFired = true;
    removeStonesOnMap();

    let storage = gy_Storage.of();

    gy_Death death;
    while (death = storage.next())
    {
      sendNetworkEvent("gy_spawn" .. death.toString());
    }

    gy_Death lastDeath = storage.getLastDeathOnThisMap();
    if (lastDeath != NULL)
      sendNetworkEvent("gy_mark" .. lastDeath.toString());
  }

  // TODO: if rewriting Graveyard, separate translatable from verbatim text.
  // Translation should happen when an obituary is shown, not when it's created.
  override void worldThingDied(WorldEvent event)
  {
    Actor thing = event.thing;
    if (thing == NULL || thing.player == NULL) { return; }

    string killerPart = (event.thing.target == NULL)
      ? ""
      : string.format("\n%s: %s",
                      StringTable.localize("$GY_KILLER"),
                      thing.target.player
                        ? thing.target.player.getUserName()
                        : thing.target.getTag());
    string obituary = string.format("%s: %s%s\n%s\n%s",
                                    StringTable.localize("$GY_VICTIM"),
                                    thing.player.getUserName(),
                                    killerPart,
                                    SystemTime.format("%F %T", _now),
                                    level.timeFormatted());

    let storage = gy_Storage.of();
    let death   = gy_Death.of(thing.pos, obituary);
    storage.registerDeath(death);

    removeMarkers();
    sendNetworkEvent("gy_mark" .. death.toString());
    sendNetworkEvent("gy_spawn" .. death.toString());
  }

  override void networkProcess(ConsoleEvent event)
  {
    if (event.name.left(8) == "gy_spawn")
    {
      let death = gy_Death.fromString(event.name.mid(8));
      let pos   = death.getLocation();
      int seed  = abs(int(pos.x + pos.y + pos.z));
      int i     = seed % 4;
      let c     = string.format("gy_Stone%d", i);

      let stone = gy_Stone(Actor.spawn(c, death.getLocation(), ALLOW_REPLACE));
      stone.setObituary(death.getObituary());

      double scaleX = (1 + (seed % 13 - 6) / 6.0 / 10.0);
      double scaleY = (1 + (seed %  7 - 3) / 3.0 / 10.0);
      stone.a_setScale(scaleX, scaleY);
    }
    if (event.name.left(7) == "gy_mark")
    {
      removeMarkers();

      let death = gy_Death.fromString(event.name.mid(7));
      let pos   = death.getLocation();
      int seed  = abs(int(pos.x + pos.y + pos.z));
      int i     = seed % 4;
      let c     = string.format("gy_Marker%d", i);

      Actor.spawn(c, death.getLocation(), ALLOW_REPLACE);
    }
    else if (event.name == "gy_remove_all")
    {
      gy_Storage.of().clearAll();
      removeStonesOnMap();
      print("GY_REMOVE_ALL_MESSAGE");
    }
    else if (event.name == "gy_remove_map")
    {
      gy_Storage.of().clearThisMap();
      removeStonesOnMap();
      print("GY_REMOVE_MAP_MESSAGE");
    }
  }

  override void renderOverlay(RenderEvent event)
  {
    // Workaround to get the current time, which is UI-scoped.
    // Part 1/2.
    int second = level.time / TICRATE + 1;
    if (second > _lastSecond)
    {
      setNow(second, SystemTime.now());
    }
  }

  private void removeMarkers()
  {
    foreach (marker : ThinkerIterator.create("gy_Marker", Thinker.STAT_MapMarker))
    {
      marker.destroy();
      break;
    }
  }

  private void print(string message)
  {
    Console.printf("%s", StringTable.localize(message, false));
  }

  private void removeStonesOnMap()
  {
    let i = ThinkerIterator.create("gy_Stone");
    Actor a;
    while (a = Actor(i.next()))
    {
      a.destroy();
    }
  }

  // Workaround to get the current time, which is UI-scoped.
  // Part 2/2.
  private void setNow(int lastSecond, int now) const
  {
    _lastSecond = lastSecond;
    _now = now;
  }

  private transient bool _isFired;
  private int _lastSecond;
  private int _now;
}

6.2. Death

class gy_Death
{
  static gy_Death of(vector3 location, string obituary)
  {
    let result = new("gy_Death");
    result._location = location;
    result._obituary = obituary;
    return result;
  }

  static gy_Death fromString(string s)
  {
    Array<string> t;
    s.split(t, ":");
    vector3 location = (t[0].toDouble(), t[1].toDouble(), t[2].toDouble());
    string obituary = t[3];
    for (int i = 4; i < t.size(); ++i)
    {
      obituary.appendFormat(":%s", t[i]);
    }
    return gy_Death.of(location, obituary);
  }

  string toString()
  {
    return string.format("%.2f:%.2f:%.2f:%s",
                         _location.x,
                         _location.y,
                         _location.z,
                         _obituary);
  }

  vector3 getLocation() const { return _location; }
  string  getObituary() const { return _obituary; }

  private vector3 _location;
  private string  _obituary;
}

6.3. Stone

class gy_Stone : Actor
{
  Default
  {
    Radius 16;
    Height 1;
    Tag "$GY_GRAVESTONE";
  }

  void setObituary(string obituary)
  {
    _obituary = obituary;
  }

  override bool used(Actor user)
  {
    Console.printf("%s", _obituary);
    return Super.used(user);
  }

  override void beginPlay()
  {
    setOrigin((pos.x, pos.y, floorZ), false);
  }

  private string _obituary;
}

class gy_Stone0 : gy_Stone { States { Spawn: gy_t a -1; Stop; } }
class gy_Stone1 : gy_Stone { States { Spawn: gy_t b -1; Stop; } }
class gy_Stone2 : gy_Stone { States { Spawn: gy_t c -1; Stop; } }
class gy_Stone3 : gy_Stone { States { Spawn: gy_t d -1; Stop; } }
sprite gy_ta0, 33, 49
{
  patch gy_tomb1, 0, 0
  offset 16, 49
  xScale 1.5
  yScale 1.8
}

sprite gy_tb0, 33, 49
{
  patch gy_tomb2, 0, 0
  offset 16, 49
  xScale 1.5
  yScale 1.8
}

sprite gy_tc0, 32, 30
{
  patch gy_tomb3, 0, 0
  offset 16, 30
  xScale 1.1
  yScale 1.32
}

sprite gy_td0, 38, 34
{
  patch gy_tomb4, 0, 0
  offset 19, 34
  xScale 1.1
  yScale 1.32
}

6.4. Automap marker

class gy_Marker : MapMarker {}
class gy_Marker0 : gy_Marker { States { Spawn: gy_t a -1; Stop; } }
class gy_Marker1 : gy_Marker { States { Spawn: gy_t b -1; Stop; } }
class gy_Marker2 : gy_Marker { States { Spawn: gy_t c -1; Stop; } }
class gy_Marker3 : gy_Marker { States { Spawn: gy_t d -1; Stop; } }

6.5. Storage

nosave string gy_hashes = "";
nosave string gy_locations = "";
nosave string gy_obituaries = "";
class gy_Storage
{
  static gy_Storage of()
  {
    let result = new("gy_Storage");
    result._hashes     = Dictionary.fromString(Cvar.getCvar("gy_hashes"    ).getString());
    result._locations  = Dictionary.fromString(Cvar.getCvar("gy_locations" ).getString());
    result._obituaries = Dictionary.fromString(Cvar.getCvar("gy_obituaries").getString());
    result._hashesIterator = DictionaryIterator.create(result._hashes);
    return result;
  }

  gy_Death next()
  {
    string checksum = level.getChecksum();
    bool hasNext;
    while ((hasNext = _hashesIterator.next()) && _hashesIterator.value() != checksum);
    return hasNext ? getDeathAt(_hashesIterator.key()) : NULL;
  }

  gy_Death getLastDeathOnThisMap()
  {
    string checksum = level.getChecksum();
    int maxIndex = -1;
    let i = DictionaryIterator.create(_hashes);
    while (i.next())
    {
      if (i.value() == checksum)
        maxIndex = max(maxIndex, i.key().toInt());
    }

    return (maxIndex == -1) ? NULL : getDeathAt(string.format("%d", maxIndex));
  }

  void registerDeath(gy_Death death)
  {
    string i = string.format("%d", getNewIndex());
    let    l = death.getLocation();

    _hashes    .insert(i, level.getChecksum());
    _locations .insert(i, string.format("%.2f:%.2f:%.2f", l.x, l.y, l.z));
    _obituaries.insert(i, death.getObituary());

    write();
  }

  void clearAll()
  {
    _hashes     = Dictionary.create();
    _locations  = Dictionary.create();
    _obituaries = Dictionary.create();

    write();
  }

  void clearThisMap()
  {
    Dictionary newHashes     = Dictionary.create();
    Dictionary newLocations  = Dictionary.create();
    Dictionary newObituaries = Dictionary.create();
    int index = 0;

    string checksum = level.getChecksum();
    let i = DictionaryIterator.create(_hashes);
    while (i.next())
    {
      if (i.value() != checksum)
      {
        string s = string.format("%d", index);
        newHashes    .insert(s, i.value());
        newLocations .insert(s, _locations .at(i.key()));
        newObituaries.insert(s, _obituaries.at(i.key()));
        ++index;
      }
    }

    _hashes     = newHashes;
    _locations  = newLocations;
    _obituaries = newObituaries;

    write();
  }

  private gy_Death getDeathAt(string i)
  {
    Array<string> lc;
    _locations.at(i).split(lc, ":");
    vector3 location = (lc[0].toDouble(), lc[1].toDouble(), lc[2].toDouble());

    return gy_Death.of(location, _obituaries.at(i));
  }

  private int getNewIndex()
  {
    // Clearing gravestones on a particular map creates gaps in indexes,
    // that's why new index is not just dictionary size.
    int maxIndex = 0;
    let i = DictionaryIterator.create(_hashes);
    while (i.next())
      maxIndex = max(maxIndex, i.key().toInt());

    return maxIndex + 1;
  }

  private void write()
  {
    Cvar.getCvar("gy_hashes"    ).setString(_hashes    .toString());
    Cvar.getCvar("gy_locations" ).setString(_locations .toString());
    Cvar.getCvar("gy_obituaries").setString(_obituaries.toString());
  }

  private Dictionary _hashes;
  private DictionaryIterator _hashesIterator;
  private Dictionary _locations;
  private Dictionary _obituaries;
}

7. Patches

7.1. Soulspheres

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

version 4.14.3

class gy_SoulStone0 : gy_Stone0 replaces gy_Stone0
{
  override void beginPlay()
  {
    Actor.spawn("SoulSphere", pos, ALLOW_REPLACE);
    Super.beginPlay();
  }
}

class gy_SoulStone1 : gy_Stone1 replaces gy_Stone1
{
  override void beginPlay()
  {
    Actor.spawn("SoulSphere", pos, ALLOW_REPLACE);
    Super.beginPlay();
  }
}

class gy_SoulStone2 : gy_Stone2 replaces gy_Stone2
{
  override void beginPlay()
  {
    Actor.spawn("SoulSphere", pos, ALLOW_REPLACE);
    Super.beginPlay();
  }
}

class gy_SoulStone3 : gy_Stone3 replaces gy_Stone3
{
  override void beginPlay()
  {
    Actor.spawn("SoulSphere", pos, ALLOW_REPLACE);
    Super.beginPlay();
  }
}

7.2. Doom dead bodies

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

version 4.14.3

class gy_DoomDead0 : gy_Stone replaces gy_Stone0 { Default {+XFLIP} States { Spawn: PLAY N -1; Stop; } }
class gy_DoomDead1 : gy_Stone replaces gy_Stone1 { Default {+XFLIP} States { Spawn: PLAY W -1; Stop; } }
class gy_DoomDead2 : gy_Stone replaces gy_Stone2 { States { Spawn: PLAY N -1; Stop; } }
class gy_DoomDead3 : gy_Stone replaces gy_Stone3 { States { Spawn: PLAY W -1; Stop; } }

// Note: XFLIP doesn't work on automap.
class gy_DoomDeadMarker0 : gy_Marker replaces gy_Marker0 { States { Spawn: PLAY N -1; Stop; } }
class gy_DoomDeadMarker1 : gy_Marker replaces gy_Marker1 { States { Spawn: PLAY W -1; Stop; } }
class gy_DoomDeadMarker2 : gy_Marker replaces gy_Marker2 { States { Spawn: PLAY N -1; Stop; } }
class gy_DoomDeadMarker3 : gy_Marker replaces gy_Marker3 { States { Spawn: PLAY W -1; Stop; } }

8. Test

Basic check: the mod can be loaded without errors.

wait 2; map map01; wait 2; quit

Created: 2026-08-25 Tue 15:45