Stage 4: upgrades, and a quartermaster to spend them at
ci / verify (push) Successful in 48s

Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.

The four ambiguities in the brief were settled with the user first, since
each changes what gets written:

  damage      base x (1 + sum additive) x product multiplicative. The flat
              +5% every upgrade carries, spread's -10%, doubleshot's -50%
              and glass cannon's +100% pool; sniper multiplies on top, so
              two snipers is 4x and not +200%.
  glass       half the LEVELLED maximum, multiplying if taken twice, so the
              price does not fade to a rounding error by level 15.
  poison      independent stacks, not a refresh.
  split       +/-45 degrees from the original heading.

Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.

Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.

Two invariants this collided with, both now pinned:

  - bullet speed gained a ceiling. Wall collision samples once per tick, so
    anything over a tile per tick tunnels; two snipers asked for 2480 u/s
    against a 1920 threshold, and a tunnelling bullet looks like a bullet.
  - BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
    now the longest-travelling bullet in the game. test_interest measured
    the worst case from static content, which upgrades quietly invalidated.

Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.

tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.

Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.

check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 15:34:58 +02:00
parent a943aa19f6
commit b351bc2d55
47 changed files with 2438 additions and 92 deletions
+59
View File
@@ -0,0 +1,59 @@
class_name UpgradeDef
extends Resource
## One upgrade, as data. Every field here is a modifier the simulation already
## knows how to apply, so adding an upgrade is a table entry and never a branch
## in [SimWorld] -- the same rule bosses follow.
##
## Note what is NOT here: the flat +5% damage every upgrade carries. That is a
## property of *taking an upgrade*, not of any particular one, so it lives in
## [PlayerStats] where it is applied once per upgrade held. Baking it into each
## definition would mean seven places to change it and seven places to get it
## wrong.
enum Rarity { COMMON, UNCOMMON, RARE, LEGENDARY }
@export var id: StringName = &"upgrade"
@export var display_name: String = "Upgrade"
## Shown on the choice card. Says what it does, in the player's terms.
@export var description: String = ""
@export var rarity: Rarity = Rarity.COMMON
# --- Damage -----------------------------------------------------------------
## Added to the additive pool: base x (1 + sum of these) x product of the
## multiplicative ones. Negative for the upgrades that trade damage away.
@export var damage_add: float = 0.0
## Multiplied in after the pool. Sniper is the only thing that uses this, and
## the brief calls it out as multiplicative specifically so it stays sharp no
## matter how many additive percentages have piled up.
@export var damage_mult: float = 1.0
# --- Everything else --------------------------------------------------------
## Multiplies the character's levelled maximum health, so the cost of trading
## health away scales instead of fading out by level 15.
@export var max_hp_mult: float = 1.0
## Below 1.0 means slower. Applied to the cooldown as a division, so 0.5 here is
## genuinely half the shots per second.
@export var fire_rate_mult: float = 1.0
@export var bullet_speed_mult: float = 1.0
## Extra projectiles fanned out to the sides of the aim.
@export var side_shots: int = 0
## Extra projectiles parallel to the aim, offset sideways.
@export var parallel_shots: int = 0
## How many times one shot may split on hitting something. The brief's "cannot
## split twice unless the upgrade is taken again" is exactly this being a count
## rather than a flag.
@export var split_charges: int = 0
## Fraction of a hit's damage dealt again over the poison window.
@export var poison_fraction: float = 0.0
## Chance, per tick, that a shot deletes an enemy projectile it is passing
## through.
@export var erase_chance: float = 0.0
static func rarity_name(r: Rarity) -> String:
match r:
Rarity.COMMON: return "common"
Rarity.UNCOMMON: return "uncommon"
Rarity.RARE: return "rare"
Rarity.LEGENDARY: return "legendary"
return "?"
+1
View File
@@ -0,0 +1 @@
uid://d0mhvxc26ff0
+35 -5
View File
@@ -169,13 +169,13 @@ func send_welcome(peer_id: int) -> void:
## hack with no work required; tiles are streamed instead (send_map_chunks).
func send_enter_instance(peer_id: int, id: int, kind: int, server_tick: int,
boss_id: String, spawn: Vector2, map_w: int, map_h: int,
portals: PackedByteArray, dungeon: String) -> void:
portals: PackedByteArray, dungeon: String, npc: Vector2) -> void:
if _is_local(peer_id):
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w,
map_h, portals, dungeon)
map_h, portals, dungeon, npc)
else:
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn,
map_w, map_h, portals, dungeon)
map_w, map_h, portals, dungeon, npc)
func send_map_chunks(peer_id: int, instance_id: int, data: PackedByteArray) -> void:
@@ -213,6 +213,13 @@ func send_select_result(peer_id: int, result: int, reason: String) -> void:
s_select_result.rpc_id(peer_id, result, reason)
func send_upgrades(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_upgrades(data)
else:
s_upgrades.rpc_id(peer_id, data)
func send_roster(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_roster(data)
@@ -234,6 +241,16 @@ func select_character(character_id: String) -> void:
c_select_character.rpc_id(1, character_id)
## Spend a level-up. Intent only: the index names one of the three options the
## SERVER put on the table, and the server checks the player is standing at the
## NPC before it means anything.
func choose_upgrade(index: int) -> void:
if server != null:
server.on_choose_upgrade(LOCAL_PEER, index)
elif state == State.ONLINE:
c_choose_upgrade.rpc_id(1, index)
func create_character(character_name: String) -> void:
if server != null:
server.on_create_character(LOCAL_PEER, character_name)
@@ -271,6 +288,13 @@ func c_create_character(character_name: String) -> void:
server.on_create_character(multiplayer.get_remote_sender_id(), character_name)
@rpc("any_peer", "call_remote", "reliable", 1)
func c_choose_upgrade(index: int) -> void:
if server == null:
return
server.on_choose_upgrade(multiplayer.get_remote_sender_id(), index)
@rpc("any_peer", "call_remote", "unreliable_ordered", 4)
func c_input(data: PackedByteArray) -> void:
if server == null:
@@ -291,11 +315,11 @@ func s_welcome(peer_id: int, _version: int) -> void:
@rpc("authority", "call_remote", "reliable", 1)
func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2, map_w: int, map_h: int, portals: PackedByteArray,
dungeon: String) -> void:
dungeon: String, npc: Vector2) -> void:
if client == null:
return
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w,
map_h, portals, dungeon)
map_h, portals, dungeon, npc)
@rpc("authority", "call_remote", "reliable", 1)
@@ -323,6 +347,12 @@ func s_select_result(result: int, reason: String) -> void:
client.on_select_result(result, reason)
@rpc("authority", "call_remote", "reliable", 1)
func s_upgrades(data: PackedByteArray) -> void:
if client != null:
client.on_upgrades(data)
@rpc("authority", "call_remote", "reliable", 1)
func s_roster(data: PackedByteArray) -> void:
if client != null:
+4 -2
View File
@@ -22,10 +22,11 @@ const LEGEND := {
"P": MapGrid.Kind.FLOOR,
"S": MapGrid.Kind.FLOOR,
"T": MapGrid.Kind.FLOOR,
"U": MapGrid.Kind.FLOOR,
}
## Legend characters that record a position rather than only painting a tile.
const MARKERS := ["B", "D", "P", "S", "T"]
const MARKERS := ["B", "D", "P", "S", "T", "U"]
## Wide, with pillars to break the Warden's rings and pits that shape where you
@@ -99,6 +100,7 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict
## The hub. Hand-authored like the boss arenas.
##
## P a dungeon portal S player spawn T practice target
## U the upgrade NPC
##
## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER. Two
## of them now: the real run and the Proving Grounds, side by side so they can
@@ -116,7 +118,7 @@ static func lobby() -> PackedStringArray:
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.........T.............................#",
"#.........T...................U.........#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
+165
View File
@@ -0,0 +1,165 @@
class_name Upgrades
extends RefCounted
## Every upgrade in the game, in code, like the rest of `src/content/`.
##
## [constant ORDER] is a wire format: an upgrade's index is the byte that rides
## the upgrade-state message and the character save. Append, never reorder.
const SPLIT_SHOT := &"split_shot"
const GLASS_CANNON := &"glass_cannon"
const SPREAD := &"spread"
const SNIPER := &"sniper"
const DOUBLESHOT := &"doubleshot"
const POISON := &"poison"
const ERASER := &"eraser"
const ORDER: Array[StringName] = [
SPLIT_SHOT,
GLASS_CANNON,
SPREAD,
SNIPER,
DOUBLESHOT,
POISON,
ERASER,
]
## Draw weights by rarity. Relative, not percentages -- what matters is that a
## legendary is a story and a common is Tuesday.
const WEIGHTS := {
UpgradeDef.Rarity.COMMON: 100,
UpgradeDef.Rarity.UNCOMMON: 45,
UpgradeDef.Rarity.RARE: 18,
UpgradeDef.Rarity.LEGENDARY: 4,
}
static func get_def(id: StringName) -> UpgradeDef:
match id:
SPLIT_SHOT: return split_shot()
GLASS_CANNON: return glass_cannon()
SPREAD: return spread()
SNIPER: return sniper()
DOUBLESHOT: return doubleshot()
POISON: return poison()
ERASER: return eraser()
return null
## Wire value. 0 is reserved for "no upgrade", so an id's value is its position
## plus one -- same convention as [Items].
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 &""
return ORDER[index - 1]
## Pick [param count] distinct upgrades, weighted by rarity.
##
## Distinct within one offer only -- an upgrade you already hold can and should
## come up again, because taking Split Shot twice is how a shot splits twice.
static func roll_offer(rng: RandomNumberGenerator, count: int) -> Array[StringName]:
var pool := ORDER.duplicate()
var picked: Array[StringName] = []
while picked.size() < count and not pool.is_empty():
var total := 0
for id in pool:
total += _weight_of(id)
var roll := rng.randi_range(1, maxi(total, 1))
for i in pool.size():
roll -= _weight_of(pool[i])
if roll <= 0:
picked.append(pool[i])
pool.remove_at(i)
break
return picked
static func _weight_of(id: StringName) -> int:
var def := get_def(id)
return int(WEIGHTS.get(def.rarity, 1)) if def != null else 1
# --- The upgrades -----------------------------------------------------------
static func split_shot() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = SPLIT_SHOT
u.display_name = "Split Shot"
u.description = "Hitting an enemy spawns two more of the same shot, at 45° to either side, behind it. Take it again to split again."
u.rarity = UpgradeDef.Rarity.COMMON
u.split_charges = 1
return u
static func glass_cannon() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = GLASS_CANNON
u.display_name = "Glass Cannon"
u.description = "Double damage, half health. The health is half of whatever your level gives you, so it stays a real price."
u.rarity = UpgradeDef.Rarity.COMMON
u.damage_add = 1.0
u.max_hp_mult = 0.5
return u
static func spread() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = SPREAD
u.display_name = "Spread"
u.description = "Two more shots in a cone either side of your aim. 10% damage."
u.rarity = UpgradeDef.Rarity.COMMON
u.side_shots = 2
u.damage_add = -0.10
return u
## The only multiplicative damage source, and the only thing that touches bullet
## speed. Both are capped downstream: PlayerStats clamps the speed under the
## tunnelling threshold, because a bullet that moves more than a tile per tick
## walks straight through walls.
static func sniper() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = SNIPER
u.display_name = "Sniper"
u.description = "Double damage, multiplied on top of everything else. Half the fire rate, double the bullet speed."
u.rarity = UpgradeDef.Rarity.COMMON
u.damage_mult = 2.0
u.fire_rate_mult = 0.5
u.bullet_speed_mult = 2.0
return u
static func doubleshot() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = DOUBLESHOT
u.display_name = "Doubleshot"
u.description = "One more shot travelling parallel to the rest. 50% damage."
u.rarity = UpgradeDef.Rarity.UNCOMMON
u.parallel_shots = 1
u.damage_add = -0.50
return u
static func poison() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = POISON
u.display_name = "Poison"
u.description = "Every shot deals another 50% of its damage over the next 10 seconds. Stacks with itself — every hit adds another dose."
u.rarity = UpgradeDef.Rarity.RARE
u.poison_fraction = 0.5
return u
static func eraser() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = ERASER
u.display_name = "Eraser"
u.description = "Your shots have a 1% chance to delete an enemy projectile they pass through."
u.rarity = UpgradeDef.Rarity.LEGENDARY
u.erase_chance = 0.01
return u
+1
View File
@@ -0,0 +1 @@
uid://c4s2g0bi42sko
+31 -1
View File
@@ -117,8 +117,36 @@ const ESCAPE_CHANNEL_TICKS := 60 # 1 second
## connection now runs the same one-second channel (see SimPlayer.linkdead),
## which only works if being shot cannot cancel it.
# --- Upgrades ---------------------------------------------------------------
## Damage every upgrade adds on top of whatever else it does, into the additive
## pool. A property of taking an upgrade rather than of any one upgrade, so it
## lives here and is applied once per upgrade held.
const UPGRADE_DAMAGE_BONUS := 0.05
## Options offered per level gained.
const UPGRADE_CHOICES := 3
## Angle between consecutive shots in a Spread cone.
const SPREAD_STEP_DEG := 13.0
## Sideways gap between Doubleshot's parallel projectiles.
const PARALLEL_OFFSET := 15.0
## How far to either side a Split Shot child leaves the enemy it was born on.
const SPLIT_ANGLE_DEG := 45.0
## How long one dose of Poison takes to deliver its damage.
const POISON_DURATION_TICKS := 600 # 10 seconds
## How close to the hub's upgrade NPC you must stand to spend a choice.
## Enforced on the server, like the portal: standing somewhere is the only
## thing a client cannot lie about.
const UPGRADE_NPC_RADIUS := 70.0
# --- Bullets ----------------------------------------------------------------
const MAX_BULLETS := 4096
## Hard ceiling on any bullet, however many speed multipliers stack up.
##
## Wall collision samples a bullet's position once per tick, so anything faster
## than one tile (MapGrid.TILE = 32px) per tick tunnels straight through
## geometry. At 60Hz that threshold is 1920 u/s; this leaves a margin for the
## sampling to stay honest. Two Snipers would ask for 2480 and get this instead.
## Pinned by test_bullet_pool.gd.
const MAX_BULLET_SPEED := 1500.0
const TEAM_PLAYER := 0
const TEAM_ENEMY := 1
@@ -161,7 +189,9 @@ const ACTOR_INTEREST_RADIUS := 800.0
## test_interest.gd computes that from the real content and asserts this covers
## it, so adding a faster or longer-lived bullet fails a test instead of
## producing bullets that wink into existence.
const BULLET_INTEREST_RADIUS := 2200.0
## Raised for upgrades: a Sniper's shot travels MAX_BULLET_SPEED * lifetime,
## which is further than anything the enemy content fires.
const BULLET_INTEREST_RADIUS := 2900.0
# --- Map streaming ----------------------------------------------------------
## How far around a player the server streams map tiles. Comfortably wider than
+5 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=8 format=3]
[gd_scene load_steps=9 format=3]
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
@@ -7,6 +7,7 @@
[ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"]
[ext_resource type="Script" path="res://src/view/sfx.gd" id="6"]
[ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"]
[ext_resource type="Script" path="res://src/ui/upgrade_screen.gd" id="8"]
[node name="Game" type="Node2D"]
script = ExtResource("1")
@@ -29,3 +30,6 @@ script = ExtResource("6")
[node name="CharacterSelect" type="CanvasLayer" parent="."]
script = ExtResource("7")
[node name="UpgradeScreen" type="CanvasLayer" parent="."]
script = ExtResource("8")
+2
View File
@@ -44,6 +44,8 @@ static func make_lobby(instance_id: int) -> Instance:
var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0)
inst.world.set_map(built["grid"])
inst.world.portals = built["portals"]
inst.world.upgrade_npc = built["npc"]
inst.world.has_upgrade_npc = true
inst.world.spawn_point = built["spawn"]
inst.state = State.ACTIVE
# A single inert practice target so players can feel out the gun before
+46 -1
View File
@@ -28,6 +28,22 @@ var died_unix: int = 0
## bags, and so a server restart does not quietly confiscate everyone's potions.
var inventory: Array[StringName] = []
## Upgrades taken, in the order they were taken. Duplicates are meaningful --
## two Split Shots really is two splits -- so this is a list, not a set.
##
## The derived numbers (damage, fire rate, health multiplier) are NOT stored:
## [PlayerStats] rebuilds them from this list, so a saved stat can never
## disagree with the upgrades that produced it.
var upgrades: Array[StringName] = []
## Level-ups not yet spent at the hub NPC. They queue: reaching two levels in
## one run owes you two choices, because losing one for doing well is a
## punishment nobody would guess at.
var pending_choices: int = 0
## The choices currently on the table, held so that walking away and coming back
## shows the same three. Without this, closing and reopening the screen would be
## a free reroll until a legendary turned up.
var offer: Array[StringName] = []
## Suggested names, offered when creating a character so the field is never
## blank. Deliberately a pair of short word lists rather than a big table: the
@@ -80,8 +96,12 @@ static func sanitize_name(raw: String) -> String:
return out if not out.is_empty() else "adventurer"
## What this character actually walks around with: the level's health, scaled by
## whatever the upgrades do to it. Shown on the roster screen, so it has to be
## the real number and not the pre-upgrade one.
func max_hp() -> int:
return Progression.max_hp_for_level(level)
return maxi(1, roundi(float(Progression.max_hp_for_level(level))
* PlayerStats.build(upgrades).max_hp_mult))
func xp_progress() -> float:
@@ -120,9 +140,19 @@ func to_dict() -> Dictionary:
# Items.ORDER being appended to, and a human editing it should be able
# to tell what a character is carrying.
"inventory": _inventory_ids(),
"upgrades": _name_list(upgrades),
"pending_choices": pending_choices,
"offer": _name_list(offer),
}
static func _name_list(ids: Array[StringName]) -> Array:
var out := []
for id in ids:
out.append(String(id))
return out
func _inventory_ids() -> Array:
var out := []
for item in inventory:
@@ -150,9 +180,24 @@ static func from_dict(d: Dictionary) -> Character:
var carried_id := StringName(String(raw))
carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE)
c.set_inventory(carried)
# Unknown upgrade ids are dropped rather than kept as dead entries, so a
# save from a build with an upgrade this one lacks still produces coherent
# stats instead of a phantom that counts toward the +5% and does nothing.
c.upgrades = _known_upgrades(d.get("upgrades", []))
c.pending_choices = maxi(int(d.get("pending_choices", 0)), 0)
c.offer = _known_upgrades(d.get("offer", []))
return c
static func _known_upgrades(raw: Array) -> Array[StringName]:
var out: Array[StringName] = []
for entry in raw:
var id := StringName(String(entry))
if Upgrades.get_def(id) != null:
out.append(id)
return out
## Replace the whole inventory, padded or trimmed to the current slot count.
func set_inventory(items: Array[StringName]) -> void:
inventory.resize(SimConfig.INVENTORY_SLOTS)
+41
View File
@@ -116,6 +116,47 @@ func set_inventory(account_id: int, character_id: String, items: Array[StringNam
save()
## Award level-up choices and, if nothing is on the table yet, roll one.
##
## The offer is rolled HERE and stored, not generated on demand when the screen
## opens: an offer that regenerated per request would be a free reroll, and a
## player would simply close and reopen until a legendary appeared.
func grant_choices(account_id: int, character_id: String, count: int) -> void:
var c := get_character(account_id, character_id)
if c == null or count <= 0:
return
c.pending_choices += count
_refresh_offer(c)
save()
## Spend one choice on [param index] of the character's current offer. Returns
## the upgrade taken, or an empty id if the choice was not available -- the
## caller is the server, and "not available" is a refusal, not an error.
func take_upgrade(account_id: int, character_id: String, index: int) -> StringName:
var c := get_character(account_id, character_id)
if c == null or not c.active or c.pending_choices <= 0:
return &""
if index < 0 or index >= c.offer.size():
return &""
var chosen := c.offer[index]
c.upgrades.append(chosen)
c.pending_choices -= 1
# Cleared before re-rolling, so the next choice is a fresh three rather than
# the two that were passed over.
c.offer.clear()
_refresh_offer(c)
save()
return chosen
func _refresh_offer(c: Character) -> void:
if c.pending_choices > 0 and c.offer.is_empty():
c.offer = Upgrades.roll_offer(_rng, SimConfig.UPGRADE_CHOICES)
elif c.pending_choices <= 0:
c.offer.clear()
func grant_xp(account_id: int, character_id: String, amount: int) -> int:
var c := get_character(account_id, character_id)
if c == null:
+38 -1
View File
@@ -23,6 +23,9 @@ signal item_dropped(item: StringName)
## The account's character roster changed: created, selected, levelled or died.
signal characters_changed
signal select_failed(reason: String)
## The played character's upgrade state changed: a level banked a choice, or one
## was spent.
signal upgrades_changed
var my_peer: int = 0
var instance_id: int = 0
@@ -95,6 +98,15 @@ var portals: Array[Dictionary] = []
## and the boss's health ceiling -- an easier dungeon's boss has less of it, and
## a bar computed from the unscaled definition would sit near empty all fight.
var dungeon_id: StringName = &""
## Where the hub's upgrade NPC stands. Only meaningful in the hub.
var upgrade_npc := Vector2.ZERO
## Upgrade state for the played character, server-pushed. The client never
## invents an entry and never rolls an offer -- the three on the table were
## chosen by the server and held there, so closing the screen is not a reroll.
var upgrades_pending: int = 0
var upgrade_offer: Array[StringName] = []
var upgrades_taken: Array[StringName] = []
## Offset the view applies when drawing the world: screen = world + this.
## Published by the game scene every frame, rather than assumed, so aiming
@@ -339,6 +351,30 @@ func needs_character() -> bool:
return characters_known and selected_character.is_empty()
func on_upgrades(data: PackedByteArray) -> void:
var decoded := NetCodec.decode_upgrade_state(data)
upgrades_pending = int(decoded["pending"])
upgrade_offer = decoded["offer"]
upgrades_taken = decoded["taken"]
upgrades_changed.emit()
hud_dirty.emit()
## True when the player is close enough for the server to accept a choice. The
## same radius the server checks, so the screen is never open on a choice that
## would be refused.
func at_upgrade_npc() -> bool:
return instance_kind == Protocol.InstanceKind.LOBBY \
and predicted_pos.distance_to(upgrade_npc) <= SimConfig.UPGRADE_NPC_RADIUS
## The player's live combat numbers, rebuilt from the upgrades the server says
## they hold. Display only -- the server computes its own copy and that is the
## one that decides anything.
func stats() -> PlayerStats:
return PlayerStats.build(upgrades_taken)
func on_roster(data: PackedByteArray) -> void:
roster = NetCodec.decode_roster(data)
hud_dirty.emit()
@@ -346,10 +382,11 @@ func on_roster(data: PackedByteArray) -> void:
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2, map_w: int, map_h: int, portals_data: PackedByteArray,
dungeon: String) -> void:
dungeon: String, npc: Vector2) -> void:
instance_id = id
instance_kind = kind as Protocol.InstanceKind
portals = NetCodec.decode_portals(portals_data)
upgrade_npc = npc
dungeon_id = StringName(dungeon)
# Scaled the same way the server scaled it, so the boss bar reads as a
# fraction of the health this particular run's boss actually has.
+49
View File
@@ -462,6 +462,55 @@ static func decode_portals(data: PackedByteArray) -> Array[Dictionary]:
return out
# --- Upgrade state ----------------------------------------------------------
# Sent to one peer when its played character's upgrades change: a level gained,
# a choice spent, a character swapped in. Cold, and about the played character
# only -- nobody needs to know what anyone else has taken.
static func encode_upgrade_state(pending: int, offer: Array[StringName],
taken: Array[StringName]) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(clampi(pending, 0, 255))
b.put_u8(mini(offer.size(), 255))
for id in offer:
b.put_u8(Upgrades.index_of(id))
b.put_u16(mini(taken.size(), 65535))
for id in taken:
b.put_u8(Upgrades.index_of(id))
return b.data_array
## Returns { "pending": int, "offer": Array[StringName], "taken": Array[StringName] }.
static func decode_upgrade_state(data: PackedByteArray) -> Dictionary:
var offer: Array[StringName] = []
var taken: Array[StringName] = []
var out := {"pending": 0, "offer": offer, "taken": taken}
if data.size() < 2:
return out
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
out["pending"] = b.get_u8()
var offer_count := b.get_u8()
for _i in offer_count:
if b.get_available_bytes() < 1:
return out
var id := Upgrades.by_index(b.get_u8())
if not id.is_empty():
offer.append(id)
if b.get_available_bytes() < 2:
return out
var taken_count := b.get_u16()
for _i in taken_count:
if b.get_available_bytes() < 1:
return out
var id := Upgrades.by_index(b.get_u8())
if not id.is_empty():
taken.append(id)
return out
# --- Character roster -------------------------------------------------------
# Sent once at login and after any change. Low frequency and carries strings,
# like the online roster, so it is the same fixed-header-then-utf8 shape.
+8 -1
View File
@@ -19,7 +19,9 @@ extends RefCounted
## message both ends parse positionally.
## 7: more than one dungeon. enter_instance carries a portal LIST and the id of
## the dungeon you are standing in, replacing the single portal position.
const VERSION := 7
## 8: upgrades. A new server -> client upgrade-state message, a new
## client -> server choice message, and two more SelectResult values.
const VERSION := 8
const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32
@@ -46,6 +48,11 @@ enum SelectResult {
## uninterruptible exit from danger -- strictly better than the one-second
## escape channel, and it would make that channel pointless.
NOT_IN_HUB,
## Upgrades are spent standing at the hub's NPC. Same rule as the portal:
## where you are is the one thing a modified client cannot fake.
NOT_AT_THE_NPC,
## No unspent level-up, or an option index that was not on the table.
NOTHING_TO_CHOOSE,
}
## Player flags packed into the snapshot's per-player byte.
+63 -2
View File
@@ -307,9 +307,63 @@ func _enter_world_as(peer_id: int, c: Character) -> void:
p.adopt(c)
GameLog.info("server", "peer %d playing '%s' (level %d)"
% [peer_id, c.display_name, c.level])
_send_upgrades(peer_id)
_broadcast_roster()
## Spend one level-up on one of the three options the server put on the table.
##
## Everything about this is checked here rather than trusted: that there is a
## choice owing, that the index names an option the server itself offered, and
## that the player is standing at the NPC. The index is the only thing the
## client contributes, and it selects from a list the client did not write.
func on_choose_upgrade(peer_id: int, index: 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():
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED,
"not signed in")
return
var here := instance_of(peer_id)
if here == null or here.kind != Protocol.InstanceKind.LOBBY:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB,
"upgrades are chosen in the hub")
return
var p: SimPlayer = here.world.players.get(peer_id)
if p == null or not here.world.at_upgrade_npc(p.pos):
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AT_THE_NPC,
"stand at the quartermaster to spend a level")
return
var taken := store.take_upgrade(account, character_id, index)
if taken.is_empty():
Net.send_select_result(peer_id, Protocol.SelectResult.NOTHING_TO_CHOOSE,
"nothing to choose")
_send_upgrades(peer_id)
return
# Rebuild the player's numbers from the new list. adopt() does exactly this
# and nothing else that matters here, so it stays the single place where a
# character's record becomes a player's stats.
var c := store.get_character(account, character_id)
if c != null:
p.adopt(c)
GameLog.info("server", "peer %d took upgrade '%s'" % [peer_id, taken])
Net.send_select_result(peer_id, Protocol.SelectResult.OK, "")
_send_upgrades(peer_id)
_send_characters(peer_id)
func _send_upgrades(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 c := store.get_character(account, character_id)
if c == null:
return
Net.send_upgrades(peer_id, NetCodec.encode_upgrade_state(
c.pending_choices, c.offer, c.upgrades))
func on_input(peer_id: int, data: PackedByteArray) -> void:
var inst := instance_of(peer_id)
if inst == null:
@@ -332,7 +386,8 @@ func _place(peer_id: int, inst: Instance) -> void:
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
String(inst.boss_id), inst.world.spawn_point,
inst.world.map.width, inst.world.map.height,
NetCodec.encode_portals(inst.world.portals), String(inst.dungeon_id))
NetCodec.encode_portals(inst.world.portals), String(inst.dungeon_id),
inst.world.upgrade_npc)
# Seed the area around the spawn before anything else, so the player is not
# briefly standing in an unrendered void on arrival.
_stream_map(peer_id, inst)
@@ -489,6 +544,10 @@ func _grant_xp(peer_id: int, amount: int) -> void:
mine.total_xp = earned.total_xp
if levels <= 0:
return
# One choice per level, banked rather than offered immediately: the NPC is
# in the hub and the level was earned in a dungeon, so they have to queue or
# they would be lost.
store.grant_choices(account, character_id, levels)
# A level raises max health immediately, and heals by the amount gained --
# a level-up mid-fight should feel like relief, not like a bar that grew
# further away from full.
@@ -501,8 +560,10 @@ func _grant_xp(peer_id: int, amount: int) -> void:
p.level = c.level
p.max_hp = c.max_hp()
p.hp = mini(p.hp + (p.max_hp - before), p.max_hp)
GameLog.info("server", "peer %d reached level %d" % [peer_id, c.level])
GameLog.info("server", "peer %d reached level %d (%d choice(s) pending)"
% [peer_id, c.level, c.pending_choices])
_send_characters(peer_id)
_send_upgrades(peer_id)
## Death is permanent. The character is retired -- kept for archival, never
+32
View File
@@ -20,6 +20,19 @@ var team := PackedByteArray()
var kind := PackedByteArray()
var alive := PackedByteArray()
# --- Player upgrade riders, server-only -------------------------------------
# Carried on the bullet rather than looked up from the shooter, because a shot
# already in flight keeps the properties it was fired with -- levelling up
# mid-flight must not retroactively poison a bullet that left before you took
# Poison. All three stay 0 on a client replica, which never resolves a hit and
# so has no use for them.
## Times this shot may still split on hitting something.
var split := PackedInt32Array()
## Fraction of this shot's damage to apply again as poison.
var poison := PackedFloat32Array()
## Chance per tick that this shot deletes an enemy projectile it overlaps.
var erase := PackedFloat32Array()
## The geometry bullets die against. Set by the owning SimWorld; identical on
## server and client, which is what keeps the replica in step.
var map: MapGrid = null
@@ -58,6 +71,9 @@ func _init() -> void:
team.resize(n)
kind.resize(n)
alive.resize(n)
split.resize(n)
poison.resize(n)
erase.resize(n)
func clear() -> void:
@@ -97,6 +113,11 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int,
team[slot] = bullet_team
kind[slot] = bullet_kind
alive[slot] = 1
# Cleared on every spawn, so a reused slot never inherits the last
# occupant's upgrades.
split[slot] = 0
poison[slot] = 0.0
erase[slot] = 0.0
if forced_uid != 0:
uid[slot] = forced_uid
else:
@@ -107,6 +128,17 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int,
return slot
## Attach a shooter's upgrade riders. Called immediately after spawn() by the
## server; never by the replica.
func set_mods(slot: int, split_charges: int, poison_fraction: float,
erase_chance: float) -> void:
if slot < 0:
return
split[slot] = split_charges
poison[slot] = poison_fraction
erase[slot] = erase_chance
func clear_spawn_log() -> void:
spawn_log.clear()
wall_kill_log.clear()
+6
View File
@@ -52,12 +52,17 @@ static func _build_lobby() -> Dictionary:
if not markers["T"].is_empty():
var m: Vector2i = markers["T"][0]
target = grid.tile_centre(m.x, m.y)
var npc := grid.tile_centre(size.x * 3 / 4, size.y / 2)
if not markers["U"].is_empty():
var m: Vector2i = markers["U"][0]
npc = grid.tile_centre(m.x, m.y)
return {
"grid": grid,
"rooms": [] as Array[Rect2i],
"spawn": spawn,
"portals": portals,
"dummy": target,
"npc": npc,
"boss_pos": Vector2.ZERO,
"boss_room": Rect2i(),
}
@@ -148,6 +153,7 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
"spawn": spawn,
"portals": [] as Array[SimPortal],
"dummy": Vector2.ZERO,
"npc": Vector2.ZERO,
"boss_pos": boss_pos,
"boss_room": boss_room,
}
+78
View File
@@ -0,0 +1,78 @@
class_name PlayerStats
extends RefCounted
## A player's combat numbers, computed from the upgrades they hold.
##
## Derived, never stored: the character record keeps the list of upgrade ids and
## this is rebuilt from it. One source of truth means a saved stat can never
## disagree with the upgrades that produced it -- the same reasoning that makes
## [Progression] derive level from experience rather than storing both.
## The damage formula, settled with the user:
##
## base x (1 + sum of additive) x product of multiplicative
##
## The +5% every upgrade carries, Spread's 10%, Doubleshot's 50% and Glass
## Cannon's +100% all pool into the additive term. Sniper's 2x multiplies the
## result, which is why it stays worth taking however many percentages have
## already piled up -- and why two Snipers is 4x rather than +200%.
var damage: int = SimConfig.PLAYER_BULLET_DAMAGE
var fire_cooldown: int = SimConfig.PLAYER_FIRE_COOLDOWN
var bullet_speed: float = SimConfig.PLAYER_BULLET_SPEED
## Multiplies the level's maximum health. See SimPlayer.recompute_max_hp().
var max_hp_mult: float = 1.0
var side_shots: int = 0
var parallel_shots: int = 0
var split_charges: int = 0
var poison_fraction: float = 0.0
var erase_chance: float = 0.0
## How many upgrades produced these numbers, duplicates included. Only used for
## display, but it is the number a player counts.
var upgrade_count: int = 0
## True when the bullet-speed ceiling actually bit. Surfaced so the choice
## screen can say a second Sniper buys no more speed instead of silently
## selling one.
var speed_capped: bool = false
static func build(ids: Array[StringName]) -> PlayerStats:
var s := PlayerStats.new()
var additive := 0.0
var multiplicative := 1.0
var fire_rate := 1.0
var speed := 1.0
var hp := 1.0
for id in ids:
var def := Upgrades.get_def(id)
if def == null:
continue # an upgrade this build no longer has: ignored, not fatal
s.upgrade_count += 1
# Every upgrade carries this, whatever else it does.
additive += SimConfig.UPGRADE_DAMAGE_BONUS
additive += def.damage_add
multiplicative *= def.damage_mult
fire_rate *= def.fire_rate_mult
speed *= def.bullet_speed_mult
hp *= def.max_hp_mult
s.side_shots += def.side_shots
s.parallel_shots += def.parallel_shots
s.split_charges += def.split_charges
s.poison_fraction += def.poison_fraction
s.erase_chance += def.erase_chance
# Floored at 1 rather than allowed to reach zero. Stacking Doubleshot and
# Spread can in principle drive the additive pool below 100%, and a shot
# that deals nothing is indistinguishable from a bug.
s.damage = maxi(1, roundi(float(SimConfig.PLAYER_BULLET_DAMAGE)
* (1.0 + additive) * multiplicative))
# A division, so "half the fire rate" is half the shots per second rather
# than half the cooldown.
s.fire_cooldown = maxi(1, roundi(float(SimConfig.PLAYER_FIRE_COOLDOWN)
/ maxf(fire_rate, 0.01)))
var wanted := SimConfig.PLAYER_BULLET_SPEED * speed
s.bullet_speed = minf(wanted, SimConfig.MAX_BULLET_SPEED)
s.speed_capped = wanted > SimConfig.MAX_BULLET_SPEED
s.max_hp_mult = maxf(hp, 0.01)
s.erase_chance = clampf(s.erase_chance, 0.0, 1.0)
return s
+1
View File
@@ -0,0 +1 @@
uid://57ld2euau2ua
+70
View File
@@ -0,0 +1,70 @@
class_name PoisonTrack
extends RefCounted
## Damage over time on one actor, from any number of independent doses.
##
## The user chose independent stacks: every poisoned hit starts its own full
## window rather than refreshing a shared one, so a fast weapon can have dozens
## running at once. That could have meant walking dozens of timers per enemy per
## tick, and it does not, because every dose lasts exactly the same number of
## ticks:
##
## - the total damage per tick is one running float, [member rate];
## - doses therefore expire in the order they were added, so the pending
## expiries are a plain FIFO and only the front of it is ever examined.
##
## The result is O(1) per tick per actor no matter how many doses are live,
## which is what makes "43 concurrent stacks" a non-event rather than a budget.
## Damage per tick, summed over every live dose.
var rate: float = 0.0
## Fractional damage carried between ticks. A single dose is far under one hit
## point per tick, so without this it would round to nothing forever.
var _carry: float = 0.0
var _expiry := PackedInt32Array()
var _rate := PackedFloat32Array()
var _head: int = 0
## Add a dose worth [param total_damage] spread over the poison window.
func add(total_damage: float, at_tick: int) -> void:
if total_damage <= 0.0:
return
var per_tick := total_damage / float(SimConfig.POISON_DURATION_TICKS)
_expiry.append(at_tick + SimConfig.POISON_DURATION_TICKS)
_rate.append(per_tick)
rate += per_tick
## Retire finished doses and return whole hit points to apply this tick.
func step(at_tick: int) -> int:
while _head < _expiry.size() and _expiry[_head] <= at_tick:
rate -= _rate[_head]
_head += 1
if _head >= _expiry.size():
# Everything expired. Reset rather than subtract to zero: the running
# sum accumulates float error, and "nothing is poisoned" has to mean
# exactly nothing.
_expiry.clear()
_rate.clear()
_head = 0
rate = 0.0
_carry = 0.0
return 0
if rate <= 0.0:
return 0
_carry += rate
if _carry < 1.0:
return 0
var whole := int(_carry)
_carry -= float(whole)
return whole
func active() -> bool:
return rate > 0.0
## Live doses, for tests and the debug overlay.
func dose_count() -> int:
return _expiry.size() - _head
+1
View File
@@ -0,0 +1 @@
uid://vuiinq8qng3i
+8
View File
@@ -15,6 +15,14 @@ var phase_tick: int = 0
## player can always disengage by walking out -- which is the trade for the
## boss room having no door that locks.
var room := Rect2()
## Poison doses ticking on the boss. Lazy for the same reason enemies' are.
var poison: PoisonTrack = null
func poison_track() -> PoisonTrack:
if poison == null:
poison = PoisonTrack.new()
return poison
func hp_fraction() -> float:
+9
View File
@@ -19,6 +19,15 @@ var target_dir := Vector2.ZERO
## sight. Refreshed by the world every tick; null means idle, which is the
## normal state for most of a dungeon.
var target: SimPlayer = null
## Poison doses ticking on this enemy. Built lazily: most enemies in a dungeon
## are never poisoned, and most runs never see the upgrade at all.
var poison: PoisonTrack = null
func poison_track() -> PoisonTrack:
if poison == null:
poison = PoisonTrack.new()
return poison
func hp_fraction() -> float:
+18 -2
View File
@@ -42,6 +42,10 @@ var inventory: Array[StringName] = []
var prev_buttons: int = 0
var prev_slot: int = -1
## Combat numbers derived from the character's upgrades. Rebuilt whenever the
## upgrade list changes, never stored on disk -- see [PlayerStats].
var stats := PlayerStats.new()
## 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
@@ -86,7 +90,7 @@ func can_fire() -> bool:
## SimConfig.SPAWN_GRACE_TICKS for a dungeon.
func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
pos = spawn
max_hp = Progression.max_hp_for_level(level)
recompute_max_hp()
hp = max_hp
alive = true
spawn_grace = grace
@@ -120,11 +124,23 @@ func adopt(c: Character) -> void:
level = c.level
total_xp = c.total_xp
colour = c.colour
max_hp = c.max_hp()
stats = PlayerStats.build(c.upgrades)
recompute_max_hp()
hp = mini(hp, max_hp)
set_inventory(c.inventory)
## Maximum health is the level's value scaled by whatever the upgrades do to it
## -- Glass Cannon halves it. Multiplying the LEVELLED value rather than the
## base is what keeps that trade a real price at level 15 instead of a rounding
## error. Floored at 1: an upgrade must never be able to make you unkillable by
## making you already dead.
func recompute_max_hp() -> void:
max_hp = maxi(1, roundi(float(Progression.max_hp_for_level(level))
* stats.max_hp_mult))
hp = mini(hp, max_hp)
## 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.
+141 -12
View File
@@ -37,6 +37,11 @@ var map: MapGrid = null
## A list rather than a single position because which dungeon an entrance opens
## is the whole reason there is more than one.
var portals: Array[SimPortal] = []
## Where the hub's upgrade NPC stands. Only meaningful when
## [member has_upgrade_npc] is true -- the map is centred on the origin, so
## Vector2.ZERO is a real position and cannot double as "there isn't one".
var upgrade_npc := Vector2.ZERO
var has_upgrade_npc: bool = false
var spawn_point := Vector2(0.0, 240.0)
## Arrival protection granted to players entering this world. 0 in the hub,
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
@@ -131,6 +136,14 @@ func portal_at(at: Vector2) -> SimPortal:
return best
## Whether [param at] is close enough to spend a level-up. Checked on the
## server for the same reason the portal is: where a player is standing is the
## one thing a modified client cannot fake.
func at_upgrade_npc(at: Vector2) -> bool:
return has_upgrade_npc \
and at.distance_to(upgrade_npc) <= SimConfig.UPGRADE_NPC_RADIUS
func alive_player_count() -> int:
var n := 0
for p in players.values():
@@ -186,6 +199,7 @@ func step() -> void:
_step_boss()
pool.step()
_resolve_bullet_hits()
_step_poison()
_emit_wall_kill_events()
_emit_spawn_events()
else:
@@ -276,18 +290,44 @@ func _button_edge(p: SimPlayer, frame: InputFrame) -> int:
return edge
## One trigger pull. How many bullets that is, and what they carry, comes
## entirely from the shooter's [PlayerStats] -- there is no per-upgrade branch
## here, which is what lets a new upgrade be a table entry in [Upgrades].
func _fire_player_shot(p: SimPlayer) -> void:
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
p.fire_cooldown = p.stats.fire_cooldown
events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id})
var dir := Vector2.RIGHT.rotated(p.aim)
pool.spawn(
p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET,
dir * SimConfig.PLAYER_BULLET_SPEED,
var forward := Vector2.RIGHT.rotated(p.aim)
var muzzle := p.pos + forward * SimConfig.PLAYER_MUZZLE_OFFSET
# The aimed shot plus Spread's cone, alternating sides so an odd count still
# comes out symmetric about the aim.
_spawn_player_bullet(p, muzzle, forward)
for i in p.stats.side_shots:
var step := float(i / 2 + 1) * SimConfig.SPREAD_STEP_DEG
var side_sign := 1.0 if i % 2 == 0 else -1.0
_spawn_player_bullet(p, muzzle,
forward.rotated(deg_to_rad(step * side_sign)))
# Doubleshot's extras travel parallel rather than fanned, so they are an
# offset at the muzzle and not an angle.
for i in p.stats.parallel_shots:
var gap := float(i / 2 + 1) * SimConfig.PARALLEL_OFFSET
var lateral_sign := 1.0 if i % 2 == 0 else -1.0
_spawn_player_bullet(p, muzzle + forward.orthogonal() * gap * lateral_sign,
forward)
func _spawn_player_bullet(p: SimPlayer, at: Vector2, dir: Vector2) -> void:
var slot := pool.spawn(
at,
dir * p.stats.bullet_speed,
SimConfig.PLAYER_BULLET_RADIUS,
SimConfig.PLAYER_BULLET_LIFETIME,
SimConfig.PLAYER_BULLET_DAMAGE,
p.stats.damage,
SimConfig.TEAM_PLAYER,
SimConfig.KIND_PLAYER_SHOT)
pool.set_mods(slot, p.stats.split_charges, p.stats.poison_fraction,
p.stats.erase_chance)
# --- Items and loot ---------------------------------------------------------
@@ -592,7 +632,12 @@ func _resolve_bullet_hits() -> void:
var consumed := false
if boss != null and boss.alive \
and Movement.circles_overlap(bp, br, boss.pos, boss.def.radius):
_damage_boss(pool.damage[i])
# Poison is taken from the damage the boss ACTUALLY took, so a
# phase that takes 30% extra is poisoned 30% harder too.
var applied := _damage_boss(pool.damage[i])
if pool.poison[i] > 0.0:
boss.poison_track().add(float(applied) * pool.poison[i], tick)
_split_shot(i, boss.pos, boss.def.radius)
consumed = true
if not consumed:
for e in enemies.values():
@@ -600,10 +645,82 @@ func _resolve_bullet_hits() -> void:
continue
if Movement.circles_overlap(bp, br, e.pos, e.def.radius):
_damage_enemy(e, pool.damage[i])
if pool.poison[i] > 0.0:
e.poison_track().add(
float(pool.damage[i]) * pool.poison[i], tick)
_split_shot(i, e.pos, e.def.radius)
consumed = true
break
if consumed:
_kill_bullet(i)
elif pool.erase[i] > 0.0:
_try_erase(i)
## Split Shot: two children of the shot that just landed, leaving the target at
## 45 degrees to either side.
##
## They are born just PAST the target rather than on it. A child spawned inside
## the thing that was just hit would be resolved against it again on the same
## tick -- a free second hit, and with several charges a free chain of them.
##
## Children inherit the remaining lifetime rather than a fresh one, so splitting
## cannot extend a shot's reach indefinitely; a split at the end of a shot's
## life produces two short-lived children, which is the conservative reading of
## "two of the same bullet".
func _split_shot(slot: int, from: Vector2, target_radius: float) -> void:
if pool.split[slot] <= 0:
return
var v: Vector2 = pool.vel[slot]
var speed := v.length()
if speed < 0.001:
return
var charges := pool.split[slot] - 1
var clearance := target_radius + pool.radius[slot] + 2.0
for side_sign in [1.0, -1.0]:
var dir := (v / speed).rotated(deg_to_rad(SimConfig.SPLIT_ANGLE_DEG) * side_sign)
var child := pool.spawn(from + dir * clearance, dir * speed,
pool.radius[slot], pool.life[slot], pool.damage[slot],
pool.team[slot], pool.kind[slot])
pool.set_mods(child, charges, pool.poison[slot], pool.erase[slot])
## Eraser: at most ONE roll per shot per tick, against the first enemy
## projectile it is overlapping.
##
## Rolling once per overlapping pair would multiply the stated 1% by however
## many bullets happened to occupy the same place, which in a boss ring is a
## lot. One opportunity per tick, taken or not, keeps the number the player was
## promised close to the number they get.
##
## The scan is O(bullets) per erasing shot, and runs only for shots that carry
## the upgrade -- which is why it is worth nothing until a legendary is drawn.
func _try_erase(slot: int) -> void:
var p: Vector2 = pool.pos[slot]
var r: float = pool.radius[slot]
for j in pool.high_water:
if pool.alive[j] == 0 or pool.team[j] != SimConfig.TEAM_ENEMY:
continue
if not Movement.circles_overlap(p, r, pool.pos[j], pool.radius[j]):
continue
if rng.randf() < pool.erase[slot]:
_kill_bullet(j)
return
## One tick of every live poison dose. Cheap regardless of how many are running
## -- see [PoisonTrack] for why.
func _step_poison() -> void:
for e in enemies.values():
if not e.alive or e.poison == null:
continue
var dealt: int = e.poison.step(tick)
if dealt > 0:
_damage_enemy(e, dealt, true)
if boss != null and boss.alive and boss.poison != null:
var on_boss: int = boss.poison.step(tick)
if on_boss > 0:
_damage_boss(on_boss, true, false)
## Bullets removed early must be announced -- clients cannot derive a hit.
@@ -624,9 +741,14 @@ func _damage_player(p: SimPlayer, amount: int) -> void:
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
func _damage_enemy(e: SimEnemy, amount: int) -> void:
## [param silent] suppresses the ENEMY_HIT event. Poison ticks use it: they
## land many times a second on the reliable channel, and the client learns hp
## from the snapshot anyway. Death is still announced either way, because that
## is what the experience award is keyed on.
func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
e.hp = maxi(e.hp - amount, 0)
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
if not silent:
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)
@@ -635,16 +757,23 @@ func _damage_enemy(e: SimEnemy, amount: int) -> void:
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)})
func _damage_boss(amount: int) -> void:
## Returns the damage actually applied, after the phase's armour multiplier, so
## the caller can derive poison from the same number.
##
## [param scale] is false for poison ticks, whose damage was already scaled when
## the dose was applied -- scaling again would compound the multiplier.
func _damage_boss(amount: int, silent: bool = false, scale: bool = true) -> int:
var phase := boss.current_phase()
var mult := 1.0 if phase == null else phase.damage_taken_mult
var mult := 1.0 if phase == null or not scale else phase.damage_taken_mult
var applied := maxi(1, roundi(float(amount) * mult))
boss.hp = maxi(boss.hp - applied, 0)
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
if not silent:
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)})
return applied
## Bullets that died against geometry. A client is only streamed the map near
+18
View File
@@ -147,6 +147,7 @@ func _draw_hud() -> void:
_draw_roster()
_draw_inventory()
_draw_pickup_prompt()
_draw_level_up_notice()
if not client.my_alive:
# Centred on the canvas, which is only correct because _canvas actually
@@ -161,6 +162,23 @@ func _draw_hud() -> void:
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
## Unspent level-ups. Shown wherever you are, because the choice is banked in a
## dungeon and spent in the hub -- if it only appeared next to the NPC, a player
## would have to already know to go and look.
func _draw_level_up_notice() -> void:
if client.upgrades_pending <= 0:
return
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004)
var where := " — see the quartermaster in the hub" \
if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
_canvas.draw_string(ThemeDB.fallback_font,
Vector2(MARGIN, MARGIN + 74.0),
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
"" if client.upgrades_pending == 1 else "S", where],
HORIZONTAL_ALIGNMENT_LEFT, -1, 14,
Color(1.0, 0.85, 0.4, 0.6 + 0.4 * pulse))
## 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
+206
View File
@@ -0,0 +1,206 @@
extends CanvasLayer
## The quartermaster: spend a level-up, and review what you have taken.
##
## The three options are the SERVER's, held on the character until one is
## spent -- closing and reopening this screen shows the same three, because
## otherwise it would be a reroll button and everyone would press it until a
## legendary appeared.
##
## The brief asked for a separate screen listing upgrades already taken. It is
## the lower half of this one instead: the two are read together (what do I
## have, what should I add) and splitting them would mean closing one panel to
## answer a question raised by the other.
signal choose_requested(index: int)
signal closed
const RARITY_COLOURS := {
UpgradeDef.Rarity.COMMON: Color(0.72, 0.76, 0.84),
UpgradeDef.Rarity.UNCOMMON: Color(0.55, 0.85, 0.6),
UpgradeDef.Rarity.RARE: Color(0.55, 0.7, 1.0),
UpgradeDef.Rarity.LEGENDARY: Color(1.0, 0.72, 0.3),
}
var _title: Label
var _cards: HBoxContainer
var _taken: Label
var _summary: Label
var _status: Label
func _ready() -> void:
layer = 28
visible = false
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root)
var scrim := ColorRect.new()
scrim.color = Color(0.03, 0.03, 0.06, 0.9)
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(scrim)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre)
var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(760.0, 0.0)
panel.add_theme_constant_override("separation", 10)
centre.add_child(panel)
_title = Label.new()
_title.add_theme_font_size_override("font_size", 26)
panel.add_child(_title)
_cards = HBoxContainer.new()
_cards.add_theme_constant_override("separation", 10)
panel.add_child(_cards)
_status = Label.new()
_status.add_theme_color_override("font_color", Color(1.0, 0.6, 0.5))
panel.add_child(_status)
panel.add_child(_rule())
var taken_title := Label.new()
taken_title.text = "ALREADY TAKEN"
taken_title.add_theme_font_size_override("font_size", 15)
taken_title.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78))
panel.add_child(taken_title)
_taken = Label.new()
_taken.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_taken.custom_minimum_size = Vector2(740.0, 0.0)
panel.add_child(_taken)
_summary = Label.new()
_summary.add_theme_color_override("font_color", Color(0.65, 0.72, 0.85))
_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_summary.custom_minimum_size = Vector2(740.0, 0.0)
panel.add_child(_summary)
var close := Button.new()
close.text = "Close (E)"
close.custom_minimum_size = Vector2(160.0, 34.0)
close.pressed.connect(func() -> void: closed.emit())
panel.add_child(close)
func _rule() -> Control:
var line := ColorRect.new()
line.color = Color(0.2, 0.22, 0.3)
line.custom_minimum_size = Vector2(0.0, 1.0)
return line
func set_status(text: String) -> void:
_status.text = text
## Rebuild from the server's numbers. Called on every upgrade change, so the
## screen can never show an offer that has already been spent.
func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -> void:
_status.text = ""
if pending > 0:
_title.text = "CHOOSE AN UPGRADE (%d waiting)" % pending
else:
_title.text = "QUARTERMASTER — nothing to spend"
for child in _cards.get_children():
child.queue_free()
if pending > 0:
for i in offer.size():
_cards.add_child(_make_card(i, offer[i]))
_taken.text = _taken_text(taken)
_summary.text = _summary_text(taken)
func _make_card(index: int, id: StringName) -> Control:
var def := Upgrades.get_def(id)
var card := VBoxContainer.new()
card.custom_minimum_size = Vector2(240.0, 0.0)
card.add_theme_constant_override("separation", 6)
if def == null:
return card
var tint: Color = RARITY_COLOURS.get(def.rarity, Color.WHITE)
var name_label := Label.new()
name_label.text = def.display_name
name_label.add_theme_font_size_override("font_size", 18)
name_label.add_theme_color_override("font_color", tint)
card.add_child(name_label)
var rarity := Label.new()
rarity.text = UpgradeDef.rarity_name(def.rarity).to_upper()
rarity.add_theme_font_size_override("font_size", 11)
rarity.add_theme_color_override("font_color", Color(tint, 0.7))
card.add_child(rarity)
var body := Label.new()
body.text = def.description
body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
body.custom_minimum_size = Vector2(230.0, 96.0)
body.add_theme_font_size_override("font_size", 13)
card.add_child(body)
# Shown on every card, because it applies to every card. The brief asks for
# the choice screen to show this buff as well as the upgrade's own effects,
# and a player comparing three cards should not have to remember it.
var bonus := Label.new()
bonus.text = "+%d%% damage (every upgrade)" % roundi(
SimConfig.UPGRADE_DAMAGE_BONUS * 100.0)
bonus.add_theme_font_size_override("font_size", 12)
bonus.add_theme_color_override("font_color", Color(0.55, 0.8, 0.6))
card.add_child(bonus)
var take := Button.new()
take.text = "Take"
take.custom_minimum_size = Vector2(0.0, 34.0)
take.pressed.connect(func() -> void: choose_requested.emit(index))
card.add_child(take)
return card
## Counted rather than listed one per line: "Split Shot x3" is the number that
## matters, and a level 15 character has fourteen of these.
func _taken_text(taken: Array[StringName]) -> String:
if taken.is_empty():
return "nothing yet"
var counts := {}
var order: Array[StringName] = []
for id in taken:
if not counts.has(id):
counts[id] = 0
order.append(id)
counts[id] += 1
var parts: Array[String] = []
for id in order:
var def := Upgrades.get_def(id)
var name_text := def.display_name if def != null else String(id)
parts.append("%s x%d" % [name_text, int(counts[id])] if int(counts[id]) > 1
else name_text)
return " ".join(parts)
## What the upgrades actually add up to. Built through PlayerStats, the same
## class the server fires with, so this cannot drift from the real numbers.
func _summary_text(taken: Array[StringName]) -> String:
var s := PlayerStats.build(taken)
var shots := 1 + s.side_shots + s.parallel_shots
var per_second := float(SimConfig.TICK_RATE) / float(s.fire_cooldown)
var parts: Array[String] = [
"%d damage per shot" % s.damage,
"%.1f shots/sec" % per_second,
"%d projectile%s per shot" % [shots, "" if shots == 1 else "s"],
"%d bullet speed%s" % [roundi(s.bullet_speed),
" (capped)" if s.speed_capped else ""],
]
if s.max_hp_mult != 1.0:
parts.append("%d%% max health" % roundi(s.max_hp_mult * 100.0))
if s.split_charges > 0:
parts.append("splits %dx" % s.split_charges)
if s.poison_fraction > 0.0:
parts.append("+%d%% as poison" % roundi(s.poison_fraction * 100.0))
if s.erase_chance > 0.0:
parts.append("%.1f%% erase" % (s.erase_chance * 100.0))
return " · ".join(parts)
+1
View File
@@ -0,0 +1 @@
uid://cy32nmdfkt57i
+3
View File
@@ -98,6 +98,9 @@ const ENEMY_IDLE: Array[Rect2] = [
Rect2(288, 336, 16, 16), # red flask -> practice target
]
const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon
## The hub's quartermaster. A different character strip from the knight the
## player wears, so an NPC never reads as another player standing still.
const NPC_IDLE := Rect2(128, 36, 16, 28)
## 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
+35 -1
View File
@@ -8,11 +8,17 @@ extends Node2D
@onready var menu: CanvasLayer = $GameMenu
@onready var sfx: Node = $Sfx
@onready var characters: CanvasLayer = $CharacterSelect
@onready var upgrades: CanvasLayer = $UpgradeScreen
var _bound: ClientRuntime = null
## Opened deliberately from the menu, as opposed to forced open by having no
## character to play.
var _roster_open: bool = false
## The quartermaster panel. Opened by walking to the NPC and pressing interact,
## and closed the moment you walk away -- the server refuses a choice made from
## anywhere else, so leaving the panel open at a distance would only offer a
## button that gets rejected.
var _upgrades_open: bool = false
func _ready() -> void:
@@ -27,6 +33,8 @@ func _ready() -> void:
Net.select_character(id))
characters.create_requested.connect(func(n: String) -> void: Net.create_character(n))
characters.closed.connect(func() -> void: _roster_open = false)
upgrades.closed.connect(func() -> void: _upgrades_open = false)
upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i))
var _screen_centre := Vector2.ZERO
@@ -66,7 +74,11 @@ func _process(_delta: float) -> void:
_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))
_bound.upgrades_changed.connect(_refresh_upgrades)
_refresh_upgrades()
_bound.select_failed.connect(func(why: String) -> void:
characters.set_status(why)
upgrades.set_status(why))
_refresh_characters()
# The roster screen is shown exactly when there is nothing to play: first
# login, or after the last living character died.
@@ -79,11 +91,33 @@ func _process(_delta: float) -> void:
_roster_open = false
characters.visible = forced or _roster_open
characters.set_dismissible(not forced)
if _bound != null and _upgrades_open and not _bound.at_upgrade_npc():
_upgrades_open = false
upgrades.visible = _upgrades_open
_follow_camera()
menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
func _unhandled_input(event: InputEvent) -> void:
if not event.is_action_pressed("interact"):
return
if _bound == null or not _bound.at_upgrade_npc():
return
# Interact is also pick-up. Loot wins, exactly as it does on the server, so
# the key never does one thing here and another there.
if not _bound.loot_in_reach().is_empty():
return
_upgrades_open = not _upgrades_open
_refresh_upgrades()
func _refresh_upgrades() -> void:
if _bound != null:
upgrades.refresh(_bound.upgrades_pending, _bound.upgrade_offer,
_bound.upgrades_taken)
## Routed through the same held-escape channel the F key uses, rather than a
## direct "teleport me" message -- the server has no such message, and adding
## one would hand clients an instant, uninterruptible exit.
+30
View File
@@ -55,6 +55,7 @@ func _draw() -> void:
return
_draw_terrain()
_draw_portals()
_draw_upgrade_npc()
for l in client.ground_loot():
if _visible(l["pos"]):
_draw_loot(l)
@@ -185,6 +186,35 @@ func _draw_portals() -> void:
_draw_portal_label(at, def)
## The quartermaster. Drawn with a prompt rather than left to be discovered:
## the whole upgrade system is behind one unmarked figure in a large room.
func _draw_upgrade_npc() -> void:
if client.instance_kind != Protocol.InstanceKind.LOBBY:
return
var at := client.upgrade_npc
if not _visible(at):
return
var src := Art.frame(Art.NPC_IDLE, Art.anim_frame(_anim_time, 3))
_draw_sprite(Art.TILESET, src, at, Color.WHITE, false, Art.PLAYER_ANCHOR)
var waiting := client.upgrades_pending
var tint := Color(1.0, 0.85, 0.4) if waiting > 0 else Color(0.65, 0.7, 0.82)
var label := "QUARTERMASTER"
if waiting > 0:
label = "QUARTERMASTER — %d level-up%s to spend" % [
waiting, "" if waiting == 1 else "s"]
draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 34.0), label,
HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
if client.at_upgrade_npc():
draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 18.0),
"E talk", HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13,
Color(0.8, 0.85, 0.95))
# A pending choice pulses, so it is visible from across the hub.
if waiting > 0:
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.005)
draw_arc(at, 26.0 + 4.0 * pulse, 0.0, TAU, 32,
Color(1.0, 0.85, 0.4, 0.25 + 0.35 * pulse), 2.0)
func _draw_portal_label(at: Vector2, def: DungeonDef) -> void:
var font := ThemeDB.fallback_font
var top := at - Vector2(0.0, SimConfig.PORTAL_RADIUS + 26.0)