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:
@@ -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)
|
||||
Reference in New Issue
Block a user