extends Node3D
## Game CLIENT root (N1): starts the in-process LocalServer (server/local_server.gd) with the local
## character, sends it commands (send()) and turns its events + position snapshots into the picture:
## scenery, one view per entity (player.gd, monster.gd, loot.gd, npc.gd, skill_area.gd), FX / SFX,
## HUD, camera, music, AUTO. Nothing here decides a fight, a drop or a reward.
## Scene layout: env_root = the map's scenery (kept in a cache when you leave, so going back is
## instant), map_root = the views of its monsters, loot, NPCs and ground spells (freed on leaving).
## N2 co-op: a normal start shows the title (lobby.gd): Solo / Host / Join. Host = this game runs the
## LocalServer for everybody (net/net_server.gd listens); Join = `server` is a RemoteServer
## (net/remote_server.gd) talking to a host, the character is a replica, other players are
## player.gd views in remote mode. Events carry "_m" (map) / "_to" (player): this client only draws
## the ones for its map / itself.
## Command-line (after "--"):  --solo | --host[=port] | --join=ip[:port] --name=<hero> --pass=<pw>
##   [--user=<account>] (skip the title; account = --name unless --user, created when missing)
##   --nettest=host|a|b (loopback network test, net/net_test.gd)
##   --server[=port] [--data=user://server] (N3 dedicated headless server: server/dedicated.gd, no
##   client at all)  --n3test=... (N3 multi-process test, net/n3_test.gd)
## Command-line (after "--"):  --fresh (ignore save)  --selftest  --shot=<png path>  --map=<map id>
##   screenshot extras: --shotat=x,z  --shotcam=yaw,pitch,dist  --shotui  --shotjob=mage|archer (fight pose)
##     --shotsize=WxH (window size)  --shotdemo (sample gear, cards, quests)  --shotwin=items,status,quests,refine,equip
##     --shotmap (large minimap)  --shotmon=<monster id> (that monster posed where the player stands, player hidden)
##     --shotcalm (no monster attacks the shot character)
##     --shotfeel=swing|impact|crit (a Swordman's melee hit: mid-swing trail / impact frame / crit)
##     --shotskin=<skin item id> (weapon costume on a Swordsman's sword; combine with --shotfeel)
##     --shotskill=<skill id>:windup|release|impact (skill_fx_shot.gd: the owning job uses it on a still slime)
##     --shotauto=battle | settings:<tab> | path   (AUTO running for a while / settings window tab / auto-path on the big map)
##   title screens: --shotlobby=<screen> [--shotout=<png>] [--shotsize=WxH] [--tod=h] (fake data, no network;
##     screens: lobby.gd SHOT_SCREENS, or "solo" = press Solo and shoot the game)  --notitlebg (no town backdrop)

const WorldBuilder = preload("res://scripts/world_builder.gd")
var PlayerScript:
	get:
		return load("res://scripts/player.gd")
var MonsterScript:
	get:
		return load("res://scripts/monster.gd")
var LootScript:
	get:
		return load("res://scripts/loot.gd")
var NpcScript:
	get:
		return load("res://scripts/npc.gd")
var AreaScript:
	get:
		return load("res://scripts/skill_area.gd")
var ZoneScript:
	get:
		return load("res://scripts/skill_zone.gd")
const CameraRig = preload("res://scripts/camera_rig.gd")
# startup: compiled on first use (perf_apply.py hunk 6, boot_prewarm.gd)
var HudScript:
	get:
		return load("res://scripts/hud.gd")
const Fx = preload("res://scripts/fx.gd")
var SelfTest:
	get:
		return load("res://scripts/selftest.gd")
const MusicScript = preload("res://scripts/music.gd")
const EnvAssets = preload("res://scripts/env_assets.gd")
var AutoScript:
	get:
		return load("res://scripts/auto_play.gd")
var LocalServer:
	get:
		return load("res://scripts/server/local_server.gd")
const CharacterState = preload("res://scripts/server/character_state.gd")
const LobbyScript = preload("res://scripts/lobby.gd")
var NetServerScript:
	get:
		return load("res://scripts/net/net_server.gd")
var RemoteServerScript:
	get:
		return load("res://scripts/net/remote_server.gd")
const NetProto = preload("res://scripts/net/net_proto.gd")
var HitFeelScript:
	get:
		return load("res://scripts/hit_feel.gd")
var SkillFxScript:
	get:
		return load("res://scripts/skill_fx.gd")
var SkillFxShot:
	get:
		return load("res://scripts/skill_fx_shot.gd")

## Tests / tools that start the game directly (no title screen).
const DIRECT_FLAGS := ["--selftest", "--envtest", "--sfxtest", "--tripotest"]
## Chat bubble life (s).
const BUBBLE_TIME := 5.0

## Shown on the title after a session ended (host closed, connection lost).
static var title_notice := ""
## The command-line mode (--solo / --host / --join) is used once; back at the title it is ignored.
static var _cli_used := false
## Sessions started in this process (a new one starts with a fresh local character object).
static var _sessions := 0

signal map_changed(id: String)
## A monster the local player killed (AUTO session kill count); `mon` = its view.
signal monster_killed(mon)

## Single-player: the in-process server. (N2: a network client with the same submit / event API.)
var server
## Server entity id of the local player.
var my_id := 0
## The local player's view (player.gd).
var player
var hud
var rig
var music
## AUTO system (auto_play.gd): a client-side bot that only sends commands.
var auto
## Melee game feel (hit_feel.gd): hits shown when the blade connects, hit-stop, crit shake.
var feel
## Skill presentation (skill_fx.gd): wind-up / release / impact of every skill (view only).
var skillfx
var args: PackedStringArray
var test_mode := false
## Active map id, its scenery root and the root of its entity views.
var map_id := ""
var env_root: Node3D
var map_root: Node3D
## entity id -> view node (the local player included).
var views: Dictionary = {}
var traveling := false
## Result of the last fresh scenery build ({"trees": n, "rocks": n}, empty when it came from the cache).
var last_build: Dictionary = {}
var _env_cache: Dictionary = {}
var _fade: ColorRect
var _title: Label
## Host mode: the network front of the LocalServer (net/net_server.gd), else null.
var net_server = null
## Join mode: `server` is a RemoteServer and PlayerState.ch a replica.
var net_client := false
var lobby = null
## Title backdrop camera (orbits the town behind the title) and the map its scenery belongs to.
var _title_cam: Camera3D = null
var _title_env_map := ""
var _title_orbit := 0.6
var _title_hidden: Array = []
var _interest_t := 0.0
var _leaving := false


func _ready() -> void:
	randomize()
	args = OS.get_cmdline_user_args()
	if "--server" in args or _arg_value("--server") != "":
		# N3 dedicated server: only the server (no scenery, player, HUD, camera or music)
		var ded = load("res://scripts/server/dedicated.gd").new()
		add_child(ded)
		if _arg_value("--n3test") != "":
			_add_test_driver("res://scripts/net/n3_test.gd", "N3Test")
		return
	# the network test never saves into the real character store (autosave off, own store folder)
	test_mode = "--selftest" in args or _shot_path() != "" or _arg_value("--nettest") != "" or _arg_value("--n3test") != "" or _arg_value("--shotlobby") != "" or _arg_value("--promo") != ""
	_build_fade()
	if _arg_value("--nettest") != "":
		# the loopback network test drives the game from outside the scene (it survives going back
		# to the title), see net/net_test.gd
		_add_test_driver("res://scripts/net/net_test.gd", "NetTest")
	if _arg_value("--n3test") != "":
		_add_test_driver("res://scripts/net/n3_test.gd", "N3Test")
	match _start_mode():
		"solo":
			_begin_local(0)
		"host":
			var hp := _arg_value("--host")
			_begin_local(int(hp) if hp.is_valid_int() else NetProto.DEFAULT_PORT)
		"join":
			var addr: Array = NetProto.parse_address(_arg_value("--join"))
			_show_lobby()
			lobby.set_busy(true)
			lobby.set_status("Connecting to %s:%d ..." % [addr[0], int(addr[1])])
			var user := _arg_value("--user") if _arg_value("--user") != "" else _arg_value("--name")
			_start_join(str(addr[0]), int(addr[1]), {"user": user, "pw": _arg_value("--pass"), "mode": "auto", "char": _arg_value("--name")})
		_:
			_show_lobby()


