Health survives a run now, so a character sitting on the select screen can be nearly dead -- and the screen was still printing only the ceiling. "level 11, 2000 hp" for a character on 160 is a lie told at the exact moment it matters most, because the next thing that happens is the player walks it into a dungeon under permadeath. The roster message carries current hit points alongside the maximum, and the screen shows a fraction only when it means something: a healthy character still reads "1300 hp" rather than "1300/1300". The field is inserted mid-record, which shifts every field after it, so the protocol version goes to 10 -- a mismatched client would read the active flag out of the middle of a health value. tools/screenshot.gd had no capture for this screen, which is how a UI change to it could have shipped unlooked-at. It now shows a made-up roster of three characters -- whole, badly hurt, nearly dead -- for the same reason the upgrade screen is fed a made-up offer: the point is the layout, not a dungeon run. Getting there needs the player walked off the quartermaster first, or the game scene re-opens the upgrade screen over the top of it every frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4aeb6d229d
commit
a6084c1a8d
5 files changed
+110
-7
No files matched your search
+10
-3
@@ -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),
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
+57
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user