Files
transcience/tools/screenshot.gd
T
claudeandClaude Opus 5.5 19d2295bec
ci / verify (push) Successful in 53s
A UI pass: prompts that tell the truth, and a HUD that does not collide
Prompts named the default keys whatever was bound. Every action is
rebindable, but the HUD said "WASD move", "LMB fire", "hold F" and "1-4
use", the quartermaster said "E talk" and the upgrade screen "Close (E)".
All of them now go through Settings.short_label(), and a test rebinds
fire, escape, interact and a slot key and checks each prompt follows.

The HUD's top-left was a developer's status line -- name, instance id,
health as text, frame rate -- with the boss bar drawn over its right end
and the level-up notice sharing a row with the escape bar. It is now
name and level, where you are, health with its numbers, experience,
escape and level-ups, stacked by a running cursor so nothing has a fixed
row to collide on. Instance and fps moved behind F1, which is who they
were for. Arrival protection is a centred banner rather than a note
beside the health bar, because it is the one moment the rules change.

A hit used to tint the whole screen red, which in a bullet hell dims the
bullets at exactly the moment you are among them. It is an edge now, and
the same edge pulses below a third of your health. New: a message feed
above the inventory for what the server just confirmed -- items taken,
used and dropped, levels gained -- and party health top-right in a
dungeon, where the hub shows who is online. The pickup prompt now also
names the dungeon under your feet when you stand on its ring, in the
same order the server resolves that key: loot first, then the entrance.

The boss bar used to announce the boss to anyone within the actor
interest radius -- which passes through walls -- so a player in the
entrance room was told the Warden was near. It waits until the fog has
actually lit the boss once, then stays for the fight.

Permadeath was announced only by the roster screen appearing. The client
records its own death from the server's event and the roster says who
fell, at what level, and where. The connect screen lost a name field
that did nothing for a person -- it only named the characters bots
create -- gained a proper panel, remembers the last server, and shows
the protocol number a mismatch refusal will quote.

And screenshot.tscn ran as the player's real account against their real
character store, walking their character into dungeons for pictures --
with health now carried between runs, a stray hit would have stuck. It
has its own store and account now, like every diagnostic, and a seeded
character. The real store was checked: nothing it carries changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:41:04 +02:00

340 lines
12 KiB
GDScript