## A test script that lives under the scene root (survives going back to the title).
func _add_test_driver(path: String, node_name: String) -> void:
	if get_tree().root.get_node_or_null(node_name) == null:
		var nt = load(path).new()
		nt.name = node_name
		get_tree().root.add_child.call_deferred(nt)


## "solo", "host", "join" (command line / tests) or "" (show the title).
func _start_mode() -> String:
	for f in DIRECT_FLAGS:
		if f in args:
			return "solo"
	if _shot_path() != "" or _arg_value("--promo") != "" or _arg_value("--tripopose") != "" or _arg_value("--tripostrip") != "" or _arg_value("--tripolineup") != "":
		return "solo"
	if _cli_used:
		return ""
	_cli_used = true
	if "--host" in args or _arg_value("--host") != "":
		return "host"
	# --join alone (the patch launcher) only fills the lobby's Server field; auto-join needs a password too
	if _arg_value("--join") != "" and _arg_value("--pass") != "":
		return "join"
	if "--solo" in args:
		return "solo"
	return ""


func _show_lobby() -> void:
	lobby = LobbyScript.new()
	add_child(lobby)
	lobby.chosen.connect(_on_lobby_choice)
	if title_notice != "":
		lobby.show_error(title_notice)
		title_notice = ""
	var shot := _arg_value("--shotlobby")
	if shot != "":
		# screenshot of a title screen with fake data, no network (docs / checks):
		#   --shotlobby=<screen> [--shotout=<png>] [--shotsize=WxH]   (screens: lobby.gd SHOT_SCREENS)
		#   --shotlobby=<png path> = the title screen (older form)
		#   --shotlobby=solo = press Solo on the title, shoot the game (fresh hero, nothing saved)
		var what := shot
		var out := _arg_value("--shotout")
		if shot.ends_with(".png"):
			what = "title"
			out = shot
		if out == "":
			out = "user://lobby_%s.png" % what
		var sz := _arg_value("--shotsize")
		if sz != "":
			var wh := sz.split("x")
			DisplayServer.window_set_size(Vector2i(int(wh[0]), int(wh[1])))
		for i in 4:
			await get_tree().process_frame
		await _title_backdrop()
		if what == "solo":
			# title -> Solo: the backdrop scenery must become the game's (no second build), fresh test hero
			var before := env_root
			lobby.solo_btn.pressed.emit()
			for i in 150:
				await get_tree().process_frame
			get_viewport().get_texture().get_image().save_png(out)
			print("SHOT saved %s (solo: map %s, scenery reused %s, title camera gone %s)" % [out, map_id, str(before != null and env_root == before), str(_title_cam == null)])
			get_tree().quit(0)
			return
		if not LobbyScript.SHOT_SCREENS.has(what):
			print("SHOT unknown lobby screen ", what, " (", ", ".join(LobbyScript.SHOT_SCREENS), ")")
			get_tree().quit(1)
			return
		lobby.shot_setup(what)
		for i in 90:
			await get_tree().process_frame
		lobby.face_front()
		for i in 3:
			await get_tree().process_frame
		get_viewport().get_texture().get_image().save_png(out)
		print("SHOT saved %s (%s, %s)" % [out, what, str(get_viewport().get_visible_rect().size)])
		get_tree().quit(0)
		return
	_title_backdrop()


## Title backdrop: the start map's live scenery with a slowly orbiting camera (not in headless runs
## or with --notitlebg). The scenery is handed to the game when a session starts on that map
## (_show_map finds it in the cache), so it is never built twice.
func _title_backdrop() -> void:
	if DisplayServer.get_name() == "headless" or "--notitlebg" in args or env_root != null or player != null:
		return
	# let the title draw once first (the build takes a moment): wait for a real drawn frame
	await get_tree().process_frame
	await RenderingServer.frame_post_draw
	await get_tree().process_frame
	if lobby == null or player != null or env_root != null:
		return
	var id := GameData.start_map()
	WorldBuilder.set_map(id)
	_title_env_map = WorldBuilder.map_id()
	env_root = Node3D.new()
	env_root.name = "Env_" + _title_env_map
	add_child(env_root)
	last_build = WorldBuilder.build(env_root)
	# zone / warning signs would compete with the logo: hidden while the title is up
	for l in env_root.find_children("*", "Label3D", true, false):
		if (l as Label3D).visible:
			(l as Label3D).visible = false
			_title_hidden.append(l)
	_title_cam = Camera3D.new()
	_title_cam.fov = 55.0
	add_child(_title_cam)
	_title_cam.current = true
	_orbit_title(0.0)
	if lobby:
		lobby.backdrop_ready()
	add_child(load("res://scripts/boot_prewarm.gd").new())


func _orbit_title(delta: float) -> void:
	_title_orbit += delta * 0.035
	var c := WorldBuilder.town_center() if WorldBuilder.has_town() else WorldBuilder.spawn_point()
	# high enough to clear the roofs, looking down on the square (the cards cover the middle)
	var r := WorldBuilder.town_half() * 1.75 if WorldBuilder.has_town() else 28.0
	_title_cam.position = c + Vector3(sin(_title_orbit) * r, r * 0.6, cos(_title_orbit) * r)
	_title_cam.look_at(c + Vector3(0, -1.0, 0))


func _on_lobby_choice(mode: String, opts: Dictionary) -> void:
	match mode:
		"solo":
			_close_lobby()
			_begin_local(0)
		"host":
			_close_lobby()
			_begin_local(int(opts.get("port", NetProto.DEFAULT_PORT)))
		"join":
			_start_join(str(opts["ip"]), int(opts["port"]), opts)
		"play", "create", "delete":
			# character select (N3): answered by the server
			if net_client and server and server.has_method("char_action"):
				server.char_action(mode, str(opts.get("name", "")), str(opts.get("confirm", "")))
		"back":
			# character select -> title (disconnect)
			if net_client and server:
				_on_join_failed("")


func _close_lobby() -> void:
	if lobby:
		lobby.queue_free()
		lobby = null
	if _title_cam:
		_title_cam.queue_free()
		_title_cam = null
	for l in _title_hidden:
		if is_instance_valid(l):
			l.visible = true
	_title_hidden.clear()


