d8197885ca
ci / verify (push) Successful in 46s
The roster sent to clients now contains living characters only. Retirement stays server-side bookkeeping for archival; from the player's side a dead character is simply gone, and listing it offers a choice that cannot be taken. The XP bar only moved on a level-up or a character swap because it read the character roster, which is re-sent only when the SET of characters changes. Experience now rides the snapshot -- four bytes on a message already going out at 20Hz -- and the server mirrors each grant into the world immediately rather than only when a level is crossed. The create field starts with a suggested name instead of blank, and offers another after each creation. Two bugs found while testing, both mine: The first was a bad patch of my own: a change meant for the snapshot decoder also matched inside decode_characters, which then read a four-byte field its encoder never wrote and ran off the end of every packet. This is precisely the "encodes but decodes wrong" failure the codec tests exist to catch, and it was caught within a minute of the test being written. Chasing that exposed a real robustness gap: StreamPeerBuffer.get_utf8_string() pushes an engine error and returns garbage when the buffer is short, so a truncated or hostile character/roster packet produced error spam instead of degrading. Both decoders now bounds-check every field, with tests that slice each packet at many lengths and assert it degrades rather than inventing entries -- the same guarantee the input decoder already had. 206 tests. check.sh, test.sh, smoke.sh and both diagnostics pass.
158 lines
4.9 KiB
GDScript
158 lines
4.9 KiB
GDScript
extends CanvasLayer
|
|
## Character roster: pick one, or make one.
|
|
##
|
|
## Shown when the player has no character in the world -- at first login, and
|
|
## again when their last one dies. Permadeath makes this a screen players will
|
|
## see repeatedly, so dead characters stay listed rather than vanishing: seeing
|
|
## the run you lost is the point of keeping the record.
|
|
|
|
signal select_requested(character_id: String)
|
|
signal create_requested(character_name: String)
|
|
signal closed
|
|
|
|
var _list: VBoxContainer
|
|
var _name_field: LineEdit
|
|
var _create_button: Button
|
|
var _status: Label
|
|
var _title: Label
|
|
var _close_button: Button
|
|
var _known: Array[Dictionary] = []
|
|
var _rng := RandomNumberGenerator.new()
|
|
var _selected: String = ""
|
|
|
|
|
|
func _ready() -> void:
|
|
layer = 30
|
|
_rng.randomize()
|
|
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.92)
|
|
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(460.0, 0.0)
|
|
panel.add_theme_constant_override("separation", 8)
|
|
centre.add_child(panel)
|
|
|
|
_title = Label.new()
|
|
_title.text = "CHARACTERS"
|
|
_title.add_theme_font_size_override("font_size", 26)
|
|
panel.add_child(_title)
|
|
|
|
_list = VBoxContainer.new()
|
|
_list.add_theme_constant_override("separation", 4)
|
|
panel.add_child(_list)
|
|
|
|
var spacer := Control.new()
|
|
spacer.custom_minimum_size = Vector2(0.0, 10.0)
|
|
panel.add_child(spacer)
|
|
|
|
var row := HBoxContainer.new()
|
|
panel.add_child(row)
|
|
_name_field = LineEdit.new()
|
|
_name_field.placeholder_text = "new character name"
|
|
_name_field.text = Character.random_name(_rng)
|
|
_name_field.max_length = Character.MAX_NAME
|
|
_name_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
row.add_child(_name_field)
|
|
_create_button = Button.new()
|
|
_create_button.text = "Create"
|
|
_create_button.pressed.connect(_on_create)
|
|
row.add_child(_create_button)
|
|
|
|
_close_button = Button.new()
|
|
_close_button.text = "Back to the hub"
|
|
_close_button.pressed.connect(func() -> void: closed.emit())
|
|
panel.add_child(_close_button)
|
|
|
|
_status = Label.new()
|
|
_status.add_theme_font_size_override("font_size", 12)
|
|
_status.add_theme_color_override("font_color", Color(1.0, 0.6, 0.5))
|
|
panel.add_child(_status)
|
|
|
|
visible = false
|
|
|
|
|
|
func _on_create() -> void:
|
|
var wanted := _name_field.text.strip_edges()
|
|
if wanted.is_empty():
|
|
set_status("give the character a name")
|
|
return
|
|
create_requested.emit(wanted)
|
|
# Offer the next suggestion straight away rather than clearing to blank.
|
|
_name_field.text = Character.random_name(_rng)
|
|
|
|
|
|
func set_status(text: String) -> void:
|
|
_status.text = text
|
|
|
|
|
|
## Rebuild from the server's roster. Called on every change rather than diffed:
|
|
## the list is at most a handful of rows and correctness matters more than
|
|
## avoiding a few Control allocations.
|
|
func refresh(characters: Array[Dictionary], selected: String) -> void:
|
|
_known = characters
|
|
_selected = selected
|
|
for child in _list.get_children():
|
|
child.queue_free()
|
|
|
|
# Everything the server sends is alive: a dead character is gone as far as
|
|
# the player is concerned, and retirement is the server's own bookkeeping.
|
|
var living := characters.size()
|
|
for c in characters:
|
|
_list.add_child(_row_for(c))
|
|
|
|
if characters.is_empty():
|
|
var empty := Label.new()
|
|
empty.text = "No characters yet. Name one below to begin."
|
|
empty.add_theme_color_override("font_color", Color(0.7, 0.75, 0.85))
|
|
_list.add_child(empty)
|
|
|
|
var room := living < CharacterStore.MAX_ACTIVE
|
|
_create_button.disabled = not room
|
|
_name_field.editable = room
|
|
_title.text = "CHARACTERS (%d / %d living)" % [living, CharacterStore.MAX_ACTIVE]
|
|
if not room:
|
|
set_status("%d living characters is the limit -- one must fall first"
|
|
% CharacterStore.MAX_ACTIVE)
|
|
|
|
|
|
func _row_for(c: Dictionary) -> Control:
|
|
var row := HBoxContainer.new()
|
|
|
|
# The character's colour, which is its only identity until cosmetics exist.
|
|
var swatch := ColorRect.new()
|
|
swatch.color = c["colour"]
|
|
swatch.custom_minimum_size = Vector2(14.0, 14.0)
|
|
row.add_child(swatch)
|
|
|
|
var playing := String(c["id"]) == _selected
|
|
var label := Label.new()
|
|
label.text = " %s level %d %d hp%s" % [
|
|
c["name"], int(c["level"]), int(c["max_hp"]),
|
|
" <- playing" if playing else ""]
|
|
label.add_theme_color_override("font_color", Color(0.85, 0.9, 1.0))
|
|
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
row.add_child(label)
|
|
|
|
var play := Button.new()
|
|
play.text = "Play"
|
|
play.disabled = playing
|
|
play.pressed.connect(func() -> void: select_requested.emit(String(c["id"])))
|
|
row.add_child(play)
|
|
return row
|
|
|
|
|
|
## Whether the player may dismiss this screen. False when they have nothing to
|
|
## play, in which case there is nowhere to dismiss it to.
|
|
func set_dismissible(can_close: bool) -> void:
|
|
_close_button.visible = can_close
|