Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
.staging/
bin/
obj/
28 changes: 28 additions & 0 deletions GhostMode/DamageBlocker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using DeadworksManaged.Api;

namespace GhostMode;

public sealed class DamageBlocker
{
public HookResult Handle(TakeDamageEvent args)
{
var target = args.Entity;
var attacker = args.Info.Attacker;
if (target is null || attacker is null) return HookResult.Continue;
if (target.EntityIndex == attacker.EntityIndex) return HookResult.Continue;

int targetSlot = -1, attackerSlot = -1;
foreach (var controller in Players.GetAll())
{
var pawn = controller.GetHeroPawn();
if (pawn is null) continue;
int idx = pawn.EntityIndex;
if (idx == target.EntityIndex) targetSlot = controller.EntityIndex - 1;
else if (idx == attacker.EntityIndex) attackerSlot = controller.EntityIndex - 1;
if (targetSlot >= 0 && attackerSlot >= 0) break;
}

if (targetSlot < 0 || attackerSlot < 0) return HookResult.Continue;
return targetSlot == attackerSlot ? HookResult.Continue : HookResult.Stop;
}
}
39 changes: 39 additions & 0 deletions GhostMode/GhostMode.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>GhostMode</RootNamespace>
<AssemblyName>GhostMode</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>

<DeadlockDir Condition="'$(DeadlockDir)' == ''">$(DEADLOCK_GAME_DIR)</DeadlockDir>
<DeadlockBin>$(DeadlockDir)\game\bin\win64</DeadlockBin>
</PropertyGroup>

<ItemGroup>
<Reference Include="DeadworksManaged.Api">
<HintPath>$(DeadlockBin)\managed\DeadworksManaged.Api.dll</HintPath>
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</Reference>
<Reference Include="Google.Protobuf">
<HintPath>$(DeadlockBin)\managed\Google.Protobuf.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>

<Target Name="DeployToGame" AfterTargets="Build">
<ItemGroup>
<DeployFiles Include="$(OutputPath)GhostMode.dll;$(OutputPath)GhostMode.pdb" />
</ItemGroup>
<Copy SourceFiles="@(DeployFiles)"
DestinationFolder="$(DeadlockBin)\managed\plugins"
SkipUnchangedFiles="false"
Retries="0"
ContinueOnError="WarnAndContinue" />
</Target>

</Project>
94 changes: 94 additions & 0 deletions GhostMode/GhostModePlugin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using DeadworksManaged.Api;

namespace GhostMode;

public class GhostModePlugin : DeadworksPluginBase
{
public override string Name => "GhostMode";

private readonly PlayerIsolation _isolation = new();
private readonly DamageBlocker _damage = new();

private Heroes _defaultHero;
private int _defaultTeam;

public override void OnLoad(bool isReload)
{
try
{
var heroName = Env("DEFAULT_HERO", "Haze");
if (!Enum.TryParse<Heroes>(heroName, ignoreCase: true, out _defaultHero))
{
Console.WriteLine($"[{Name}] Unknown DEFAULT_HERO '{heroName}', using Haze.");
_defaultHero = Heroes.Haze;
}

var teamStr = Env("DEFAULT_TEAM", "2");
if (!int.TryParse(teamStr, out _defaultTeam)) _defaultTeam = 2;

Console.WriteLine(
$"[{Name}] {(isReload ? "Reloaded" : "Loaded")} (team={_defaultTeam}, hero={_defaultHero}).");
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] OnLoad failed: {ex}");
}
}

public override void OnUnload() => Console.WriteLine($"[{Name}] Unloaded.");

public override void OnStartupServer()
{
try
{
ServerConfig.Apply();
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] OnStartupServer failed: {ex}");
}
}

public override void OnClientFullConnect(ClientFullConnectEvent args)
{
try
{
var controller = args.Controller;
if (controller is null) return;
controller.ChangeTeam(_defaultTeam);
controller.SelectHero(_defaultHero);
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] OnClientFullConnect failed: {ex}");
}
}