## Single-player (host_port 0) or host a co-op game on host_port: the LocalServer runs here with
## the local character.
func _begin_local(host_port: int) -> void:
	if _sessions > 0:
		PlayerState.set_character(CharacterState.new())
		PlayerState.ch.new_character("Boss")
	_sessions += 1
	server = LocalServer.new()
	add_child(server)
	server.autosave_enabled = not test_mode
	if _arg_value("--nettest") != "":
		server.store_dir = "user://nettest/chars"
	server.event.connect(_on_event)
	server.snapshot.connect(_on_snapshot)
	var loaded := false
	if not ("--fresh" in args or test_mode):
		# user://server/chars/<name>.json (the old user://save.json is migrated on the first run)
		var how: String = server.load_last_character(PlayerState.ch)
		loaded = how != ""
		print("CHAR %s: %s (Lv %d)" % [how if loaded else "new", PlayerState.char_name, PlayerState.base_lv])
	if loaded:
		# saved while dead (older builds autosaved on the death screen): start at the save point
		PlayerState.revive_if_dead()
	if not loaded:
		PlayerState.save_pos = WorldBuilder.TOWN_SPAWN
		PlayerState.save_map = GameData.start_map()
		PlayerState.map_id = GameData.start_map()
		PlayerState.pos = WorldBuilder.TOWN_SPAWN
	var forced := _arg_value("--map")
	if forced != "" and not GameData.map(forced).is_empty() and forced != PlayerState.map_id:
		PlayerState.map_id = forced
		WorldBuilder.set_map(forced)
		PlayerState.pos = WorldBuilder.spawn_point()
	var pe = server.add_player(PlayerState.ch)
	_make_player(pe.id)
	server.enter_map(pe, PlayerState.map_id, PlayerState.pos)
	_make_client_ui()
	# promo clip recording (scripts/tools/promo_director.gd, not part of the game)
	if _arg_value("--promo") != "":
		add_child(load("res://scripts/tools/promo_director.gd").new())
	PlayerState.msg("Welcome to Pandolia Online! Left-click to move / attack. Hotbar: F1-F9. Windows (classic keys): Alt+E items, Alt+Q equipment, Alt+A status, Alt+S skills, Alt+U quests. Settings: Esc / Alt+O. Music: M / [ ]. AUTO: Z / F12. Chat: Enter.", Color(0.7, 0.9, 1))
	if host_port > 0:
		net_server = NetServerScript.new()
		net_server.server = server
		net_server.reserved = [str(PlayerState.char_name)]
		if _arg_value("--nettest") != "":
			net_server.pw_iter = 300
			# a clean test store before the accounts open (net/net_test.gd wipes it too)
			load("res://scripts/net/net_test.gd").wipe("user://nettest")
		if _arg_value("--nettest") != "":
			net_server.test_drop = "red_tonic"
			net_server.bind_ip = "127.0.0.1"
		add_child(net_server)
		net_server.note.connect(func(t): print("NET ", t))
		var err: String = net_server.start(host_port)
		if err != "":
			print("NET ERROR ", err)
			net_server.queue_free()
			net_server = null
			leave_to_title(err)
			return
		else:
			var ips: Array = NetProto.local_addresses()
			PlayerState.msg("Hosting a co-op game on port %d (up to %d friends). Friends join with: %s" % [host_port, NetProto.MAX_CLIENTS,
				", ".join(ips) if not ips.is_empty() else "your IP"], Color(1, 0.9, 0.5))
			print("NET hosting on port %d %s" % [host_port, str(ips)])
	# --cardart=<dir>: render the card monsters for the card icons (scripts/tools/card_art.gd)
	if _arg_value("--cardart") != "":
		add_child(load("res://scripts/tools/card_art.gd").new())
	if "--tripotest" in args or _arg_value("--tripopose") != "" or "--tripoawaken" in args or _arg_value("--tripostrip") != "" or _arg_value("--tripolineup") != "":
		add_child(load("res://scripts/tripo_test.gd").new())  # Tripo model checks / hero pose shots
	if "--selftest" in args:
		SelfTest.run(self)
	elif _shot_path() != "":
		_take_shot(_shot_path())


func _shot_path() -> String:
	for a in args:
		if a.begins_with("--shot="):
			return a.substr(7)
	return ""


## The local player's view + camera (before the server puts it on a map).
func _make_player(pid: int) -> void:
	my_id = pid
	player = PlayerScript.new()
	player.world = self
	player.id = my_id
	player.net_mode = net_client
	add_child(player)
	views[my_id] = player
	rig = CameraRig.new()
	add_child(rig)
	rig.target = player
	if feel == null:
		feel = HitFeelScript.new()
		feel.name = "HitFeel"
		feel.world = self
		add_child(feel)
	if skillfx == null:
		skillfx = SkillFxScript.new()
		skillfx.name = "SkillFx"
		skillfx.world = self
		add_child(skillfx)


## Camera, AUTO, HUD, music (after the first map is shown).
func _make_client_ui() -> void:
	rig.snap()
	apply_map_camera()
	player.cam = rig.cam
	auto = AutoScript.new()
	auto.main = self
	auto.name = "Auto"
	add_child(auto)
	auto._on_player_ready()
	hud = HudScript.new()
	hud.player = player
	hud.world = self
	add_child(hud)
	player.hud = hud
	music = MusicScript.new()
	music.main = self
	add_child(music)
	Settings.apply_audio()
	PlayerState.leveled.connect(func(kind): Fx.level_up(self, player, kind))


# ---------------------------------------------------------------- co-op: join a host

## Connects to a server: opts {user, pw, mode: login | register | auto, char: play this one at once
## ("" = show the character select)}. The title stays up until the server answers.
func _start_join(ip: String, port: int, opts: Dictionary) -> void:
	if server != null:
		return
	# the replica: never an object a local server used
	PlayerState.set_character(CharacterState.new())
	_sessions += 1
	net_client = true
	var rs = RemoteServerScript.new()
	rs.ch = PlayerState.ch
	add_child(rs)
	server = rs
	server.event.connect(_on_event)
	server.snapshot.connect(_on_snapshot)
	rs.logged_in.connect(_on_logged_in)
	rs.failed.connect(_on_join_failed)
	rs.lost.connect(func(reason): leave_to_title(reason))
	rs.account.connect(_on_account)
	rs.notice.connect(_on_notice)
	var err: String = rs.connect_to(ip, port, str(opts.get("user", "")), str(opts.get("pw", "")), str(opts.get("mode", "login")), str(opts.get("char", "")))
	if err != "":
		_on_join_failed(err)


func _on_logged_in(pid: int) -> void:
	print("NET logged in as %s (id %d)" % [PlayerState.char_name, pid])
	_close_lobby()
	_make_player(pid)
	_make_client_ui()
	PlayerState.msg("Connected! You are playing on the host's world. Chat: Enter (/p party, /w name whisper, /party invite name, /who).", Color(0.7, 0.9, 1))


func _on_notice(text: String, ok: bool) -> void:
	if lobby:
		lobby.show_notice(text, ok)


## Logged into an account: the title shows the character select.
func _on_account(info: Dictionary) -> void:
	if lobby == null:
		_show_lobby()
	lobby.show_chars(info)


func _on_join_failed(reason: String) -> void:
	print("NET join failed: ", reason)
	if server:
		server.close()
		remove_child(server)
		server.queue_free()
		server = null
	net_client = false
	if lobby == null:
		_show_lobby()
	lobby.show_error(reason)


## Ends the session (host: everybody is told and saved; client: disconnects) and shows the title.
func leave_to_title(notice: String = "") -> void:
	if _leaving:
		return
	_leaving = true
	title_notice = notice
	if net_server:
		net_server.shutdown("The host left the game.")
	elif net_client and server:
		server.close()
	elif server:
		save()
	get_tree().reload_current_scene.call_deferred()


# ---------------------------------------------------------------- talking to the server

## Sends one command for the local player; returns the server's answer (LocalServer: at once).
func send(cmd: Dictionary) -> Dictionary:
	var r: Dictionary = server.submit(cmd, my_id)
	_refresh_me()
	return r


## Server-side body of the local player (single-player tests / tools only: the real client never
## touches server objects).
func me():
	return server.player(my_id)


## View of an entity id (null when this client has none).
func view(id: int):
	var v = views.get(id, null)
	return v if v != null and is_instance_valid(v) else null


func _refresh_me() -> void:
	if player:
		player.apply_state(server.player_view_state(my_id))


func _on_snapshot(entries: Dictionary) -> void:
	for id in entries:
		var v = views.get(id, null)
		if v != null and is_instance_valid(v):
			v.apply_snap(entries[id])
	_refresh_me()


## Is this event for this client? "_to" = one player only, "_m" = players on that map.
func _wants(e: Dictionary) -> bool:
	if e.has("_to"):
		return int(e["_to"]) == my_id
	if e.has("_m"):
		return str(e["_m"]) == map_id
	return true


