ci / verify (push) Successful in 53s
At 331 of 1500 health the bar read as nearly full. The pack's bar sprite is light all the way through, so the EMPTY part was the brightest thing on it. Every bar now has a dark track inside its frame, scaled with the bar's height -- a fixed inset left the thin experience bar four pixels of track and six of white -- so empty reads as empty. Health carries its number inside the bar, and a damage trail: the part the last hit took stays in a darker red for a moment and then drains, so a hit reads as an amount rather than as a bar that is suddenly shorter. Two quick hits read as one, measured from where the first began. The trail was pale at first and the number vanished against it; darker fixed that. Experience flashes and shows "+N xp" on a kill, summed across a burst so it does not flicker. Every block of HUD text sits on a dark panel, since the floor art is busy enough to beat a text shadow. Centre banners for the moments that change what you should do: a level gained, a boss changing phase (not the phase it was found in), and the escape channel -- which was a second bar in the corner and is now one bar where you are looking, with the key to keep holding. Party members you cannot see get a marker at the edge of sight pointing the way to them. Empty inventory slots step back, so the blank parchment square is no longer the most prominent thing in the row. The connect screen opens on a real boss pattern behind its panel -- the bosses' own emitters, fired into a pool and drawn by the game's own bullet renderer, dimmed. It is primed with five seconds of ticks: every pattern starts at the centre of the screen, which is where the panel is, so unprimed it showed nothing for its first second or two. The title is spaced and underlined, Credits is on the screen itself, Join says Enter works and has focus, the port is validated before it can fail, and "connecting" animates so a slow attempt does not look frozen. The boss-bar logic is pulled out into track_boss() so it can be tested: it waits for the fog to light the boss, stays for the fight, has to be seen again in the next dungeon -- every dungeon's boss is actor 1, so the id alone would have carried "seen" across -- and announces only real phase changes. screenshot.tscn captures the escape banner by progress rather than by frame count, because the channel is server ticks and a completed escape teleports the player out mid-sequence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
369 lines
13 KiB
GDScript
369 lines
13 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 := 200
|
|
const ENTER_GAME := 204
|
|
const AT_GAME := 370
|
|
const OPEN_MENU := 380
|
|
const AT_INGAME_MENU := 410
|
|
const OPEN_SETTINGS := 420
|
|
const AT_SETTINGS := 450
|
|
const OPEN_CREDITS := 460
|
|
const AT_CREDITS := 490
|
|
const OPEN_UPGRADES := 500
|
|
const AT_UPGRADES := 530
|
|
const OPEN_ROSTER := 540
|
|
const AT_ROSTER := 570
|
|
const ENTER_DUNGEON := 580
|
|
const AT_DUNGEON := 650
|
|
const TO_TUNNEL := 660
|
|
const AT_TUNNEL := 720
|
|
const AT_TUNNEL_DEBUG := 730
|
|
const TO_BOSS := 740
|
|
const AT_BOSS := 810
|
|
const ESCAPE_START := 830
|
|
## A safety net only: the run normally ends the moment the escape shot is taken.
|
|
const DONE := 1710
|
|
|
|
var _frame: int = 0
|
|
var _left_shot: bool = false
|
|
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, because the boss is firing and one landed ring would end the shot.
|
|
# High until just before the picture and low after, so the damage trail
|
|
# -- the part of the bar the drop took -- is still draining when it is taken.
|
|
if _frame > TO_BOSS and not _left_shot:
|
|
_hold_health(0.62 if _frame < AT_BOSS - 10 else 0.22)
|
|
# Captured by progress rather than by frame count. The channel is one second
|
|
# of server ticks and frames run as fast as the display allows, so a fixed
|
|
# frame number lands anywhere from a quarter of the way in to after it has
|
|
# completed -- and a completed escape teleports the player out mid-sequence.
|
|
if _frame >= ESCAPE_START and not _left_shot:
|
|
Net.client.request_escape = true
|
|
if Net.client.my_escape >= 0.45:
|
|
_grab("leaving")
|
|
Net.client.request_escape = false
|
|
_left_shot = true
|
|
Net.shutdown()
|
|
get_tree().quit(0)
|
|
return
|
|
if _frame == AT_BOSS - 30:
|
|
Net.server._grant_xp(Net.LOCAL_PEER, 46)
|
|
# Frames, not seconds, and this runs as fast as the display allows -- so
|
|
# early enough that the banner's fade-in is over even at a high frame rate.
|
|
if _frame == AT_BOSS - 60:
|
|
_game.get_node("HUD").banner("LEVEL 7", "a choice waits at the quartermaster")
|
|
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
|
|
# And one out of sight, so the marker pointing the way to them shows.
|
|
var away := inst.world.add_player(998, "Lark")
|
|
away.pos = at + Vector2(-560.0, 260.0)
|
|
away.colour = Color(0.55, 0.85, 1.0)
|
|
away.spawn_grace = 0
|
|
Net.server.peer_names[998] = "Lark"
|
|
Net.server.peer_instance[998] = 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)
|
|
|
|
|
|
## 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)
|