Skip to content

Commit

Permalink
Bevy run (#120)
Browse files Browse the repository at this point in the history
Add a `bevy run` command which conveniently runs your Bevy app.

It mostly wraps `cargo run`, but also provides a `web` sub command,
which makes it a lot easier to target the browser.

This option will compile your app for WASM, create JS bindings, add an
`index.html` file if you don't provide one yourself and serves the build
locally to open it in your browser.
The default `index.html` file is mostly what we had in
`bevy_quickstart`.

This is the last part of #24.
Closes #8.

# Testing

1. Checkout the `bevy-run` branch.
2. Run `cargo install --path .` to install this version of the Bevy CLI.
3. Navigate to your Bevy app.
4. Run `bevy run web`.

Note that your app must be compatible with WASM. If you have features or
profiles enabled by default which are not compatible with WASM, you need
to disable them. E.g. `bevy run --no-default-features web`.
If you have a custom `index.html` configured for `trunk`, it might also
not work out of the box. You can try removing the entire `web` folder to
try the default setup.

A good example project is of course `bevy_new_2d`, which you can test on
this branch: <TheBevyFlock/bevy_new_2d#312>
  • Loading branch information
TimJentzsch authored Oct 3, 2024
1 parent f586dd5 commit 4c9c578
Show file tree
Hide file tree
Showing 10 changed files with 999 additions and 19 deletions.
669 changes: 658 additions & 11 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@ toml_edit = { version = "0.22.21", default-features = false, features = [

# Understanding package versions
semver = { version = "1.0.23", features = ["serde"] }

# Serving the app for the browser
actix-files = "0.6.6"
actix-web = "4.9.0"

# Opening the app in the browser
webbrowser = "1.0.2"
163 changes: 163 additions & 0 deletions assets/web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Bevy App</title>
<style>
/* Styles for the loading screen */
:root {
--web-bg-color: #282828;
}

* {
margin: 0;
padding: 0;
border: 0;
}

html,
body {
width: 100%;
height: 100%;
}

.center {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}

#game {
background-color: var(--web-bg-color);
}

.spinner {
width: 128px;
height: 128px;
border: 64px solid transparent;
border-bottom-color: #ececec;
border-right-color: #b2b2b2;
border-top-color: #787878;
border-radius: 50%;
box-sizing: border-box;
animation: spin 1.2s linear infinite;
}

@keyframes spin {
0% {
transform: rotate(0deg);
}

100% {
transform: rotate(360deg);
}
}

#bevy {
/* Hide Bevy app before it loads */
height: 0;
}
</style>
</head>

<body>
<div id="game" class="center">
<div id="loading-screen" class="center">
<span class="spinner"></span>
</div>

<canvas id="bevy">Javascript and canvas support is required</canvas>
</div>

<script type="module">
// Starting the game
import init from "./build/bevy_app.js";
init().catch((error) => {
if (
!error.message.startsWith(
"Using exceptions for control flow, don't mind me. This isn't actually an error!"
)
) {
throw error;
}
});
</script>

<script type="module">
// Hide loading screen when the game starts.
const loading_screen = document.getElementById("loading-screen");
const bevy = document.getElementById("bevy");
const observer = new MutationObserver(() => {
if (bevy.height > 1) {
loading_screen.style.display = "none";
observer.disconnect();
}
});
observer.observe(bevy, { attributeFilter: ["height"] });
</script>

<script type="module">
// Script to restart the audio context
// Taken from https://developer.chrome.com/blog/web-audio-autoplay/#moving-forward
(function () {
// An array of all contexts to resume on the page
const audioContextList = [];

// An array of various user interaction events we should listen for
const userInputEventNames = [
"click",
"contextmenu",
"auxclick",
"dblclick",
"mousedown",
"mouseup",
"pointerup",
"touchend",
"keydown",
"keyup",
];

// A proxy object to intercept AudioContexts and
// add them to the array for tracking and resuming later
self.AudioContext = new Proxy(self.AudioContext, {
construct(target, args) {
const result = new target(...args);
audioContextList.push(result);
return result;
},
});

// To resume all AudioContexts being tracked
function resumeAllContexts(event) {
let count = 0;

audioContextList.forEach((context) => {
if (context.state !== "running") {
context.resume();
} else {
count++;
}
});

// If all the AudioContexts have now resumed then we
// unbind all the event listeners from the page to prevent
// unnecessary resume attempts
if (count == audioContextList.length) {
userInputEventNames.forEach((eventName) => {
document.removeEventListener(eventName, resumeAllContexts);
});
}
}

// We bind the resume function for each user interaction
// event on the page
userInputEventNames.forEach((eventName) => {
document.addEventListener(eventName, resumeAllContexts);
});
})();
</script>
</body>
</html>
9 changes: 4 additions & 5 deletions src/bin/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use anyhow::Result;
use bevy_cli::build::BuildArgs;
use bevy_cli::{build::BuildArgs, run::RunArgs};
use clap::{Args, Parser, Subcommand};

fn main() -> Result<()> {
Expand All @@ -11,6 +11,7 @@ fn main() -> Result<()> {
}
Subcommands::Lint { args } => bevy_cli::lint::lint(args)?,
Subcommands::Build(args) => bevy_cli::build::build(&args)?,
Subcommands::Run(args) => bevy_cli::run::run(&args)?,
}

Ok(())
Expand All @@ -29,16 +30,14 @@ pub struct Cli {
}

/// Available subcommands for `bevy`.
#[expect(
clippy::large_enum_variant,
reason = "Only constructed once, not expected to have a performance impact."
)]
#[derive(Subcommand)]
pub enum Subcommands {
/// Create a new Bevy project from a specified template.
New(NewArgs),
/// Build your Bevy app.
Build(BuildArgs),
/// Run your Bevy app.
Run(RunArgs),
/// Check the current project using Bevy-specific lints.
///
/// This command requires `bevy_lint` to be installed, and will fail if it is not. Please see
Expand Down
2 changes: 0 additions & 2 deletions src/external_cli/cargo/run.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
#![expect(dead_code, reason = "Temporarily unused until #120 is merged.")]

use std::process::Command;

use clap::Args;
Expand Down
12 changes: 11 additions & 1 deletion src/external_cli/wasm_bindgen.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::process::Command;
use std::{path::Path, process::Command};

use super::arg_builder::ArgBuilder;

Expand Down Expand Up @@ -28,3 +28,13 @@ pub(crate) fn bundle(package_name: &str, profile: &str) -> anyhow::Result<()> {
anyhow::ensure!(status.success(), "Failed to bundle project for the web.");
Ok(())
}

/// Determine if a file path in the target folder is an artifact generated by wasm-bindgen.
pub(crate) fn is_bindgen_artifact(path: &Path) -> bool {
// The JS interface wrapping the WASM binary
let js_path = Path::new("bevy_app.js");
// The WASM bindgen
let wasm_path = Path::new("bevy_app_bg.wasm");

path == js_path || path == wasm_path
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ pub mod build;
pub mod external_cli;
pub mod lint;
pub mod manifest;
pub mod run;
pub mod template;
48 changes: 48 additions & 0 deletions src/run/args.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use clap::{ArgAction, Args, Subcommand};

use crate::external_cli::{arg_builder::ArgBuilder, cargo::run::CargoRunArgs};

#[derive(Debug, Args)]
pub struct RunArgs {
/// The subcommands available for the run command.
#[command(subcommand)]
pub subcommand: Option<RunSubcommands>,

/// Commands to forward to `cargo run`.
#[clap(flatten)]
pub cargo_args: CargoRunArgs,
}

impl RunArgs {
/// Whether to run the app in the browser.
pub(crate) fn is_web(&self) -> bool {
matches!(self.subcommand, Some(RunSubcommands::Web(_)))
}

/// The profile used to compile the app.
pub(crate) fn profile(&self) -> &str {
self.cargo_args.compilation_args.profile()
}

/// Generate arguments for `cargo`.
pub(crate) fn cargo_args_builder(&self) -> ArgBuilder {
self.cargo_args.args_builder(self.is_web())
}
}

#[derive(Debug, Subcommand)]
pub enum RunSubcommands {
/// Run your app in the browser.
Web(RunWebArgs),
}

#[derive(Debug, Args)]
pub struct RunWebArgs {
/// The port to run the web server on.
#[arg(short, long, default_value_t = 4000)]
pub port: u16,

/// Open the app in the browser.
#[arg(short = 'o', long = "open", action = ArgAction::SetTrue, default_value_t = false)]
pub open: bool,
}
49 changes: 49 additions & 0 deletions src/run/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use args::RunSubcommands;

use crate::{
build::ensure_web_setup,
external_cli::{cargo, wasm_bindgen, CommandHelpers},
manifest::package_name,
};

pub use self::args::RunArgs;

mod args;
mod serve;

pub fn run(args: &RunArgs) -> anyhow::Result<()> {
let cargo_args = args.cargo_args_builder();

if let Some(RunSubcommands::Web(web_args)) = &args.subcommand {
ensure_web_setup()?;

// If targeting the web, run a web server with the WASM build
println!("Building for WASM...");
cargo::build::command().args(cargo_args).ensure_status()?;

println!("Bundling for the web...");
wasm_bindgen::bundle(&package_name()?, args.profile())?;

let port = web_args.port;
let url = format!("http://localhost:{port}");

// Serving the app is blocking, so we open the page first
if web_args.open {
match webbrowser::open(&url) {
Ok(()) => println!("Your app is running at <{url}>!"),
Err(error) => {
println!("Failed to open the browser automatically, open the app at <{url}>. (Error: {error:?}")
}
}
} else {
println!("Open your app at <{url}>!");
}

serve::serve(port, args.profile())?;
} else {
// For native builds, wrap `cargo run`
cargo::run::command().args(cargo_args).ensure_status()?;
}

Ok(())
}
58 changes: 58 additions & 0 deletions src/run/serve.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! Serving the app locally for the browser.
use actix_web::{rt, web, App, HttpResponse, HttpServer, Responder};
use std::path::Path;

use crate::external_cli::wasm_bindgen;

/// If the user didn't provide an `index.html`, serve a default one.
async fn serve_default_index() -> impl Responder {
let content = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/web/index.html"
));

// Build the HTTP response with appropriate headers to serve the content as a file
HttpResponse::Ok()
.insert_header((
actix_web::http::header::CONTENT_TYPE,
"text/html; charset=utf-8",
))
.body(content)
}

/// Launch a web server running the Bevy app.
pub(crate) fn serve(port: u16, profile: &str) -> anyhow::Result<()> {
let profile = profile.to_string();

rt::System::new().block_on(
HttpServer::new(move || {
let mut app = App::new();

// Serve the build artifacts at the `/build/*` route
// A custom `index.html` will have to call `/build/bevy_app.js`
app = app.service(
actix_files::Files::new("/build", wasm_bindgen::get_target_folder(&profile))
.path_filter(|path, _| wasm_bindgen::is_bindgen_artifact(path)),
);

// If the app has an assets folder, serve it under `/assets`
if Path::new("assets").exists() {
app = app.service(actix_files::Files::new("/assets", "./assets"))
}

if Path::new("web").exists() {
// Serve the contents of the `web` folder under `/`, if it exists
app = app.service(actix_files::Files::new("/", "./web").index_file("index.html"));
} else {
// If the user doesn't provide a custom web setup, serve a default `index.html`
app = app.route("/", web::get().to(serve_default_index))
}

app
})
.bind(("127.0.0.1", port))?
.run(),
)?;

Ok(())
}

0 comments on commit 4c9c578

Please sign in to comment.