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:
1 parent
a943aa19f6
commit
b351bc2d55
47 files changed
+2438
-92
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user