diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index 91d3509..4c2b80f 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -618,7 +618,13 @@ static func encode_characters(chars: Array[Character], selected: String) -> Pack b.put_u8(clampi(c.level, 1, 255)) b.put_u32(maxi(c.total_xp, 0)) b.put_u8(clampi(roundi(c.xp_progress() * 255.0), 0, 255)) - b.put_u16(clampi(c.max_hp(), 1, 65535)) + var ceiling := c.max_hp() + b.put_u16(clampi(ceiling, 1, 65535)) + # Current health as well as the ceiling. Health survives a run now, so a + # character on the roster can be badly hurt, and a screen that showed + # only the maximum would advertise a wounded character as whole -- which + # under permadeath is the worst possible thing to be wrong about. + b.put_u16(clampi(ceiling if c.hp == Character.HP_FULL else c.hp, 1, 65535)) b.put_u8(1 if c.active else 0) b.put_u32(c.colour.to_rgba32()) for i in SimConfig.INVENTORY_SLOTS: @@ -657,8 +663,8 @@ static func decode_characters(data: PackedByteArray) -> Dictionary: return {"selected": selected, "characters": out} var count := b.get_u8() # Bytes each entry needs after its two strings: level, xp, progress, - # max_hp, active, colour, then one byte per inventory slot. - var fixed := 1 + 4 + 1 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS + # max_hp, hp, active, colour, then one byte per inventory slot. + var fixed := 1 + 4 + 1 + 2 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS for _i in count: var id := _safe_utf8(b) var display := _safe_utf8(b) @@ -671,6 +677,7 @@ static func decode_characters(data: PackedByteArray) -> Dictionary: "xp": b.get_u32(), "progress": float(b.get_u8()) / 255.0, "max_hp": b.get_u16(), + "hp": b.get_u16(), "active": b.get_u8() == 1, "colour": Color.hex(b.get_u32()), "inventory": _read_inventory(b), diff --git a/src/net/protocol.gd b/src/net/protocol.gd index 92d2465..20b85ff 100644 --- a/src/net/protocol.gd +++ b/src/net/protocol.gd @@ -22,7 +22,11 @@ extends RefCounted ## 8: upgrades. A new server -> client upgrade-state message, a new ## client -> server choice message, and two more SelectResult values. ## 9: telegraphed boss attacks. A new TELEGRAPH event, appended to the enum. -const VERSION := 9 +## 10: health survives a run, so the character roster carries current hit +## points as well as the ceiling. Inserted mid-record, which shifts every +## field after it -- a mismatched client would read the active flag out of +## the middle of a health value. +const VERSION := 10 const DEFAULT_PORT := 27015 const MAX_CLIENTS := 32 diff --git a/src/ui/character_select.gd b/src/ui/character_select.gd index 9e87f35..6649474 100644 --- a/src/ui/character_select.gd +++ b/src/ui/character_select.gd @@ -140,8 +140,17 @@ func _row_for(c: Dictionary) -> Control: 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"]), + # Current health is shown only when it is not full. Health carries between + # runs now, so "1300 hp" on a character sitting on 160 would be a lie told + # at the exact moment it matters most -- but printing "1300/1300" on every + # healthy character is noise, so the fraction appears only when it means + # something. + var ceiling := int(c["max_hp"]) + var current := int(c.get("hp", ceiling)) + var health := "%d hp" % ceiling if current >= ceiling \ + else "%d/%d hp" % [current, ceiling] + label.text = " %s level %d %s%s" % [ + c["name"], int(c["level"]), health, " <- 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 diff --git a/tests/unit/test_net_codec.gd b/tests/unit/test_net_codec.gd index e7c68db..d25b2cf 100644 --- a/tests/unit/test_net_codec.gd +++ b/tests/unit/test_net_codec.gd @@ -651,3 +651,30 @@ func test_an_unknown_event_type_stops_the_stream_rather_than_desyncing_it() -> v assert_eq(got.size(), 1, "an event whose length is unknown unaligns everything after it, " + "so the only safe answer is to stop") + + +func test_the_roster_carries_current_health_as_well_as_the_ceiling() -> void: + # Health survives a run now, so a character on the select screen can be + # badly hurt. Sending only the maximum would advertise it as whole, which + # under permadeath is the worst thing on that screen to be wrong about. + var rng := RandomNumberGenerator.new() + var whole := Character.create("Whole", rng) + var hurt := Character.create("Hurt", rng) + hurt.hp = 42 + var got: Array = NetCodec.decode_characters( + NetCodec.encode_characters([whole, hurt] as Array[Character], ""))["characters"] + assert_eq(got.size(), 2) + assert_eq(int(got[0]["hp"]), int(got[0]["max_hp"]), + "a character that has never been hurt reads as full") + assert_eq(int(got[1]["hp"]), 42) + assert_gt(int(got[1]["max_hp"]), 42, "and the ceiling is still its own number") + + +func test_roster_health_is_never_sent_as_zero() -> void: + # A zero on this screen reads as a dead character the player is being + # offered, which cannot happen: the roster is living characters only. + var c := Character.create("Edge", RandomNumberGenerator.new()) + c.hp = 0 + var got: Array = NetCodec.decode_characters( + NetCodec.encode_characters([c] as Array[Character], ""))["characters"] + assert_gte(int(got[0]["hp"]), 1) diff --git a/tools/screenshot.gd b/tools/screenshot.gd index fbb3a40..c7e16a8 100644 --- a/tools/screenshot.gd +++ b/tools/screenshot.gd @@ -27,7 +27,9 @@ const OPEN_CREDITS := 290 const AT_CREDITS := 320 const OPEN_UPGRADES := 330 const AT_UPGRADES := 360 -const DONE := 370 +const OPEN_ROSTER := 370 +const AT_ROSTER := 400 +const DONE := 410 var _frame: int = 0 var _game: Node = null @@ -67,6 +69,10 @@ func _process(_delta: float) -> void: # Held until the shot is taken; one frame of it is not enough. if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES: _stand_at_the_npc() + # Same reason: the game scene owns this screen's visibility too and decides + # it every frame, so one assignment lasts exactly one frame. + if _frame > OPEN_ROSTER and _frame <= AT_ROSTER: + _show_the_roster() match _frame: AT_MENU: _grab("menu") @@ -108,11 +114,61 @@ func _process(_delta: float) -> void: _game.get_node("UpgradeScreen").refresh(2, offer, taken) AT_UPGRADES: _grab("upgrades") + OPEN_ROSTER: + _show_the_roster() + AT_ROSTER: + _grab("roster") DONE: Net.shutdown() get_tree().quit(0) +## Put the roster on screen with a made-up set of characters. +## +## Made up for the same reason the upgrade offer is: the point is the layout, +## and earning three characters at three different health levels first would +## make the shot depend on a dungeon run. One whole, one badly hurt, one barely +## alive -- health survives a run now, so the wounded rows are the reason this +## screen is worth a picture at all. +## +## Walking off the quartermaster first is not optional: the game scene re-opens +## the upgrade screen every frame the player is standing on the NPC, and it +## draws over this one. +func _show_the_roster() -> void: + var away: Vector2 = Net.client.upgrade_npc + Vector2(600.0, 0.0) + Net.client.predicted_pos = away + var inst := Net.server.instance_of(Net.LOCAL_PEER) + if inst != null: + var p: SimPlayer = inst.world.players.get(Net.LOCAL_PEER) + if p != null: + p.pos = away + _game.set("_upgrades_open", false) + _game.set("_roster_open", true) + var roster: Array[Dictionary] = [ + _roster_entry("Whole", 4, 1300, 1300, Color(0.5, 0.9, 1.0)), + _roster_entry("Bruised", 7, 1600, 940, Color(1.0, 0.8, 0.4)), + _roster_entry("Nearly", 11, 2000, 160, Color(1.0, 0.5, 0.5)), + ] + _game.get_node("CharacterSelect").refresh(roster, "c4") + + +## One roster row in the shape decode_characters produces. +func _roster_entry(who: String, level: int, ceiling: int, current: int, + tint: Color) -> Dictionary: + return { + "id": "c%d" % level, + "name": who, + "level": level, + "xp": 0, + "progress": 0.4, + "max_hp": ceiling, + "hp": current, + "active": true, + "colour": tint, + "inventory": [0, 0, 0, 0] as Array[int], + } + + func _grab(shot_name: String) -> void: var path := "%s/shot_%s.png" % [_dir, shot_name] var err := get_viewport().get_texture().get_image().save_png(path)