func _on_event(e: Dictionary) -> void:
	if not _wants(e):
		return
	# settings "Other players' skill effects" off: their cast / skill / fx presentation is skipped
	if Settings.skip_event(e, self):
		return
	match str(e.get("t", "")):
		"map":
			if int(e.get("id", 0)) == my_id:
				_show_map(str(e["map"]))
		"spawn":
			_spawn_view(e)
		"despawn":
			if int(e["id"]) == my_id:
				return
			var v = view(int(e["id"]))
			views.erase(int(e["id"]))
			if v:
				# out of the lookup groups at once (queue_free only happens at the end of the frame)
				for g in ["monsters", "loot", "npcs", "skill_areas", "remote_players"]:
					if v.is_in_group(g):
						v.remove_from_group(g)
				v.queue_free()
		"pos":
			var v2 = view(int(e["id"]))
			if v2:
				v2.global_position = e["pos"]
				if v2 == player:
					rig.snap()
		"hit":
			# a skill hit shows at its impact (skill_fx.gd), a basic melee hit when the blade connects
			# (hit_feel.gd); everything else at once
			if skillfx != null and skillfx.take_hit(e):
				pass
			elif feel == null or not feel.take_hit(e):
				_on_hit(e)
		"mob_hurt":
			var mv = view(int(e["id"]))
			if mv:
				var hw := _held(int(e["id"]))
				mv.on_hurt(int(e["hp"]), int(e["max_hp"]), hw <= 0.0)
				if hw > 0.0:
					# its melee hit shows later: flinch then
					feel.later(hw, mv.hurt_fx)
		"mob_break":
			# Combat v2 break gauge (0..1) for the target frame
			var mb = view(int(e["id"]))
			if mb:
				mb.set_meta("brk", float(e.get("g", 0.0)))
				if bool(e.get("on", false)):
					Fx.float_text(self, mb.global_position + Vector3(0, 2.4, 0), "BREAK!", Color(1.0, 0.75, 0.2), 52)
		"mob_hp":
			var mv2 = view(int(e["id"]))
			if mv2:
				mv2.set_hp(int(e["hp"]), int(e["max_hp"]), bool(e.get("show", false)))
		"status":
			var sw := _held(int(e["id"]))
			if sw > 0.0:
				# Frozen! from a spell whose impact is not shown yet: wait for it
				feel.later(sw, _on_event.bind(e))
				return
			var sv = view(int(e["id"]))
			if sv:
				sv.on_status(str(e["key"]), bool(e["on"]), float(e.get("dur", 0.0)), int(e.get("lv", 1)))
		"attack":
			var av = view(int(e["id"]))
			if av:
				av.on_attack(view(int(e.get("target", 0))), bool(e.get("ranged", false)), float(e.get("delay", 1.8)))
		"mob_tele":
			# a boss heavy attack winds up (server/mob_ai.gd "heavy"): danger circle + roar + strike (monster.gd, boss_fx.gd)
			var tv = view(int(e["id"]))
			if tv and tv.has_method("on_telegraph"):
				tv.on_telegraph(e)
		"died":
			_on_died(e)
		"respawned":
			if int(e["id"]) == my_id:
				player.on_respawned()
				if hud:
					hud.show_death(false)
			else:
				var rv = view(int(e["id"]))
				if rv and rv.has_method("on_respawned"):
					rv.on_respawned()
		"plook":
			var lv = view(int(e["id"]))
			if lv and lv.get("remote"):
				lv.set_look(e)
		"chat":
			_on_chat(e)
		"party":
			if hud:
				hud.set_party(e.get("members", []), int(e.get("leader", 0)), my_id)
		"duel_invite", "duel", "duel_ring", "pk", "arena", "guild", "gw":
			# PvP duels (pvp_client.gd)
			load("res://scripts/pvp_client.gd").on_event(self, e)
		"party_invite":
			if hud:
				hud.ask("%s invites you to a party." % str(e.get("from", "?")), "Join party", func(): send({"t": "chat", "text": "/party accept"}))
		"result":
			if str(e.get("cmd", "")) == "refine" and hud:
				hud.on_refine_result(e)
			elif hud and hud.has_method("on_cmd_result"):
				# Starwell pulls (ui/mount_hud.gd)
				hud.on_cmd_result(e)
		"float":
			var fw := _held(int(e.get("id", 0)))
			if fw > 0.0:
				# "Stun!" etc. on a monster whose hit is not shown yet: wait for it
				feel.later(fw, _on_event.bind(e))
				return
			var at = _ev_pos(e)
			if at != null:
				Fx.float_text(self, at, str(e["text"]), e["color"], int(e.get("size", 52)))
		"heal_text":
			# a Heal / First Aid number waits for its release (skill_fx.gd)
			if skillfx and skillfx.hold_heal(e):
				return
			var hv = view(int(e["id"]))
			if hv:
				Fx.dmg_text(self, hv.global_position + Vector3(0, 1.7, 0), "+%d" % int(e["n"]), "heal")
		"shout":
			var shv = view(int(e["id"]))
			if shv:
				Fx.shout(self, shv, str(e["text"]))
		"cast":
			var cv = view(int(e["id"]))
			if cv and (skillfx == null or not skillfx.on_cast(e)):
				Fx.cast_circle(self, cv, e["color"], float(e["time"]))
		"skill":
			# a skill went off: release + impact presentation (skill_fx.gd); on an asura: the Mega look (asura_ride.gd)
			if skillfx:
				load("res://scripts/asura_ride.gd").present_skill(self, skillfx, e)
		"awaken_gauge":
			# live gauge for the HUD bar / Awaken button (server/awaken.gd sends it to the owner)
			if PlayerState.ch != null and ("awaken_gauge" in PlayerState.ch):
				PlayerState.ch.awaken_gauge = float(e.get("g", 0))
		"awaken":
			# Awaken (docs/PAUSE_NOTES.md "Awaken"): {id, on, time} -> transformation / golden aura (skill_fx.gd)
			if skillfx:
				skillfx.on_awaken(e)
		"asura":
			# Asura Rider (server/asura.gd): {id, on, asura, time}. The ride look follows the mount id (player.gd /
			# asura_ride.gd); a network client keeps its own ride clock for the HUD ring.
			if int(e.get("id", 0)) == my_id and PlayerState.ch != null and ("asura_until" in PlayerState.ch):
				var tn := Time.get_ticks_msec() / 1000.0
				if bool(e.get("on", false)):
					if float(PlayerState.ch.asura_until) < tn:
						PlayerState.ch.asura_until = tn + float(e.get("time", 30.0))
				elif float(PlayerState.ch.asura_until) > tn:
					PlayerState.ch.asura_until = 0.0
			load("res://scripts/asura_ride.gd").on_ride_event(self, e)
		"asura_skill":
			load("res://scripts/asura_ride.gd").skill_fx(self, e)
		"asura":
			# Asura Rider (server/asura.gd): {id, on, asura, time}. The ride look follows the mount id (player.gd /
			# asura_ride.gd); a network client keeps its own ride clock for the HUD ring.
			if int(e.get("id", 0)) == my_id and PlayerState.ch != null and ("asura_until" in PlayerState.ch):
				var tn := Time.get_ticks_msec() / 1000.0
				if bool(e.get("on", false)):
					if float(PlayerState.ch.asura_until) < tn:
						PlayerState.ch.asura_until = tn + float(e.get("time", 30.0))
				elif float(PlayerState.ch.asura_until) > tn:
					PlayerState.ch.asura_until = 0.0
		"asura_skill":
			load("res://scripts/asura_ride.gd").skill_fx(self, e)
		"asura_move":
			# Asura Rider: the beast performs an attack (beast motion, glide, FX, MEGA tag) - asura_ride.gd
			load("res://scripts/asura_ride.gd").on_move(self, e)
		"leap":
			# Skyfall Charge (server/skills_v2.gd _leap): {id, from, to, time} -> jump arc + dive + landing (skill_fx.gd)
			if skillfx:
				skillfx.on_leap(e)
		"swing":
			var wv = view(int(e["id"]))
			if wv and wv.has_method("on_swing"):
				# "d" (attack delay) = a basic attack: its hit waits for the blade (hit_feel.gd)
				var impact: float = wv.on_swing(float(e.get("d", 0.0)))
				if feel and e.has("d"):
					feel.on_swing(int(e["id"]), impact)
		"arrow":
			if skillfx and skillfx.on_arrow(e):
				return
			var from: Vector3 = e["from"]
			var to: Vector3 = e["to"]
			# a held fire gun: the shot leaves its muzzle (gun_hold.gd)
			var shooter = view(int(e.get("id", 0)))
			if shooter != null:
				from = load("res://scripts/gun_hold.gd").muzzle(shooter, from)
			for i in int(e.get("n", 1)):
				Fx.projectile(self, from + Vector3(0, i * 0.2, 0), to + Vector3(0, i * 0.2, 0), e.get("col", Color(1, 0.95, 0.75)), float(e["time"]), 1.0, 0.1)
		"fx":
			if skillfx == null or not skillfx.on_fx(e):
				_on_fx(e)
		"stolen":
			var stv = view(int(e["id"]))
			if stv:
				stv.stolen = true
		"area_cell":
			var arv = view(int(e["id"]))
			if arv:
				arv.remove_cell(e["pos"])
		"npc_open":
			if int(e["id"]) == my_id and hud:
				var n := npc_def(str(e["npc"]))
				if not n.is_empty():
					hud.open_npc(n)
		"picked":
			if int(e["id"]) == my_id:
				player.picked.emit(str(e["item"]), int(e["qty"]))
		"warp_start":
			if int(e["id"]) == my_id:
				_travel_out()
		"arrived":
			if int(e["id"]) == my_id:
				_travel_in()


