Initial commit

This commit is contained in:
Kristofers Solo 2024-09-17 20:19:16 +03:00
commit f79be932a3
53 changed files with 7589 additions and 0 deletions

View File

@ -0,0 +1,147 @@
# Copy this file to `config.toml` to speed up your builds.
#
# # Faster linker
#
# One of the slowest aspects of compiling large Rust programs is the linking time. This file configures an
# alternate linker that may improve build times. When choosing a new linker, you have two options:
#
# ## LLD
#
# LLD is a linker from the LLVM project that supports Linux, Windows, MacOS, and WASM. It has the greatest
# platform support and the easiest installation process. It is enabled by default in this file for Linux
# and Windows. On MacOS, the default linker yields higher performance than LLD and is used instead.
#
# To install, please scroll to the corresponding table for your target (eg. `[target.x86_64-pc-windows-msvc]`
# for Windows) and follow the steps under `LLD linker`.
#
# For more information, please see LLD's website at <https://lld.llvm.org>.
#
# ## Mold
#
# Mold is a newer linker written by one of the authors of LLD. It boasts even greater performance, specifically
# through its high parallelism, though it only supports Linux.
#
# Mold is disabled by default in this file. If you wish to enable it, follow the installation instructions for
# your corresponding target, disable LLD by commenting out its `-Clink-arg=...` line, and enable Mold by
# *uncommenting* its `-Clink-arg=...` line.
#
# There is a fork of Mold named Sold that supports MacOS, but it is unmaintained and is about the same speed as
# the default ld64 linker. For this reason, it is not included in this file.
#
# For more information, please see Mold's repository at <https://github.com/rui314/mold>.
#
# # Nightly configuration
#
# Be warned that the following features require nightly Rust, which is expiremental and may contain bugs. If you
# are having issues, skip this section and use stable Rust instead.
#
# There are a few unstable features that can improve performance. To use them, first install nightly Rust
# through Rustup:
#
# ```
# rustup toolchain install nightly
# ```
#
# Finally, uncomment the lines under the `Nightly` heading for your corresponding target table (eg.
# `[target.x86_64-unknown-linux-gnu]` for Linux) to enable the following features:
#
# ## `share-generics`
#
# Usually rustc builds each crate separately, then combines them all together at the end. `share-generics` forces
# crates to share monomorphized generic code, so they do not duplicate work.
#
# In other words, instead of crate 1 generating `Foo<String>` and crate 2 generating `Foo<String>` separately,
# only one crate generates `Foo<String>` and the other adds on to the pre-exiting work.
#
# Note that you may have some issues with this flag on Windows. If compiling fails due to the 65k symbol limit,
# you may have to disable this setting. For more information and possible solutions to this error, see
# <https://github.com/bevyengine/bevy/issues/1110>.
#
# ## `threads`
#
# This option enables rustc's parallel frontend, which improves performance when parsing, type checking, borrow
# checking, and more. We currently set `threads=0`, which defaults to the amount of cores in your CPU.
#
# For more information, see the blog post at <https://blog.rust-lang.org/2023/11/09/parallel-rustc.html>.
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = [
# LLD linker
#
# You may need to install it:
#
# - Ubuntu: `sudo apt-get install lld clang`
# - Fedora: `sudo dnf install lld clang`
# - Arch: `sudo pacman -S lld clang`
"-Clink-arg=-fuse-ld=lld",
# Mold linker
#
# You may need to install it:
#
# - Ubuntu: `sudo apt-get install mold clang`
# - Fedora: `sudo dnf install mold clang`
# - Arch: `sudo pacman -S mold clang`
# "-Clink-arg=-fuse-ld=/usr/bin/mold",
# Nightly
# "-Zshare-generics=y",
# "-Zthreads=0",
]
[target.x86_64-apple-darwin]
rustflags = [
# LLD linker
#
# The default ld64 linker is faster, you should continue using it instead.
#
# You may need to install it:
#
# Brew: `brew install llvm`
# Manually: <https://lld.llvm.org/MachO/index.html>
# "-Clink-arg=-fuse-ld=/usr/local/opt/llvm/bin/ld64.lld",
# Nightly
# "-Zshare-generics=y",
# "-Zthreads=0",
]
[target.aarch64-apple-darwin]
rustflags = [
# LLD linker
#
# The default ld64 linker is faster, you should continue using it instead.
#
# You may need to install it:
#
# Brew: `brew install llvm`
# Manually: <https://lld.llvm.org/MachO/index.html>
# "-Clink-arg=-fuse-ld=/opt/homebrew/opt/llvm/bin/ld64.lld",
# Nightly
# "-Zshare-generics=y",
# "-Zthreads=0",
]
[target.x86_64-pc-windows-msvc]
# LLD linker
#
# You may need to install it:
#
# ```
# cargo install -f cargo-binutils
# rustup component add llvm-tools
# ```
linker = "rust-lld.exe"
rustdocflags = ["-Clinker=rust-lld.exe"]
rustflags = [
# Nightly
# "-Zshare-generics=n", # This needs to be off if you use dynamic linking on Windows.
# "-Zthreads=0",
]
# Optional: Uncommenting the following improves compile times, but reduces the amount of debug info to 'line number tables only'
# In most cases the gains are negligible, but if you are on macos and have slow compile times you should see significant gains.
# [profile.dev]
# debug = 1

84
.github/workflows/ci.yaml vendored Normal file
View File

@ -0,0 +1,84 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: --deny warnings
RUSTDOCFLAGS: --deny warnings
jobs:
# Run tests.
test:
name: Tests
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: Populate target directory from cache
uses: Leafwing-Studios/cargo-cache@v2
with:
sweep-cache: true
- name: Run tests
run: |
cargo test --locked --workspace --all-features --all-targets
# Workaround for https://github.com/rust-lang/cargo/issues/6669
cargo test --locked --workspace --all-features --doc
# Run clippy lints.
clippy:
name: Clippy
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install dependencies
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: Populate target directory from cache
uses: Leafwing-Studios/cargo-cache@v2
with:
sweep-cache: true
- name: Run clippy lints
run: cargo clippy --locked --workspace --all-targets --all-features -- --deny warnings
# Check formatting.
format:
name: Format
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Run cargo fmt
run: cargo fmt --all -- --check
# Check documentation.
doc:
name: Docs
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: Populate target directory from cache
uses: Leafwing-Studios/cargo-cache@v2
with:
sweep-cache: true
- name: Check documentation
run: cargo doc --locked --workspace --all-features --document-private-items --no-deps

273
.github/workflows/release.yaml vendored Normal file
View File