public override HookResult OnTakeDamage(TakeDamageEvent args)
{
try
{
return _damage.Handle(args);
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] OnTakeDamage failed: {ex}");
return HookResult.Continue;
}
}

public override void OnCheckTransmit(CheckTransmitEvent args)
{
try
{
_isolation.Handle(args);
}
catch (Exception ex)
{
Console.WriteLine($"[{Name}] OnCheckTransmit failed: {ex}");
}
}

private static string Env(string key, string fallback) =>
Environment.GetEnvironmentVariable($"DEADWORKS_ENV_{key}") ?? fallback;
}
22 changes: 22 additions & 0 deletions GhostMode/PlayerIsolation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using DeadworksManaged.Api;

namespace GhostMode;

public sealed class PlayerIsolation
{
public void Handle(CheckTransmitEvent args)
{
int viewerSlot = args.PlayerSlot;
for (int i = 0; i < Players.MaxSlot; i++)
{
if (i == viewerSlot) continue;
var controller = Players.FromSlot(i);
if (controller is null) continue;

args.Hide(controller);

var pawn = controller.GetHeroPawn();
if (pawn is not null) args.Hide(pawn);
}
}
}
27 changes: 27 additions & 0 deletions GhostMode/ServerConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using DeadworksManaged.Api;

namespace GhostMode;

public static class ServerConfig
{
public static void Apply()
{
SetInt("citadel_trooper_spawn_enabled", 0);
SetInt("citadel_npc_spawn_enabled", 0);
SetInt("citadel_allow_duplicate_heroes", 1);
SetInt("citadel_voice_all_talk", 1);
SetInt("citadel_player_spawn_time_max_respawn_time", 5);
SetInt("citadel_bots_enabled", 0);
}

private static void SetInt(string name, int value)
{
var cv = ConVar.Find(name);
if (cv is null || !cv.IsValid)
{
Console.WriteLine($"[GhostMode] convar not found: {name}");
return;
}
cv.SetInt(value);
}
}
56 changes: 56 additions & 0 deletions LockTimer/Hud/MinimapWaypoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Numerics;
using DeadworksManaged.Api;

namespace LockTimer.Hud;

/// <summary>
/// Emits a minimap-only ping at a per-player target location, re-sending
/// periodically so the client marker stays alive.
/// </summary>
public sealed class MinimapWaypoint
{
// Client ping markers expire after ~2s; resend with a safety margin to
// avoid flicker.
private const long ResendIntervalMs = 1500;

// Matches PingCommonData.entity_index proto default: "no entity".
private const uint NoEntityIndex = 16777215;

// High bits tag the id as ours (ASCII "LT"); low bits are the slot so
// each player gets a stable id and new pings replace the old one.
private const uint PingIdPrefix = 0x4C540000u;

private readonly Dictionary<int, long> _lastSentAt = new();

public void Remove(int slot) => _lastSentAt.Remove(slot);

public void Clear() => _lastSentAt.Clear();

public void Tick(int slot, Vector3? target, long nowMs)
{
if (target is null) return;
if (_lastSentAt.TryGetValue(slot, out var last) && nowMs - last < ResendIntervalMs)
return;

SendPing(slot, target.Value);
_lastSentAt[slot] = nowMs;
}

private static void SendPing(int slot, Vector3 location)
{
var msg = new CCitadelUserMsg_MapPing
{
PingData = new PingCommonData
{
PingMessageId = PingIdPrefix | (uint)slot,
PingLocation = new CMsgVector { X = location.X, Y = location.Y, Z = location.Z },
EntityIndex = NoEntityIndex,
SenderPlayerSlot = -1,
},
PingMarkerAndSoundInfo = ChatMsgPingMarkerInfo.KEpingMarkerInfoOnlyMiniMap,
IsMinimapPing = true,
};

NetMessages.Send(msg, RecipientFilter.Single(slot));
}
}
Loading