## World position of an event: an entity (id + "h" metres up) or a point ("at").
func _ev_pos(e: Dictionary):
	if e.has("at"):
		return e["at"]
	var v = view(int(e.get("id", 0)))
	if v == null:
		return null
	return v.global_position + Vector3(0, float(e.get("h", 1.5)), 0)


func _spawn_view(e: Dictionary) -> void:
	var id := int(e["id"])
	if views.has(id):
		return
	match str(e["kind"]):
		"mob":
			var m = MonsterScript.new()
			m.setup(id, str(e["mid"]), e["pos"], int(e.get("hp", -1)), int(e.get("max_hp", -1)))
			map_root.add_child(m)
			views[id] = m
		"loot":
			var l = LootScript.new()
			l.setup(id, str(e["item"]), int(e["qty"]), e["pos"])
			map_root.add_child(l)
			views[id] = l
		"npc":
			var n = NpcScript.new()
			n.id = id
			n.npc = npc_def(str(e["npc"]))
			map_root.add_child(n)
			views[id] = n
		"area":
			var a = AreaScript.new()
			a.setup(id, e["cells"], float(e.get("dur", 5.0)))
			map_root.add_child(a)
			views[id] = a
		"zone":
			# second-job ground effect / trap (server/map_instance.gd ZoneEnt)
			var z = ZoneScript.new()
			z.setup(id, e)
			map_root.add_child(z)
			views[id] = z
		"player":
			# another player (co-op): the hero model of their job + weapon, name, HP bar
			var rp = PlayerScript.new()
			rp.remote = true
			rp.world = self
			rp.id = id
			rp.look = {"name": e.get("name", "?"), "job": e.get("job", "novice"), "weapon": e.get("weapon", ""),
				"skin": e.get("skin", ""), "shield": e.get("shield", ""), "lv": e.get("lv", 1), "hp": e.get("hp", 1), "max_hp": e.get("max_hp", 1), "mount": e.get("mount", "")}
			rp.position = e["pos"]
			rp._net_to = e["pos"]
			map_root.add_child(rp)
			views[id] = rp
			if bool(e.get("dead", false)):
				rp.on_died()


## A chat line: the log (map / party / whisper colours) + a bubble over the speaker.
func _on_chat(e: Dictionary) -> void:
	var chn := str(e.get("ch", "map"))
	var who := str(e.get("name", "?"))
	var text := str(e.get("text", ""))
	var line := "%s: %s" % [who, text]
	var col := Color(1, 1, 1)
	match chn:
		"party":
			line = "(Party) " + line
			col = Color(0.55, 1, 0.6)
		"whisper":
			line = ("[To %s] %s" % [str(e.get("to", "")), text]) if int(e.get("id", 0)) == my_id else ("[From %s] %s" % [who, text])
			col = Color(1, 0.7, 0.95)
	PlayerState.msg(line, col)
	if chn != "whisper":
		var v = view(int(e.get("id", 0)))
		if v:
			_bubble(v, text)


func _bubble(v: Node3D, text: String) -> void:
	var old = v.get_node_or_null("ChatBubble")
	if old:
		old.free()
	var l := Label3D.new()
	l.name = "ChatBubble"
	l.text = text.left(60)
	l.font_size = 30
	l.outline_size = 8
	l.pixel_size = 0.006
	l.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	l.no_depth_test = true
	l.modulate = Color(1, 1, 0.9)
	l.outline_modulate = Color(0.1, 0.07, 0.05)
	l.width = 400
	l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	l.position.y = 2.35
	v.add_child(l)
	get_tree().create_timer(BUBBLE_TIME).timeout.connect(l.queue_free)


## Seconds until a delayed melee hit on entity `id` is shown (0 = none waiting).
func _held(id: int) -> float:
	return feel.hold_left(id) if feel else 0.0


## Event "hit" (or its impact frame, hit_feel.gd: `dst_view` = the target's view kept from then,
## it may be dying already).
func _on_hit(e: Dictionary, dst_view = null) -> void:
	var dst = dst_view if dst_view != null and is_instance_valid(dst_view) else view(int(e["dst"]))
	if dst == null:
		return
	if dst == player:
		# a monster hit (or missed) the local player
		var pp: Vector3 = player.global_position + Vector3(0, 1.7, 0)
		if bool(e["miss"]):
			Fx.dmg_text(self, pp, "Lucky!" if bool(e.get("lucky", false)) else "Miss", "miss")
			return
		player.on_hurt()
		Fx.dmg_text(self, pp, str(e["dmg"]), "taken_crit" if bool(e.get("crit", false)) else "taken")
		Fx.hit_spark(self, player.global_position + Vector3(0, 0.8, 0), Color(1, 0.55, 0.5))
		return
	var p: Vector3 = dst.global_position + Vector3(0, 1.5, 0)
	if bool(e["miss"]):
		Fx.dmg_text(self, p, "Miss", "miss")
	else:
		var crit := bool(e.get("crit", false))
		# Combat v2: super-crits in gold, bigger (fx.gd "super")
		Fx.dmg_text(self, p, str(e["dmg"]) + ("!" if crit else ""), "super" if bool(e.get("super", false)) else ("crit" if crit else "hit"))
		Fx.hit_spark(self, dst.global_position + Vector3(0, 0.7, 0), Color(1, 0.95, 0.8), crit)
		if bool(e.get("double", false)):
			Fx.float_text(self, p + Vector3(0, 0.45, 0), "Double!", Color(1, 0.7, 0.4), 34)
	if int(e["src"]) == my_id and hud:
		hud.set_target(dst)


func _on_died(e: Dictionary) -> void:
	var v = view(int(e["id"]))
	if str(e.get("kind", "")) == "player":
		if int(e["id"]) == my_id:
			player.on_died()
			if hud:
				hud.show_death(true)
		elif v and v.has_method("on_died"):
			v.on_died()
		return
	var pos: Vector3 = e.get("pos", v.global_position if v else Vector3.ZERO)
	# killed by a melee hit that is not shown yet: the poof + fall wait for the blade (the kill
	# itself, AUTO's kill count and the view lookup change now)
	var wait := _held(int(e["id"]))
	var parent: Node = map_root if map_root else self
	if wait > 0.0:
		feel.later(wait, _death_poof.bind(parent, pos, e.get("color", Color.WHITE), bool(e.get("boss", false))))
	else:
		_death_poof(parent, pos, e.get("color", Color.WHITE), bool(e.get("boss", false)))
	if v:
		v.on_died(wait <= 0.0)
		if wait > 0.0:
			feel.later(wait, v.die_fx)
	views.erase(int(e["id"]))
	if int(e.get("killer", 0)) == my_id:
		monster_killed.emit(v)


