Implement health, death & respawn

- Add Health HUD element
- Add Damage to weapons
- Bullet does damage to players
- Players die and respawn
- Hit decal spawned by server
- Disable various processing
  when player is not alive
main
copygirl 4 years ago
parent 8b244761e1
commit 286dfb8061
  1. 10
      scene/ClientScene.tscn
  2. 4
      scene/Player.tscn
  3. 94
      src/HUD/Health.cs
  4. 28
      src/HUD/RadialMenu.cs
  5. 9
      src/Items/CreativeBuilding.cs
  6. 25
      src/Items/Weapon.cs
  7. 24
      src/Objects/Bullet.cs
  8. 1
      src/Objects/HitDecal.cs
  9. 2
      src/Objects/LocalPlayer.cs
  10. 43
      src/Objects/Player.cs
  11. 10
      src/World.cs

@ -1,4 +1,4 @@
[gd_scene load_steps=16 format=2]
[gd_scene load_steps=17 format=2]
[ext_resource path="res://scene/GameScene.tscn" type="PackedScene" id=1]
[ext_resource path="res://src/HUD/Cursor.cs" type="Script" id=2]
@ -12,6 +12,7 @@
[ext_resource path="res://ui_theme.tres" type="Theme" id=10]
[ext_resource path="res://src/HUD/RadialMenu.cs" type="Script" id=11]
[ext_resource path="res://src/HUD/WeaponInfo.cs" type="Script" id=12]
[ext_resource path="res://src/HUD/Health.cs" type="Script" id=13]
[sub_resource type="StyleBoxFlat" id=1]
bg_color = Color( 0, 0, 0, 0.752941 )
@ -63,7 +64,10 @@ __meta__ = {
[node name="HUD" type="CanvasLayer" parent="." index="4"]
[node name="RadialMenu" type="Node2D" parent="HUD" index="0"]
[node name="Health" type="Node2D" parent="HUD" index="0"]
script = ExtResource( 13 )
[node name="RadialMenu" type="Node2D" parent="HUD" index="1"]
visible = false
script = ExtResource( 11 )
@ -84,7 +88,7 @@ __meta__ = {
"_edit_use_anchors_": false
}
[node name="EscapeMenu" parent="HUD" index="1" instance=ExtResource( 7 )]
[node name="EscapeMenu" parent="HUD" index="2" instance=ExtResource( 7 )]
visible = false
[node name="CursorLayer" type="CanvasLayer" parent="." index="5"]

@ -76,6 +76,7 @@ SpreadIncrease = 1.0
RecoilMin = 3.0
RecoilMax = 5.0
BulletVelocity = 1200
Damage = 0.22
[node name="Tip" type="Node2D" parent="Items/Revolver"]
position = Vector2( 15, -2.5 )
@ -99,6 +100,7 @@ EffectiveRange = 240
MaximumRange = 360
BulletVelocity = 1000
BulletsPerShot = 6
Damage = 0.6
BulletOpacity = 0.1
[node name="Tip" type="Node2D" parent="Items/Shotgun"]
@ -121,6 +123,7 @@ RecoilMax = 8.0
EffectiveRange = 480
MaximumRange = 920
BulletVelocity = 4000
Damage = 0.8
BulletOpacity = 0.4
[node name="Tip" type="Node2D" parent="Items/Rifle"]
@ -143,6 +146,7 @@ Spread = 0.6
SpreadIncrease = 0.8
RecoilMin = 1.0
RecoilMax = 2.5
Damage = 0.12
BulletOpacity = 0.15
[node name="Tip" type="Node2D" parent="Items/Assault Rifle"]

@ -0,0 +1,94 @@
using System;
using Godot;
public class Health : Node2D
{
private static readonly TimeSpan VISIBLE_TIME = TimeSpan.FromSeconds(1.0);
private static readonly TimeSpan FADE_TIME = TimeSpan.FromSeconds(1.5);
[Export] public int Segments { get; set; } = 6;
[Export] public int InnerRadius { get; set; } = 14;
[Export] public int OuterRadius { get; set; } = 24;
[Export] public float Separation { get; set; } = 2.0F;
private float _startAngle;
private float _health;
private float _visibilityTimer;
public override void _Ready()
{
_startAngle = (-Mathf.Tau / 4) - (Mathf.Tau / Segments / 2);
Visible = false;
}
public override void _Process(float delta)
{
if (!(this.GetClient().LocalPlayer is Player player))
{ Visible = false; return; }
if (player.Health >= 1.0F) {
if (!Visible) return;
_visibilityTimer += delta;
if (_visibilityTimer > (VISIBLE_TIME + FADE_TIME).TotalSeconds)
{ Visible = false; return; }
else if (_visibilityTimer > VISIBLE_TIME.TotalSeconds)
Modulate = new Color(Colors.White, 1.0F - (float)(
(_visibilityTimer - VISIBLE_TIME.TotalSeconds) / FADE_TIME.TotalSeconds));
} else {
Visible = true;
Modulate = Colors.White;
_visibilityTimer = 0.0F;
}
Position = player.GetGlobalTransformWithCanvas().origin;
_health = player.Health;
Update();
}
public override void _Draw()
{
var vertices = new Vector2[6];
for (var i = 0; i < Segments; i++) {
var angle1 = _startAngle + Mathf.Tau * ( i / (float)Segments);
var angle3 = _startAngle + Mathf.Tau * ((i + 1) / (float)Segments);
var angle2 = (angle1 + angle3) / 2;
var sep1 = Mathf.Polar2Cartesian(Separation, angle1 + Mathf.Tau / 4);
var sep2 = Mathf.Polar2Cartesian(Separation, angle3 - Mathf.Tau / 4);
vertices[0] = Mathf.Polar2Cartesian(InnerRadius, angle2);
vertices[1] = Mathf.Polar2Cartesian(InnerRadius, angle1) + sep1;
vertices[2] = Mathf.Polar2Cartesian(OuterRadius, angle1) + sep1;
vertices[3] = Mathf.Polar2Cartesian(OuterRadius, angle2);
vertices[4] = Mathf.Polar2Cartesian(OuterRadius, angle3) + sep2;
vertices[5] = Mathf.Polar2Cartesian(InnerRadius, angle3) + sep2;
DrawColoredPolygon(vertices, new Color(Colors.Black, 0.4F), antialiased: true);
}
for (var i = 0; i < Segments; i++) {
var fullness = Mathf.Clamp((_health * Segments) - i, 0.0F, 1.0F);
if (fullness <= 0.1) return;
var angle1 = _startAngle + Mathf.Tau * ( i / (float)Segments);
var angle3 = _startAngle + Mathf.Tau * ((i + 1) / (float)Segments);
var angle2 = (angle1 + angle3) / 2;
var sep1 = Mathf.Polar2Cartesian(Separation + 1, angle1 + Mathf.Tau / 4);
var sep2 = Mathf.Polar2Cartesian(Separation + 1, angle3 - Mathf.Tau / 4);
var outerRadius = Mathf.Lerp(InnerRadius, OuterRadius, fullness);
vertices[0] = Mathf.Polar2Cartesian(InnerRadius + 1, angle2);
vertices[1] = Mathf.Polar2Cartesian(InnerRadius + 1, angle1) + sep1;
vertices[2] = Mathf.Polar2Cartesian(outerRadius - 1, angle1) + sep1;
vertices[3] = Mathf.Polar2Cartesian(outerRadius - 1, angle2);
vertices[4] = Mathf.Polar2Cartesian(outerRadius - 1, angle3) + sep2;
vertices[5] = Mathf.Polar2Cartesian(InnerRadius + 1, angle3) + sep2;
DrawColoredPolygon(vertices, new Color(Colors.Red, 0.5F), antialiased: true);
}
}
}

@ -4,9 +4,9 @@ using Godot;
// TODO: Display number of rounds for weapons? Add an even smaller font for this?
public class RadialMenu : Node2D
{
[Export] public int MinSegments { get; set; } = 8;
[Export] public int InnerRadius { get; set; } = 32;
[Export] public int OuterRadius { get; set; } = 64;
[Export] public int MinElements { get; set; } = 8;
[Export] public float Separation { get; set; } = 2F;
public Cursor Cursor { get; private set; }
@ -18,7 +18,7 @@ public class RadialMenu : Node2D
public override void _Ready()
{
_startAngle = (-Mathf.Tau / 4) - (Mathf.Tau / MinElements / 2);
_startAngle = (-Mathf.Tau / 4) - (Mathf.Tau / MinSegments / 2);
Cursor = this.GetClient()?.Cursor;
ActiveName = GetNode<Label>("ActiveName");
@ -29,14 +29,14 @@ public class RadialMenu : Node2D
public override void _UnhandledInput(InputEvent ev)
{
if (GetItems() == null) return;
if (this.GetClient().LocalPlayer?.IsAlive != true) return;
var items = GetItems();
if (ev.IsActionPressed("interact_select")) {
Position = this.GetClient().Cursor.ScreenPosition.Round();
Visible = true;
Modulate = Colors.White;
var items = GetItems();
_selected = items.Current;
items.Current = null;
@ -47,7 +47,6 @@ public class RadialMenu : Node2D
if (ev.IsActionPressed("interact_select_dec") || ev.IsActionPressed("interact_select_inc")) {
var diff = ev.IsActionPressed("interact_select_inc") ? 1 : -1;
var items = GetItems();
// TODO: Should current item be equipped until radial menu disappears again?
// Perhaps for balance reasons?
@ -78,6 +77,7 @@ public class RadialMenu : Node2D
public override void _Process(float delta)
{
if (this.GetClient().LocalPlayer?.IsAlive != true) Visible = false;
if (!Visible) return;
if (_showUntil != null) {
@ -92,11 +92,9 @@ public class RadialMenu : Node2D
}
var items = GetItems();
if (items == null) return;
var cursor = ToLocal(this.GetClient().Cursor.ScreenPosition);
var angle = cursor.Angle() - _startAngle;
var index = (int)((angle / Mathf.Tau + 1) % 1 * MinElements);
var index = (int)((angle / Mathf.Tau + 1) % 1 * MinSegments);
if ((cursor.Length() > InnerRadius) && (index < items.Count) && (items[index] != _selected)) {
_selected = items[index];
ActiveName.Text = _selected?.Name ?? "";
@ -112,14 +110,12 @@ public class RadialMenu : Node2D
public override void _Draw()
{
var items = GetItems();
var vertices = new Vector2[5];
var colors = new Color[5];
var numElements = Math.Max(MinElements, items.Count);
for (var i = 0; i < numElements; i++) {
var angle1 = _startAngle + Mathf.Tau * ( i / (float)numElements);
var angle3 = _startAngle + Mathf.Tau * ((i + 1) / (float)numElements);
var numSegments = Math.Max(MinSegments, items.Count);
for (var i = 0; i < numSegments; i++) {
var angle1 = _startAngle + Mathf.Tau * ( i / (float)numSegments);
var angle3 = _startAngle + Mathf.Tau * ((i + 1) / (float)numSegments);
var angle2 = (angle1 + angle3) / 2;
var sep1 = Mathf.Polar2Cartesian(Separation, angle1 + Mathf.Tau / 4);
@ -136,9 +132,7 @@ public class RadialMenu : Node2D
vertices[4] = Mathf.Polar2Cartesian(innerRadius , angle3) + sep2;
var color = new Color(0.1F, 0.1F, 0.1F, isSelected ? 0.7F : 0.4F);
for (var j = 0; j < colors.Length; j++) colors[j] = color;
DrawPolygon(vertices, colors, antialiased: true);
DrawColoredPolygon(vertices, color, antialiased: true);
if (i < items.Count) {
var sprite = (items[i].GetNodeOrNull("Icon") as Sprite) ?? (items[i] as Sprite);

@ -35,7 +35,7 @@ public class CreativeBuilding : Node2D
public override void _UnhandledInput(InputEvent ev)
{
if (!Visible || !(Player is LocalPlayer)) return;
if (!Visible || !(Player is LocalPlayer) || !Player.IsAlive) return;
if (ev.IsActionPressed("interact_primary")) {
GetTree().SetInputAsHandled();
@ -52,7 +52,7 @@ public class CreativeBuilding : Node2D
public override void _Process(float delta)
{
if (!(Player is LocalPlayer)) return;
if (!Visible) _currentMode = null;
if (!Visible || !Player.IsAlive) _currentMode = null;
if (_currentMode == BuildMode.Placing) {
if (!_canBuild) _currentMode = null;
@ -88,7 +88,8 @@ public class CreativeBuilding : Node2D
public override void _Draw()
{
if (!Cursor.Visible || EscapeMenu.Instance.Visible) return;
if (!(Player is LocalPlayer) || !Cursor.Visible ||
EscapeMenu.Instance.Visible) return;
var green = Color.FromHsv(1.0F / 3, 1.0F, 1.0F, 0.4F);
var red = Color.FromHsv(0.0F, 1.0F, 1.0F, 0.4F);
@ -112,6 +113,7 @@ public class CreativeBuilding : Node2D
private void PlaceLine(int x, int y, Facing direction, int length)
{
if (Player.NetworkID != GetTree().GetRpcSenderId()) return;
if (!Player.IsAlive) return;
// TODO: Make sure position is a reasonable distance away.
if (!Enum.IsDefined(typeof(Facing), direction)) return;
@ -133,6 +135,7 @@ public class CreativeBuilding : Node2D
private void BreakLine(int x, int y, Facing direction, int length)
{
if (Player.NetworkID != GetTree().GetRpcSenderId()) return;
if (!Player.IsAlive) return;
// TODO: Make sure position is a reasonable distance away.
if (!Enum.IsDefined(typeof(Facing), direction)) return;

@ -1,4 +1,5 @@
using System;
using System.Security.Cryptography.X509Certificates;
using Godot;
// TODO: "Click" sound when attempting to fire when not ready, or empty.
@ -22,6 +23,7 @@ public class Weapon : Sprite
[Export] public int MaximumRange { get; set; } = 640;
[Export] public int BulletVelocity { get; set; } = 2000;
[Export] public int BulletsPerShot { get; set; } = 1;
[Export] public float Damage { get; set; } = 0.0F;
[Export] public float BulletOpacity { get; set; } = 0.2F;
@ -71,7 +73,14 @@ public class Weapon : Sprite
_currentSpreadInc = Mathf.Max(0, _currentSpreadInc - spreadDecrease * delta);
_currentRecoil = Mathf.Max(0, _currentRecoil - recoilDecrease * delta);
if (Visible) {
if (!Player.IsAlive) {
_fireDelay = 0.0F;
_reloading = null;
Rounds = Capacity;
HoldingTrigger = null;
// TODO: Technically only needs to be called once.
if (Player is LocalPlayer) Update();
} else if (Visible) {
if (HoldingTrigger is TimeSpan holding)
HoldingTrigger = holding + TimeSpan.FromSeconds(delta);
@ -152,7 +161,7 @@ public class Weapon : Sprite
private void SendAimAngle(float value)
{
if (this.GetGame() is Server) {
if (Player.NetworkID != GetTree().GetRpcSenderId()) return;
if ((Player.NetworkID != GetTree().GetRpcSenderId()) || !Player.IsAlive) return;
if (float.IsNaN(value = Mathf.PosMod(value, Mathf.Tau))) return;
RPC.Unreliable(SendAimAngle, value);
@ -171,7 +180,8 @@ public class Weapon : Sprite
protected virtual bool FireInternal(float aimDirection, bool toRight, int seed)
{
if (!Visible || _lowered || (_reloading != null) || (Rounds <= 0) || (_fireDelay > 0)) return false;
if (!Visible || _lowered || !Player.IsAlive ||
(_reloading != null) || (Rounds <= 0) || (_fireDelay > 0)) return false;
if (this.GetGame() is Client)
GetNodeOrNull<AudioStreamPlayer2D>("Fire")?.Play();
@ -184,7 +194,8 @@ public class Weapon : Sprite
var spread = (Mathf.Deg2Rad(Spread) + _currentSpreadInc) * Mathf.Clamp(random.NextGaussian(0.4F), -1, 1);
var dir = Mathf.Polar2Cartesian(1, angle + spread);
var color = new Color(Player.Color, BulletOpacity);
var bullet = new Bullet(Player.Position + tip, dir, EffectiveRange, MaximumRange, BulletVelocity, color);
var bullet = new Bullet(Player.Position + tip, dir, EffectiveRange, MaximumRange,
BulletVelocity, Damage / BulletsPerShot, color);
this.GetWorld().AddChild(bullet);
}
@ -218,7 +229,9 @@ public class Weapon : Sprite
private bool ReloadInternal()
{
if (!Visible || (Rounds >= Capacity) || (_reloading != null)) return false;
if (!Visible || !Player.IsAlive ||
(Rounds >= Capacity) || (_reloading != null)) return false;
// TODO: Play reload sound.
_reloading = ReloadTime;
return true;
@ -237,7 +250,7 @@ public class Weapon : Sprite
public override void _Draw()
{
if (!(Player is LocalPlayer) || _lowered) return;
if (!(Player is LocalPlayer) || !Player.IsAlive || _lowered) return;
// Draws an "aiming cone" to show where bullets might travel.
var tip = TipOffset + new Vector2(4, 0);

@ -5,12 +5,12 @@ public class Bullet : Node2D
{
private static readonly TimeSpan LIFE_TIME = TimeSpan.FromSeconds(0.6);
private static readonly TimeSpan FADE_TIME = TimeSpan.FromSeconds(0.6);
private static readonly PackedScene HIT_DECAL = GD.Load<PackedScene>("res://scene/HitDecal.tscn");
public Vector2 Direction { get; }
public int EffectiveRange { get; }
public int MaximumRange { get; }
public int Velocity { get; }
public float Damage { get; }
public Color Color { get; }
private readonly Vector2 _startPosition;
@ -18,24 +18,32 @@ public class Bullet : Node2D
private float _distance;
public Bullet(Vector2 position, Vector2 direction,
int effectiveRange, int maximumRange, int velocity, Color color)
int effectiveRange, int maximumRange, int velocity,
float damage, Color color)
{
_startPosition = Position = position;
Direction = direction;
EffectiveRange = effectiveRange;
MaximumRange = maximumRange;
Velocity = velocity;
Damage = damage;
Color = color;
}
protected void OnCollide(CollisionObject2D obj, Vector2 hitPosition)
protected virtual void OnCollide(CollisionObject2D obj, Vector2 hitPosition)
{
var sprite = obj.GetNodeOrNull<Sprite>("Sprite");
if (sprite == null) return;
var hole = HIT_DECAL.Init<HitDecal>();
// TODO: Add a global game setting to specify whether shooter or server announces successful hit.
// For now, server is the most straight-forward. Eventually, support client predictive movement?
if (!(this.GetGame() is Server) || !(obj.GetNodeOrNull("Sprite") is Sprite sprite)) return;
var world = this.GetWorld();
var path = world.GetPathTo(sprite);
var color = new Color(Color, (1 + Color.a) / 2);
hole.Add(sprite, hitPosition, color);
RPC.Reliable(world.SpawnHit, path, hitPosition, color);
if (obj is Player player) {
var rangeFactor = Math.Min(1.0F, (MaximumRange - _distance) / (MaximumRange - EffectiveRange));
player.Health -= Damage * rangeFactor;
}
// TODO: Also spawn a ghost of the player who was hit so they can see where they got shot?
}
public override void _Ready()

@ -1,7 +1,6 @@
using System;
using Godot;
// TODO: When spawned, add to multiple nearby sprites.
public class HitDecal : Sprite
{
private static readonly TimeSpan LIFE_TIME = TimeSpan.FromSeconds(5.0);

@ -30,7 +30,7 @@ public class LocalPlayer : Player
{
var moveDir = 0.0F;
var jumpPressed = false;
if (!EscapeMenu.Instance.Visible) {
if (!EscapeMenu.Instance.Visible && IsAlive) {
moveDir = Input.GetActionStrength("move_right") - Input.GetActionStrength("move_left");
jumpPressed = Input.IsActionJustPressed("move_jump");
}

@ -1,7 +1,13 @@
using System;
using Godot;
public class Player : KinematicBody2D, IInitializable
{
private static readonly TimeSpan TIME_BEFORE_REGEN = TimeSpan.FromSeconds(1.0);
private static readonly TimeSpan REGEN_TIMER = TimeSpan.FromSeconds(1.0 / 3);
private static readonly float REGEN_AMOUNT = 0.025F;
private static readonly TimeSpan RESPAWN_TIMER = TimeSpan.FromSeconds(5);
[Export] public NodePath DisplayNamePath { get; set; }
[Export] public NodePath SpritePath { get; set; }
@ -9,11 +15,16 @@ public class Player : KinematicBody2D, IInitializable
public Sprite Sprite { get; private set; }
public IItems Items { get; private set; }
public int NetworkID { get => int.Parse(Name); set => Name = value.ToString(); }
public string DisplayName { get => DisplayNameLabel.Text; set => DisplayNameLabel.Text = value; }
public Color Color { get => Sprite.Modulate; set => Sprite.Modulate = value; }
public float Health { get; set; } = 1.0F;
public bool IsAlive => Health > 0.0F;
private float _previousHealth;
private float _regenDelay;
private float _respawnDelay;
public void Initialize()
{
DisplayNameLabel = GetNode<Label>(DisplayNamePath);
@ -21,8 +32,10 @@ public class Player : KinematicBody2D, IInitializable
Items = GetNode<IItems>("Items");
RsetConfig("position", MultiplayerAPI.RPCMode.Puppetsync);
RsetConfig("modulate", MultiplayerAPI.RPCMode.Puppetsync);
RsetConfig(nameof(DisplayName), MultiplayerAPI.RPCMode.Puppetsync);
RsetConfig(nameof(Color), MultiplayerAPI.RPCMode.Puppetsync);
RsetConfig(nameof(Health), MultiplayerAPI.RPCMode.Puppet);
}
public override void _Ready()
@ -30,9 +43,35 @@ public class Player : KinematicBody2D, IInitializable
public override void _Process(float delta)
{
if ((Position.y > 9000) && (this.GetGame() is Server))
if (this.GetGame() is Server) {
if (Position.y > 9000) Health -= 0.01F;
if (IsAlive && (Health < 1.0F)) {
if ((_regenDelay += delta) > (TIME_BEFORE_REGEN + REGEN_TIMER).TotalSeconds) {
_regenDelay -= (float)REGEN_TIMER.TotalSeconds;
Health = Mathf.Min(1.0F, Health + REGEN_AMOUNT);
}
} else _regenDelay = 0.0F;
if (!IsAlive && ((_respawnDelay += delta) > RESPAWN_TIMER.TotalSeconds)) {
// TODO: Move respawning related code to its own method.
// Can't use RPC helper method here since player is not a LocalPlayer here.
RpcId(NetworkID, nameof(LocalPlayer.ResetPosition), Vector2.Zero);
Rset("modulate", Colors.White);
Health = 1.0F;
_respawnDelay = 0.0F;
// TODO: Add invulnerability timer? Or some other way to prevent "void" damage
// after server considers player respawned, but it hasn't teleported yet.
}
if (_previousHealth != Health) {
RsetId(NetworkID, nameof(Health), Health);
if (Health < _previousHealth) _regenDelay = 0.0F;
if ((Health <= 0) && (_previousHealth > 0))
Rset("modulate", new Color(0.35F, 0.35F, 0.35F, 0.8F));
_previousHealth = Health;
}
}
}

@ -13,6 +13,8 @@ public class World : Node
public PackedScene BlockScene { get; private set; }
public PackedScene PlayerScene { get; private set; }
public PackedScene LocalPlayerScene { get; private set; }
private static readonly PackedScene HIT_DECAL = GD.Load<PackedScene>("res://scene/HitDecal.tscn");
// TODO: Make all of these static and readonly, hardcode the values..?
public override void _Ready()
{
@ -64,6 +66,14 @@ public class World : Node
this.GetClient().FireLocalPlayerSpawned(localPlayer);
}
[Puppet]
public void SpawnHit(NodePath spritePath, Vector2 hitPosition, Color color)
{
var hit = HIT_DECAL.Init<HitDecal>();
var sprite = this.GetWorld().GetNode<Sprite>(spritePath);
hit.Add(sprite, hitPosition, color);
}
[PuppetSync]
public void Despawn(NodePath path)
{

Loading…
Cancel
Save