extends Node
## Boot the client windowed, capture a few screens, and quit.
##
## SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn
##
## This exists because UI is the one part of the game the rest of the tooling
## cannot check. `check.sh` proves it parses, the suite proves the theme
## resolves, the smoke test proves nothing errors -- and all three passed
## while the entire interface rendered unstyled, because Godot's theme
## inheritance breaks at the first non-Control parent and nothing had ever
## looked at the screen.
##
## Needs a real display, so it is not part of any automated gate. Run it after
## touching anything under src/ui/ and open the files.
const GAME := preload("res://src/game.tscn")
const GAME_SCENE := preload("res://src/view/game_scene.gd")
## Its own store and its own account, like every diagnostic. Without these the
## tool hosted a listen server on the player's REAL character store as the
## player's REAL account, and walked their character into dungeons to take
## pictures -- with health carried between runs, a stray hit would have stuck,
## and a death would have retired someone's character for a screenshot.
const STORE := "user://screenshot_characters.json"
const ACCOUNT := 777001
## Frame numbers, not seconds: the point is a deterministic sequence, and a
## windowed client at 60fps gets there in about four seconds.
const AT_MENU := 30
const ENTER_GAME := 34
const AT_GAME := 200
const OPEN_MENU := 210
const AT_INGAME_MENU := 240
const OPEN_SETTINGS := 250
const AT_SETTINGS := 280
const OPEN_CREDITS := 290
const AT_CREDITS := 320
const OPEN_UPGRADES := 330
const AT_UPGRADES := 360
const OPEN_ROSTER := 370
const AT_ROSTER := 400
const ENTER_DUNGEON := 410
const AT_DUNGEON := 480
const TO_TUNNEL := 490
const AT_TUNNEL := 550
const AT_TUNNEL_DEBUG := 560
const TO_BOSS := 570
const AT_BOSS := 640
const DONE := 650
var _frame: int = 0
var _game: Node = null
var _menu: Control = null
var _dir: String = "user://"
func _ready() -> void:
GameOpts.parse()
GameOpts.store_path = STORE
GameOpts.account_override = ACCOUNT
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE))
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
Settings.load_and_apply()
var from_env := OS.get_environment("SHOT_DIR")
if not from_env.is_empty():
_dir = from_env
# Made rather than assumed. The example in CLAUDE.md points at a fresh
# /tmp/shots, and a missing directory fails every capture one at a time
# with an error that names the file rather than the folder.
DirAccess.make_dir_recursive_absolute(_dir)
get_window().size = Vector2i(1280, 720)
_menu = preload("res://src/ui/main_menu.gd").new()
add_child(_menu)
## Put the player at the quartermaster on BOTH sides. Setting only the client's
## predicted position does not hold: reconciliation pulls it back to whatever
## the server says within a tick or two, the game scene notices it is no longer
## at the NPC, and closes the screen again.
func _stand_at_the_npc() -> void:
var npc: Vector2 = Net.client.upgrade_npc
Net.client.predicted_pos = npc
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 = npc
_game.set("_upgrades_open", true)
func _process(_delta: float) -> void:
_frame += 1
# 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()
# On an entrance for the hub shot, so its prompt is in the picture.
if _frame > AT_GAME - 40 and _frame <= AT_GAME:
_stand_on_a_portal()
# 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()
# Held low, so the low-health edge is in the picture -- and held at all,
# because the boss is firing and one landed ring would end the shot.
if _frame > TO_BOSS and _frame <= AT_BOSS:
_hold_health(0.22)
match _frame:
AT_MENU:
_grab("menu")
ENTER_GAME:
_menu.queue_free()
Net.host(27455)
_seed_character()
Net.start_local_client()
_game = GAME.instantiate()
add_child(_game)
AT_GAME:
_grab("game")
OPEN_MENU:
_game.get_node("GameMenu").open()
AT_INGAME_MENU:
_grab("menu_ingame")
OPEN_SETTINGS:
_game.get_node("GameMenu").close()
_game.get_node("SettingsScreen").open()
AT_SETTINGS:
_grab("settings")
OPEN_CREDITS:
_game.get_node("SettingsScreen").visible = false
_game.get_node("CreditsScreen").open()
AT_CREDITS:
_grab("credits")
OPEN_UPGRADES:
# Driven with a made-up offer rather than by earning one: the point
# here is the card layout, and levelling a character up first would
# make the shot depend on a dungeon run.
_game.get_node("CreditsScreen").visible = false
# The game scene owns this screen's visibility and re-asserts it
# every frame, closing it unless the player is at the NPC -- so
# setting `visible` directly lasted exactly one frame. Stand the
# player at the quartermaster and open it the way the game does.
_stand_at_the_npc()
var offer: Array[StringName] = [
Upgrades.SPLIT_SHOT, Upgrades.POISON, Upgrades.ERASER]
var taken: Array[StringName] = [Upgrades.SNIPER, Upgrades.SPREAD]
_game.get_node("UpgradeScreen").refresh(2, offer, taken)
AT_UPGRADES:
_grab("upgrades")
OPEN_ROSTER:
_show_the_roster()
AT_ROSTER:
_grab("roster")
ENTER_DUNGEON:
# The fog is what these two are for, and the hub is the worst place to
# judge it: one big room with little in it to cast a shadow.
_game.set("_roster_open", false)
_game.get_node("CharacterSelect").set_fallen("")
Net.server._send_to_dungeon(Net.LOCAL_PEER)
AT_DUNGEON:
_grab("dungeon")
TO_TUNNEL:
_stand_in_a_tunnel()
AT_TUNNEL:
_grab("tunnel")
# The same view with F1's overlay: the outline is where each fog ray
# stopped, which is the quickest way to tell a rendering mistake from
# a geometry one.
_game.get_node("WorldView").debug_hitboxes = true
AT_TUNNEL_DEBUG:
_grab("tunnel_debug")
TO_BOSS:
_game.get_node("WorldView").debug_hitboxes = false
_stage_a_boss_fight()
AT_BOSS:
_grab("boss_fight")
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)
# A death, as it is announced on the screen a death sends you to.
_game.get_node("CharacterSelect").set_fallen(GAME_SCENE.fallen_line(
{"name": "Quillfell", "level": 9, "where": "The Choir Vault"}))
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")
## A character worth photographing: levelled, carrying things, so the HUD has
## something in every slot and a bar that is not empty. Made in the scratch
## store before the local client says hello, so it is who the client resumes.
func _seed_character() -> void:
var store := Net.server.store
var c := store.create_character(ACCOUNT, "Ash")
store.grant_xp(ACCOUNT, c.id, Progression.total_xp_for_level(6) + 260)
var bag: Array[StringName] = [Items.HEALTH_POTION, Items.WARDENS_RATION,
Items.HEALTH_POTION, Items.NONE]
store.set_inventory(ACCOUNT, c.id, bag)
store.flush()
func _stand_on_a_portal() -> void:
if Net.client == null or Net.client.portals.is_empty():
return
var at: Vector2 = Net.client.portals[0]["pos"]
Net.client.predicted_pos = at
var p := _player()
if p != null:
p.pos = at
func _player() -> SimPlayer:
var inst := Net.server.instance_of(Net.LOCAL_PEER)
return inst.world.players.get(Net.LOCAL_PEER) if inst != null else null
func _hold_health(share: float) -> void:
var p := _player()
if p != null:
p.hp = maxi(1, int(float(p.max_hp) * share))
## Into the boss arena, low on health, with a party member beside you and a
## few messages in the feed: everything on the HUD that only exists mid-fight,
## in one picture.
##
## The party member is a world player with no socket behind it -- added to the
## simulation and the roster, never to the instance's send list, so the server
## never tries to talk to it.
func _stage_a_boss_fight() -> void:
var inst := Net.server.instance_of(Net.LOCAL_PEER)
if inst == null:
return
var at := inst.boss_spawn + Vector2(-200.0, 60.0)
Net.client.predicted_pos = at
var me := _player()
if me != null:
me.pos = at
me.spawn_grace = 0
var friend := inst.world.add_player(999, "Wren")
friend.pos = at + Vector2(-40.0, -70.0)
friend.colour = Color(1.0, 0.75, 0.45)
friend.hp = int(float(friend.max_hp) * 0.6)
friend.spawn_grace = 0
Net.server.peer_names[999] = "Wren"
Net.server.peer_instance[999] = inst.id
Net.server._broadcast_roster()
var hud := _game.get_node("HUD")
hud.notify("+ Health Potion", Items.get_def(Items.HEALTH_POTION).tint)
hud.notify("Warden's Ration used", Items.get_def(Items.WARDENS_RATION).tint)
hud.notify("LEVEL 7 -- a choice waits at the quartermaster", Color(1.0, 0.85, 0.4))
## Move the player, on both sides, into the tunnel closest to where it spawned:
## somewhere with walls close on two sides and a corner in view, which is where
## the old fog looked worst.
func _stand_in_a_tunnel() -> void:
var inst := Net.server.instance_of(Net.LOCAL_PEER)
if inst == null or inst.rooms.is_empty():
return
var map := inst.world.map
var start: Rect2i = inst.rooms[0]
var spawn := map.to_tile(inst.world.spawn_point)
var best := Vector2i(-1, -1)
var best_d := INF
for ty in map.height:
for tx in map.width:
if map.at(tx, ty) != MapGrid.Kind.FLOOR:
continue
var here := Vector2i(tx, ty)
var in_room := inst.boss_room.has_point(here)
for r: Rect2i in inst.rooms:
if r.grow(1).has_point(here):
in_room = true
if in_room:
continue
# A couple of tiles clear of the first room, so the tunnel's walls
# are on screen and not just the doorway.
if start.grow(3).has_point(here):
continue
var d := float((here - spawn).length_squared())
if d < best_d:
best_d = d
best = here
if best.x < 0:
return
var at := map.tile_centre(best.x, best.y)
Net.client.predicted_pos = at
var p: SimPlayer = inst.world.players.get(Net.LOCAL_PEER)
if p != null:
p.pos = at
## 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)
if err != OK:
printerr("could not write %s (error %d)" % [path, err])
return
print("saved ", path)