func _death_poof(parent: Node, pos: Vector3, color: Color, big: bool) -> void:
	if is_instance_valid(parent) and parent.is_inside_tree():
		Fx.death_poof(parent, pos, color, big)


func _on_fx(e: Dictionary) -> void:
	match str(e["fx"]):
		"element_bolts":
			Fx.element_bolts(self, e["at"], str(e["el"]), int(e["n"]))
		"projectile":
			Fx.projectile(self, e["from"], e["to"], e["color"], float(e["time"]), float(e.get("len", 0.7)), float(e.get("thick", 0.06)))
		"ring":
			Fx.ring(self, e["at"], float(e["r"]), e["color"], float(e.get("time", 0.45)))
		"fire_ball":
			Fx.fire_ball(self, e["from"], e["to"], float(e["r"]))
		"magnum_break":
			Fx.magnum_break(self, e["at"], float(e["r"]))
		"heal":
			var v = view(int(e["id"]))
			if v:
				Fx.heal(self, v)
		"buff_aura":
			var v2 = view(int(e["id"]))
			if v2:
				Fx.buff_aura(self, v2, e["color"])


# ---------------------------------------------------------------- maps + travel

## Shows map `id`: frees the old map's entity views, parks its scenery in the cache, then shows the
## new scenery (cached or freshly built). The server sends the new map's entities right after.
func _show_map(id: String) -> void:
	if map_root:
		for k in views.keys():
			if views[k] != player:
				views.erase(k)
		remove_child(map_root)
		map_root.queue_free()
		map_root = null
	for a in get_tree().get_nodes_in_group("skill_areas"):
		a.queue_free()
	if env_root:
		remove_child(env_root)
		# the title backdrop's scenery (map_id still "") goes into the cache under its own map
		_env_cache[map_id if map_id != "" else _title_env_map] = env_root
		env_root = null
	if WorldBuilder.map_id() != id:
		WorldBuilder.set_map(id)
	map_id = WorldBuilder.map_id()
	last_build = {}
	if _env_cache.has(map_id):
		env_root = _env_cache[map_id]
		_env_cache.erase(map_id)
		add_child(env_root)
	else:
		env_root = Node3D.new()
		env_root.name = "Env_" + map_id
		add_child(env_root)
		last_build = WorldBuilder.build(env_root)
	map_root = Node3D.new()
	map_root.name = "Map_" + map_id
	add_child(map_root)
	apply_map_camera()
	map_changed.emit(map_id)


## Tests / tools: put the local player on map `id` right away (same position, no fade).
func load_map(id: String) -> void:
	server.debug_load_map(me(), id)


## Dungeons look down steeper (look.cam_pitch) so the cave walls never hide the player.
func apply_map_camera() -> void:
	if rig == null:
		return
	rig.pitch = float(WorldBuilder.current.get("look", {}).get("cam_pitch", CameraRig.DEFAULT_PITCH))
	rig._apply()


## The server started moving us to another map: fade out (the map swaps under the black).
func _travel_out() -> void:
	traveling = true
	if player:
		player._cancel_targeting()
	if hud:
		hud.set_target(null)
	_fade_to(1.0, LocalServer.TRAVEL_FADE)


## Arrived on the new map: map name title, fade in, then you can act again.
func _travel_in() -> void:
	await get_tree().process_frame
	_show_title(WorldBuilder.map_name())
	await _fade_to(0.0, 0.35)
	traveling = false


## Same-map teleport, or a trip to another map (debug / tools; the game warps on the server).
func warp(to_map: String, at: Vector3) -> void:
	server.warp(me(), to_map, at)


func _build_fade() -> void:
	var cl := CanvasLayer.new()
	cl.layer = 50
	add_child(cl)
	_fade = ColorRect.new()
	_fade.set_anchors_preset(Control.PRESET_FULL_RECT)
	_fade.color = Color(0.02, 0.02, 0.05, 0.0)
	_fade.mouse_filter = Control.MOUSE_FILTER_IGNORE
	cl.add_child(_fade)
	_title = Label.new()
	_title.set_anchors_preset(Control.PRESET_CENTER_TOP)
	_title.offset_top = 150
	_title.offset_left = -400
	_title.offset_right = 400
	_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	_title.add_theme_font_size_override("font_size", 44)
	_title.add_theme_color_override("font_color", Color(1, 0.96, 0.84))
	_title.add_theme_color_override("font_outline_color", Color(0.18, 0.12, 0.08))
	_title.add_theme_constant_override("outline_size", 12)
	_title.modulate.a = 0.0
	_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
	cl.add_child(_title)


func _fade_to(a: float, time: float) -> void:
	var tw := create_tween()
	tw.tween_property(_fade, "color:a", a, time)
	await tw.finished


func _show_title(text: String) -> void:
	_title.text = text
	var tw := create_tween()
	tw.tween_property(_title, "modulate:a", 1.0, 0.4)
	tw.tween_interval(1.8)
	tw.tween_property(_title, "modulate:a", 0.0, 0.8)


# ---------------------------------------------------------------- debug / test helpers (server side)

## Spawns a monster on the current map (server) and returns the server entity. Tests / tools only.
func _spawn(id: String, center: Vector3, radius: float, respawn: bool = true):
	return me().map.spawn_mob(id, center, radius, respawn)


## Drops loot on the current map (server). Tests / tools only.
func drop_item(id: String, qty: int, at: Vector3):
	return me().map.drop_item(id, qty, at)


## Drop chance % after the config rates (server rule, map_instance.gd).
func drop_chance(id: String, base: float) -> float:
	return me().map.drop_chance(id, base)


func teleport_player(p: Vector3) -> void:
	server.teleport(me(), p)


func random_field_point() -> Vector3:
	return me().map.random_field_point()


## Server monster entities on the current map (tests / tools).
func mobs() -> Array:
	return me().map.mobs.duplicate()


static func npc_def(id: String) -> Dictionary:
	for n in GameData.npcs:
		if str(n.get("id", "")) == id:
			return n
	return {}


## Asks the server to save the local character (Save button, save NPC, closing the window).
func save() -> void:
	if test_mode or net_client or server == null:
		return
	server.save_player(me())


func _notification(what: int) -> void:
	if what == NOTIFICATION_WM_CLOSE_REQUEST:
		save()
		if net_server:
			# friends are saved on this PC and told why they were dropped
			net_server.shutdown("The host closed the game.")
		elif net_client and server:
			server.close()


## Network client: monsters / players outside the snapshot radius stop updating - hide them.
func _process(delta: float) -> void:
	if _title_cam and is_instance_valid(_title_cam):
		_orbit_title(delta)
	if not net_client or player == null:
		return
	# prediction look-ahead: half the round trip + half a snapshot interval
	player.net_lag = clampf(float(server.rtt) * 0.5 + 0.5 / NetProto.SNAP_HZ, 0.03, 0.5)
	_interest_t -= delta
	if _interest_t > 0.0:
		return
	_interest_t = 0.25
	var at: Vector3 = player.global_position
	# the server removes entities beyond INTEREST + HYSTERESIS (N3); this only hides stragglers
	var r := NetProto.INTEREST + NetProto.HYSTERESIS + 2.0
	for id in views:
		var v = views[id]
		if v == player or not is_instance_valid(v):
			continue
		if v.is_in_group("monsters") or v.is_in_group("remote_players"):
			var far: bool = Vector2(v.global_position.x - at.x, v.global_position.z - at.z).length() > r
			if v.visible == far:
				v.visible = not far