@ -0,0 +1,273 @@
name: Release
on:
# Trigger this workflow when a tag is pushed in the format `v1.2.3`.
push:
tags:
# Pattern syntax: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
- "v[0-9]+.[0-9]+.[0-9]+*"
# Trigger this workflow manually via workflow dispatch.
workflow_dispatch:
inputs:
version:
description: 'Version number in the format `v1.2.3`'
required: true
type: string
# Configure the release workflow by editing these values.
env:
# The base filename of the binary produced by `cargo build`.
cargo_build_binary_name: the-labyrinth-of-echoes
# The path to the assets directory.
assets_path: assets
# Whether to upload the packages produced by this workflow to a GitHub release.
upload_to_github: true
# The itch.io project to upload to in the format `user-name/project-name`.
# There will be no upload to itch.io if this is commented out.
upload_to_itch: kristoferssolo/https://kristoferssolo.itch.io/the-labyrinth-of-echoes
############
# ADVANCED #
############
# The ID of the app produced by this workflow.
# Applies to macOS releases.
# Must contain only A-Z, a-z, 0-9, hyphen, and period: https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleidentifier
app_id: kristoferssolo.the-labyrinth-of-echoes
# The base filename of the binary in the package produced by this workflow.
# Applies to Windows, macOS, and Linux releases.
# Defaults to `cargo_build_binary_name` if commented out.
#app_binary_name: the-labyrinth-of-echoes
# The name of the `.zip` or `.dmg` file produced by this workflow.
# Defaults to `app_binary_name` if commented out.
#app_package_name: the-labyrinth-of-echoes
# The display name of the app produced by this workflow.
# Applies to macOS releases.
# Defaults to `app_package_name` if commented out.
#app_display_name: The Labyrinth Of Echoes
# The short display name of the app produced by this workflow.
# Applies to macOS releases.
# Must be 15 or fewer characters: https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundlename
# Defaults to `app_display_name` if commented out.
#app_short_name: The Labyrint…
# Before enabling LFS, please take a look at GitHub's documentation for costs and quota limits:
# https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-storage-and-bandwidth-usage
git_lfs: false
jobs:
# Determine the version number for this workflow.
get-version:
runs-on: ubuntu-latest
steps:
- name: Get version number from tag
id: tag
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "${GITHUB_OUTPUT}"
outputs:
# Use the input from workflow dispatch, or fall back to the git tag.
version: ${{ inputs.version || steps.tag.outputs.tag }}
# Build and package a release for each platform.
build:
needs:
- get-version
env:
version: ${{ needs.get-version.outputs.version }}
strategy:
matrix:
include:
- platform: web
targets: wasm32-unknown-unknown
profile: release
binary_ext: .wasm
package_ext: .zip
runner: ubuntu-latest
- platform: linux
targets: x86_64-unknown-linux-gnu
profile: release-native
features: bevy/wayland
package_ext: .zip
runner: ubuntu-latest
- platform: windows
targets: x86_64-pc-windows-msvc
profile: release-native
binary_ext: .exe
package_ext: .zip
runner: windows-latest
- platform: macos
targets: x86_64-apple-darwin aarch64-apple-darwin
profile: release-native
app_suffix: .app/Contents/MacOS
package_ext: .dmg
runner: macos-latest
runs-on: ${{ matrix.runner }}
permissions:
# Required to create a GitHub release: https://docs.github.com/en/rest/releases/releases#create-a-release
contents: write
defaults:
run:
shell: bash
steps:
- name: Set up environment
run: |
# Default values:
echo "app_binary_name=${app_binary_name:=${{ env.cargo_build_binary_name }}}" >> "${GITHUB_ENV}"
echo "app_package_name=${app_package_name:=${app_binary_name}}" >> "${GITHUB_ENV}"
echo "app_display_name=${app_display_name:=${app_package_name}}" >> "${GITHUB_ENV}"
echo "app_short_name=${app_short_name:=${app_display_name}}" >> "${GITHUB_ENV}"
# File paths:
echo "app=tmp/app/${app_package_name}"'${{ matrix.app_suffix }}' >> "${GITHUB_ENV}"
echo "package=${app_package_name}-"'${{ matrix.platform }}${{ matrix.package_ext }}' >> "${GITHUB_ENV}"
# macOS environment:
if [ '${{ matrix.platform }}' == 'macos' ]; then
echo 'MACOSX_DEPLOYMENT_TARGET=11.0' >> "${GITHUB_ENV}" # macOS 11.0 Big Sur is the first version to support universal binaries.
echo "SDKROOT=$(xcrun --sdk macosx --show-sdk-path)" >> "${GITHUB_ENV}"
fi
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: ${{ env.git_lfs }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.targets }}
- name: Populate cargo cache
uses: Leafwing-Studios/cargo-cache@v2
with:
sweep-cache: true
- name: Install dependencies (Linux)
if: ${{ matrix.platform == 'linux' }}
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: Prepare output directories
run: rm -rf tmp; mkdir -p tmp/binary '${{ env.app }}'
- name: Install cargo-binstall (Web)
if: ${{ matrix.platform == 'web' }}
uses: cargo-bins/cargo-binstall@v1.9.0
- name: Install and run trunk (Web)
if: ${{ matrix.platform == 'web' }}
run: |
cargo binstall --no-confirm trunk wasm-bindgen-cli wasm-opt
trunk build --locked --release --dist '${{ env.app }}'
- name: Build binaries (non-Web)
if: ${{ matrix.platform != 'web' }}
run: |
for target in ${{ matrix.targets }}; do
cargo build --locked --profile='${{ matrix.profile }}' --target="${target}" --no-default-features --features='${{ matrix.features }}'
mv target/"${target}"/'${{ matrix.profile }}/${{ env.cargo_build_binary_name }}${{ matrix.binary_ext }}' tmp/binary/"${target}"'${{ matrix.binary_ext }}'
done
- name: Add binaries to app (non-Web)
if: ${{ matrix.platform != 'web' }}
run: |
if [ '${{ matrix.platform }}' == 'macos' ]; then
lipo tmp/binary/*'${{ matrix.binary_ext }}' -create -output '${{ env.app }}/${{ env.app_binary_name }}${{ matrix.binary_ext }}'
else
mv tmp/binary/*'${{ matrix.binary_ext }}' '${{ env.app }}/${{ env.app_binary_name }}${{ matrix.binary_ext }}'
fi
- name: Add assets to app (non-Web)
if: ${{ matrix.platform != 'web' }}
run: cp -r ./'${{ env.assets_path }}' '${{ env.app }}' || true # Ignore error if assets folder does not exist
- name: Add metadata to app (macOS)
if: ${{ matrix.platform == 'macos' }}
run: |
cat >'${{ env.app }}/../Info.plist' <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>${{ env.app_display_name }}</string>
<key>CFBundleExecutable</key>
<string>${{ env.app_binary_name }}</string>
<key>CFBundleIdentifier</key>
<string>${{ env.app_id }}</string>
<key>CFBundleName</key>
<string>${{ env.app_short_name }}</string>
<key>CFBundleShortVersionString</key>
<string>${{ env.version }}</string>
<key>CFBundleVersion</key>
<string>${{ env.version }}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
</dict>
</plist>
EOF
- name: Package app (non-Windows)
if: ${{ matrix.platform != 'windows' }}
working-directory: tmp/app
run: |
if [ '${{ matrix.platform }}' == 'macos' ]; then
ln -s /Applications .
hdiutil create -fs HFS+ -volname '${{ env.app_package_name }}' -srcfolder . '${{ env.package }}'
else
zip --recurse-paths '${{ env.package }}' '${{ env.app_package_name }}'
fi
- name: Package app (Windows)
if: ${{ matrix.platform == 'windows' }}
working-directory: tmp/app
shell: pwsh
run: Compress-Archive -Path '${{ env.app_package_name }}' -DestinationPath '${{ env.package }}'
- name: Upload package to workflow artifacts
uses: actions/upload-artifact@v4
with:
path: tmp/app/${{ env.package }}
name: package-${{ matrix.platform }}
retention-days: 1
- name: Upload package to GitHub release
if: ${{ env.upload_to_github == 'true' }}
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: tmp/app/${{ env.package }}
asset_name: ${{ env.package }}
release_name: ${{ env.version }}
tag: ${{ env.version }}
overwrite: true
# Check if upload to itch.io is enabled.
# This is needed because the `env` context can't be used in the `if:` condition of a job:
# https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability
check-upload-to-itch:
runs-on: ubuntu-latest
steps:
- name: Do nothing
run: 'true'
outputs:
target: ${{ env.upload_to_itch }}
# Upload all packages to itch.io.
upload-to-itch:
runs-on: ubuntu-latest
needs:
- get-version
- check-upload-to-itch
- build
if: ${{ needs.check-upload-to-itch.outputs.target != '' }}
steps:
- name: Download all packages
uses: actions/download-artifact@v4
with:
pattern: package-*
path: tmp
- name: Install butler
run: |
curl -L -o butler.zip 'https://broth.itch.zone/butler/linux-amd64/LATEST/archive/default'
unzip butler.zip
chmod +x butler
./butler -V
- name: Upload all packages to itch.io
env:
BUTLER_API_KEY: ${{ secrets.BUTLER_CREDENTIALS }}
run: |
for channel in $(ls tmp); do
./butler push \
--fix-permissions \
--userversion='${{ needs.get-version.outputs.version }}' \
tmp/"${channel}"/* \
'${{ env.upload_to_itch }}':"${channel#package-}"
done

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Rust builds
/target
# This file contains environment-specific configuration like linker settings
.cargo/config.toml

4716
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

88
Cargo.toml Normal file
View File

@ -0,0 +1,88 @@
[package]
name = "the-labyrinth-of-echoes"
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
version = "0.1.0"
edition = "2021"
[dependencies]
bevy = { version = "0.14", features = ["wayland"] }
rand = "0.8"
# Compile low-severity logs out of native builds for performance.
log = { version = "0.4", features = [
"max_level_debug",
"release_max_level_warn",
] }
# Compile low-severity logs out of web builds for performance.
tracing = { version = "0.1", features = [
"max_level_debug",
"release_max_level_warn",
] }
[features]
default = [
# Default to a native dev build.
"dev_native",
]
dev = [
# Improve compile times for dev builds by linking Bevy as a dynamic library.
"bevy/dynamic_linking",
"bevy/bevy_dev_tools",
]
dev_native = [
"dev",
# Enable asset hot reloading for native dev builds.
"bevy/file_watcher",
# Enable embedded asset hot reloading for native dev builds.
"bevy/embedded_watcher",
]
# Idiomatic Bevy code often triggers these lints, and the CI workflow treats them as errors.
# In some cases they may still signal poor code quality however, so consider commenting out these lines.
[lints.clippy]
# Bevy supplies arguments to systems via dependency injection, so it's natural for systems to
# request more than 7 arguments -- which triggers this lint.
too_many_arguments = "allow"
# Queries that access many components may trigger this lint.
type_complexity = "allow"
# Compile with Performance Optimizations:
# https://bevyengine.org/learn/quick-start/getting-started/setup/#compile-with-performance-optimizations
# Enable a small amount of optimization in the dev profile.
[profile.dev]
opt-level = 1
# Enable a large amount of optimization in the dev profile for dependencies.
[profile.dev.package."*"]
opt-level = 3
# Remove expensive debug assertions due to <https://github.com/bevyengine/bevy/issues/14291>
[profile.dev.package.wgpu-types]
debug-assertions = false
# The default profile is optimized for Wasm builds because
# that's what [Trunk reads](https://github.com/trunk-rs/trunk/issues/605).
# Optimize for size in the wasm-release profile to reduce load times and bandwidth usage on web.
[profile.release]
# Compile the entire crate as one unit.
# Slows compile times, marginal improvements.
codegen-units = 1
# Do a second optimization pass over the entire program, including dependencies.
# Slows compile times, marginal improvements.
lto = "thin"
# Optimize with size in mind (also try "z", sometimes it is better).
# Slightly slows compile times, great improvements to file size and runtime performance.
opt-level = "s"
# Strip all debugging information from the binary to slightly reduce file size.
strip = "debuginfo"
# Override some settings for native builds.
[profile.release-native]
# Default to release profile values.
inherits = "release"
# Optimize with performance in mind.
opt-level = 3
# Keep debug information in the binary.
strip = "none"

131
QUICKSTART.md Normal file
View File

@ -0,0 +1,131 @@
_Brought to you by the Bevy Jam working group._
# Bevy Quickstart
This template is a great way to get started on a new [Bevy](https://bevyengine.org/) game—especially for a game jam!
Start with a [basic project structure](#write-your-game) and [CI / CD](#release-your-game) that can deploy to [itch.io](https://itch.io).
You can [try this template in your web browser!](https://the-bevy-flock.itch.io/bevy-quickstart)
[@ChristopherBiscardi](https://github.com/ChristopherBiscardi) made a video on how to use this template from start to finish:
[<img src="./docs/img/thumbnail.png" width=40% height=40% alt="A video tutorial for bevy_quickstart"/>](https://www.youtube.com/watch?v=ESBRyXClaYc)
## Prerequisites
We assume that you know how to use Bevy already and have seen the [official Quick Start Guide](https://bevyengine.org/learn/quick-start/introduction/).
If you're new to Bevy, the patterns used in this template may look a bit weird at first glance.
See our [Design Document](./docs/design.md) for more information on how we structured the code and why.
## Create a new game
Install [`cargo-generate`](https://github.com/cargo-generate/cargo-generate) and run the following command:
```sh
cargo generate TheBevyFlock/bevy_quickstart --branch cargo-generate
```
Then navigate to the newly generated directory and run the following commands:
```sh
git branch --move main
cargo update
git commit -am 'Initial commit'
```
Then [create a GitHub repository](https://github.com/new) and push your local repository to it.
<details>
<summary>This template can also be set up manually.</summary>
Navigate to the top of [this GitHub repository](https://github.com/TheBevyFlock/bevy_quickstart/) and select `Use this template > Create a new repository`:
![UI demonstration](./docs/img/readme-manual-setup.png)
Clone your new Github repository to a local repository and push a commit with the following changes:
- Delete `LICENSE`, `README`, and `docs/` files.
- Search for and replace instances of `bevy_quickstart` with the name of your project.
- Adjust the `env` variables in [`.github/workflows/release.yaml`](./.github/workflows/release.yaml).
</details>
## Write your game
The best way to get started is to play around with what you find in [`src/demo/`](./src/demo).
This template comes with a basic project structure that you may find useful:
| Path | Description |
| -------------------------------------------------- | ------------------------------------------------------------------ |
| [`src/lib.rs`](./src/lib.rs) | App setup |
| [`src/asset_tracking.rs`](./src/asset_tracking.rs) | A high-level way to load collections of asset handles as resources |
| [`src/audio/`](./src/audio) | Marker components for sound effects and music |
| [`src/demo/`](./src/demo) | Example game mechanics & content (replace with your own code) |
| [`src/dev_tools.rs`](./src/dev_tools.rs) | Dev tools for dev builds (press \` aka backtick to toggle) |
| [`src/screens/`](./src/screens) | Splash screen, title screen, gameplay screen, etc. |
| [`src/theme/`](./src/theme) | Reusable UI widgets & theming |
Feel free to move things around however you want, though.
> [!Tip]
> Be sure to check out the [3rd-party tools](./docs/tooling.md) we recommend!
## Run your game
Running your game locally is very simple:
- Use `cargo run` to run a native dev build.
- Use [`trunk serve`](https://trunkrs.dev/) to run a web dev build.
If you're using [VS Code](https://code.visualstudio.com/), this template comes with a [`.vscode/tasks.json`](./.vscode/tasks.json) file.
<details>
<summary>Run release builds</summary>
- Use `cargo run --profile release-native --no-default-features` to run a native release build.
- Use `trunk serve --release --no-default-features` to run a web release build.
</details>
<details>
<summary>Linux dependencies</summary>
If you are using Linux, make sure you take a look at Bevy's [Linux dependencies](https://github.com/bevyengine/bevy/blob/main/docs/linux_dependencies.md).
Note that this template enables Wayland support, which requires additional dependencies as detailed in the link above.
Wayland is activated by using the `bevy/wayland` feature in the [`Cargo.toml`](./Cargo.toml).
</details>
<details>
<summary>(Optional) Improve your compile times</summary>
[`.cargo/config_fast_builds.toml`](./.cargo/config_fast_builds.toml) contains documentation on how to set up your environment to improve compile times.
After you've fiddled with it, rename it to `.cargo/config.toml` to enable it.
</details>
## Release your game
This template uses [GitHub workflows](https://docs.github.com/en/actions/using-workflows) to run tests and build releases.
See [Workflows](./docs/workflows.md) for more information.
## Known Issues
There are some known issues in Bevy that require some arcane workarounds.
To keep this template simple, we have opted not to include those workarounds.
You can read about them in the [Known Issues](./docs/known-issues.md) document.
## License
The source code in this repository is licensed under any of the following at your option:
- [CC0-1.0 License](./LICENSE-CC0-1.0.txt)
- [MIT License](./LICENSE-MIT.txt)
- [Apache License, Version 2.0](./LICENSE-Apache-2.0.txt)
The CC0 license explicitly does not waive patent rights, but we confirm that we hold no patent rights to anything presented in this repository.
## Credits
The [assets](./assets) in this repository are all 3rd-party. See the [credits screen](./src/screens/credits.rs) for more information.

12
README.md Normal file
View File

@ -0,0 +1,12 @@
# Maze Ascension: The Labyrinth of Echoes
"Maze Ascension: The Labyrinth of Echoes" is a minimalist maze exploration
game built using the Bevy engine. The game features simple visuals with
hexagonal tiles forming the maze structure on a white background with black
borders, and a stickman-style player character. Players navigate through
multiple levels of increasing difficulty, progressing vertically as they
climb up through levels. The game includes power-ups and abilities hidden
throughout the maze, and later introduces the ability to move between levels
freely. This unique blend of puzzle-solving, exploration, and vertical
progression offers a fresh twist on traditional maze gameplay, presented in
an accessible and clean visual style.

11
README.norg Normal file
View File

@ -0,0 +1,11 @@
* Maze Ascension: The Labyrinth of Echoes
"Maze Ascension: The Labyrinth of Echoes" is a minimalist maze exploration
game built using the Bevy engine. The game features simple visuals with
hexagonal tiles forming the maze structure on a white background with black
borders, and a stickman-style player character. Players navigate through
multiple levels of increasing difficulty, progressing vertically as they
climb up through levels. The game includes power-ups and abilities hidden
throughout the maze, and later introduces the ability to move between levels
freely. This unique blend of puzzle-solving, exploration, and vertical
progression offers a fresh twist on traditional maze gameplay, presented in
an accessible and clean visual style.

13
Trunk.toml Normal file
View File

@ -0,0 +1,13 @@
[build]
# Point to our `index.html`.
target = "web/index.html"
# Set the output directory for the web build.
dist = "target/trunk"
# This is needed in order to host the game on itch.io.
public_url = "./"
[serve]
# Required in order to receive 404s for missing assets, which is what Bevy expects.
no_spa = true
# Open a browser tab once the initial build is complete.
open = true

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/images/ducky.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

BIN
assets/images/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

354
docs/design.md Normal file
View File

@ -0,0 +1,354 @@
# Design philosophy
The high-level goal of this template is to feel like the official template that is currently missing from Bevy.
The exists an [official CI template](https://github.com/bevyengine/bevy_github_ci_template), but, in our opinion,
that one is currently more of an extension to the [Bevy examples](https://bevyengine.org/examples/) than an actual template.
We say this because it is extremely bare-bones and as such does not provide things that in practice are necessary for game development.
## Principles
So, how would an official template that is built for real-world game development look like?
The Bevy Jam working group has agreed on the following guiding design principles:
- Show how to do things in pure Bevy. This means using no 3rd-party dependencies.
- Have some basic game code written out already.
- Have everything outside of code already set up.
- Nice IDE support.
- `cargo-generate` support.
- Workflows that provide CI and CD with an auto-publish to itch.io.
- Builds configured for performance by default.
- Answer questions that will quickly come up when creating an actual game.
- How do I structure my code?
- How do I preload assets?
- What are best practices for creating UI?
- etc.
The last point means that in order to make this template useful for real-life projects,
we have to make some decisions that are necessarily opinionated.
These opinions are based on the experience of the Bevy Jam working group and
what we have found to be useful in our own projects.
If you disagree with any of these, it should be easy to change them.
Bevy is still young, and many design patterns are still being discovered and refined.
Most do not even have an agreed name yet. For some prior work in this area that inspired us,
see [the Unofficial Bevy Cheatbook](https://bevy-cheatbook.github.io/) and [bevy_best_practices](https://github.com/tbillington/bevy_best_practices).
## Pattern Table of Contents
- [Plugin Organization](#plugin-organization)
- [Widgets](#widgets)
- [Asset Preloading](#asset-preloading)
- [Spawn Commands](#spawn-commands)
- [Interaction Callbacks](#interaction-callbacks)
- [Dev Tools](#dev-tools)
- [Screen States](#screen-states)
When talking about these, use their name followed by "pattern",
e.g. "the widgets pattern", or "the plugin organization pattern".
## Plugin Organization
### Pattern
Structure your code into plugins like so:
```rust
// game.rs
mod player;
mod enemy;
mod powerup;
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.add_plugins((player::plugin, enemy::plugin, powerup::plugin));
}
```
```rust
// player.rs / enemy.rs / powerup.rs
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.add_systems(Update, (your, systems, here));
}
```
### Reasoning
Bevy is great at organizing code into plugins. The most lightweight way to do this is by using simple functions as plugins.
By splitting your code like this, you can easily keep all your systems and resources locally grouped. Everything that belongs to the `player` is only in `player.rs`, and so on.
A good rule of thumb is to have one plugin per file,
but feel free to leave out a plugin if your file does not need to do anything with the `App`.
## Widgets
### Pattern
Spawn your UI elements by extending the [`Widgets` trait](../src/theme/widgets.rs):
```rust
pub trait Widgets {
fn button(&mut self, text: impl Into<String>) -> EntityCommands;
fn header(&mut self, text: impl Into<String>) -> EntityCommands;
fn label(&mut self, text: impl Into<String>) -> EntityCommands;
fn text_input(&mut self, text: impl Into<String>) -> EntityCommands;
fn image(&mut self, texture: Handle<Texture>) -> EntityCommands;
fn progress_bar(&mut self, progress: f32) -> EntityCommands;
}
```
### Reasoning
This pattern is inspired by [sickle_ui](https://github.com/UmbraLuminosa/sickle_ui).
`Widgets` is implemented for `Commands` and similar, so you can easily spawn UI elements in your systems.
By encapsulating a widget inside a function, you save on a lot of boilerplate code and can easily change the appearance of all widgets of a certain type.
By returning `EntityCommands`, you can easily chain multiple widgets together and insert children into a parent widget.
## Asset Preloading
### Pattern
Define your assets with a resource that maps asset paths to `Handle`s.
If you're defining the assets in code, add their paths as constants.
Otherwise, load them dynamically from e.g. a file.
```rust
#[derive(Resource, Debug, Deref, DerefMut, Reflect)]
#[reflect(Resource)]
pub struct ImageHandles(HashMap<String, Handle<Image>>);
impl ImageHandles {
pub const PATH_PLAYER: &'static str = "images/player.png";
pub const PATH_ENEMY: &'static str = "images/enemy.png";
pub const PATH_POWERUP: &'static str = "images/powerup.png";
}
impl FromWorld for ImageHandles {
fn from_world(world: &mut World) -> Self {
let asset_server = world.resource::<AssetServer>();
let paths = [
ImageHandles::PATH_PLAYER,
ImageHandles::PATH_ENEMY,
ImageHandles::PATH_POWERUP,
];
let map = paths
.into_iter()
.map(|path| (path.to_string(), asset_server.load(path)))
.collect();
Self(map)
}
}
```
Then start preloading in the `assets::plugin`:
```rust
pub(super) fn plugin(app: &mut App) {
app.register_type::<ImageHandles>();
app.init_resource::<ImageHandles>();
}
```
And finally add a loading check to the `screens::loading::plugin`:
```rust
fn all_assets_loaded(
image_handles: Res<ImageHandles>,
) -> bool {
image_handles.all_loaded(&asset_server)
}
```
### Reasoning
This pattern is inspired by [bevy_asset_loader](https://github.com/NiklasEi/bevy_asset_loader).
By preloading your assets, you can avoid hitches during gameplay.
We start loading as soon as the app starts and wait for all assets to be loaded in the loading screen.
By using strings as keys, you can dynamically load assets based on input data such as a level file.
If you prefer a purely static approach, you can also use an `enum YourAssetHandleKey` and `impl AsRef<str> for YourAssetHandleKey`.
You can also mix the dynamic and static approach according to your needs.
## Spawn Commands
### Pattern
Spawn a game object by using a custom command. Inside the command,
run the spawning code with `world.run_system_once` or `world.run_system_once_with`:
```rust
// monster.rs
#[derive(Debug)]
pub struct SpawnMonster {
pub health: u32,
pub transform: Transform,
}
impl Command for SpawnMonster {
fn apply(self, world: &mut World) {
world.run_system_once_with(self, spawn_monster);
}
}
fn spawn_monster(
spawn_monster: In<SpawnMonster>,
mut commands: Commands,
) {
commands.spawn((
Name::new("Monster"),
Health::new(spawn_monster.health),
SpatialBundle::from_transform(spawn_monster.transform),
// other components
));
}
```
And then to use a spawn command, add it to `Commands`:
```rust
// dangerous_forest.rs
fn spawn_forest_goblin(mut commands: Commands) {
commands.add(SpawnMonster {
health: 100,
transform: Transform::from_xyz(10.0, 0.0, 0.0),
});
}
```
### Reasoning
By encapsulating the spawning of a game object in a custom command,
you save on boilerplate code and can easily change the behavior of spawning.
We use `world.run_system_once_with` to run the spawning code with the same syntax as a regular system.
That way you can easily add system parameters to access things like assets and resources while spawning the entity.
A limitation of this approach is that calling code cannot extend the spawn call with additional components or children,
as custom commands don't return `Entity` or `EntityCommands`. This kind of usage will be possible in future Bevy versions.
## Interaction Callbacks
### Pattern
When spawning an entity that can be interacted with, such as a button that can be pressed,
use an observer to handle the interaction:
```rust
fn spawn_button(mut commands: Commands) {
// See the Widgets pattern for information on the `button` method
commands.button("Pay up!").observe(pay_money);
}
fn pay_money(_trigger: Trigger<OnPress>, mut money: ResMut<Money>) {
money.0 -= 10.0;
}
```
The event `OnPress`, which is [defined in this template](../src/theme/interaction.rs),
is triggered when the button is [`Interaction::Pressed`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html#variant.Pressed).
If you have many interactions that only change a state, consider using the following helper function:
```rust
fn spawn_button(mut commands: Commands) {
commands.button("Play the game").observe(enter_state(Screen::Gameplay));
}
fn enter_state<S: FreelyMutableState>(
new_state: S,
) -> impl Fn(Trigger<OnPress>, ResMut<NextState<S>>) {
move |_trigger, mut next_state| next_state.set(new_state.clone())
}
```
### Reasoning
This pattern is inspired by [bevy_mod_picking](https://github.com/aevyrie/bevy_mod_picking).
By pairing the system handling the interaction with the entity as an observer,
the code running on interactions can be scoped to the exact context of the interaction.
For example, the code for what happens when you press a *specific* button is directly attached to that exact button.
This also keeps the interaction logic close to the entity that is interacted with,
allowing for better code organization.
## Dev Tools
### Pattern
Add all systems that are only relevant while developing the game to the [`dev_tools` plugin](../src/dev_tools.rs):
```rust
// dev_tools.rs
pub(super) fn plugin(app: &mut App) {
app.add_systems(Update, (draw_debug_lines, show_debug_console, show_fps_counter));
}
```
### Reasoning
The `dev_tools` plugin is only included in dev builds.
By adding your dev tools here, you automatically guarantee that they are not included in release builds.
## Screen States
### Pattern
Use the [`Screen`](../src/screen/mod.rs) enum to represent your game's screens as states:
```rust
#[derive(States, Debug, Hash, PartialEq, Eq, Clone, Default)]
pub enum Screen {
#[default]
Splash,
Loading,
Title,
Credits,
Gameplay,
Victory,
Leaderboard,
MultiplayerLobby,
SecretMinigame,
}
```
Constrain entities that should only be present in a certain screen to that screen by adding a
[`StateScoped`](https://docs.rs/bevy/latest/bevy/prelude/struct.StateScoped.html) component to them.
Transition between screens by setting the [`NextState<Screen>`](https://docs.rs/bevy/latest/bevy/prelude/enum.NextState.html) resource.
For each screen, create a plugin that handles the setup and teardown of the screen with `OnEnter` and `OnExit`:
```rust
// game_over.rs
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Victory), show_victory_screen);
app.add_systems(OnExit(Screen::Victory), reset_highscore);
}
fn show_victory_screen(mut commands: Commands) {
commands.
.ui_root()
.insert((Name::new("Victory screen"), StateScoped(Screen::Victory)))
.with_children(|parent| {
// Spawn UI elements.
});
}
fn reset_highscore(mut highscore: ResMut<Highscore>) {
*highscore = default();
}
```
### Reasoning
"Screen" is not meant as a physical screen, but as "what kind of screen is the game showing right now", e.g. the title screen, the loading screen, the credits screen, the victory screen, etc.
These screens usually correspond to different logical states of your game that have different systems running.
By using dedicated `State`s for each screen, you can easily manage systems and entities that are only relevant for a certain screen.
This allows you to flexibly transition between screens whenever your game logic requires it.

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
docs/img/thumbnail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

27
docs/known-issues.md Normal file
View File

@ -0,0 +1,27 @@
# Known Issues
## My audio is stuttering on web
There are a number of issues with audio on web, so this is not an exhaustive list. The short version is that you can try the following:
- If you use materials, make sure to force render pipelines to [load at the start of the game](https://github.com/rparrett/bevy_pipelines_ready/blob/main/src/lib.rs).
- Keep the FPS high.
- Advise your users to play on Chromium-based browsers.
- Apply the suggestions from the blog post [Workaround for the Choppy Music in Bevy Web Builds](https://necrashter.github.io/bevy-choppy-music-workaround).
## My game window is flashing white for a split second when I start the game on native
The game window is created before the GPU is ready to render everything.
This means that it will start with a white screen for a little bit.
The workaround is to [spawn the Window hidden](https://github.com/bevyengine/bevy/blob/release-0.14.0/examples/window/window_settings.rs#L29-L32)
and then [make it visible after a few frames](https://github.com/bevyengine/bevy/blob/release-0.14.0/examples/window/window_settings.rs#L56-L64).
## My character or camera is not moving smoothly
Choppy movement is often caused by movement updates being tied to the frame rate.
See the [physics_in_fixed_timestep](https://github.com/bevyengine/bevy/blob/main/examples/movement/physics_in_fixed_timestep.rs) example
for how to fix this.
A camera not moving smoothly is pretty much always caused by the camera position being tied too tightly to the character's position.
To give the camera some inertia, use the [`smooth_nudge`](https://github.com/bevyengine/bevy/blob/main/examples/movement/smooth_follow.rs#L127-L142)
to interpolate the camera position towards its target position.

64
docs/tooling.md Normal file
View File

@ -0,0 +1,64 @@
# Recommended 3rd-party tools
Check out the [Bevy Assets](https://bevyengine.org/assets/) page for more great options.
## Libraries
A few libraries that the authors of this template have vetted and think you might find useful:
| Name | Category | Description |
| -------------------------------------------------------------------------------------- | -------------- | ------------------------------------- |
| [`leafwing-input-manager`](https://github.com/Leafwing-Studios/leafwing-input-manager) | Input | Input -> Action mapping |
| [`bevy_mod_picking`](https://github.com/aevyrie/bevy_mod_picking) | Input | Advanced mouse interaction |
| [`bevy-inspector-egui`](https://github.com/jakobhellermann/bevy-inspector-egui) | Debugging | Live entity inspector |
| [`bevy_mod_debugdump`](https://github.com/jakobhellermann/bevy_mod_debugdump) | Debugging | Schedule inspector |
| [`avian`](https://github.com/Jondolf/avian) | Physics | Physics engine |
| [`bevy_rapier`](https://github.com/dimforge/bevy_rapier) | Physics | Physics engine (not ECS-driven) |
| [`bevy_common_assets`](https://github.com/NiklasEi/bevy_common_assets) | Asset loading | Asset loaders for common file formats |
| [`bevy_asset_loader`](https://github.com/NiklasEi/bevy_asset_loader) | Asset loading | Asset management tools |
| [`iyes_progress`](https://github.com/IyesGames/iyes_progress) | Asset loading | Progress tracking |
| [`bevy_kira_audio`](https://github.com/NiklasEi/bevy_kira_audio) | Audio | Advanced audio |
| [`sickle_ui`](https://github.com/UmbraLuminosa/sickle_ui) | UI | UI widgets |
| [`bevy_egui`](https://github.com/mvlabat/bevy_egui) | UI / Debugging | UI framework (great for debug UI) |
| [`tiny_bail`](https://github.com/benfrankel/tiny_bail) | Error handling | Error handling macros |
In particular:
- `leafwing-input-manager` and `bevy_mod_picking` are very likely to be upstreamed into Bevy in the near future.
- `bevy-inspector-egui` and `bevy_mod_debugdump` help fill the gap until Bevy has its own editor.
- `avian` or `bevy_rapier` helps fill the gap until Bevy has its own physics engine. `avian` is easier to use, while `bevy_rapier` is more performant.
- `sickle_ui` is well-aligned with `bevy_ui` and helps fill the gap until Bevy has a full collection of UI widgets.
None of these are necessary, but they can save you a lot of time and effort.
## VS Code extensions
If you're using [VS Code](https://code.visualstudio.com/), the following extensions are highly recommended:
| Name | Description |
|-----------------------------------------------------------------------------------------------------------|-----------------------------------|
| [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) | Rust support |
| [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) | TOML support |
| [vscode-ron](https://marketplace.visualstudio.com/items?itemName=a5huynh.vscode-ron) | RON support |
| [Dependi](https://marketplace.visualstudio.com/items?itemName=fill-labs.dependi) | `crates.io` dependency resolution |
| [EditorConfig for VS Code](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) | `.editorconfig` support |
> [!Note]
> <details>
> <summary>About the included rust-analyzer settings</summary>
>
> This template sets [`rust-analyzer.cargo.targetDir`](https://rust-analyzer.github.io/generated_config.html#rust-analyzer.cargo.targetDir)
> to `true` in [`.vscode/settings.json`](../.vscode/settings.json).
>
> This makes `rust-analyzer` use a different `target` directory than `cargo`,
> which means that you can run commands like `cargo run` even while `rust-analyzer` is still indexing.
> As a trade-off, this will use more disk space.
>
> If that is an issue for you, you can set it to `false` or remove the setting entirely.
> </details>
## Other templates
There are many other Bevy templates out there.
Check out the [templates category](https://bevyengine.org/assets/#templates) on Bevy Assets for more options.
Even if you don't end up using them, they are a great way to learn how to implement certain features you might be interested in.

125
docs/workflows.md Normal file
View File

@ -0,0 +1,125 @@
# Workflows
This template uses [GitHub workflows](https://docs.github.com/en/actions/using-workflows) for [CI / CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd), defined in [`.github/workflows/`](../.github/workflows).
## CI (testing)
The [CI workflow](.github/workflows/ci.yaml) will trigger on every commit or PR to `main`, and do the following:
- Run tests.
- Run Clippy lints.
- Check formatting.
- Check documentation.
> [!Tip]
> <details>
> <summary>You may want to set up a <a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets">GitHub ruleset</a> to require that all commits to <code>main</code> pass CI.</summary>
>
> <img src="img/workflow-ruleset.png" alt="A screenshot showing a GitHub ruleset with status checks enabled" width="100%">
> </details>
## CD (releasing)
The [CD workflow](../.github/workflows/release.yaml) will trigger on every pushed tag in the format `v1.2.3`, and do the following:
- Create a release build for Windows, macOS, Linux, and web.
- (Optional) Upload to [GitHub releases](https://docs.github.com/en/repositories/releasing-projects-on-github).
- (Optional) Upload to [itch.io](https://itch.io).
<details>
<summary>This workflow can also be triggered manually.</summary>
In your GitHub repository, navigate to `Actions > Release > Run workflow`:
![A screenshot showing a manually triggered workflow on GitHub Actions](./img/workflow-dispatch-release.png)
Enter a version number in the format `v1.2.3`, then hit the green `Run workflow` button.
</details>
> [!Important]
> Using this workflow requires some setup. We will go through this now.
### Configure environment variables
The release workflow can be configured by tweaking the environment variables in [`.github/workflows/release.yaml`](../.github/workflows/release.yaml).
<details>
<summary>Click here for a list of variables and how they're used.</summary>
```yaml
# The base filename of the binary produced by `cargo build`.
cargo_build_binary_name: bevy_quickstart
# The path to the assets directory.
assets_path: assets
# Whether to upload the packages produced by this workflow to a GitHub release.
upload_to_github: true
# The itch.io project to upload to in the format `user-name/project-name`.
# There will be no upload to itch.io if this is commented out.
upload_to_itch: the-bevy-flock/bevy-quickstart
############
# ADVANCED #
############
# The ID of the app produced by this workflow.
# Applies to macOS releases.
# Must contain only A-Z, a-z, 0-9, hyphens, and periods: https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleidentifier
app_id: the-bevy-flock.bevy-quickstart
# The base filename of the binary in the package produced by this workflow.
# Applies to Windows, macOS, and Linux releases.
# Defaults to `cargo_build_binary_name` if commented out.
app_binary_name: bevy_quickstart
# The name of the `.zip` or `.dmg` file produced by this workflow.
# Defaults to `app_binary_name` if commented out.
app_package_name: bevy_quickstart
# The display name of the app produced by this workflow.
# Applies to macOS releases.
# Defaults to `app_package_name` if commented out.
app_display_name: Bevy Quickstart
# The short display name of the app produced by this workflow.
# Applies to macOS releases.
# Must be 15 or fewer characters: https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundlename
# Defaults to `app_display_name` if commented out.
app_short_name: Bevy Quickstart
# Before enabling LFS, please take a look at GitHub's documentation for costs and quota limits:
# https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-storage-and-bandwidth-usage
git_lfs: false
```
</details>
The values are set automatically by `cargo generate`, or you can edit them yourself and push a commit.
### Set up itch.io upload
#### Add butler credentials
<details>
<summary>In your GitHub repository, navigate to <code>Settings > Secrets and variables > Actions</code>.</summary>
![A screenshot showing where to add secrets in the GitHub Actions settings](./img/workflow-secrets.png)
</details>
Hit `New repository secret` and enter the following values, then hit `Add secret`:
- **Name:** `BUTLER_CREDENTIALS`
- **Secret:** Your [itch.io API key](https://itch.io/user/settings/api-keys) (create a new one if necessary)
#### Create itch.io project
Create a new itch.io project with the same user and project name as in the `upload_to_itch` variable in [`.github/workflows/release.yaml`](../.github/workflows/release.yaml).
Hit `Save & view page` at the bottom of the page.
[Trigger the release workflow](#cd-releasing) for the first time. Once it's done, go back to itch.io and hit `Edit game` in the top left.
Set `Kind of project` to `HTML`, then find the newly uploaded `web` build and tick the box that says "This file will be played in the browser".
![A screenshot showing a web build selected in the itch.io uploads](img/workflow-itch-release.png)

64
src/asset_tracking.rs Normal file
View File

@ -0,0 +1,64 @@
//! A high-level way to load collections of asset handles as resources.
use std::collections::VecDeque;
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.init_resource::<ResourceHandles>();
app.add_systems(PreUpdate, load_resource_assets);
}
pub trait LoadResource {
/// This will load the [`Resource`] as an [`Asset`]. When all of its asset dependencies
/// have been loaded, it will be inserted as a resource. This ensures that the resource only
/// exists when the assets are ready.
fn load_resource<T: Resource + Asset + Clone + FromWorld>(&mut self) -> &mut Self;
}
impl LoadResource for App {
fn load_resource<T: Resource + Asset + Clone + FromWorld>(&mut self) -> &mut Self {
self.init_asset::<T>();
let world = self.world_mut();
let value = T::from_world(world);
let assets = world.resource::<AssetServer>();
let handle = assets.add(value);
let mut handles = world.resource_mut::<ResourceHandles>();
handles
.waiting
.push_back((handle.untyped(), |world, handle| {
let assets = world.resource::<Assets<T>>();
if let Some(value) = assets.get(handle.id().typed::<T>()) {
world.insert_resource(value.clone());
}
}));
self
}
}
/// A function that inserts a loaded resource.
type InsertLoadedResource = fn(&mut World, &UntypedHandle);
#[derive(Resource, Default)]
struct ResourceHandles {
// Use a queue for waiting assets so they can be cycled through and moved to
// `finished` one at a time.
waiting: VecDeque<(UntypedHandle, InsertLoadedResource)>,
finished: Vec<UntypedHandle>,
}
fn load_resource_assets(world: &mut World) {
world.resource_scope(|world, mut resource_handles: Mut<ResourceHandles>| {
world.resource_scope(|world, assets: Mut<AssetServer>| {
for _ in 0..resource_handles.waiting.len() {
let (handle, insert_fn) = resource_handles.waiting.pop_front().unwrap();
if assets.is_loaded_with_dependencies(&handle) {
insert_fn(world, &handle);
resource_handles.finished.push(handle);
} else {
resource_handles.waiting.push_back((handle, insert_fn));
}
}
});
});
}

37
src/audio.rs Normal file
View File

@ -0,0 +1,37 @@
use bevy::prelude::*;
/// An organizational marker component that should be added to a spawned [`AudioBundle`] if it is in the
/// general "music" category (ex: global background music, soundtrack, etc).
///
/// This can then be used to query for and operate on sounds in that category. For example:
///
/// ```
/// use bevy::prelude::*;
/// use the-labyrinth-of-echoes::audio::Music;
///
/// fn set_music_volume(sink_query: Query<&AudioSink, With<Music>>) {
/// for sink in &sink_query {
/// sink.set_volume(0.5);
/// }
/// }
/// ```
#[derive(Component, Default)]
pub struct Music;
/// An organizational marker component that should be added to a spawned [`AudioBundle`] if it is in the
/// general "sound effect" category (ex: footsteps, the sound of a magic spell, a door opening).
///
/// This can then be used to query for and operate on sounds in that category. For example:
///
/// ```
/// use bevy::prelude::*;
/// use the-labyrinth-of-echoes::audio::SoundEffect;
///
/// fn set_sound_effect_volume(sink_query: Query<&AudioSink, With<SoundEffect>>) {
/// for sink in &sink_query {
/// sink.set_volume(0.5);
/// }
/// }
/// ```
#[derive(Component, Default)]
pub struct SoundEffect;

177
src/demo/animation.rs Normal file
View File

@ -0,0 +1,177 @@
//! Player sprite animation.
//! This is based on multiple examples and may be very different for your game.
//! - [Sprite flipping](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_flipping.rs)
//! - [Sprite animation](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_animation.rs)
//! - [Timers](https://github.com/bevyengine/bevy/blob/latest/examples/time/timers.rs)
use bevy::prelude::*;
use rand::prelude::*;
use std::time::Duration;
use crate::{
audio::SoundEffect,
demo::{movement::MovementController, player::PlayerAssets},
AppSet,
};
pub(super) fn plugin(app: &mut App) {
// Animate and play sound effects based on controls.
app.register_type::<PlayerAnimation>();
app.add_systems(
Update,
(
update_animation_timer.in_set(AppSet::TickTimers),
(
update_animation_movement,
update_animation_atlas,
trigger_step_sound_effect,
)
.chain()
.run_if(resource_exists::<PlayerAssets>)
.in_set(AppSet::Update),
),
);
}
/// Update the sprite direction and animation state (idling/walking).
fn update_animation_movement(
mut player_query: Query<(&MovementController, &mut Sprite, &mut PlayerAnimation)>,
) {
for (controller, mut sprite, mut animation) in &mut player_query {
let dx = controller.intent.x;
if dx != 0.0 {
sprite.flip_x = dx < 0.0;
}
let animation_state = if controller.intent == Vec2::ZERO {
PlayerAnimationState::Idling
} else {
PlayerAnimationState::Walking
};
animation.update_state(animation_state);
}
}
/// Update the animation timer.
fn update_animation_timer(time: Res<Time>, mut query: Query<&mut PlayerAnimation>) {
for mut animation in &mut query {
animation.update_timer(time.delta());
}
}
/// Update the texture atlas to reflect changes in the animation.
fn update_animation_atlas(mut query: Query<(&PlayerAnimation, &mut TextureAtlas)>) {
for (animation, mut atlas) in &mut query {
if animation.changed() {
atlas.index = animation.get_atlas_index();
}
}
}
/// If the player is moving, play a step sound effect synchronized with the
/// animation.
fn trigger_step_sound_effect(
mut commands: Commands,
player_assets: Res<PlayerAssets>,
mut step_query: Query<&PlayerAnimation>,
) {
for animation in &mut step_query {
if animation.state == PlayerAnimationState::Walking
&& animation.changed()
&& (animation.frame == 2 || animation.frame == 5)
{
let rng = &mut rand::thread_rng();
let random_step = player_assets.steps.choose(rng).unwrap();
commands.spawn((
AudioBundle {
source: random_step.clone(),
settings: PlaybackSettings::DESPAWN,
},
SoundEffect,
));
}
}
}
/// Component that tracks player's animation state.
/// It is tightly bound to the texture atlas we use.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct PlayerAnimation {
timer: Timer,
frame: usize,
state: PlayerAnimationState,
}
#[derive(Reflect, PartialEq)]
pub enum PlayerAnimationState {
Idling,
Walking,
}
impl PlayerAnimation {
/// The number of idle frames.
const IDLE_FRAMES: usize = 2;
/// The duration of each idle frame.
const IDLE_INTERVAL: Duration = Duration::from_millis(500);
/// The number of walking frames.
const WALKING_FRAMES: usize = 6;
/// The duration of each walking frame.
const WALKING_INTERVAL: Duration = Duration::from_millis(50);
fn idling() -> Self {
Self {
timer: Timer::new(Self::IDLE_INTERVAL, TimerMode::Repeating),
frame: 0,
state: PlayerAnimationState::Idling,
}
}
fn walking() -> Self {
Self {
timer: Timer::new(Self::WALKING_INTERVAL, TimerMode::Repeating),
frame: 0,
state: PlayerAnimationState::Walking,
}
}
pub fn new() -> Self {
Self::idling()
}
/// Update animation timers.
pub fn update_timer(&mut self, delta: Duration) {
self.timer.tick(delta);
if !self.timer.finished() {
return;
}
self.frame = (self.frame + 1)
% match self.state {
PlayerAnimationState::Idling => Self::IDLE_FRAMES,
PlayerAnimationState::Walking => Self::WALKING_FRAMES,
};
}
/// Update animation state if it changes.
pub fn update_state(&mut self, state: PlayerAnimationState) {
if self.state != state {
match state {
PlayerAnimationState::Idling => *self = Self::idling(),
PlayerAnimationState::Walking => *self = Self::walking(),
}
}
}
/// Whether animation changed this tick.
pub fn changed(&self) -> bool {
self.timer.finished()
}
/// Return sprite index in the atlas.
pub fn get_atlas_index(&self) -> usize {
match self.state {
PlayerAnimationState::Idling => self.frame,
PlayerAnimationState::Walking => 6 + self.frame,
}
}
}

20
src/demo/level.rs Normal file
View File

@ -0,0 +1,20 @@
//! Spawn the main level.
use bevy::{ecs::world::Command, prelude::*};
use crate::demo::player::SpawnPlayer;
pub(super) fn plugin(_app: &mut App) {
// No setup required for this plugin.
// It's still good to have a function here so that we can add some setup
// later if needed.
}
/// A [`Command`] to spawn the level.
/// Functions that accept only `&mut World` as their parameter implement [`Command`].
/// We use this style when a command requires no configuration.
pub fn spawn_level(world: &mut World) {
// The only thing we have in our level is a player,
// but add things like walls etc. here.
SpawnPlayer { max_speed: 400.0 }.apply(world);
}

20
src/demo/mod.rs Normal file
View File

@ -0,0 +1,20 @@
//! Demo gameplay. All of these modules are only intended for demonstration
//! purposes and should be replaced with your own game logic.
//! Feel free to change the logic found here if you feel like tinkering around
//! to get a feeling for the template.
use bevy::prelude::*;
mod animation;
pub mod level;
mod movement;
pub mod player;
pub(super) fn plugin(app: &mut App) {
app.add_plugins((
animation::plugin,
movement::plugin,
player::plugin,
level::plugin,
));
}

84
src/demo/movement.rs Normal file
View File

@ -0,0 +1,84 @@
//! Handle player input and translate it into movement through a character
//! controller. A character controller is the collection of systems that govern
//! the movement of characters.
//!
//! In our case, the character controller has the following logic:
//! - Set [`MovementController`] intent based on directional keyboard input.
//! This is done in the `player` module, as it is specific to the player
//! character.
//! - Apply movement based on [`MovementController`] intent and maximum speed.
//! - Wrap the character within the window.
//!
//! Note that the implementation used here is limited for demonstration
//! purposes. If you want to move the player in a smoother way,
//! consider using a [fixed timestep](https://github.com/bevyengine/bevy/blob/main/examples/movement/physics_in_fixed_timestep.rs).
use bevy::{prelude::*, window::PrimaryWindow};
use crate::AppSet;
pub(super) fn plugin(app: &mut App) {
app.register_type::<(MovementController, ScreenWrap)>();
app.add_systems(
Update,
(apply_movement, apply_screen_wrap)
.chain()
.in_set(AppSet::Update),
);
}
/// These are the movement parameters for our character controller.
/// For now, this is only used for a single player, but it could power NPCs or
/// other players as well.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MovementController {
/// The direction the character wants to move in.
pub intent: Vec2,
/// Maximum speed in world units per second.
/// 1 world unit = 1 pixel when using the default 2D camera and no physics
/// engine.
pub max_speed: f32,
}
impl Default for MovementController {
fn default() -> Self {
Self {
intent: Vec2::ZERO,
// 400 pixels per second is a nice default, but we can still vary this per character.
max_speed: 400.0,
}
}
}
fn apply_movement(
time: Res<Time>,
mut movement_query: Query<(&MovementController, &mut Transform)>,
) {
for (controller, mut transform) in &mut movement_query {
let velocity = controller.max_speed * controller.intent;
transform.translation += velocity.extend(0.0) * time.delta_seconds();
}
}
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct ScreenWrap;
fn apply_screen_wrap(
window_query: Query<&Window, With<PrimaryWindow>>,
mut wrap_query: Query<&mut Transform, With<ScreenWrap>>,
) {
let Ok(window) = window_query.get_single() else {
return;
};
let size = window.size() + 256.0;
let half_size = size / 2.0;
for mut transform in &mut wrap_query {
let position = transform.translation.xy();
let wrapped = (position + half_size).rem_euclid(size) - half_size;
transform.translation = wrapped.extend(transform.translation.z);
}
}

153
src/demo/player.rs Normal file
View File

@ -0,0 +1,153 @@
//! Plugin handling the player character in particular.
//! Note that this is separate from the `movement` module as that could be used
//! for other characters as well.
use bevy::{
ecs::{system::RunSystemOnce as _, world::Command},
prelude::*,
render::texture::{ImageLoaderSettings, ImageSampler},
};
use crate::{
asset_tracking::LoadResource,
demo::{
animation::PlayerAnimation,
movement::{MovementController, ScreenWrap},
},
screens::Screen,
AppSet,
};
pub(super) fn plugin(app: &mut App) {
app.register_type::<Player>();
app.load_resource::<PlayerAssets>();
// Record directional input as movement controls.
app.add_systems(
Update,
record_player_directional_input.in_set(AppSet::RecordInput),
);
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Default, Reflect)]
#[reflect(Component)]
pub struct Player;
/// A command to spawn the player character.
#[derive(Debug)]
pub struct SpawnPlayer {
/// See [`MovementController::max_speed`].
pub max_speed: f32,
}
impl Command for SpawnPlayer {
fn apply(self, world: &mut World) {
world.run_system_once_with(self, spawn_player);
}
}
fn spawn_player(
In(config): In<SpawnPlayer>,
mut commands: Commands,
player_assets: Res<PlayerAssets>,
mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
// A texture atlas is a way to split one image with a grid into multiple
// sprites. By attaching it to a [`SpriteBundle`] and providing an index, we
// can specify which section of the image we want to see. We will use this
// to animate our player character. You can learn more about texture atlases in
// this example: https://github.com/bevyengine/bevy/blob/latest/examples/2d/texture_atlas.rs
let layout = TextureAtlasLayout::from_grid(UVec2::splat(32), 6, 2, Some(UVec2::splat(1)), None);
let texture_atlas_layout = texture_atlas_layouts.add(layout);
let player_animation = PlayerAnimation::new();
commands.spawn((
Name::new("Player"),
Player,
SpriteBundle {
texture: player_assets.ducky.clone(),
transform: Transform::from_scale(Vec2::splat(8.0).extend(1.0)),
..Default::default()
},
TextureAtlas {
layout: texture_atlas_layout.clone(),
index: player_animation.get_atlas_index(),
},
MovementController {
max_speed: config.max_speed,
..default()
},
ScreenWrap,
player_animation,
StateScoped(Screen::Gameplay),
));
}
fn record_player_directional_input(
input: Res<ButtonInput<KeyCode>>,
mut controller_query: Query<&mut MovementController, With<Player>>,
) {
// Collect directional input.
let mut intent = Vec2::ZERO;
if input.pressed(KeyCode::KeyW) || input.pressed(KeyCode::ArrowUp) {
intent.y += 1.0;
}
if input.pressed(KeyCode::KeyS) || input.pressed(KeyCode::ArrowDown) {
intent.y -= 1.0;
}
if input.pressed(KeyCode::KeyA) || input.pressed(KeyCode::ArrowLeft) {
intent.x -= 1.0;
}
if input.pressed(KeyCode::KeyD) || input.pressed(KeyCode::ArrowRight) {
intent.x += 1.0;
}
// Normalize so that diagonal movement has the same speed as
// horizontal and vertical movement.
// This should be omitted if the input comes from an analog stick instead.
let intent = intent.normalize_or_zero();
// Apply movement intent to controllers.
for mut controller in &mut controller_query {
controller.intent = intent;
}
}
#[derive(Resource, Asset, Reflect, Clone)]
pub struct PlayerAssets {
// This #[dependency] attribute marks the field as a dependency of the Asset.
// This means that it will not finish loading until the labeled asset is also loaded.
#[dependency]
pub ducky: Handle<Image>,
#[dependency]
pub steps: Vec<Handle<AudioSource>>,
}
impl PlayerAssets {
pub const PATH_DUCKY: &'static str = "images/ducky.png";
pub const PATH_STEP_1: &'static str = "audio/sound_effects/step1.ogg";
pub const PATH_STEP_2: &'static str = "audio/sound_effects/step2.ogg";
pub const PATH_STEP_3: &'static str = "audio/sound_effects/step3.ogg";
pub const PATH_STEP_4: &'static str = "audio/sound_effects/step4.ogg";
}
impl FromWorld for PlayerAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
ducky: assets.load_with_settings(
PlayerAssets::PATH_DUCKY,
|settings: &mut ImageLoaderSettings| {
// Use `nearest` image sampling to preserve the pixel art style.
settings.sampler = ImageSampler::nearest();
},
),
steps: vec![
assets.load(PlayerAssets::PATH_STEP_1),
assets.load(PlayerAssets::PATH_STEP_2),
assets.load(PlayerAssets::PATH_STEP_3),
assets.load(PlayerAssets::PATH_STEP_4),
],
}
}
}

30
src/dev_tools.rs Normal file
View File

@ -0,0 +1,30 @@
//! Development tools for the game. This plugin is only enabled in dev builds.
use bevy::{
dev_tools::{
states::log_transitions,
ui_debug_overlay::{DebugUiPlugin, UiDebugOptions},
},
input::common_conditions::input_just_pressed,
prelude::*,
};
use crate::screens::Screen;
pub(super) fn plugin(app: &mut App) {
// Log `Screen` state transitions.
app.add_systems(Update, log_transitions::<Screen>);
// Toggle the debug overlay for UI.
app.add_plugins(DebugUiPlugin);
app.add_systems(
Update,
toggle_debug_ui.run_if(input_just_pressed(TOGGLE_KEY)),
);
}
const TOGGLE_KEY: KeyCode = KeyCode::Backquote;
fn toggle_debug_ui(mut options: ResMut<UiDebugOptions>) {
options.toggle();
}

96
src/lib.rs Normal file
View File

@ -0,0 +1,96 @@
mod asset_tracking;
pub mod audio;
mod demo;
#[cfg(feature = "dev")]
mod dev_tools;
mod screens;
mod theme;
use bevy::{
asset::AssetMetaCheck,
audio::{AudioPlugin, Volume},
prelude::*,
};
pub struct AppPlugin;
impl Plugin for AppPlugin {
fn build(&self, app: &mut App) {
// Order new `AppStep` variants by adding them here:
app.configure_sets(
Update,
(AppSet::TickTimers, AppSet::RecordInput, AppSet::Update).chain(),
);
// Spawn the main camera.
app.add_systems(Startup, spawn_camera);
// Add Bevy plugins.
app.add_plugins(
DefaultPlugins
.set(AssetPlugin {
// Wasm builds will check for meta files (that don't exist) if this isn't set.
// This causes errors and even panics on web build on itch.
// See https://github.com/bevyengine/bevy_github_ci_template/issues/48.
meta_check: AssetMetaCheck::Never,
..default()
})
.set(WindowPlugin {
primary_window: Window {
title: "The Labyrinth Of Echoes".to_string(),
canvas: Some("#bevy".to_string()),
fit_canvas_to_parent: true,
prevent_default_event_handling: true,
..default()
}
.into(),
..default()
})
.set(AudioPlugin {
global_volume: GlobalVolume {
volume: Volume::new(0.3),
},
..default()
}),
);
// Add other plugins.
app.add_plugins((
asset_tracking::plugin,
demo::plugin,
screens::plugin,
theme::plugin,
));
// Enable dev tools for dev builds.
#[cfg(feature = "dev")]
app.add_plugins(dev_tools::plugin);
}
}
/// High-level groupings of systems for the app in the `Update` schedule.
/// When adding a new variant, make sure to order it in the `configure_sets`
/// call above.
#[derive(SystemSet, Debug, Clone, Copy, Eq, PartialEq, Hash, PartialOrd, Ord)]
enum AppSet {
/// Tick timers.
TickTimers,
/// Record player input.
RecordInput,
/// Do everything else (consider splitting this into further variants).
Update,
}
fn spawn_camera(mut commands: Commands) {
commands.spawn((
Name::new("Camera"),
Camera2dBundle::default(),
// Render all UI to this camera.
// Not strictly necessary since we only use one camera,
// but if we don't use this component, our UI will disappear as soon
// as we add another camera. This includes indirect ways of adding cameras like using
// [ui node outlines](https://bevyengine.org/news/bevy-0-14/#ui-node-outline-gizmos)
// for debugging. So it's good to have this here for future-proofing.
IsDefaultUiCamera,
));
}

9
src/main.rs Normal file
View File

@ -0,0 +1,9 @@
// Disable console on Windows for non-dev builds.
#![cfg_attr(not(feature = "dev"), windows_subsystem = "windows")]
use bevy::prelude::*;
use the_labyrinth_of_echoes::AppPlugin;
fn main() -> AppExit {
App::new().add_plugins(AppPlugin).run()
}

73
src/screens/credits.rs Normal file
View File

@ -0,0 +1,73 @@
//! A credits screen that can be accessed from the title screen.
use bevy::prelude::*;
use crate::{asset_tracking::LoadResource, audio::Music, screens::Screen, theme::prelude::*};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Credits), spawn_credits_screen);
app.load_resource::<CreditsMusic>();
app.add_systems(OnEnter(Screen::Credits), play_credits_music);
app.add_systems(OnExit(Screen::Credits), stop_music);
}
fn spawn_credits_screen(mut commands: Commands) {
commands
.ui_root()
.insert(StateScoped(Screen::Credits))
.with_children(|children| {
children.header("Made by");
children.label("Joe Shmoe - Implemented aligator wrestling AI");
children.label("Jane Doe - Made the music for the alien invasion");
children.header("Assets");
children.label("Bevy logo - All rights reserved by the Bevy Foundation. Permission granted for splash screen use when unmodified.");
children.label("Ducky sprite - CC0 by Caz Creates Games");
children.label("Button SFX - CC0 by Jaszunio15");
children.label("Music - CC BY 3.0 by Kevin MacLeod");
children.button("Back").observe(enter_title_screen);
});
}
fn enter_title_screen(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}
#[derive(Resource, Asset, Reflect, Clone)]
pub struct CreditsMusic {
#[dependency]
music: Handle<AudioSource>,
entity: Option<Entity>,
}
impl FromWorld for CreditsMusic {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
music: assets.load("audio/music/Monkeys Spinning Monkeys.ogg"),
entity: None,
}
}
}
fn play_credits_music(mut commands: Commands, mut music: ResMut<CreditsMusic>) {
music.entity = Some(
commands
.spawn((
AudioBundle {
source: music.music.clone(),
settings: PlaybackSettings::LOOP,
},
Music,
))
.id(),
);
}
fn stop_music(mut commands: Commands, mut music: ResMut<CreditsMusic>) {
if let Some(entity) = music.entity.take() {
commands.entity(entity).despawn_recursive();
}
}

67
src/screens/gameplay.rs Normal file
View File

@ -0,0 +1,67 @@
//! The screen state for the main gameplay.
use bevy::{input::common_conditions::input_just_pressed, prelude::*};
use crate::{
asset_tracking::LoadResource, audio::Music, demo::level::spawn_level as spawn_level_command,
screens::Screen,
};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Gameplay), spawn_level);
app.load_resource::<GameplayMusic>();
app.add_systems(OnEnter(Screen::Gameplay), play_gameplay_music);
app.add_systems(OnExit(Screen::Gameplay), stop_music);
app.add_systems(
Update,
return_to_title_screen
.run_if(in_state(Screen::Gameplay).and_then(input_just_pressed(KeyCode::Escape))),
);
}
fn spawn_level(mut commands: Commands) {
commands.add(spawn_level_command);
}
#[derive(Resource, Asset, Reflect, Clone)]
pub struct GameplayMusic {
#[dependency]
handle: Handle<AudioSource>,
entity: Option<Entity>,
}
impl FromWorld for GameplayMusic {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
handle: assets.load("audio/music/Fluffing A Duck.ogg"),
entity: None,
}
}
}
fn play_gameplay_music(mut commands: Commands, mut music: ResMut<GameplayMusic>) {
music.entity = Some(
commands
.spawn((
AudioBundle {
source: music.handle.clone(),
settings: PlaybackSettings::LOOP,
},
Music,
))
.id(),
);
}
fn stop_music(mut commands: Commands, mut music: ResMut<GameplayMusic>) {
if let Some(entity) = music.entity.take() {
commands.entity(entity).despawn_recursive();
}
}
fn return_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}

47
src/screens/loading.rs Normal file
View File

@ -0,0 +1,47 @@
//! A loading screen during which game assets are loaded.
//! This reduces stuttering, especially for audio on WASM.
use bevy::prelude::*;
use crate::{
demo::player::PlayerAssets,
screens::{credits::CreditsMusic, gameplay::GameplayMusic, Screen},
theme::{interaction::InteractionAssets, prelude::*},
};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Loading), spawn_loading_screen);
app.add_systems(
Update,
continue_to_title_screen.run_if(in_state(Screen::Loading).and_then(all_assets_loaded)),
);
}
fn spawn_loading_screen(mut commands: Commands) {
commands
.ui_root()
.insert(StateScoped(Screen::Loading))
.with_children(|children| {
children.label("Loading...").insert(Style {
justify_content: JustifyContent::Center,
..default()
});
});
}
fn continue_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}
fn all_assets_loaded(
player_assets: Option<Res<PlayerAssets>>,
interaction_assets: Option<Res<InteractionAssets>>,
credits_music: Option<Res<CreditsMusic>>,
gameplay_music: Option<Res<GameplayMusic>>,
) -> bool {
player_assets.is_some()
&& interaction_assets.is_some()
&& credits_music.is_some()
&& gameplay_music.is_some()
}

33
src/screens/mod.rs Normal file
View File

@ -0,0 +1,33 @@
//! The game's main screen states and transitions between them.
mod credits;
mod gameplay;
mod loading;
mod splash;
mod title;
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.init_state::<Screen>();
app.enable_state_scoped_entities::<Screen>();
app.add_plugins((
credits::plugin,
gameplay::plugin,
loading::plugin,
splash::plugin,
title::plugin,
));
}
/// The game's main screen states.
#[derive(States, Debug, Hash, PartialEq, Eq, Clone, Default)]
pub enum Screen {
#[default]
Splash,
Loading,
Title,
Credits,
Gameplay,
}

153
src/screens/splash.rs Normal file
View File

@ -0,0 +1,153 @@
//! A splash screen that plays briefly at startup.
use bevy::{
input::common_conditions::input_just_pressed,
prelude::*,
render::texture::{ImageLoaderSettings, ImageSampler},
};
use crate::{screens::Screen, theme::prelude::*, AppSet};
pub(super) fn plugin(app: &mut App) {
// Spawn splash screen.
app.insert_resource(ClearColor(SPLASH_BACKGROUND_COLOR));
app.add_systems(OnEnter(Screen::Splash), spawn_splash_screen);
// Animate splash screen.
app.add_systems(
Update,
(
tick_fade_in_out.in_set(AppSet::TickTimers),
apply_fade_in_out.in_set(AppSet::Update),
)
.run_if(in_state(Screen::Splash)),
);
// Add splash timer.
app.register_type::<SplashTimer>();
app.add_systems(OnEnter(Screen::Splash), insert_splash_timer);
app.add_systems(OnExit(Screen::Splash), remove_splash_timer);
app.add_systems(
Update,
(
tick_splash_timer.in_set(AppSet::TickTimers),
check_splash_timer.in_set(AppSet::Update),
)
.run_if(in_state(Screen::Splash)),
);
// Exit the splash screen early if the player hits escape.
app.add_systems(
Update,
continue_to_loading_screen
.run_if(input_just_pressed(KeyCode::Escape).and_then(in_state(Screen::Splash))),
);
}
const SPLASH_BACKGROUND_COLOR: Color = Color::srgb(0.157, 0.157, 0.157);
const SPLASH_DURATION_SECS: f32 = 1.8;
const SPLASH_FADE_DURATION_SECS: f32 = 0.6;
fn spawn_splash_screen(mut commands: Commands, asset_server: Res<AssetServer>) {
commands
.ui_root()
.insert((
Name::new("Splash screen"),
BackgroundColor(SPLASH_BACKGROUND_COLOR),
StateScoped(Screen::Splash),
))
.with_children(|children| {
children.spawn((
Name::new("Splash image"),
ImageBundle {
style: Style {
margin: UiRect::all(Val::Auto),
width: Val::Percent(70.0),
..default()
},
image: UiImage::new(asset_server.load_with_settings(
// This should be an embedded asset for instant loading, but that is
// currently [broken on Windows Wasm builds](https://github.com/bevyengine/bevy/issues/14246).
"images/splash.png",
|settings: &mut ImageLoaderSettings| {
// Make an exception for the splash image in case
// `ImagePlugin::default_nearest()` is used for pixel art.
settings.sampler = ImageSampler::linear();
},
)),
..default()
},
UiImageFadeInOut {
total_duration: SPLASH_DURATION_SECS,
fade_duration: SPLASH_FADE_DURATION_SECS,
t: 0.0,
},
));
});
}
#[derive(Component, Reflect)]
#[reflect(Component)]
struct UiImageFadeInOut {
/// Total duration in seconds.
total_duration: f32,
/// Fade duration in seconds.
fade_duration: f32,
/// Current progress in seconds, between 0 and [`Self::total_duration`].
t: f32,
}
impl UiImageFadeInOut {
fn alpha(&self) -> f32 {
// Normalize by duration.
let t = (self.t / self.total_duration).clamp(0.0, 1.0);
let fade = self.fade_duration / self.total_duration;
// Regular trapezoid-shaped graph, flat at the top with alpha = 1.0.
((1.0 - (2.0 * t - 1.0).abs()) / fade).min(1.0)
}
}
fn tick_fade_in_out(time: Res<Time>, mut animation_query: Query<&mut UiImageFadeInOut>) {
for mut anim in &mut animation_query {
anim.t += time.delta_seconds();
}
}
fn apply_fade_in_out(mut animation_query: Query<(&UiImageFadeInOut, &mut UiImage)>) {
for (anim, mut image) in &mut animation_query {
image.color.set_alpha(anim.alpha())
}
}
#[derive(Resource, Debug, Clone, PartialEq, Reflect)]
#[reflect(Resource)]
struct SplashTimer(Timer);
impl Default for SplashTimer {
fn default() -> Self {
Self(Timer::from_seconds(SPLASH_DURATION_SECS, TimerMode::Once))
}
}
fn insert_splash_timer(mut commands: Commands) {
commands.init_resource::<SplashTimer>();
}
fn remove_splash_timer(mut commands: Commands) {
commands.remove_resource::<SplashTimer>();
}
fn tick_splash_timer(time: Res<Time>, mut timer: ResMut<SplashTimer>) {
timer.0.tick(time.delta());
}
fn check_splash_timer(timer: ResMut<SplashTimer>, mut next_screen: ResMut<NextState<Screen>>) {
if timer.0.just_finished() {
next_screen.set(Screen::Loading);
}
}
fn continue_to_loading_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Loading);
}

35
src/screens/title.rs Normal file
View File

@ -0,0 +1,35 @@
//! The title screen that appears when the game starts.
use bevy::prelude::*;
use crate::{screens::Screen, theme::prelude::*};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Title), spawn_title_screen);
}
fn spawn_title_screen(mut commands: Commands) {
commands
.ui_root()
.insert(StateScoped(Screen::Title))
.with_children(|children| {
children.button("Play").observe(enter_gameplay_screen);
children.button("Credits").observe(enter_credits_screen);
#[cfg(not(target_family = "wasm"))]
children.button("Exit").observe(exit_app);
});
}
fn enter_gameplay_screen(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Gameplay);
}
fn enter_credits_screen(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Credits);
}
#[cfg(not(target_family = "wasm"))]
fn exit_app(_trigger: Trigger<OnPress>, mut app_exit: EventWriter<AppExit>) {
app_exit.send(AppExit::Success);
}

104
src/theme/interaction.rs Normal file
View File

@ -0,0 +1,104 @@
use bevy::prelude::*;
use crate::{asset_tracking::LoadResource, audio::SoundEffect};
pub(super) fn plugin(app: &mut App) {
app.register_type::<InteractionPalette>();
app.load_resource::<InteractionAssets>();
app.add_systems(
Update,
(
trigger_on_press,
apply_interaction_palette,
trigger_interaction_sound_effect,
)
.run_if(resource_exists::<InteractionAssets>),
);
}
/// Palette for widget interactions. Add this to an entity that supports
/// [`Interaction`]s, such as a button, to change its [`BackgroundColor`] based
/// on the current interaction state.
#[derive(Component, Debug, Reflect)]
#[reflect(Component)]
pub struct InteractionPalette {
pub none: Color,
pub hovered: Color,
pub pressed: Color,
}
/// Event triggered on a UI entity when the [`Interaction`] component on the same entity changes to
/// [`Interaction::Pressed`]. Observe this event to detect e.g. button presses.
#[derive(Event)]
pub struct OnPress;
fn trigger_on_press(
interaction_query: Query<(Entity, &Interaction), Changed<Interaction>>,
mut commands: Commands,
) {
for (entity, interaction) in &interaction_query {
if matches!(interaction, Interaction::Pressed) {
commands.trigger_targets(OnPress, entity);
}
}
}
fn apply_interaction_palette(
mut palette_query: Query<
(&Interaction, &InteractionPalette, &mut BackgroundColor),
Changed<Interaction>,
>,
) {
for (interaction, palette, mut background) in &mut palette_query {
*background = match interaction {
Interaction::None => palette.none,
Interaction::Hovered => palette.hovered,
Interaction::Pressed => palette.pressed,
}
.into();
}
}
#[derive(Resource, Asset, Reflect, Clone)]
pub struct InteractionAssets {
#[dependency]
hover: Handle<AudioSource>,
#[dependency]
press: Handle<AudioSource>,
}
impl InteractionAssets {
pub const PATH_BUTTON_HOVER: &'static str = "audio/sound_effects/button_hover.ogg";
pub const PATH_BUTTON_PRESS: &'static str = "audio/sound_effects/button_press.ogg";
}
impl FromWorld for InteractionAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
hover: assets.load(Self::PATH_BUTTON_HOVER),
press: assets.load(Self::PATH_BUTTON_PRESS),
}
}
}
fn trigger_interaction_sound_effect(
interaction_query: Query<&Interaction, Changed<Interaction>>,
interaction_assets: Res<InteractionAssets>,
mut commands: Commands,
) {
for interaction in &interaction_query {
let source = match interaction {
Interaction::Hovered => interaction_assets.hover.clone(),
Interaction::Pressed => interaction_assets.press.clone(),
_ => continue,
};
commands.spawn((
AudioBundle {
source,
settings: PlaybackSettings::DESPAWN,
},
SoundEffect,
));
}
}

23
src/theme/mod.rs Normal file
View File

@ -0,0 +1,23 @@
//! Reusable UI widgets & theming.
// Unused utilities may trigger this lints undesirably.
#![allow(dead_code)]
pub mod interaction;
pub mod palette;
mod widgets;
#[allow(unused_imports)]
pub mod prelude {
pub use super::{
interaction::{InteractionPalette, OnPress},
palette as ui_palette,
widgets::{Containers as _, Widgets as _},
};
}
use bevy::prelude::*;
pub(super) fn plugin(app: &mut App) {
app.add_plugins(interaction::plugin);
}

10
src/theme/palette.rs Normal file
View File

@ -0,0 +1,10 @@
use bevy::prelude::*;
pub const BUTTON_HOVERED_BACKGROUND: Color = Color::srgb(0.186, 0.328, 0.573);
pub const BUTTON_PRESSED_BACKGROUND: Color = Color::srgb(0.286, 0.478, 0.773);
pub const BUTTON_TEXT: Color = Color::srgb(0.925, 0.925, 0.925);
pub const LABEL_TEXT: Color = Color::srgb(0.867, 0.827, 0.412);
pub const HEADER_TEXT: Color = Color::srgb(0.867, 0.827, 0.412);
pub const NODE_BACKGROUND: Color = Color::srgb(0.286, 0.478, 0.773);

154
src/theme/widgets.rs Normal file
View File

@ -0,0 +1,154 @@
//! Helper traits for creating common widgets.
use bevy::{ecs::system::EntityCommands, prelude::*, ui::Val::*};
use crate::theme::{interaction::InteractionPalette, palette::*};
/// An extension trait for spawning UI widgets.
pub trait Widgets {
/// Spawn a simple button with text.
fn button(&mut self, text: impl Into<String>) -> EntityCommands;
/// Spawn a simple header label. Bigger than [`Widgets::label`].
fn header(&mut self, text: impl Into<String>) -> EntityCommands;
/// Spawn a simple text label.
fn label(&mut self, text: impl Into<String>) -> EntityCommands;
}
impl<T: Spawn> Widgets for T {
fn button(&mut self, text: impl Into<String>) -> EntityCommands {
let mut entity = self.spawn((
Name::new("Button"),
ButtonBundle {
style: Style {
width: Px(200.0),
height: Px(65.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
background_color: BackgroundColor(NODE_BACKGROUND),
..default()
},
InteractionPalette {
none: NODE_BACKGROUND,
hovered: BUTTON_HOVERED_BACKGROUND,
pressed: BUTTON_PRESSED_BACKGROUND,
},
));
entity.with_children(|children| {
children.spawn((
Name::new("Button Text"),
TextBundle::from_section(
text,
TextStyle {
font_size: 40.0,
color: BUTTON_TEXT,
..default()
},
),
));
});
entity
}
fn header(&mut self, text: impl Into<String>) -> EntityCommands {
let mut entity = self.spawn((
Name::new("Header"),
NodeBundle {
style: Style {
width: Px(500.0),
height: Px(65.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
background_color: BackgroundColor(NODE_BACKGROUND),
..default()
},
));
entity.with_children(|children| {
children.spawn((
Name::new("Header Text"),
TextBundle::from_section(
text,
TextStyle {
font_size: 40.0,
color: HEADER_TEXT,
..default()
},
),
));
});
entity
}
fn label(&mut self, text: impl Into<String>) -> EntityCommands {
let entity = self.spawn((
Name::new("Label"),
TextBundle::from_section(
text,
TextStyle {
font_size: 24.0,
color: LABEL_TEXT,
..default()
},
)
.with_style(Style {
width: Px(500.0),
..default()
}),
));
entity
}
}
/// An extension trait for spawning UI containers.
pub trait Containers {
/// Spawns a root node that covers the full screen
/// and centers its content horizontally and vertically.
fn ui_root(&mut self) -> EntityCommands;
}
impl Containers for Commands<'_, '_> {
fn ui_root(&mut self) -> EntityCommands {
self.spawn((
Name::new("UI Root"),
NodeBundle {
style: Style {
width: Percent(100.0),
height: Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
flex_direction: FlexDirection::Column,
row_gap: Px(10.0),
position_type: PositionType::Absolute,
..default()
},
..default()
},
))
}
}
/// An internal trait for types that can spawn entities.
/// This is here so that [`Widgets`] can be implemented on all types that
/// are able to spawn entities.
/// Ideally, this trait should be [part of Bevy itself](https://github.com/bevyengine/bevy/issues/14231).
trait Spawn {
fn spawn<B: Bundle>(&mut self, bundle: B) -> EntityCommands;
}
impl Spawn for Commands<'_, '_> {
fn spawn<B: Bundle>(&mut self, bundle: B) -> EntityCommands {
self.spawn(bundle)
}
}
impl Spawn for ChildBuilder<'_> {
fn spawn<B: Bundle>(&mut self, bundle: B) -> EntityCommands {
self.spawn(bundle)
}
}

38
web/index.html Normal file
View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description"
content="Maze exploration game built using the Bevy engine">
<meta name="keywords" content="game, bevy">
<title>Maze Ascension: The Labyrinth of Echoes</title>
<link data-trunk rel="copy-dir" href="../assets" />
<link data-trunk rel="inline" href="style.css" />
<link data-trunk rel="inline" type="module" href="restart-audio-context.js" />
<link data-trunk
rel="rust"
data-cargo-no-default-features
data-wasm-opt="s"
href="../" />
</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">
// 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>
</body>
</html>

View File

@ -0,0 +1,57 @@
// taken from https://developer.chrome.com/blog/web-audio-autoplay/#moving-forward
(() => {
// 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(_) {
let count = 0;
for (const context of audioContextList) {
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) {
for (const eventName of userInputEventNames) {
document.removeEventListener(eventName, resumeAllContexts);
}
}
}
// We bind the resume function for each user interaction
// event on the page
for (const eventName of userInputEventNames) {
document.addEventListener(eventName, resumeAllContexts);
}
})();

56
web/style.css Normal file
View File

@ -0,0 +1,56 @@
:root {
/* Consider adjusting this color to match your splash screen! */
--loading-screen-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;
}
#loading-screen {
background-color: var(--loading-screen-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;
}