42568a40ad
ci / verify (push) Successful in 48s
SimWorld knows nothing about characters, so Instance.add_peer builds a fresh SimPlayer -- level 1, base stats, empty inventory. Something has to give that player back its character, and only character SELECT ever did. Every portal into a dungeon and every escape back to the hub therefore reset the player's level, experience, upgrades and bag. The record on disk stayed correct throughout, which is what made it read as a display glitch: the level shown was 1 because the level being played really was 1, and the first kill's experience grant partially repaired it, so the numbers appeared to come and go. _place now adopts, through a single _adopt_character that every transfer runs. That let _enter_world_as drop its adopt/reset/adopt dance -- three lines that existed only because reset_for_instance clobbered the health adopt had just computed -- and let the level-up path derive maximum health through recompute_max_hp instead of keeping a second copy of the formula. diag_upgrades now walks hub -> dungeon -> hub after taking an upgrade and asserts level, experience, upgrades, damage, maximum health and inventory all survive each leg. With the fix reverted it reports exactly what was described: level 1, no upgrades, base damage, empty bag. One of the new checks compared health against a formula fed the player's own level, which agrees with itself even when the level is wrong; it compares against the character record instead. Also drops "(every upgrade)" from the choice cards. That a flat bonus rides along with all of them is a design principle, not something a player needs told -- the number is enough. check.sh clean, 409 tests, SMOKE PASS, all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
6.8 KiB
GDScript
206 lines
6.8 KiB
GDScript
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. Deliberately NOT
|
|
# labelled "every upgrade": that a flat bonus rides along with all of them
|
|
# is a design principle, and the player only needs the number.
|
|
var bonus := Label.new()
|
|
bonus.text = "+%d%% damage" % 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)
|