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:
@@ -12,6 +12,10 @@ extends Resource
|
||||
@export var stationary: bool = true
|
||||
@export var spawn_pos := Vector2(0.0, -140.0)
|
||||
@export var phases: Array[BossPhase] = []
|
||||
## What the kill leaves behind. Bosses are the guaranteed source: a run that
|
||||
## reaches the end should always be worth something, so unlike trash loot these
|
||||
## entries are normally chance 1.0.
|
||||
@export var loot: Array[LootDrop] = []
|
||||
|
||||
|
||||
## Index of the phase that matches [param hp_fraction]. Later entries win, so a
|
||||
|
||||
@@ -36,3 +36,6 @@ enum Move {
|
||||
@export var emitters: Array[BulletEmitter] = []
|
||||
## The emitter timeline wraps at this many ticks.
|
||||
@export var pattern_loop_ticks: int = 240
|
||||
## What this enemy may leave behind. Rolled once per entry on death, against
|
||||
## the world's own RNG. Empty for anything that should drop nothing.
|
||||
@export var loot: Array[LootDrop] = []
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
class_name ItemDef
|
||||
extends Resource
|
||||
## Data-only description of an item, in the same spirit as [EnemyDef]: adding an
|
||||
## item is a table entry in [Items], never a branch in the simulation.
|
||||
##
|
||||
## Items are deliberately thin. A slot holds an id and nothing else -- no
|
||||
## charges, no durability, no stack count -- because everything the game
|
||||
## currently needs is expressible as "one id per slot", and the wire format,
|
||||
## the persistence record and the UI all get simpler for it. When something
|
||||
## needs a count, add it here rather than teaching four layers about it.
|
||||
|
||||
enum Effect {
|
||||
## Consumed and does nothing. Not a placeholder: the boss ration exists to
|
||||
## prove the player-instanced loot path works end to end, and it can only
|
||||
## do that if using it is a real, observable transaction.
|
||||
NONE,
|
||||
## Restores [member effect_value] percent of MAXIMUM health, so a potion
|
||||
## keeps its value at level 15 instead of becoming a rounding error.
|
||||
HEAL,
|
||||
}
|
||||
|
||||
@export var id: StringName = &"item"
|
||||
@export var display_name: String = "Item"
|
||||
@export var effect: Effect = Effect.NONE
|
||||
@export var effect_value: float = 0.0
|
||||
## Index into the renderer's item icon table. Same idea as [member
|
||||
## EnemyDef.visual]: the simulation never learns that art exists.
|
||||
@export var visual: int = 0
|
||||
## HUD tint. View-only, kept here so one table describes the whole item.
|
||||
@export var tint := Color(0.85, 0.85, 0.9)
|
||||
@@ -0,0 +1 @@
|
||||
uid://t2fhc2hd1575
|
||||
@@ -0,0 +1,28 @@
|
||||
class_name LootDrop
|
||||
extends Resource
|
||||
## One entry in an enemy's or boss's loot table.
|
||||
##
|
||||
## [member instanced] is the interesting field, and it is the reason loot has
|
||||
## two visibilities rather than one:
|
||||
##
|
||||
## - false -- a single entity in the instance that everyone can see and the
|
||||
## first to reach takes. This is the default, and it is what makes loot a
|
||||
## thing a party negotiates over.
|
||||
## - true -- one entity per eligible player, each visible only to its owner.
|
||||
## Nobody competes, nobody is denied. The server filters these out of every
|
||||
## other peer's snapshot, so it is an interest-management rule and not merely
|
||||
## a UI convention: a modified client is not told the others exist.
|
||||
|
||||
@export var item: StringName = &""
|
||||
## Probability in [0, 1], rolled once per kill against the world's own RNG.
|
||||
@export var chance: float = 1.0
|
||||
@export var instanced: bool = false
|
||||
|
||||
|
||||
static func make(item_id: StringName, drop_chance: float,
|
||||
player_instanced: bool = false) -> LootDrop:
|
||||
var d := LootDrop.new()
|
||||
d.item = item_id
|
||||
d.chance = drop_chance
|
||||
d.instanced = player_instanced
|
||||
return d
|
||||
@@ -0,0 +1 @@
|
||||
uid://de021jelm6hcg
|
||||
@@ -14,6 +14,10 @@ const ENEMY_STALKER := &"stalker"
|
||||
const ENEMY_DUMMY := &"dummy"
|
||||
const BOSS_WARDEN := &"warden"
|
||||
|
||||
## How often an ordinary enemy leaves a potion. "Rare" is the design brief: a
|
||||
## dungeon run should be survivable on what it hands you, but never comfortably.
|
||||
const TRASH_POTION_CHANCE := 0.08
|
||||
|
||||
|
||||
static func enemy(id: StringName) -> EnemyDef:
|
||||
match id:
|
||||
@@ -57,6 +61,7 @@ static func drifter() -> EnemyDef:
|
||||
fan.lifetime = 240
|
||||
fan.kind = SimConfig.KIND_ORB
|
||||
d.emitters = [fan]
|
||||
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
|
||||
return d
|
||||
|
||||
|
||||
@@ -83,6 +88,7 @@ static func turret() -> EnemyDef:
|
||||
ring.lifetime = 300
|
||||
ring.kind = SimConfig.KIND_ORB
|
||||
d.emitters = [ring]
|
||||
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
|
||||
return d
|
||||
|
||||
|
||||
@@ -120,6 +126,7 @@ static func stalker() -> EnemyDef:
|
||||
lunge.muzzle_offset = 10.0
|
||||
lunge.kind = SimConfig.KIND_HEAVY
|
||||
d.emitters = [lunge]
|
||||
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
|
||||
return d
|
||||
|
||||
|
||||
@@ -149,6 +156,14 @@ static func warden() -> BossDef:
|
||||
b.stationary = true
|
||||
b.spawn_pos = Vector2(0.0, -150.0)
|
||||
b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()]
|
||||
# Guaranteed, and deliberately one of each visibility. The potion is shared,
|
||||
# so a party still has something to divide up; the ration is instanced, so
|
||||
# every player who survived the fight leaves with the trophy and nobody has
|
||||
# to race for it. Between them they exercise both loot paths on every kill.
|
||||
b.loot = [
|
||||
LootDrop.make(Items.HEALTH_POTION, 1.0),
|
||||
LootDrop.make(Items.WARDENS_RATION, 1.0, true),
|
||||
]
|
||||
return b
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
class_name Items
|
||||
extends RefCounted
|
||||
## Every item in the game, defined in code for the same reasons [Content] is.
|
||||
##
|
||||
## [constant ORDER] is load-bearing: an item's position in it is the byte that
|
||||
## rides the snapshot and every item event. Item ids are strings and a slot is
|
||||
## sent 20 times a second per player, so the wire carries the index instead.
|
||||
## Append to the list, never reorder it -- and if you do reorder it, bump
|
||||
## [constant Protocol.VERSION], because every client would otherwise decode a
|
||||
## potion as a ration.
|
||||
|
||||
const NONE := &""
|
||||
const HEALTH_POTION := &"health_potion"
|
||||
const WARDENS_RATION := &"wardens_ration"
|
||||
|
||||
## Wire order. Index 0 is reserved for "empty slot" / "no item", so an id's
|
||||
## wire value is its position here plus one.
|
||||
const ORDER: Array[StringName] = [
|
||||
HEALTH_POTION,
|
||||
WARDENS_RATION,
|
||||
]
|
||||
|
||||
|
||||
static func get_def(id: StringName) -> ItemDef:
|
||||
match id:
|
||||
HEALTH_POTION: return health_potion()
|
||||
WARDENS_RATION: return wardens_ration()
|
||||
return null
|
||||
|
||||
|
||||
## Wire value for an id. 0 for an empty slot or an id this build does not know,
|
||||
## which is the safe direction: an unknown item reads as nothing rather than as
|
||||
## whatever happens to sit at that index.
|
||||
static func index_of(id: StringName) -> int:
|
||||
var at := ORDER.find(id)
|
||||
return at + 1 if at >= 0 else 0
|
||||
|
||||
|
||||
static func by_index(index: int) -> StringName:
|
||||
if index <= 0 or index > ORDER.size():
|
||||
return NONE
|
||||
return ORDER[index - 1]
|
||||
|
||||
|
||||
static func display_name_of(id: StringName) -> String:
|
||||
var def := get_def(id)
|
||||
return def.display_name if def != null else ""
|
||||
|
||||
|
||||
# --- The items --------------------------------------------------------------
|
||||
|
||||
## The only item with an effect. Percentage-based so it stays meaningful across
|
||||
## the whole level range -- a flat 40 hp would be a full heal at level 1 and
|
||||
## noise at level 15.
|
||||
static func health_potion() -> ItemDef:
|
||||
var d := ItemDef.new()
|
||||
d.id = HEALTH_POTION
|
||||
d.display_name = "Health Potion"
|
||||
d.effect = ItemDef.Effect.HEAL
|
||||
d.effect_value = 40.0
|
||||
d.visual = 0
|
||||
d.tint = Color(0.95, 0.4, 0.42)
|
||||
return d
|
||||
|
||||
|
||||
## Dropped by every boss, one per player who was alive for the kill, and does
|
||||
## nothing whatsoever when used.
|
||||
##
|
||||
## It is not filler. Player-instanced loot is a different code path from shared
|
||||
## loot -- a separate entity per owner, filtered per peer on the wire -- and the
|
||||
## cheapest way to keep that path honest is to have something in the game that
|
||||
## uses it every single boss kill. A trophy nobody has to fight over is exactly
|
||||
## the right shape for that job.
|
||||
static func wardens_ration() -> ItemDef:
|
||||
var d := ItemDef.new()
|
||||
d.id = WARDENS_RATION
|
||||
d.display_name = "Warden's Ration"
|
||||
d.effect = ItemDef.Effect.NONE
|
||||
d.visual = 1
|
||||
d.tint = Color(0.95, 0.82, 0.35)
|
||||
return d
|
||||
@@ -0,0 +1 @@
|
||||
uid://d07ue5tca3xt8
|
||||
@@ -89,6 +89,25 @@ const INPUT_MAX_LEAD := 40
|
||||
## backstop that makes the failure above self-healing whatever its cause.
|
||||
const INPUT_ACK_STALL_LIMIT := 8
|
||||
|
||||
# --- Inventory and loot -----------------------------------------------------
|
||||
## Slots a character carries. Small enough to sit on screen permanently, which
|
||||
## is the whole design: an inventory you have to open is a menu, and a menu in
|
||||
## a bullet hell is a death. Growing this is a one-line change here -- the wire
|
||||
## format, the HUD and the persistence record all read it.
|
||||
const INVENTORY_SLOTS := 4
|
||||
## How close you have to stand to pick something up. Comfortably larger than
|
||||
## PLAYER_RADIUS so walking "onto" an item is enough; well under the distance
|
||||
## at which you could grab loot you cannot see.
|
||||
const LOOT_PICKUP_RADIUS := 34.0
|
||||
## Radius of the ring player-instanced boss drops are laid out on. Each player
|
||||
## only ever sees their own, so this is purely so a debug view of all of them
|
||||
## is legible rather than one pile.
|
||||
const LOOT_INSTANCED_SPREAD := 34.0
|
||||
## Hard ceiling on ground loot in one instance. Dungeons are short-lived, so in
|
||||
## practice this only ever bites in the hub, where players can drop things and
|
||||
## nothing ever closes to clean up. Oldest goes first.
|
||||
const MAX_LOOT_PER_INSTANCE := 64
|
||||
|
||||
# --- Emergency escape -------------------------------------------------------
|
||||
const ESCAPE_CHANNEL_TICKS := 60 # 1 second
|
||||
## Taking damage does NOT interrupt the channel. It used to, which sounds like
|
||||
|
||||
@@ -23,6 +23,10 @@ var total_xp: int = 0
|
||||
var active: bool = true
|
||||
var created_unix: int = 0
|
||||
var died_unix: int = 0
|
||||
## Carried items, one id per slot, [constant Items.NONE] where empty. Stored on
|
||||
## the character rather than on the session so that swapping in the hub swaps
|
||||
## bags, and so a server restart does not quietly confiscate everyone's potions.
|
||||
var inventory: Array[StringName] = []
|
||||
|
||||
|
||||
## Suggested names, offered when creating a character so the field is never
|
||||
@@ -47,6 +51,11 @@ static func random_name(rng: RandomNumberGenerator) -> String:
|
||||
]
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
inventory.resize(SimConfig.INVENTORY_SLOTS)
|
||||
inventory.fill(Items.NONE)
|
||||
|
||||
|
||||
static func create(character_name: String, rng: RandomNumberGenerator) -> Character:
|
||||
var c := Character.new()
|
||||
# Random-but-readable: full saturation and high value, so two characters are
|
||||
@@ -107,9 +116,20 @@ func to_dict() -> Dictionary:
|
||||
"active": active,
|
||||
"created": created_unix,
|
||||
"died": died_unix,
|
||||
# Written as ids rather than indices: a save file has to survive
|
||||
# Items.ORDER being appended to, and a human editing it should be able
|
||||
# to tell what a character is carrying.
|
||||
"inventory": _inventory_ids(),
|
||||
}
|
||||
|
||||
|
||||
func _inventory_ids() -> Array:
|
||||
var out := []
|
||||
for item in inventory:
|
||||
out.append(String(item))
|
||||
return out
|
||||
|
||||
|
||||
## Tolerant of missing keys so an older save file still loads: a character that
|
||||
## has lost a field is far better than an account that will not open.
|
||||
static func from_dict(d: Dictionary) -> Character:
|
||||
@@ -123,4 +143,18 @@ static func from_dict(d: Dictionary) -> Character:
|
||||
c.active = bool(d.get("active", true))
|
||||
c.created_unix = int(d.get("created", 0))
|
||||
c.died_unix = int(d.get("died", 0))
|
||||
# Unknown ids decay to empty rather than to a wrong item -- a save written
|
||||
# by a build with an item this one has never heard of must still load.
|
||||
var carried: Array[StringName] = []
|
||||
for raw in d.get("inventory", []):
|
||||
var carried_id := StringName(String(raw))
|
||||
carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE)
|
||||
c.set_inventory(carried)
|
||||
return c
|
||||
|
||||
|
||||
## Replace the whole inventory, padded or trimmed to the current slot count.
|
||||
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
|
||||
|
||||
@@ -105,6 +105,17 @@ func retire_character(account_id: int, character_id: String) -> void:
|
||||
save()
|
||||
|
||||
|
||||
## Write a character's carried items back to disk. Separate from grant_xp
|
||||
## rather than folded into a general "save this character", so the one caller
|
||||
## reads as what it is.
|
||||
func set_inventory(account_id: int, character_id: String, items: Array[StringName]) -> void:
|
||||
var c := get_character(account_id, character_id)
|
||||
if c == null:
|
||||
return
|
||||
c.set_inventory(items)
|
||||
save()
|
||||
|
||||
|
||||
func grant_xp(account_id: int, character_id: String, amount: int) -> int:
|
||||
var c := get_character(account_id, character_id)
|
||||
if c == null:
|
||||
|
||||
@@ -15,6 +15,11 @@ signal local_hit(damage: int)
|
||||
signal shot_fired
|
||||
signal enemy_died
|
||||
signal boss_died
|
||||
## Item transactions, straight from server events. Carry the item id so the
|
||||
## view can name what happened without guessing from the inventory diff.
|
||||
signal item_picked_up(item: StringName)
|
||||
signal item_used(item: StringName)
|
||||
signal item_dropped(item: StringName)
|
||||
## The account's character roster changed: created, selected, levelled or died.
|
||||
signal characters_changed
|
||||
signal select_failed(reason: String)
|
||||
@@ -47,6 +52,10 @@ var my_max_hp: int = SimConfig.PLAYER_MAX_HP
|
||||
## Lifetime experience, straight from the snapshot so the bar moves per kill
|
||||
## rather than per roster message.
|
||||
var my_total_xp: int = 0
|
||||
## Carried items as wire indices (0 = empty slot). Replaced wholesale by every
|
||||
## snapshot, so it can never drift from what the server thinks you have --
|
||||
## there is deliberately no local "I picked that up" optimism here.
|
||||
var my_inventory: Array[int] = []
|
||||
var my_alive: bool = true
|
||||
var my_escape: float = 0.0
|
||||
var my_escaping: bool = false
|
||||
@@ -183,7 +192,23 @@ func _sample_input() -> InputFrame:
|
||||
buttons |= InputFrame.BTN_ESCAPE
|
||||
if Input.is_action_pressed("interact"):
|
||||
buttons |= InputFrame.BTN_INTERACT
|
||||
return InputFrame.make(input_tick, move, aim, buttons)
|
||||
# Number keys use a slot; shift-number drops it. The bit is sent for as long
|
||||
# as the key is held and the server takes the leading edge, so a stuck or
|
||||
# repeated packet cannot spend more than one item.
|
||||
var slot := held_slot()
|
||||
if slot >= 0:
|
||||
buttons |= InputFrame.BTN_DROP if Input.is_key_pressed(KEY_SHIFT) \
|
||||
else InputFrame.BTN_USE
|
||||
return InputFrame.make(input_tick, move, aim, buttons, maxi(slot, 0))
|
||||
|
||||
|
||||
## Which inventory slot key is down, or -1. Lowest wins, so pressing 1 while 2
|
||||
## is held reads as "now slot 1" rather than as nothing.
|
||||
func held_slot() -> int:
|
||||
for i in SimConfig.INVENTORY_SLOTS:
|
||||
if Input.is_action_pressed("use_slot_%d" % (i + 1)):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
## Scripted input so `tools/smoke.sh` can play the game with no display: orbit
|
||||
@@ -202,9 +227,20 @@ func _bot_input() -> InputFrame:
|
||||
buttons |= InputFrame.BTN_INTERACT
|
||||
# Walk onto the portal instead of orbiting, or interact never lands.
|
||||
move = (portal_pos - predicted_pos).normalized()
|
||||
var slot := 0
|
||||
if instance_kind == Protocol.InstanceKind.DUNGEON:
|
||||
# Grab at whatever is underfoot and occasionally drink, so the item
|
||||
# messages are exercised over a real socket by the smoke test. Bots are
|
||||
# poor shots and rarely produce loot, so this is coverage of the wire
|
||||
# format rather than of the drop rules -- tools/diag_loot.tscn covers
|
||||
# those.
|
||||
if _bot_tick % 90 < 6:
|
||||
buttons |= InputFrame.BTN_INTERACT
|
||||
if _bot_tick % 150 == 0:
|
||||
buttons |= InputFrame.BTN_USE
|
||||
if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900:
|
||||
buttons |= InputFrame.BTN_ESCAPE
|
||||
return InputFrame.make(input_tick, move, aim, buttons)
|
||||
return InputFrame.make(input_tick, move, aim, buttons, slot)
|
||||
|
||||
|
||||
# --- Server messages --------------------------------------------------------
|
||||
@@ -325,6 +361,7 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
request_escape = false
|
||||
request_respawn = false
|
||||
my_respawn_wait = 0.0
|
||||
my_inventory = []
|
||||
cleared_countdown = Protocol.COUNTDOWN_NONE
|
||||
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
|
||||
instance_changed.emit()
|
||||
@@ -336,6 +373,7 @@ func on_snapshot(data: PackedByteArray) -> void:
|
||||
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
|
||||
return # stale or duplicate; unreliable channel, newest wins
|
||||
cleared_countdown = int(snap["cleared_countdown"])
|
||||
my_inventory = snap["inventory"]
|
||||
snap_prev = snap_curr
|
||||
snap_curr = snap
|
||||
_interp = 0.0
|
||||
@@ -435,6 +473,18 @@ func on_events(data: PackedByteArray) -> void:
|
||||
enemy_died.emit()
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
boss_died.emit()
|
||||
SimEvent.Type.ITEM_PICKED_UP:
|
||||
if int(ev["peer"]) == my_peer:
|
||||
item_picked_up.emit(ev["item"])
|
||||
hud_dirty.emit()
|
||||
SimEvent.Type.ITEM_USED:
|
||||
if int(ev["peer"]) == my_peer:
|
||||
item_used.emit(ev["item"])
|
||||
hud_dirty.emit()
|
||||
SimEvent.Type.ITEM_DROPPED:
|
||||
if int(ev["peer"]) == my_peer:
|
||||
item_dropped.emit(ev["item"])
|
||||
hud_dirty.emit()
|
||||
_:
|
||||
pass
|
||||
|
||||
@@ -451,6 +501,40 @@ func enemies() -> Array[Dictionary]:
|
||||
return _interpolated("enemies", "id", [])
|
||||
|
||||
|
||||
## Ground loot the server has told us about. Not interpolated -- items do not
|
||||
## move -- and never filtered here: what arrives is already exactly what this
|
||||
## player is allowed to see.
|
||||
func ground_loot() -> Array:
|
||||
if snap_curr.is_empty():
|
||||
return []
|
||||
return snap_curr["loot"]
|
||||
|
||||
|
||||
## The item that pressing interact would pick up, or an empty dictionary. Purely
|
||||
## for the prompt: the server does this same search for itself and does not care
|
||||
## what the client concluded.
|
||||
func loot_in_reach() -> Dictionary:
|
||||
var best := {}
|
||||
var best_d := SimConfig.LOOT_PICKUP_RADIUS * SimConfig.LOOT_PICKUP_RADIUS
|
||||
for l: Dictionary in ground_loot():
|
||||
var d: float = predicted_pos.distance_squared_to(l["pos"])
|
||||
if d <= best_d:
|
||||
best_d = d
|
||||
best = l
|
||||
return best
|
||||
|
||||
|
||||
## True when every slot is taken, so the HUD can explain why a pickup did
|
||||
## nothing rather than looking broken.
|
||||
func inventory_full() -> bool:
|
||||
if my_inventory.is_empty():
|
||||
return false
|
||||
for index in my_inventory:
|
||||
if index == 0:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func boss_state() -> Dictionary:
|
||||
if snap_curr.is_empty() or snap_curr.get("boss") == null:
|
||||
return {}
|
||||
|
||||
+61
-2
@@ -103,6 +103,32 @@ static func encode_snapshot(world: SimWorld,
|
||||
b.put_float(world.boss.pos.y)
|
||||
b.put_u32(maxi(world.boss.hp, 0))
|
||||
b.put_u8(clampi(world.boss.phase_index, 0, 255))
|
||||
|
||||
# Only the observer's own bag. Nobody needs to see what a party member is
|
||||
# carrying, and not sending it means there is nothing to leak.
|
||||
for i in SimConfig.INVENTORY_SLOTS:
|
||||
var carried := Items.NONE
|
||||
if observer != null and i < observer.inventory.size():
|
||||
carried = observer.inventory[i]
|
||||
b.put_u8(Items.index_of(carried))
|
||||
|
||||
# Ground loot. Player-instanced items are filtered here rather than hidden
|
||||
# in the client: a peer is never told that another player's copy exists, so
|
||||
# a modified client has nothing to reveal.
|
||||
var visible_loot: Array[SimLoot] = []
|
||||
for l in world.loot.values():
|
||||
if observer != null:
|
||||
if not l.visible_to(for_peer):
|
||||
continue
|
||||
if eye.distance_squared_to(l.pos) > cull_sq:
|
||||
continue
|
||||
visible_loot.append(l)
|
||||
b.put_u16(mini(visible_loot.size(), 65535))
|
||||
for l in visible_loot:
|
||||
b.put_u32(l.id)
|
||||
b.put_float(l.pos.x)
|
||||
b.put_float(l.pos.y)
|
||||
b.put_u8(Items.index_of(l.item))
|
||||
return b.data_array
|
||||
|
||||
|
||||
@@ -114,6 +140,7 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
"tick": b.get_u32(),
|
||||
"cleared_countdown": b.get_u8(),
|
||||
"players": [], "enemies": [], "boss": null,
|
||||
"loot": [], "inventory": [],
|
||||
}
|
||||
|
||||
var pcount := b.get_u8()
|
||||
@@ -149,6 +176,19 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
"hp": b.get_u32(),
|
||||
"phase": b.get_u8(),
|
||||
}
|
||||
|
||||
var inventory: Array[int] = []
|
||||
for _i in SimConfig.INVENTORY_SLOTS:
|
||||
inventory.append(b.get_u8())
|
||||
snap["inventory"] = inventory
|
||||
|
||||
var lcount := b.get_u16()
|
||||
for _i in lcount:
|
||||
snap["loot"].append({
|
||||
"id": b.get_u32(),
|
||||
"pos": Vector2(b.get_float(), b.get_float()),
|
||||
"item": b.get_u8(),
|
||||
})
|
||||
return snap
|
||||
|
||||
|
||||
@@ -201,6 +241,11 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
|
||||
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
|
||||
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
|
||||
body.put_u32(ev["peer"])
|
||||
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
|
||||
SimEvent.Type.ITEM_DROPPED:
|
||||
body.put_u32(ev["peer"])
|
||||
# By index, like everywhere else on the wire. See Items.ORDER.
|
||||
body.put_u8(Items.index_of(ev["item"]))
|
||||
SimEvent.Type.PLAYER_RESPAWNED:
|
||||
body.put_u32(ev["peer"])
|
||||
body.put_float(ev["pos"].x)
|
||||
@@ -252,6 +297,10 @@ static func decode_events(data: PackedByteArray) -> Dictionary:
|
||||
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
|
||||
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
|
||||
ev["peer"] = b.get_u32()
|
||||
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
|
||||
SimEvent.Type.ITEM_DROPPED:
|
||||
ev["peer"] = b.get_u32()
|
||||
ev["item"] = Items.by_index(b.get_u8())
|
||||
SimEvent.Type.PLAYER_RESPAWNED:
|
||||
ev["peer"] = b.get_u32()
|
||||
ev["pos"] = Vector2(b.get_float(), b.get_float())
|
||||
@@ -393,6 +442,8 @@ static func encode_characters(chars: Array[Character], selected: String) -> Pack
|
||||
b.put_u16(clampi(c.max_hp(), 1, 65535))
|
||||
b.put_u8(1 if c.active else 0)
|
||||
b.put_u32(c.colour.to_rgba32())
|
||||
for i in SimConfig.INVENTORY_SLOTS:
|
||||
b.put_u8(Items.index_of(c.inventory[i] if i < c.inventory.size() else Items.NONE))
|
||||
return b.data_array
|
||||
|
||||
|
||||
@@ -427,8 +478,8 @@ static func decode_characters(data: PackedByteArray) -> Dictionary:
|
||||
return {"selected": selected, "characters": out}
|
||||
var count := b.get_u8()
|
||||
# Bytes each entry needs after its two strings: level, xp, progress,
|
||||
# max_hp, active, colour.
|
||||
var fixed := 1 + 4 + 1 + 2 + 1 + 4
|
||||
# max_hp, active, colour, then one byte per inventory slot.
|
||||
var fixed := 1 + 4 + 1 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS
|
||||
for _i in count:
|
||||
var id := _safe_utf8(b)
|
||||
var display := _safe_utf8(b)
|
||||
@@ -443,5 +494,13 @@ static func decode_characters(data: PackedByteArray) -> Dictionary:
|
||||
"max_hp": b.get_u16(),
|
||||
"active": b.get_u8() == 1,
|
||||
"colour": Color.hex(b.get_u32()),
|
||||
"inventory": _read_inventory(b),
|
||||
})
|
||||
return {"selected": selected, "characters": out}
|
||||
|
||||
|
||||
static func _read_inventory(b: StreamPeerBuffer) -> Array[int]:
|
||||
var out: Array[int] = []
|
||||
for _i in SimConfig.INVENTORY_SLOTS:
|
||||
out.append(b.get_u8())
|
||||
return out
|
||||
|
||||
+5
-1
@@ -13,7 +13,11 @@ extends RefCounted
|
||||
## 5: handshake carries an auth ticket instead of a bare name; added character
|
||||
## list/select/create messages, per-player max health and colour in the
|
||||
## snapshot.
|
||||
const VERSION := 5
|
||||
## 6: inventory and loot. The snapshot gained the observer's own inventory and
|
||||
## the ground-loot list, the input frame gained a slot byte, and three item
|
||||
## events were appended. Every one of those changes the byte layout of a
|
||||
## message both ends parse positionally.
|
||||
const VERSION := 6
|
||||
const DEFAULT_PORT := 27015
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
|
||||
@@ -97,6 +97,12 @@ func _dispatch_events(inst: Instance) -> void:
|
||||
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))))
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))))
|
||||
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
|
||||
SimEvent.Type.ITEM_DROPPED:
|
||||
# The simulation moved items between the ground and a bag; the
|
||||
# store is what makes that survive a restart. Same division of
|
||||
# labour as experience: the world decides, this layer banks it.
|
||||
_persist_inventory(inst, int(ev["peer"]))
|
||||
SimEvent.Type.PLAYER_DIED:
|
||||
# Deferred like the transfers below: the payload has not been
|
||||
# sent yet, and a player must still receive news of its own
|
||||
@@ -436,6 +442,23 @@ func _award_kill(inst: Instance, amount: int) -> void:
|
||||
_grant_xp(peer, amount)
|
||||
|
||||
|
||||
## Copy a player's bag back onto the character record it belongs to.
|
||||
##
|
||||
## Called on every item transaction rather than on a timer, because the whole
|
||||
## point of persisting an inventory is that a crash between "picked it up" and
|
||||
## "wrote it down" must not be a way to lose an item -- or, far worse, a way to
|
||||
## duplicate one.
|
||||
func _persist_inventory(inst: Instance, peer_id: int) -> void:
|
||||
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
||||
var character_id: String = peer_characters.get(peer_id, "")
|
||||
if account == AuthProvider.NO_ACCOUNT or character_id.is_empty():
|
||||
return
|
||||
var p: SimPlayer = inst.world.players.get(peer_id)
|
||||
if p == null:
|
||||
return
|
||||
store.set_inventory(account, character_id, p.inventory)
|
||||
|
||||
|
||||
func _grant_xp(peer_id: int, amount: int) -> void:
|
||||
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
||||
var character_id: String = peer_characters.get(peer_id, "")
|
||||
|
||||
+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)})
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ extends CanvasLayer
|
||||
const MARGIN := 24.0
|
||||
const BAR_W := 260.0
|
||||
const BAR_H := 16.0
|
||||
## Inventory slot box, and the gap between boxes.
|
||||
const SLOT := 46.0
|
||||
const SLOT_GAP := 8.0
|
||||
## How far above the bottom of the screen the inventory row sits.
|
||||
const SLOT_BOTTOM := 26.0
|
||||
|
||||
signal respawn_pressed
|
||||
|
||||
@@ -136,6 +141,8 @@ func _draw_hud() -> void:
|
||||
|
||||
_draw_cleared_countdown()
|
||||
_draw_roster()
|
||||
_draw_inventory()
|
||||
_draw_pickup_prompt()
|
||||
|
||||
if not client.my_alive:
|
||||
# Centred on the canvas, which is only correct because _canvas actually
|
||||
@@ -150,6 +157,64 @@ func _draw_hud() -> void:
|
||||
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
|
||||
|
||||
|
||||
## Four slots, always on screen. Deliberately not a panel you open: an
|
||||
## inventory you have to stop and read is a menu, and a menu is a death in a
|
||||
## game where the floor is bullets. Everything drawn here comes from the
|
||||
## snapshot, so it is what the server says you have, never a local guess.
|
||||
func _draw_inventory() -> void:
|
||||
if client.my_inventory.is_empty():
|
||||
return
|
||||
var count := client.my_inventory.size()
|
||||
var total := float(count) * SLOT + float(count - 1) * SLOT_GAP
|
||||
var origin := Vector2((_canvas.size.x - total) * 0.5,
|
||||
_canvas.size.y - SLOT_BOTTOM - SLOT)
|
||||
var held := client.held_slot()
|
||||
for i in count:
|
||||
var at := origin + Vector2(float(i) * (SLOT + SLOT_GAP), 0.0)
|
||||
var item := Items.by_index(int(client.my_inventory[i]))
|
||||
var def := Items.get_def(item)
|
||||
var frame_col := Color(0.55, 0.6, 0.72, 0.75) if i == held \
|
||||
else Color(0.3, 0.33, 0.42, 0.6)
|
||||
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), Color(0.07, 0.08, 0.12, 0.72))
|
||||
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), frame_col, false, 1.5)
|
||||
# The slot number, because the key that uses it is the only thing the
|
||||
# player actually needs to know about a slot.
|
||||
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(4.0, 13.0),
|
||||
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 11,
|
||||
Color(0.5, 0.55, 0.68))
|
||||
if def == null:
|
||||
continue
|
||||
var icon := Art.item_icon(item)
|
||||
var size := icon.size * Art.SCALE
|
||||
_canvas.draw_texture_rect_region(Art.TILESET,
|
||||
Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon)
|
||||
_canvas.draw_string(ThemeDB.fallback_font,
|
||||
Vector2(origin.x, origin.y + SLOT + 15.0),
|
||||
"1-%d use shift+1-%d drop E pick up" % [count, count],
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.5, 0.55, 0.66))
|
||||
|
||||
|
||||
## What pressing interact right now would do. The server runs the same search
|
||||
## for itself and does not care what this concluded -- this is a label, not a
|
||||
## decision.
|
||||
func _draw_pickup_prompt() -> void:
|
||||
var near := client.loot_in_reach()
|
||||
if near.is_empty() or not client.my_alive:
|
||||
return
|
||||
var item := Items.by_index(int(near["item"]))
|
||||
var def := Items.get_def(item)
|
||||
if def == null:
|
||||
return
|
||||
var full := client.inventory_full()
|
||||
var text := "inventory full -- %s stays where it is" % def.display_name \
|
||||
if full else "E take %s" % def.display_name
|
||||
var tint := Color(0.85, 0.5, 0.45) if full else def.tint
|
||||
_canvas.draw_string(ThemeDB.fallback_font,
|
||||
Vector2(_canvas.size.x * 0.5 - 150.0,
|
||||
_canvas.size.y - SLOT_BOTTOM - SLOT - 22.0),
|
||||
text, HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
|
||||
|
||||
|
||||
## A thin bar under health: progress toward the next level, and the level
|
||||
## itself. Drawn from the server's numbers, never recomputed locally.
|
||||
func _draw_xp_bar(at: Vector2) -> void:
|
||||
|
||||
@@ -99,6 +99,24 @@ const ENEMY_IDLE: Array[Rect2] = [
|
||||
]
|
||||
const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon
|
||||
|
||||
## ItemDef.visual -> atlas rect. Order matches Items.ORDER: health potion,
|
||||
## Warden's ration. Both come from the tileset's flask row -- there is no food
|
||||
## sprite in this set, so the ration is a gold flask standing in for one until
|
||||
## the art pass revisits it.
|
||||
const ITEM_ICONS: Array[Rect2] = [
|
||||
Rect2(288, 352, 16, 16), # small red flask -> health potion
|
||||
Rect2(336, 352, 16, 16), # small gold flask -> Warden's ration
|
||||
]
|
||||
|
||||
|
||||
## Icon for an item id, falling back to the first entry so an item added
|
||||
## without art draws as something rather than as nothing.
|
||||
static func item_icon(item: StringName) -> Rect2:
|
||||
var def := Items.get_def(item)
|
||||
if def == null:
|
||||
return ITEM_ICONS[0]
|
||||
return ITEM_ICONS[clampi(def.visual, 0, ITEM_ICONS.size() - 1)]
|
||||
|
||||
# --- Bullets ----------------------------------------------------------------
|
||||
# assets/local/bullets.png is composed by tools/build_local_assets.py: 8 frames
|
||||
# across, one row per SimConfig.KIND_*. The raw pack could not be used directly
|
||||
|
||||
@@ -58,6 +58,13 @@ func _process(_delta: float) -> void:
|
||||
_bound.shot_fired.connect(func() -> void: sfx.play(Art.SFX_SHOOT, -14.0))
|
||||
_bound.enemy_died.connect(func() -> void: sfx.play(Art.SFX_ENEMY_DEATH, -8.0))
|
||||
_bound.boss_died.connect(func() -> void: sfx.play(Art.SFX_BOSS_DEATH, -2.0))
|
||||
# Item feedback rides the same rule as every other sound here: it
|
||||
# plays because a server event arrived, never because the client
|
||||
# guessed a pickup succeeded.
|
||||
_bound.item_picked_up.connect(func(_i: StringName) -> void:
|
||||
sfx.play(Art.SFX_ENEMY_DEATH, -16.0))
|
||||
_bound.item_used.connect(func(_i: StringName) -> void:
|
||||
sfx.play(Art.SFX_SHOOT, -10.0))
|
||||
_bound.characters_changed.connect(_refresh_characters)
|
||||
_bound.select_failed.connect(func(why: String) -> void: characters.set_status(why))
|
||||
_refresh_characters()
|
||||
|
||||
@@ -56,6 +56,9 @@ func _draw() -> void:
|
||||
_draw_terrain()
|
||||
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
||||
_draw_portal()
|
||||
for l in client.ground_loot():
|
||||
if _visible(l["pos"]):
|
||||
_draw_loot(l)
|
||||
for e in client.enemies():
|
||||
if _visible(e["pos"]):
|
||||
_draw_enemy(e)
|
||||
@@ -80,6 +83,9 @@ func _draw_debug() -> void:
|
||||
var b := client.boss_state()
|
||||
if not b.is_empty() and client.boss_def != null:
|
||||
DebugDraw.draw_boss(self, b["pos"], client.boss_def.radius, Rect2())
|
||||
for l in client.ground_loot():
|
||||
draw_arc(l["pos"], SimConfig.LOOT_PICKUP_RADIUS, 0.0, TAU, 24,
|
||||
Color(1.0, 0.9, 0.4, 0.5), 1.0)
|
||||
for p in client.remote_players():
|
||||
DebugDraw.draw_player(self, p["pos"], p["aim"])
|
||||
DebugDraw.draw_player(self, client.predicted_pos, client.aim)
|
||||
@@ -172,6 +178,24 @@ func _draw_portal() -> void:
|
||||
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
|
||||
|
||||
|
||||
## An item on the floor. The server has already decided this player may see it
|
||||
## -- player-instanced loot belonging to someone else never reaches the client
|
||||
## at all -- so there is nothing to filter here beyond the fog.
|
||||
func _draw_loot(l: Dictionary) -> void:
|
||||
var item := Items.by_index(int(l["item"]))
|
||||
var def := Items.get_def(item)
|
||||
if def == null:
|
||||
return
|
||||
var pos: Vector2 = l["pos"]
|
||||
# A slow bob and a glow ring. Loot has to read as "pick me up" from across a
|
||||
# room full of bullets, and a static 16px sprite on a busy floor does not.
|
||||
var t := float(Time.get_ticks_msec()) * 0.004 + float(int(l["id"])) * 0.7
|
||||
var pulse := 0.5 + 0.5 * sin(t)
|
||||
draw_circle(pos, 11.0 + 2.0 * pulse, Color(def.tint, 0.13 + 0.10 * pulse))
|
||||
_draw_sprite(Art.TILESET, Art.item_icon(item),
|
||||
pos + Vector2(0.0, -3.0 * pulse))
|
||||
|
||||
|
||||
func _draw_enemy(e: Dictionary) -> void:
|
||||
var visual := clampi(int(e["visual"]), 0, Art.ENEMY_IDLE.size() - 1)
|
||||
var first: Rect2 = Art.ENEMY_IDLE[visual]
|
||||
|
||||
Reference in New Issue
Block a user