Броня в семи местах и три зоны урона

- В реестр заведены 46 предметов одежды ремейка: слот, слой, какие части тела
  закрывает и сколько снимает из каждого вида вреда. Данные выгружены в items.csv
  (extract-items.py), в коде у каждого предмета armor(...).
- Семь мест: голова, лицо, грудь (2 слоя), ноги (2 слоя), руки, стопы, полный
  костюм. Пояс не делали - в ремейке его нет, есть только быстрая панель.
- Inventory сервера: семь мест по два слоя, вещи надеваются и снимаются.
- Damage: зона урона по точке попадания (голова верхние 0.4, ноги нижние 0.4),
  множители голова 2, тело 1, ноги 0.75; одежда снимает вред только там, где
  закрывает.
- Пакет Attack (цель, точка попадания, рука) и клиентский патч, который его
  шлёт. У игрока появилась жизнь.
- В items.csv добавлены колонки armor_slot, armor_layer, armor_zones.

Тесты: протокол 24 + 10, сервер 85. Форк собирается чисто, 3279 классов.
This commit is contained in:
sasheg dev
2026-10-21 22:43:52 +03:00
parent 9980c1561d
commit 7505ddd84e
18 changed files with 1188 additions and 262 deletions
+1
View File
@@ -52,6 +52,7 @@ $suites = @(
'net.rustnotmc.server.item.InventoryTest',
'net.rustnotmc.server.craft.CraftTest',
'net.rustnotmc.server.hitbox.HitboxTest',
'net.rustnotmc.server.combat.DamageTest',
'net.rustnotmc.server.PlayTest'
)
foreach ($suite in $suites) {
@@ -232,6 +232,9 @@ public final class ClientSession implements Runnable {
case Packets.HITBOXES_RELOAD:
onHitboxesReload(frame);
return true;
case Packets.ATTACK:
onAttack(frame);
return true;
case Packets.PLAYER_MOVE:
onPlayerMove(frame);
return true;
@@ -637,6 +640,44 @@ public final class ClientSession implements Runnable {
send(Packets.INVENTORY, this.player.inventory().toPacket());
}
/**
* The client hits something.
*
* The place of the hit decides how much it hurts, and the clothing of the
* one who is hit takes a part of it away. The client never decides any of
* that: it says where it hit, and the server counts.
*/
private void onAttack(Frame frame) {
if (this.state != SessionState.PLAYING || this.player == null) {
return;
}
Packets.Attack attack = Packets.Attack.read(new Buf(frame.body()));
Player target = this.server.playerByEntity(attack.target);
if (target == null || target == this.player) {
return;
}
net.rustnotmc.protocol.ItemType weapon = this.player.inventory()
.inHand().type();
if (weapon == null) {
return;
}
float base = weapon.damage(net.rustnotmc.protocol.ItemType.TORSO);
if (base <= 0) {
return;
}
String zone = net.rustnotmc.server.combat.Damage.zoneOf(attack.y, target.y(),
net.rustnotmc.server.Player.HEIGHT);
float hurt = net.rustnotmc.server.combat.Damage.apply(base, zone,
target.inventory().armor(),
net.rustnotmc.protocol.ItemType.HARM_MELEE);
if (target.damage(hurt)) {
this.server.log(this.account + " killed " + target.account());
} else {
this.server.log(this.account + " hit " + target.account() + " for "
+ (int) hurt + " (" + zone + ")");
}
}
/**
* The client wants the box file read again.
*
@@ -30,6 +30,12 @@ public final class Player {
/** How far a player may reach to change a block, in blocks. */
public static final double REACH = 5.0D;
/** How much life a player has, and how much a full bar holds. */
public static final float MAX_HEALTH = 100.0F;
/** How tall a player is, in blocks. */
public static final float HEIGHT = 1.8F;
/** How far a player may see, in chunks, unless it asks for less. */
public static final int DEFAULT_VIEW_DISTANCE = 6;
@@ -56,6 +62,9 @@ public final class Player {
/** What the player is carrying. The server owns it. */
private final Inventory inventory;
/** How much life the player has left. */
private float health = MAX_HEALTH;
/** What the player knows: the recipes and the nodes of the trees. */
private final Knowledge knowledge;
@@ -66,6 +75,32 @@ public final class Player {
private int craftItem = ItemRegistry.NOTHING;
private int craftSecondsLeft;
/** How much life the player has left. */
public float health() {
return this.health;
}
/**
* Hurts the player.
*
* Returns true when the hit was the last one.
*/
public boolean damage(float amount) {
if (amount <= 0.0F) {
return false;
}
this.health -= amount;
if (this.health < 0.0F) {
this.health = 0.0F;
}
return this.health <= 0.0F;
}
/** Gives the player their life back. */
public void heal(float amount) {
this.health = Math.min(MAX_HEALTH, this.health + amount);
}
Player(ClientSession session, int playerId, int entityId, String account,
double[] spawn) {
this(session, playerId, entityId, account, spawn,
@@ -125,6 +125,21 @@ public final class Server {
return this.players;
}
/**
* The player with that entity id, or null.
*
* The client only knows the entity id of what it sees, so that is the
* number a hit comes with.
*/
public Player playerByEntity(int entityId) {
for (Player player : this.players.values()) {
if (player.entityId() == entityId) {
return player;
}
}
return null;
}
/** How far a new player sees, in chunks. */
public int viewDistance() {
return this.viewDistance;
@@ -0,0 +1,131 @@
package net.rustnotmc.server.combat;
import net.rustnotmc.protocol.Hitbox;
import net.rustnotmc.protocol.ItemStack;
import net.rustnotmc.protocol.ItemType;
/**
* How much a hit hurts.
*
* A hit lands somewhere on the target, and where it landed decides how much
* it hurts: a hit on the head hurts twice as much as a hit on the body, and a
* hit on the legs hurts a little less. That is the rule of the original game,
* and the head is the only place where it is written down.
*
* The clothing takes a part of the harm away, but only for the parts of the
* body it covers: a hat does nothing for a hit in the chest.
*/
public final class Damage {
/** How much a hit on the head hurts, against a hit on the body. */
public static final float HEAD_MULTIPLIER = 2.0F;
/** How much a hit on the legs hurts, against a hit on the body. */
public static final float LEGS_MULTIPLIER = 0.75F;
/** How tall the head of a player is, in blocks. */
public static final float HEAD_HEIGHT = 0.4F;
/** How tall the legs of a player are, in blocks. */
public static final float LEGS_HEIGHT = 0.4F;
private Damage() {
}
/**
* Where a hit landed.
*
* @param hitY where the hit landed
* @param feetY where the feet of the target are
* @param height how tall the target is
*/
public static String zoneOf(double hitY, double feetY, double height) {
double above = hitY - feetY;
if (above < 0.0D) {
above = 0.0D;
}
if (above > height) {
above = height;
}
if (above >= height - HEAD_HEIGHT) {
return Hitbox.PART_HEAD;
}
if (above < LEGS_HEIGHT) {
return Hitbox.PART_LEGS;
}
return Hitbox.PART_BODY;
}
/** How much a hit on that zone hurts, before the clothing. */
public static float multiplier(String zone) {
if (Hitbox.PART_HEAD.equals(zone)) {
return HEAD_MULTIPLIER;
}
if (Hitbox.PART_LEGS.equals(zone)) {
return LEGS_MULTIPLIER;
}
return 1.0F;
}
/**
* How much of a kind of harm the clothing takes away.
*
* The pieces that cover the zone are added up, and the sum never goes
* past a whole.
*/
public static float protection(ItemStack[][] armor, String zone, int harm) {
int total = 0;
for (int i = 0; i < armor.length; i++) {
for (int layer = 0; layer < armor[i].length; layer++) {
ItemStack stack = armor[i][layer];
if (stack == null || stack.isEmpty()) {
continue;
}
ItemType type = stack.type();
if (type == null || type.armorSlot == ItemType.ARMOR_NONE) {
continue;
}
if (!covers(type, zone)) {
continue;
}
if (harm < 0 || harm >= type.protection.length) {
continue;
}
total += type.protection[harm];
}
}
if (total > 100) {
total = 100;
}
if (total < 0) {
total = 0;
}
return total / 100.0F;
}
/**
* How much damage a hit does.
*
* The base damage of the weapon, times the zone, minus what the clothing
* takes away.
*/
public static float apply(float base, String zone, ItemStack[][] armor, int harm) {
float withZone = base * multiplier(zone);
float taken = protection(armor, zone, harm);
return withZone * (1.0F - taken);
}
/** True when a piece of clothing covers that zone. */
public static boolean covers(ItemType type, String zone) {
if (type.coveredZones == 0) {
return false;
}
if (Hitbox.PART_HEAD.equals(zone)) {
return (type.coveredZones & Hitbox.ZONE_HEAD) != 0;
}
if (Hitbox.PART_LEGS.equals(zone)) {
return (type.coveredZones & Hitbox.ZONE_LEGS) != 0;
}
return (type.coveredZones & Hitbox.ZONE_BODY) != 0;
}
}
@@ -27,10 +27,24 @@ public final class Inventory {
private final ItemStack[] slots = new ItemStack[SLOTS];
private int held;
/**
* The clothing the player wears.
*
* Seven places, as in the original game: the head, the face, the chest,
* the legs, the hands, the feet and the whole body. The chest and the legs
* have two layers, the clothes under and the armour over.
*/
private final ItemStack[][] armor =
new ItemStack[net.rustnotmc.protocol.ItemType.ARMOR_SLOTS][2];
public Inventory() {
for (int i = 0; i < SLOTS; i++) {
this.slots[i] = ItemStack.EMPTY;
}
for (int i = 0; i < this.armor.length; i++) {
this.armor[i][0] = ItemStack.EMPTY;
this.armor[i][1] = ItemStack.EMPTY;
}
}
/**
@@ -156,7 +170,69 @@ public final class Inventory {
for (int i = 0; i < SLOTS; i++) {
out[i] = Packets.Slot.of(get(i));
}
return new Packets.Inventory(out, this.held);
return new Packets.Inventory(out, this.held, armorToPacket());
}
/**
* The clothing as the client is told about it.
*
* Seven places, two layers each, so fourteen stacks.
*/
private Packets.Slot[] armorToPacket() {
Packets.Slot[] out = new Packets.Slot[this.armor.length * 2];
for (int i = 0; i < this.armor.length; i++) {
for (int layer = 0; layer < 2; layer++) {
out[i * 2 + layer] = Packets.Slot.of(this.armor[i][layer]);
}
}
return out;
}
/**
* The piece of clothing in a slot, or nothing.
*
* The layer is zero for the clothes under and one for the armour over.
*/
public ItemStack armor(int slot, int layer) {
if (slot < 0 || slot >= this.armor.length || layer < 0 || layer > 1) {
return ItemStack.EMPTY;
}
ItemStack stack = this.armor[slot][layer];
return stack == null ? ItemStack.EMPTY : stack;
}
/** Puts a piece of clothing in a slot. */
public void setArmor(int slot, int layer, ItemStack stack) {
if (slot < 0 || slot >= this.armor.length || layer < 0 || layer > 1) {
return;
}
this.armor[slot][layer] = stack == null ? ItemStack.EMPTY : stack;
}
/** Every piece of clothing, slot by slot and layer by layer. */
public ItemStack[][] armor() {
return this.armor;
}
/**
* Puts a piece of clothing on, if it fits where it goes.
*
* Returns the piece that was in the way, or nothing.
*/
public ItemStack wear(int item) {
net.rustnotmc.protocol.ItemType type =
net.rustnotmc.protocol.ItemRegistry.byId(item);
if (type == null || type.armorSlot == net.rustnotmc.protocol.ItemType.ARMOR_NONE) {
return null;
}
int slot = type.armorSlot - 1;
int layer = type.armorLayer;
if (slot < 0 || slot >= this.armor.length) {
return null;
}
ItemStack was = armor(slot, layer);
setArmor(slot, layer, ItemStack.of(item, 1));
return was.isEmpty() ? null : was;
}
/** The slots that are not empty, for a look at what a player carries. */
@@ -0,0 +1,173 @@
package net.rustnotmc.server.combat;
import net.rustnotmc.protocol.Hitbox;
import net.rustnotmc.protocol.ItemRegistry;
import net.rustnotmc.protocol.ItemStack;
import net.rustnotmc.protocol.ItemType;
import net.rustnotmc.server.item.Inventory;
/**
* Tests how much a hit hurts.
*
* The rules are the ones of the original game: the head is worth twice the
* body, the legs a little less, and a piece of clothing only takes a part of
* the harm away where it covers the body.
*/
public final class DamageTest {
private static int checks;
private static int failed;
private static final java.util.List<String> failures = new java.util.ArrayList<String>();
public static void main(String[] args) {
run("a hit on the head is worth more", new Throwing() {
public void go() { testZones(); }
});
run("the multipliers are the ones of the original", new Throwing() {
public void go() { testMultipliers(); }
});
run("the clothing only helps where it covers", new Throwing() {
public void go() { testProtection(); }
});
run("the clothing is worn in seven places", new Throwing() {
public void go() { testSlots(); }
});
run("the clothes come out of the remake's table", new Throwing() {
public void go() { testTable(); }
});
System.out.println();
for (int i = 0; i < failures.size(); i++) {
System.out.println(" FAIL " + failures.get(i));
}
System.out.println();
System.out.println("checks: " + checks + ", failed: " + failed);
if (failed != 0) {
System.out.println("FAILED");
System.exit(1);
}
System.out.println("OK");
}
private interface Throwing {
void go() throws Exception;
}
private static void run(String name, Throwing body) {
checks++;
int before = failed;
try {
body.go();
} catch (Throwable t) {
failed++;
failures.add(name + ": threw " + t);
t.printStackTrace();
return;
}
if (failed == before) {
System.out.println(" ok " + name);
} else {
System.out.println(" FAIL " + name);
}
}
private static void eq(Object what, Object expected, Object actual) {
if (expected == null ? actual != null : !expected.equals(actual)) {
failed++;
failures.add(what + ": expected " + expected + " but got " + actual);
}
}
private static void yes(String what, boolean condition) {
if (!condition) {
failed++;
failures.add(what);
}
}
private static void near(Object what, double expected, double actual) {
if (Math.abs(expected - actual) > 0.0001D) {
failed++;
failures.add(what + ": expected " + expected + " but got " + actual);
}
}
private static void testZones() {
// a player of 1.8 blocks: the head is the top 0.4, the legs the bottom
eq("a hit on the top of the head", Hitbox.PART_HEAD,
Damage.zoneOf(1.75D, 0.0D, 1.8D));
eq("a hit on the face", Hitbox.PART_HEAD,
Damage.zoneOf(1.45D, 0.0D, 1.8D));
eq("a hit on the chest", Hitbox.PART_BODY,
Damage.zoneOf(1.0D, 0.0D, 1.8D));
eq("a hit on the belly", Hitbox.PART_BODY,
Damage.zoneOf(0.5D, 0.0D, 1.8D));
eq("a hit on the legs", Hitbox.PART_LEGS,
Damage.zoneOf(0.3D, 0.0D, 1.8D));
eq("a hit below the feet", Hitbox.PART_LEGS,
Damage.zoneOf(-1.0D, 0.0D, 1.8D));
}
private static void testMultipliers() {
near("the head", 2.0D, Damage.multiplier(Hitbox.PART_HEAD));
near("the body", 1.0D, Damage.multiplier(Hitbox.PART_BODY));
near("the legs", 0.75D, Damage.multiplier(Hitbox.PART_LEGS));
}
private static void testProtection() {
// a mask covers the head and takes away half of a bullet
ItemType mask = ItemRegistry.require(ItemRegistry.METALFACEMASK);
yes("the mask is a piece of clothing", mask.armorSlot == ItemType.ARMOR_HEAD);
yes("the mask covers the head", Damage.covers(mask, Hitbox.PART_HEAD));
yes("the mask does not cover the body", !Damage.covers(mask, Hitbox.PART_BODY));
Inventory inventory = new Inventory();
inventory.setArmor(mask.armorSlot - 1, mask.armorLayer, ItemStack.of(mask.id, 1));
near("a bullet in the head is half taken", 0.5D,
Damage.protection(inventory.armor(), Hitbox.PART_HEAD,
ItemType.HARM_PROJECTILE));
near("a bullet in the body is not taken", 0.0D,
Damage.protection(inventory.armor(), Hitbox.PART_BODY,
ItemType.HARM_PROJECTILE));
near("and a melee hit in the head is mostly taken", 0.7D,
Damage.protection(inventory.armor(), Hitbox.PART_HEAD,
ItemType.HARM_MELEE));
}
private static void testSlots() {
Inventory inventory = new Inventory();
eq("seven places", Integer.valueOf(7),
Integer.valueOf(inventory.armor().length));
eq("two layers each", Integer.valueOf(2),
Integer.valueOf(inventory.armor()[0].length));
ItemType helmet = ItemRegistry.require(ItemRegistry.BUCKETHELMET);
ItemStack was = inventory.wear(helmet.id);
yes("nothing was in the way", was == null);
eq("the helmet is on the head", Integer.valueOf(helmet.id),
Integer.valueOf(inventory.armor(0, 0).item));
ItemType jacket = ItemRegistry.require(ItemRegistry.METALCHEST);
inventory.wear(jacket.id);
eq("the chest armour is over the clothes", Integer.valueOf(jacket.id),
Integer.valueOf(inventory.armor(2, 1).item));
eq("and the clothes stay under", Integer.valueOf(0),
Integer.valueOf(inventory.armor(2, 0).item));
}
private static void testTable() {
// the numbers of the clothes come from the table of the remake
ItemType mask = ItemRegistry.require(ItemRegistry.METALFACEMASK);
eq("the mask takes away fifty of a bullet", Integer.valueOf(50),
Integer.valueOf(mask.protection[ItemType.HARM_PROJECTILE]));
eq("and seventy of a blow", Integer.valueOf(70),
Integer.valueOf(mask.protection[ItemType.HARM_MELEE]));
ItemType suit = ItemRegistry.require(ItemRegistry.HAZMATSUIT);
eq("the hazmat suit is a whole body", Integer.valueOf(ItemType.ARMOR_FULL_BODY),
Integer.valueOf(suit.armorSlot));
yes("it covers everything", Damage.covers(suit, Hitbox.PART_HEAD)
&& Damage.covers(suit, Hitbox.PART_BODY)
&& Damage.covers(suit, Hitbox.PART_LEGS));
}
}