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: # Forty frames, not ten: at a high frame rate ten is shorter than one # snapshot, and the picture showed the health from before the drop. _hold_health(0.62 if _frame < AT_BOSS - 40 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)