diff --git a/docs/builds.md b/docs/builds.md index e81f714b..f07a3251 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -21,6 +21,21 @@ Each platform is its own artifact: `hoppys-adventure-arcade-macos`, `-windows` a - **Windows** (x86_64): unzip and run `HoppysAdventureArcade.exe`. SmartScreen may ask you to confirm (**More info → Run anyway**), because the build isn't code-signed. - **Linux** (x86_64): unzip and run `./HoppysAdventureArcade.x86_64`. +## Run it at a booth + +Launch with `--booth` to run an unattended kiosk. Arguments go after `--`; for a macOS app, use `open -a … --args …`. + +```sh +./HoppysAdventureArcade.x86_64 -- --booth +./HoppysAdventureArcade.x86_64 -- --booth --route=heroes_vs_villains +open -a "Hoppy’s Adventure Arcade.app" --args -- --booth --route=heroes_vs_villains +``` + +- **Fullscreen.** The game starts fullscreen without changing the saved settings. +- **Idle reset.** After 90 seconds with no keyboard, mouse, touch or controller input, any game returns to the hub, whose rotating previews act as the attract screen. +- **No quitting from the menu.** The menu's **Quit Game** does nothing in booth mode; staff close the window (Cmd+Q, Alt+F4). +- **Launch route.** `--route=` opens a route at launch (`heroes_vs_villains`, `code_city_brawl`, `bay_area_blitz`, `orbital_ops`, `enchanted_repo`, `runner`, `platformer`, `beatem_up`, `hub`), with or without `--booth`. An unknown name logs an error and opens the hub. + ## Build locally ```sh diff --git a/hoppys-adventure-arcade/app/app_router.gd b/hoppys-adventure-arcade/app/app_router.gd index ecba0b17..9f9a7eb2 100644 --- a/hoppys-adventure-arcade/app/app_router.gd +++ b/hoppys-adventure-arcade/app/app_router.gd @@ -15,8 +15,19 @@ const ROUTES := { ## nonzero on the first failure. tools/build/export_game.py runs it against each export. const SMOKE_FLAG := "--smoke-routes" const SMOKE_FRAMES := 30 +## Booth mode (` -- --booth`) is for unattended kiosks: the game runs fullscreen, the menu's +## Quit does nothing, and after IDLE_SECONDS without input any route returns to the hub, whose +## rotating previews are the attract screen. `--route=` opens a route at launch, with or +## without --booth. Both are off unless passed. +const BOOTH_FLAG := "--booth" +const ROUTE_PREFIX := "--route=" +const IDLE_SECONDS := 90.0 +const STICK_DEADZONE := 0.4 var busy := false var veil: ColorRect +var booth := false +var idle_limit := IDLE_SECONDS +var idle_seconds := 0.0 func _ready() -> void: # The veil tween must keep running while a microgame has the tree paused. process_mode = Node.PROCESS_MODE_ALWAYS @@ -28,8 +39,14 @@ func _ready() -> void: veil.mouse_filter = Control.MOUSE_FILTER_IGNORE veil.modulate.a = 0 add_child(veil) - if SMOKE_FLAG in OS.get_cmdline_user_args(): + var args := OS.get_cmdline_user_args() + if SMOKE_FLAG in args: smoke_routes.call_deferred() + return + set_booth(BOOTH_FLAG in args) + var start := launch_route(args) + if start != "": + _launch.call_deferred(start) func go(route: String) -> void: if busy or not ROUTES.has(route) or not ResourceLoader.exists(ROUTES[route]): return @@ -48,8 +65,15 @@ func go(route: String) -> void: await create_tween().tween_property(veil, "modulate:a", 0.0, 0.25).finished veil.mouse_filter = Control.MOUSE_FILTER_IGNORE busy = false -func quit_game() -> void: +## Visitors can't quit a booth from the menu; staff close the window instead (force = true). +## A forced quit wins even mid-transition: auto_accept_quit is off, so nothing else would close. +func quit_game(force := false) -> void: + if booth and not force: + return if busy: + if force: + AudioService.shutdown() + get_tree().quit() return busy = true veil.mouse_filter = Control.MOUSE_FILTER_STOP @@ -59,7 +83,47 @@ func quit_game() -> void: func _notification(what: int) -> void: if what == NOTIFICATION_WM_CLOSE_REQUEST: - quit_game() + quit_game(true) + +## The route named by `--route=`, or "" when absent or unknown (with an error for typos). +static func launch_route(args: PackedStringArray) -> String: + for arg in args: + if arg.begins_with(ROUTE_PREFIX): + var route := arg.trim_prefix(ROUTE_PREFIX) + if ROUTES.has(route): + return route + push_error("Unknown --route %s; expected one of %s" % [route, ", ".join(ROUTES.keys())]) + return "" + +func _launch(route: String) -> void: + await go(route) + var scene := get_tree().current_scene + var path := scene.scene_file_path if scene else "no scene" + print("AppRouter: launched route %s (%s)" % [route, path]) + +func set_booth(enabled: bool) -> void: + booth = enabled + idle_seconds = 0.0 + set_process(enabled) + set_process_input(enabled) + if enabled: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) + print("AppRouter: booth mode on (idle return after %d s)" % int(idle_limit)) + +func _input(event: InputEvent) -> void: + if event is InputEventKey or event is InputEventMouseButton or event is InputEventJoypadButton \ + or event is InputEventScreenTouch \ + or (event is InputEventJoypadMotion and absf(event.axis_value) > STICK_DEADZONE): + idle_seconds = 0.0 + +func _process(delta: float) -> void: + idle_seconds += delta + if idle_seconds < idle_limit or busy: + return + idle_seconds = 0.0 + var scene := get_tree().current_scene + if scene == null or scene.scene_file_path != ROUTES["hub"]: + go("hub") func smoke_routes() -> void: # Let the launch scene finish its opening frames, as a player would, before switching. diff --git a/hoppys-adventure-arcade/tests/booth_mode.gd b/hoppys-adventure-arcade/tests/booth_mode.gd new file mode 100644 index 00000000..74c0ea34 --- /dev/null +++ b/hoppys-adventure-arcade/tests/booth_mode.gd @@ -0,0 +1,92 @@ +extends SceneTree +## Booth mode: idle routes return to the hub, input keeps a game alive, the menu can't quit +## the kiosk, and --route= picks a launch route. The startup flags are checked by launching +## the game as a child process, so _ready()'s own argument handling is covered. +## Run headless with --script res://tests/booth_mode.gd. +var failures := 0 +var router: Node + +func _initialize() -> void: + call_deferred("run") + +func check(ok: bool, message: String) -> void: + print("PASS: " if ok else "FAIL: ", message) + if not ok: + failures += 1 + +func wait(seconds: float) -> void: + await create_timer(seconds, true).timeout + +func settle_route() -> void: + for i in 300: + if not router.busy: + break + await process_frame + await process_frame + +## Launch the game headless with user args for two simulated seconds (fixed 60 fps, so the +## route fade always completes) and return its exit code and combined output. +func launch(user_args: Array[String]) -> Dictionary: + var args: Array[String] = ["--headless", "--path", ProjectSettings.globalize_path("res://"), + "--fixed-fps", "60", "--quit-after", "120", "--"] + args.append_array(user_args) + var output: Array = [] + var code := OS.execute(OS.get_executable_path(), args, output, true) + return {"code": code, "log": "\n".join(output)} + +func on_hub() -> bool: + return current_scene != null and current_scene.scene_file_path == router.ROUTES["hub"] + +func run() -> void: + router = root.get_node("AppRouter") + await wait(0.3) + check(not router.booth and not router.is_processing(), "Booth mode is off without --booth") + + var launch: String = router.launch_route(PackedStringArray(["--route=heroes_vs_villains"])) + check(launch == "heroes_vs_villains", "--route names a launch route") + launch = router.launch_route(PackedStringArray(["--booth"])) + check(launch == "", "No --route means the default scene") + + var plain := launch([]) + check(plain.code == 0, "A plain launch exits cleanly") + check(not plain.log.contains("booth mode on") and not plain.log.contains("launched route"), + "A plain launch starts without booth mode or a launch route") + var kiosk := launch(["--booth", "--route=heroes_vs_villains"]) + check(kiosk.code == 0, "A booth launch exits cleanly") + check(kiosk.log.contains("AppRouter: booth mode on"), "Launching with --booth turns booth mode on") + var opened: String = "AppRouter: launched route heroes_vs_villains (%s)" \ + % router.ROUTES["heroes_vs_villains"] + check(kiosk.log.contains(opened), "Launching with --route opens that route's scene") + + router.go("platformer") + await settle_route() + await wait(0.5) + check(not on_hub(), "Without booth mode an idle game stays put") + + router.set_booth(true) + router.idle_limit = 0.6 + check(router.booth and router.is_processing(), "set_booth enables the idle timer") + for i in 4: + await wait(0.25) + var key := InputEventKey.new() + key.physical_keycode = KEY_RIGHT + key.pressed = true + Input.parse_input_event(key) + Input.flush_buffered_events() + check(not on_hub(), "Input inside the idle window keeps the game running") + + await wait(1.2) + await settle_route() + check(on_hub(), "An idle game returns to the hub's attract screen") + + var before: int = router.get_child_count() + router.quit_game() + check(not router.busy and router.get_child_count() == before, + "The menu's Quit is ignored in booth mode") + + router.set_booth(false) + router.idle_limit = router.IDLE_SECONDS + root.get_node("AudioService").shutdown() + await wait(0.4) + print("Booth mode failures: ", failures) + quit(1 if failures else 0) diff --git a/hoppys-adventure-arcade/tests/booth_mode.gd.uid b/hoppys-adventure-arcade/tests/booth_mode.gd.uid new file mode 100644 index 00000000..f72ff152 --- /dev/null +++ b/hoppys-adventure-arcade/tests/booth_mode.gd.uid @@ -0,0 +1 @@ +uid://c7s3s7spaejbm