Four always-on-screen slots, items as data, and loot tables on enemies and bosses. Health potions drop rarely from trash and always from the Warden; the Warden also drops a Warden's Ration, one per living player, which does nothing at all. The ration is not filler. Player-instanced loot is a separate code path from shared loot -- a distinct entity per owner, filtered per peer in the snapshot encoder -- and the cheapest way to keep that path honest is to have something in the game that exercises it on every boss kill. Item actions ride the input frame rather than becoming new client messages. InputFrame gained BTN_USE, BTN_DROP and a slot byte, which buys the packet-loss redundancy, the replay guard on last_input_tick, ordering against movement on the same tick, and a rate limit of one action per tick -- all of which a separate RPC would have needed bolted back on. The cost is that anything in the frame which must not repeat has to be edge-triggered, since frames are resent and a starved server coasts on the last one it holds. Instanced loot is enforced in NetCodec.encode_snapshot, beside the actor interest radius: a peer is never told another player's copy exists. Hiding it client-side would have been the same mistake as relying on fog to hide enemies. Inventories live on the character and are written to the store on every transaction, so a crash between "picked it up" and "wrote it down" cannot lose or duplicate an item. Anything dropped becomes world-shared whatever it was before, and a potion used at full health is refused rather than spent. tools/diag_loot.tscn covers drop -> snapshot -> pick up -> persist -> use -> drop plus both visibilities on the wire, for the same reason diag_progression exists: bots are poor shots and almost never produce a drop. It asserts each input frame was actually consumed, after an early version silently dropped its first press and every later check passed for the wrong reason. check.sh clean, 266 tests, SMOKE PASS, all three diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+25
-3
@@ -2,26 +2,46 @@ class_name InputFrame
|
||||
extends RefCounted
|
||||
## One tick of player intent. This is the only thing a client is allowed to tell
|
||||
## the server about its own state: no positions, no hits, no damage.
|
||||
##
|
||||
## Item actions live here rather than in their own reliable message, which is
|
||||
## worth being explicit about. Using or dropping an item is a thing you do
|
||||
## *during* a fight, so it has to be ordered against your movement on the same
|
||||
## tick, and it has to be as cheap to reject as a movement vector. Riding the
|
||||
## input stream gets all of that for free: the redundancy that covers a dropped
|
||||
## packet, the replay guard on [member SimPlayer.last_input_tick], and a natural
|
||||
## rate limit of one action per tick. A separate "use item" RPC would have
|
||||
## needed every one of those bolted on again.
|
||||
|
||||
const BTN_FIRE := 1
|
||||
const BTN_ESCAPE := 2
|
||||
const BTN_INTERACT := 4
|
||||
## Use the item in [member slot]. Edge-triggered by the server, so holding the
|
||||
## key spends one potion and not sixty.
|
||||
const BTN_USE := 8
|
||||
## Drop the item in [member slot] on the ground, where anyone can take it.
|
||||
const BTN_DROP := 16
|
||||
|
||||
## Wire size in bytes: u32 tick, i8 move x/y, u16 aim, u8 buttons.
|
||||
const SIZE := 9
|
||||
## Wire size in bytes: u32 tick, i8 move x/y, u16 aim, u8 buttons, u8 slot.
|
||||
const SIZE := 10
|
||||
|
||||
var tick: int = 0
|
||||
var move := Vector2.ZERO
|
||||
var aim: float = 0.0
|
||||
var buttons: int = 0
|
||||
## Which inventory slot BTN_USE / BTN_DROP refer to. Meaningless without one of
|
||||
## those bits set; the server clamps it before use, so a hostile value indexes
|
||||
## nothing.
|
||||
var slot: int = 0
|
||||
|
||||
|
||||
static func make(p_tick: int, p_move: Vector2, p_aim: float, p_buttons: int) -> InputFrame:
|
||||
static func make(p_tick: int, p_move: Vector2, p_aim: float, p_buttons: int,
|
||||
p_slot: int = 0) -> InputFrame:
|
||||
var f := InputFrame.new()
|
||||
f.tick = p_tick
|
||||
f.move = p_move
|
||||
f.aim = p_aim
|
||||
f.buttons = p_buttons
|
||||
f.slot = p_slot
|
||||
return f
|
||||
|
||||
|
||||
@@ -37,6 +57,7 @@ func write(buf: StreamPeerBuffer) -> void:
|
||||
buf.put_8(clampi(roundi(move.y * 100.0), -100, 100))
|
||||
buf.put_u16(wrapi(roundi(aim / TAU * 65536.0), 0, 65536))
|
||||
buf.put_u8(buttons & 0xFF)
|
||||
buf.put_u8(slot & 0xFF)
|
||||
|
||||
|
||||
static func read(buf: StreamPeerBuffer) -> InputFrame:
|
||||
@@ -45,4 +66,5 @@ static func read(buf: StreamPeerBuffer) -> InputFrame:
|
||||
f.move = Vector2(float(buf.get_8()) / 100.0, float(buf.get_8()) / 100.0)
|
||||
f.aim = float(buf.get_u16()) / 65536.0 * TAU
|
||||
f.buttons = buf.get_u8()
|
||||
f.slot = buf.get_u8()
|
||||
return f
|
||||
|
||||
@@ -27,4 +27,11 @@ enum Type {
|
||||
ESCAPE_CANCELLED, ## peer
|
||||
ESCAPE_COMPLETED, ## peer -- the instance layer acts on this
|
||||
PORTAL_USED, ## peer -- the instance layer acts on this
|
||||
## Item transactions. Appended at the end of the enum on purpose: inserting
|
||||
## mid-list shifts the wire value of everything after it, which is what made
|
||||
## PLAYER_FIRED a protocol break. All three carry (peer, item) and all three
|
||||
## tell ServerRuntime the character's inventory needs persisting.
|
||||
ITEM_PICKED_UP, ## peer, item
|
||||
ITEM_USED, ## peer, item
|
||||
ITEM_DROPPED, ## peer, item
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class_name SimLoot
|
||||
extends RefCounted
|
||||
## An item lying on the ground in one [SimWorld].
|
||||
##
|
||||
## Ground loot does not move, so unlike bullets it needs no event stream: it
|
||||
## rides the snapshot alongside enemies and is re-sent 20 times a second. That
|
||||
## also means a lost packet costs nothing, which a spawn-once event could not
|
||||
## claim.
|
||||
|
||||
var id: int = 0
|
||||
var item: StringName = &""
|
||||
var pos := Vector2.ZERO
|
||||
## 0 means world-shared: everyone sees it, the first to reach it takes it.
|
||||
## Otherwise the ONLY peer that may see or take it -- the server filters it out
|
||||
## of every other snapshot, so instancing is enforced on the wire and not by
|
||||
## asking the client to be polite.
|
||||
var owner_peer: int = 0
|
||||
## World tick it appeared, used only to decide what to evict when an instance
|
||||
## somehow accumulates more loot than it should hold.
|
||||
var born_tick: int = 0
|
||||
|
||||
|
||||
## Whether [param peer_id] is allowed to see and take this.
|
||||
func visible_to(peer_id: int) -> bool:
|
||||
return owner_peer == 0 or owner_peer == peer_id
|
||||
@@ -0,0 +1 @@
|
||||
uid://r142v0ai64i6
|
||||
@@ -31,6 +31,17 @@ var regen_carry: float = 0.0
|
||||
## Ticks before a downed player may ask to return to the hub.
|
||||
var respawn_lockout: int = 0
|
||||
|
||||
## Carried items, one id per slot, [constant Items.NONE] where empty. Always
|
||||
## exactly SimConfig.INVENTORY_SLOTS long -- callers index it directly, so it
|
||||
## must never be short.
|
||||
var inventory: Array[StringName] = []
|
||||
## Buttons and slot from the previous consumed input, so item actions can be
|
||||
## edge-triggered. Without this, holding the "use" key would drink the whole
|
||||
## inventory in four ticks. The slot is part of the edge as well: tapping 2
|
||||
## while 1 is still held is a second, distinct action.
|
||||
var prev_buttons: int = 0
|
||||
var prev_slot: int = -1
|
||||
|
||||
## The peer's connection dropped, but the player is deliberately still in the
|
||||
## world. Held here rather than deleted so a disconnect cannot be used to dodge
|
||||
## a dangerous moment: a linkdead player keeps channelling the escape (and stays
|
||||
@@ -50,6 +61,11 @@ var held_input: InputFrame = InputFrame.new()
|
||||
var starved_ticks: int = 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
inventory.resize(SimConfig.INVENTORY_SLOTS)
|
||||
inventory.fill(Items.NONE)
|
||||
|
||||
|
||||
func escape_progress() -> float:
|
||||
return clampf(float(escape_ticks) / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.0, 1.0)
|
||||
|
||||
@@ -78,7 +94,16 @@ func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
|
||||
regen_carry = 0.0
|
||||
fire_cooldown = 0
|
||||
escape_ticks = 0
|
||||
# Cleared, so a button held through a transition registers as a fresh press
|
||||
# on arrival. Safe rather than merely tolerable: hp is set to max on the
|
||||
# line above, and a heal at full health is refused, so the only item a held
|
||||
# key can spend on arrival is one that does nothing anyway.
|
||||
prev_buttons = 0
|
||||
prev_slot = -1
|
||||
input_queue.clear()
|
||||
# Inventory deliberately survives. It belongs to the character, not to the
|
||||
# room: walking into a dungeon with the potions you bought is the entire
|
||||
# point of carrying them.
|
||||
|
||||
|
||||
## True once the lockout has run out and the hub is available again.
|
||||
@@ -87,6 +112,8 @@ func can_request_respawn() -> bool:
|
||||
|
||||
|
||||
## Adopt a character's stats. Called when a player picks or switches character.
|
||||
## Inventory comes along: it is stored on the character, so swapping in the hub
|
||||
## swaps bags too rather than handing one character another's potions.
|
||||
func adopt(c: Character) -> void:
|
||||
character_id = c.id
|
||||
display_name = c.display_name
|
||||
@@ -95,6 +122,53 @@ func adopt(c: Character) -> void:
|
||||
colour = c.colour
|
||||
max_hp = c.max_hp()
|
||||
hp = mini(hp, max_hp)
|
||||
set_inventory(c.inventory)
|
||||
|
||||
|
||||
## Replace the whole inventory, padding or trimming to the configured slot
|
||||
## count so a save file written when the game had a different number of slots
|
||||
## still loads into a valid player.
|
||||
func set_inventory(items: Array[StringName]) -> void:
|
||||
inventory.resize(SimConfig.INVENTORY_SLOTS)
|
||||
for i in SimConfig.INVENTORY_SLOTS:
|
||||
inventory[i] = items[i] if i < items.size() else Items.NONE
|
||||
|
||||
|
||||
func free_slot() -> int:
|
||||
for i in inventory.size():
|
||||
if inventory[i] == Items.NONE:
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
## Put [param item] in the first free slot. Returns the slot, or -1 when full.
|
||||
func add_item(item: StringName) -> int:
|
||||
var at := free_slot()
|
||||
if at >= 0:
|
||||
inventory[at] = item
|
||||
return at
|
||||
|
||||
|
||||
## Empty [param slot] and return what was in it, or [constant Items.NONE].
|
||||
## Out-of-range indices answer NONE rather than erroring: the index came off
|
||||
## the wire, so it has to be safe to be nonsense.
|
||||
func take_slot(slot_index: int) -> StringName:
|
||||
if slot_index < 0 or slot_index >= inventory.size():
|
||||
return Items.NONE
|
||||
var item := inventory[slot_index]
|
||||
inventory[slot_index] = Items.NONE
|
||||
return item
|
||||
|
||||
|
||||
## Heal by a percentage of MAXIMUM health, and report whether it did anything.
|
||||
## Refusing a wasted heal is what stops a potion being consumed at full health.
|
||||
func heal_percent(percent: float) -> int:
|
||||
if not alive or hp >= max_hp:
|
||||
return 0
|
||||
var amount := maxi(1, roundi(float(max_hp) * percent / 100.0))
|
||||
var before := hp
|
||||
hp = mini(hp + amount, max_hp)
|
||||
return hp - before
|
||||
|
||||
|
||||
## One tick of passive healing. Returns true if the visible hit points changed,
|
||||
|
||||
+179
-1
@@ -21,6 +21,9 @@ var rng := RandomNumberGenerator.new()
|
||||
var players: Dictionary[int, SimPlayer] = {}
|
||||
var enemies: Dictionary[int, SimEnemy] = {}
|
||||
var boss: SimBoss = null
|
||||
## Items lying on the ground, by actor id. Shares the id space with enemies and
|
||||
## the boss, so nothing has to reason about two kinds of id.
|
||||
var loot: Dictionary[int, SimLoot] = {}
|
||||
|
||||
## Drained by the owner every tick. See [SimEvent].
|
||||
var events: Array[Dictionary] = []
|
||||
@@ -83,6 +86,12 @@ func add_player(peer_id: int, display_name: String) -> SimPlayer:
|
||||
|
||||
func remove_player(peer_id: int) -> void:
|
||||
players.erase(peer_id)
|
||||
# Loot instanced to this peer goes with them. Nobody else can see or take
|
||||
# it, so leaving it behind would be an invisible entity the instance carries
|
||||
# until it closes.
|
||||
for id in loot.keys():
|
||||
if loot[id].owner_peer == peer_id:
|
||||
loot.erase(id)
|
||||
|
||||
|
||||
func spawn_enemy(def: EnemyDef, at: Vector2, phase_offset: int = 0) -> SimEnemy:
|
||||
@@ -179,6 +188,7 @@ func _step_players() -> void:
|
||||
p.fire_cooldown -= 1
|
||||
|
||||
var frame := _take_input(p)
|
||||
var edge := _button_edge(p, frame)
|
||||
|
||||
if not p.alive:
|
||||
if p.respawn_lockout > 0:
|
||||
@@ -199,7 +209,24 @@ func _step_players() -> void:
|
||||
|
||||
_step_escape(p, frame)
|
||||
|
||||
if portal_enabled and frame.pressed(InputFrame.BTN_INTERACT):
|
||||
# Item actions are edge-triggered; movement and fire are not. Holding
|
||||
# the key must spend one potion, and the buttons arrive repeated (the
|
||||
# client sends the last few frames every tick, and a starved server
|
||||
# coasts on the last one), so a level-triggered read would empty the
|
||||
# whole inventory in four ticks.
|
||||
if edge & InputFrame.BTN_USE:
|
||||
_use_slot(p, frame.slot)
|
||||
if edge & InputFrame.BTN_DROP:
|
||||
_drop_slot(p, frame.slot)
|
||||
# Pickup shares the interact button with the portal. Loot wins when both
|
||||
# are in reach, and only for the tick it actually took something -- a
|
||||
# full inventory must not leave you standing on the portal unable to use
|
||||
# it.
|
||||
var took_item := false
|
||||
if edge & InputFrame.BTN_INTERACT:
|
||||
took_item = _try_pickup(p)
|
||||
|
||||
if portal_enabled and not took_item and frame.pressed(InputFrame.BTN_INTERACT):
|
||||
if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS:
|
||||
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
|
||||
|
||||
@@ -221,6 +248,18 @@ func _take_input(p: SimPlayer) -> InputFrame:
|
||||
return p.held_input
|
||||
|
||||
|
||||
## Buttons newly pressed on this frame, for the actions that must not repeat.
|
||||
## The slot counts as part of the edge: tapping slot 2 while slot 1 is still
|
||||
## held is a second, distinct action, not a swallowed one.
|
||||
func _button_edge(p: SimPlayer, frame: InputFrame) -> int:
|
||||
var edge := frame.buttons & ~p.prev_buttons
|
||||
if frame.slot != p.prev_slot:
|
||||
edge |= frame.buttons & (InputFrame.BTN_USE | InputFrame.BTN_DROP)
|
||||
p.prev_buttons = frame.buttons
|
||||
p.prev_slot = frame.slot
|
||||
return edge
|
||||
|
||||
|
||||
func _fire_player_shot(p: SimPlayer) -> void:
|
||||
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
|
||||
events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id})
|
||||
@@ -235,6 +274,143 @@ func _fire_player_shot(p: SimPlayer) -> void:
|
||||
SimConfig.KIND_PLAYER_SHOT)
|
||||
|
||||
|
||||
# --- Items and loot ---------------------------------------------------------
|
||||
|
||||
## Radius used to check that a dropped item is not inside a wall. Smaller than
|
||||
## anything that walks, because loot only has to be reachable, not roomy.
|
||||
const LOOT_CLEARANCE := 6.0
|
||||
|
||||
|
||||
## Put [param item] on the ground. [param owner_peer] of 0 is world-shared;
|
||||
## anything else is visible and takeable only by that peer.
|
||||
func spawn_loot(item: StringName, at: Vector2, owner_peer: int = 0) -> SimLoot:
|
||||
if Items.get_def(item) == null:
|
||||
return null
|
||||
_make_room_for_loot()
|
||||
var l := SimLoot.new()
|
||||
l.id = next_actor_id()
|
||||
l.item = item
|
||||
l.pos = at
|
||||
l.owner_peer = owner_peer
|
||||
l.born_tick = tick
|
||||
loot[l.id] = l
|
||||
return l
|
||||
|
||||
|
||||
## Keep ground loot bounded. Only the hub can realistically reach the cap --
|
||||
## dungeons close and take their litter with them -- so the oldest item is the
|
||||
## right thing to lose: it is the one that has been ignored the longest.
|
||||
func _make_room_for_loot() -> void:
|
||||
while loot.size() >= SimConfig.MAX_LOOT_PER_INSTANCE:
|
||||
var oldest := -1
|
||||
for id in loot:
|
||||
if oldest < 0 or loot[id].born_tick < loot[oldest].born_tick:
|
||||
oldest = id
|
||||
if oldest < 0:
|
||||
return
|
||||
loot.erase(oldest)
|
||||
|
||||
|
||||
## Roll a loot table and put what it produced on the floor. Called on death, so
|
||||
## it runs inside hit resolution and uses the world's own RNG -- loot is part of
|
||||
## the simulation, not something the instance layer sprinkles on afterwards.
|
||||
func _drop_loot(table: Array[LootDrop], at: Vector2) -> void:
|
||||
for entry in table:
|
||||
if entry == null or entry.item == Items.NONE:
|
||||
continue
|
||||
# Rolled unconditionally, including for guaranteed drops. Skipping the
|
||||
# roll at chance 1.0 would make the RNG stream depend on the loot
|
||||
# table's contents, so editing a number in content.gd would silently
|
||||
# change every later roll in the world.
|
||||
if rng.randf() > entry.chance:
|
||||
continue
|
||||
if not entry.instanced:
|
||||
_place_loot(entry.item, at, Vector2.ZERO, 0)
|
||||
continue
|
||||
# One copy per player who was alive for the kill. Laid out on a ring so
|
||||
# that a debug view of every copy at once is legible; in play each
|
||||
# player is only ever sent their own, so they all appear in the middle.
|
||||
var owners := _living_peers()
|
||||
for i in owners.size():
|
||||
var angle := TAU * float(i) / float(owners.size())
|
||||
_place_loot(entry.item, at,
|
||||
Vector2.RIGHT.rotated(angle) * SimConfig.LOOT_INSTANCED_SPREAD,
|
||||
owners[i])
|
||||
|
||||
|
||||
## Spawn at [param at] + [param offset], falling back to [param at] when the
|
||||
## offset would put the item inside geometry -- unreachable loot is worse than
|
||||
## two items in the same place.
|
||||
func _place_loot(item: StringName, at: Vector2, offset: Vector2, owner_peer: int) -> void:
|
||||
var want := at + offset
|
||||
if offset != Vector2.ZERO and map.circle_blocked(want, LOOT_CLEARANCE):
|
||||
want = at
|
||||
spawn_loot(item, want, owner_peer)
|
||||
|
||||
|
||||
func _living_peers() -> Array[int]:
|
||||
var out: Array[int] = []
|
||||
for p in players.values():
|
||||
if p.alive:
|
||||
out.append(p.peer_id)
|
||||
out.sort() # stable ordering, so the ring layout is not dictionary order
|
||||
return out
|
||||
|
||||
|
||||
## Take the nearest item this player is allowed to have. Returns whether one
|
||||
## was actually picked up.
|
||||
func _try_pickup(p: SimPlayer) -> bool:
|
||||
var best: SimLoot = null
|
||||
var best_d := SimConfig.LOOT_PICKUP_RADIUS * SimConfig.LOOT_PICKUP_RADIUS
|
||||
for l in loot.values():
|
||||
if not l.visible_to(p.peer_id):
|
||||
continue
|
||||
var d := p.pos.distance_squared_to(l.pos)
|
||||
if d <= best_d:
|
||||
best_d = d
|
||||
best = l
|
||||
if best == null:
|
||||
return false
|
||||
if p.add_item(best.item) < 0:
|
||||
return false # bags full; the item stays exactly where it was
|
||||
loot.erase(best.id)
|
||||
events.append({"t": SimEvent.Type.ITEM_PICKED_UP, "peer": p.peer_id, "item": best.item})
|
||||
return true
|
||||
|
||||
|
||||
func _use_slot(p: SimPlayer, slot_index: int) -> void:
|
||||
if slot_index < 0 or slot_index >= p.inventory.size():
|
||||
return
|
||||
var item := p.inventory[slot_index]
|
||||
var def := Items.get_def(item)
|
||||
if def == null:
|
||||
return
|
||||
match def.effect:
|
||||
ItemDef.Effect.HEAL:
|
||||
# Refused rather than wasted. Spending a potion at full health is
|
||||
# not a decision anyone makes on purpose, so it must not be one a
|
||||
# mistimed keypress can make for them.
|
||||
if p.heal_percent(def.effect_value) <= 0:
|
||||
return
|
||||
_:
|
||||
pass
|
||||
p.take_slot(slot_index)
|
||||
events.append({"t": SimEvent.Type.ITEM_USED, "peer": p.peer_id, "item": item})
|
||||
|
||||
|
||||
func _drop_slot(p: SimPlayer, slot_index: int) -> void:
|
||||
if slot_index < 0 or slot_index >= p.inventory.size():
|
||||
return
|
||||
if p.inventory[slot_index] == Items.NONE:
|
||||
return
|
||||
var item := p.take_slot(slot_index)
|
||||
# Anything dropped becomes world-shared, even if it arrived as an instanced
|
||||
# drop. That is what makes dropping worth having: an item you do not want
|
||||
# should be able to reach someone who does.
|
||||
_place_loot(item, p.pos, Vector2.ZERO, 0)
|
||||
events.append({"t": SimEvent.Type.ITEM_DROPPED, "peer": p.peer_id, "item": item})
|
||||
|
||||
|
||||
func _step_escape(p: SimPlayer, frame: InputFrame) -> void:
|
||||
# A dropped connection is treated as holding the button down. Pulling the
|
||||
# plug then costs exactly what pressing escape costs -- one second of
|
||||
@@ -437,6 +613,7 @@ func _damage_enemy(e: SimEnemy, amount: int) -> void:
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
|
||||
if e.hp <= 0:
|
||||
e.alive = false
|
||||
_drop_loot(e.def.loot, e.pos)
|
||||
# The def id rides along so the instance layer can score it without
|
||||
# looking up an actor that is about to stop existing.
|
||||
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)})
|
||||
@@ -450,6 +627,7 @@ func _damage_boss(amount: int) -> void:
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
|
||||
if boss.hp <= 0:
|
||||
boss.alive = false
|
||||
_drop_loot(boss.def.loot, boss.pos)
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user