func _exit_tree() -> void:
	# scenery of maps we left is not in the tree: free it (and the GLB prototypes) ourselves
	for n in _env_cache.values():
		if is_instance_valid(n):
			n.free()
	_env_cache.clear()
	EnvAssets.clear_cache()


func _arg_value(key: String) -> String:
	for a in args:
		if a.begins_with(key + "="):
			return a.substr(key.length() + 1)
	return ""


# ---------------------------------------------------------------- screenshots

func _take_shot(path: String) -> void:
	PlayerState.is_new = false
	var sz := _arg_value("--shotsize")
	if sz != "":
		var wh := sz.split("x")
		DisplayServer.window_set_size(Vector2i(int(wh[0]), int(wh[1])))
	for i in 30:
		await get_tree().process_frame
	if "--shotdemo" in args:
		_shot_demo()
	if "--shotcalm" in args:
		# screenshot of a busy spot: nobody attacks the (low level) shot character
		for m in mobs():
			m.data = m.data.duplicate(true)
			m.data["aggressive"] = false
	var at := _arg_value("--shotat")
	if at != "":
		var xz := at.split(",")
		teleport_player(Vector3(float(xz[0]), 0, float(xz[1])))
	# --shotskin=<skin item id>: a Swordsman holding a sword with that weapon costume (weapon_skin.gd)
	if _arg_value("--shotskin") != "":
		load("res://scripts/weapon_skin.gd").shot_prepare(self, _arg_value("--shotskin"))
	if _arg_value("--shotcam") != "":
		var c := _arg_value("--shotcam").split(",")
		rig.yaw = float(c[0])
		rig.pitch = float(c[1])
		rig.dist = float(c[2])
		rig._apply()
	for i in 90:
		await get_tree().process_frame
	if "--shotui" in args:
		hud.toggle("status")
		hud.toggle("items")
	for w in _arg_value("--shotwin").split(",", false):
		if w == "refine":
			hud.open_refine(npc_def("refiner"))
		elif w == "shop":
			hud._open_shop(npc_def("tool_dealer"))
		elif w.begins_with("-") and hud.windows.has(w.substr(1)):
			# "-jobslots": close a window that opened by itself (clean UI shots)
			hud.windows[w.substr(1)].visible = false
		elif w == "equip" and "--shot3d" in args:
			# the equipment window with the 3D hero instead of the 2D portrait
			load("res://scripts/ui/equip_window.gd").show_3d = true
			hud.toggle(w)
		elif not hud.is_open(w):
			hud.toggle(w)
	# --shotnpc=<npc id>: that NPC's dialog open (e.g. the Hanamura warper)
	if _arg_value("--shotnpc") != "":
		hud.open_npc(npc_def(_arg_value("--shotnpc")))
	var stip := _arg_value("--shottip")
	if stip != "":
		_shot_tooltip(stip)
	if "--shotmap" in args:
		hud.minimap.set_big(true)
	# --shotmaps=mini|full|world|route|routearea: minimap close-up / full map / world map (map_window.gd)
	if _arg_value("--shotmaps") != "":
		await load("res://scripts/map_window.gd").shot(self, _arg_value("--shotmaps"))
	var sauto := _arg_value("--shotauto")
	if sauto != "":
		await _shot_auto(sauto)
	var smon := _arg_value("--shotmon")
	if smon != "":
		_shot_monster(smon)
		for i in 40:
			await get_tree().process_frame
	var sj := _arg_value("--shotjob")
	var sfeel := _arg_value("--shotfeel")
	var sskill := _arg_value("--shotskill")
	if sskill != "":
		await SkillFxShot.run(self, sskill)
	elif _arg_value("--shotlife") != "":
		# life skills: fish | reel | catch | kitchen | journal | home | edit | garden (life/life_shot.gd)
		await load("res://scripts/life/life_shot.gd").run(self, _arg_value("--shotlife"))
	elif _arg_value("--shotgacha") != "":
		# painted 2D gacha (tools/gacha2d_shot.gd): well | well10:<s> | well1:<s> | splash | asura | asura10:<s>
		await load("res://scripts/tools/gacha2d_shot.gd").run(self, _arg_value("--shotgacha"))
	elif _arg_value("--shotmount") != "":
		# Knight's golden dragon (mount_shot.gd): dragon | ride | walk | attack
		await load("res://scripts/mount_shot.gd").run(self, _arg_value("--shotmount"))
	elif sfeel != "":
		await _shot_feel(sfeel)
	elif sj != "":
		await _shot_fight(sj)
	elif "--shotfx" in args:
		Fx.showcase(self, player)
		for i in 16:
			await get_tree().process_frame
	else:
		for i in 10:
			await get_tree().process_frame
	# --shotsettings=<tab>[:<tone>][:<lang>]: the settings window / a tone preset (settings.gd)
	if _arg_value("--shotsettings") != "":
		await Settings.shot(_arg_value("--shotsettings"))
	var img := get_viewport().get_texture().get_image()
	img.save_png(path)
	print("SHOT saved ", path, " ", img.get_size())
	get_tree().quit(0)


## Screenshot scene: turn the player into `job` and fight the nearest monster from its west side
## (mage: Fire Bolt mid-cast; archer: Double Strafe arrows in flight). Bystanders are cleared away.
func _shot_fight(job: String) -> void:
	var ps = PlayerState
	ps.gain_exp(0, 100000)
	# a second job (--shotjob=knight...): through its first job at Job Lv 50, points spent, fee paid
	var parent := str(GameData.jobs.get(job, {}).get("parent", ""))
	if parent != "":
		ps.change_job(parent)
		ps.gain_exp(0, 50000000)
		ps.ch.skill_points = 0
		ps.ch.zeny += int(GameData.jobs[job].get("change_fee", 0))
	ps.change_job(job)
	ps.gain_exp(40000, 3000000)
	ps.stats["int"] = 50
	ps.stats["dex"] = 40
	ps.equip(str(ps.jd().get("starter", {}).keys()[0]))
	ps.skills["fire_bolt"] = 5
	ps.skills["double_strafe"] = 5
	var best = null
	var bd := 1e9
	for m in mobs():
		var d: float = m.global_position.distance_to(player.global_position)
		if not m.dead and d < bd:
			bd = d
			best = m
	if best == null:
		return
	best.atk_t = 1e6
	best.max_hp = 5000
	best.hp = 5000
	best.roam = 0.0
	best.wander_goal = best.global_position
	var at: Vector3 = best.global_position + Vector3(-7, 0, 0)
	for m in mobs():
		if m != best and (m.global_position.distance_to(at) < 5.0 or m.global_position.distance_to(best.global_position) < 4.0):
			m.map.remove_mob(m)
	teleport_player(at)
	# let the level-up pillars fade first
	for i in 100:
		await get_tree().process_frame
	send({"t": "attack", "target": best.id, "auto": false})
	hud.set_target(view(best.id))
	if job == "mage":
		player.use_skill("fire_bolt")
		for i in 60:
			await get_tree().process_frame
	elif parent != "":
		# second jobs: a normal attack pose with the starter weapon
		for i in 40:
			await get_tree().process_frame
	else:
		player.use_skill("double_strafe")
		while me().acd <= 0.0:
			await get_tree().physics_frame
		for i in 7:
			await get_tree().physics_frame


## Screenshot scene for the melee feel (hit_feel.gd): a Swordman with a +5 blade hits a still monster
## (--shotfeelmon=<id>, default Pudding Slime) put 1.3 m east of the shot spot. `when`: swing =
## mid-swing (weapon trail), impact = just after the blade connects (spark, number, flinch), crit =
## a guaranteed crit's impact (gold trail).
func _shot_feel(when: String) -> void:
	var ps = PlayerState
	ps.gain_exp(0, 100000)
	ps.change_job("swordman")
	ps.gain_exp(9000, 60000)
	ps.stats["str"] = 40
	ps.stats["dex"] = 80
	if when == "crit":
		ps.stats["luk"] = 400
	var blade: Dictionary = ps.make_inst("iron_blade", 5)
	ps.add_inst(blade)
	ps.equip_inst(blade)
	ps.hp = ps.max_hp()
	# a still Pudding Slime 1.3 m east of where the shot stands (--shotat), nobody else close by
	var at: Vector3 = player.global_position
	for m in mobs():
		if m.global_position.distance_to(at) < 8.0:
			m.map.remove_mob(m)
	var best = _spawn(_arg_value("--shotfeelmon") if _arg_value("--shotfeelmon") != "" else "pudding_slime", at + Vector3(1.3, 0, 0), 0.0, false)
	if best == null:
		return
	best.atk_t = 1e6
	best.max_hp = 50000
	best.hp = 50000
	best.roam = 0.0
	best.data = best.data.duplicate(true)
	best.data["speed"] = 0.0
	best.wander_goal = best.global_position
	teleport_player(at)
	for i in 100:
		await get_tree().process_frame
	var n0: int = feel.impacts
	send({"t": "attack", "target": best.id})
	hud.set_target(view(best.id))
	for i in 120:
		await get_tree().physics_frame
		if feel._hold.has(best.id):
			break
	if when == "swing":
		# the strike takes about one frame: hold the pose half way through it (the hit itself waits)
		feel.set_process(false)
		var mid: float = (player.impact_frac - 0.006) * float(player._track.get("len", 1.0))
		for i in 30:
			if player.anim == null or player.anim.current_animation == "slash":
				break
			await get_tree().process_frame
		while player.anim and player.anim.current_animation == "slash" and player.anim.current_animation_position < mid - 0.05:
			await get_tree().process_frame
		if player.anim and not player._track.is_empty():
			player.anim.speed_scale = 0.0
			player.anim.seek(mid, true)
			player._stop_t = 1.0
		for i in 2:
			await get_tree().process_frame
		print("SHOT feel: delay %.3f s, impact %.2f of the clip, trail visible %s" % [feel.last_delay, player.impact_frac,
			str(player.has_trail() and player._trail.visible)])
		return
	for i in 120:
		await get_tree().process_frame
		if feel.impacts > n0:
			break
	for i in 2:
		await get_tree().process_frame
	print("SHOT feel: impact after %.3f s, crit %s, trail visible %s, blade tip %s (hero at %s)" % [feel.last_delay,
		str(player._trail_crit), str(player.has_trail() and player._trail.visible),
		str(player.weapon_mesh.global_transform * player._trail_b) if player.weapon_mesh else "-", str(player.global_position)])
	if player.anim:
		print("SHOT feel: clip %s at %.2f s, track tip there %s, ribbon surfaces %d" % [player.anim.current_animation, player.anim.current_animation_position,
			str(player.track_tip(player.anim.current_animation_position)), player._trail_im.get_surface_count() if player._trail_im else -1])


## Screenshot of an item's hover card (a real tooltip needs a mouse): shown beside the Items window.
func _shot_tooltip(id: String) -> void:
	var e := {"id": id, "inst": {}, "qty": PlayerState.count(id)}
	for inst in PlayerState.gear:
		if str(inst["id"]) == id:
			e = {"id": id, "inst": inst, "qty": 1}
	var p := PanelContainer.new()
	p.add_theme_stylebox_override("panel", hud.theme_.get_stylebox("panel", "TooltipPanel"))
	p.add_child(hud.item_tooltip(e))
	hud.ui_root.add_child(p)
	p.position = Vector2(390, 120)


## Screenshot scene for the AUTO system: a Lv ~30 Swordman with Bash / Magnum Break on the hotbar.
func _shot_auto(kind: String) -> void:
	var ps = PlayerState
	if not ("--shotdemo" in args):
		ps.char_name = "Boss"
		ps.gain_exp(0, 100000)
		ps.change_job("swordman")
		ps.gain_exp(9000, 60000)
		ps.stats["str"] = 45
		ps.stats["agi"] = 30
		ps.stats["dex"] = 25
		ps.stats["vit"] = 20
		ps.skills["bash"] = 10
		ps.skills["magnum_break"] = 5
		var blade: Dictionary = ps.make_inst("iron_blade", 5)
		ps.add_inst(blade)
		ps.equip_inst(blade)
		ps.add_item("red_tonic", 25)
		ps.add_item("orange_tonic", 6)
		ps.hotbar[1] = "skill:bash"
		ps.hotbar[2] = "skill:magnum_break"
		ps.hotbar[3] = "item:orange_tonic"
		ps.hotbar[4] = "skill:first_aid"
		ps.hotbar[5] = "item:fly_wing"
		ps.add_item("fly_wing", 3)
		ps.hp = ps.max_hp()
		ps.changed.emit()
	for i in 90:
		await get_tree().process_frame
	var parts := kind.split(":")
	match parts[0]:
		"battle":
			auto.start_battle()
			for i in 900:
				await get_tree().physics_frame
		"settings":
			auto.start_battle()
			for i in 240:
				await get_tree().physics_frame
			hud.auto_ui.open()
			hud.auto_ui.show_tab(parts[1] if parts.size() > 1 else "combat")
			for i in 10:
				await get_tree().process_frame
		"path":
			hud.minimap.set_big(true)
			await get_tree().process_frame
			hud._fit_map_panel()
			ps.quests.accept(ps, "grove_cleanup")
			auto.goto_point("whisperwood_forest", Vector3(-45, 0, -22))
			for i in 120:
				await get_tree().physics_frame


## Screenshot pose: monster `id` stands (idle, facing the camera) where the player is; the player hides.
func _shot_monster(id: String) -> void:
	if not GameData.monsters.has(id):
		return
	var at: Vector3 = player.global_position
	for m in mobs():
		if m.global_position.distance_to(at) < 9.0:
			m.map.remove_mob(m)
	var mon = _spawn(id, at, 0.0, false)
	mon.data = mon.data.duplicate(true)
	mon.data["speed"] = 0.0
	mon.data["aggressive"] = false
	mon.roam = 0.0
	mon.think_t = 1e6
	player.visible = false
	await get_tree().process_frame
	var mv = view(mon.id)
	if mv:
		mv.model.rotation.y = deg_to_rad(rig.yaw)


## Screenshot props: a level-14 Swordman with refined / carded gear, ores, cards and a few quests.
func _shot_demo() -> void:
	var ps = PlayerState
	ps.char_name = "Boss"
	ps.gain_exp(0, 100000)
	ps.change_job("swordman")
	ps.gain_exp(900, 2000)
	ps.stats["str"] = 30
	ps.stats["agi"] = 20
	ps.stats["dex"] = 15
	ps.stats["vit"] = 12
	var blade: Dictionary = ps.make_inst("iron_blade", 7, ["buzzfly_card"])
	ps.add_inst(blade)
	ps.equip_inst(blade)
	var vest: Dictionary = ps.make_inst("leather_vest", 4, ["slime_card"])
	ps.add_inst(vest)
	ps.equip_inst(vest)
	ps.add_inst(ps.make_inst("hood"))
	ps.add_inst(ps.make_inst("cutter", 5))
	var bag := {"red_tonic": 12, "fly_wing": 3, "phracite": 6, "elunite": 2, "bone_miner_card": 1, "king_card": 1, "bunny_tail": 3, "jelly": 14}
	for k in bag:
		ps.add_item(k, int(bag[k]))
	ps.zeny = 12840
	for q in ["slime_trouble", "explorer", "grove_cleanup"]:
		ps.quests.accept(ps, q)
	ps.quests.active["slime_trouble"]["n"] = 3
	ps.quests.active["grove_cleanup"]["n"] = 6
	ps.quests.active["explorer"]["seen"] = [0, 1]
	ps.add_buff("blessing", 95.0, 5)
	ps.hp = ps.max_hp() * 4 / 5
